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

View File

@ -599,6 +599,10 @@ class ComplaintForm(HospitalFieldMixin, forms.ModelForm):
hospital_id = self.data.get("hospital")
elif 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:
hospital_id = self.user.hospital.id
@ -714,6 +718,10 @@ class InquiryForm(HospitalFieldMixin, forms.ModelForm):
hospital_id = self.data.get("hospital")
elif 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:
hospital_id = self.user.hospital.id
@ -729,39 +737,81 @@ class SLAConfigForm(HospitalFieldMixin, forms.ModelForm):
class Meta:
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 = {
"hospital": forms.Select(attrs={"class": "form-select"}),
"source": forms.Select(attrs={"class": "form-select"}),
"severity": forms.Select(attrs={"class": "form-select"}),
"priority": forms.Select(attrs={"class": "form-select"}),
"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"}),
"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"}),
}
def clean(self):
cleaned_data = super().clean()
hospital = cleaned_data.get("hospital")
source = cleaned_data.get("source")
severity = cleaned_data.get("severity")
priority = cleaned_data.get("priority")
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
if sla_hours and sla_hours <= 0:
raise ValidationError({"sla_hours": "SLA hours must be greater than 0"})
# Validate reminder hours < SLA hours
if sla_hours and reminder_hours and reminder_hours >= sla_hours:
# Validate first reminder 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"})
# Check for unique combination (excluding current instance when editing)
if hospital and severity and priority:
queryset = ComplaintSLAConfig.objects.filter(hospital=hospital, severity=severity, priority=priority)
filters = {}
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:
queryset = queryset.exclude(pk=self.instance.pk)
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
@ -807,13 +857,27 @@ class EscalationRuleForm(HospitalFieldMixin, forms.ModelForm):
def __init__(self, *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
if self.user and self.user.is_px_admin():
self.fields["escalate_to_user"].queryset = User.objects.filter(is_active=True)
# Get hospital context
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:
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:
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":
# Handle form submission
form = ComplaintForm(request.POST, user=request.user)
form = ComplaintForm(request.POST, request=request)
if not form.is_valid():
# Debug: print form errors
@ -622,7 +622,7 @@ def complaint_create(request):
if hospital_id:
initial_data["hospital"] = hospital_id
form = ComplaintForm(user=request.user, initial=initial_data)
form = ComplaintForm(request=request, initial=initial_data)
context = {
"form": form,
@ -1449,7 +1449,7 @@ def inquiry_create(request):
base_layout = "layouts/source_user_base.html" if source_user else "layouts/base.html"
if request.method == "POST":
form = InquiryForm(request.POST, user=request.user)
form = InquiryForm(request.POST, request=request)
if form.is_valid():
try:
@ -1484,7 +1484,7 @@ def inquiry_create(request):
if hospital_id:
initial_data["hospital"] = hospital_id
form = InquiryForm(user=request.user, initial=initial_data)
form = InquiryForm(request=request, initial=initial_data)
context = {
"form": form,
@ -2249,7 +2249,7 @@ def sla_config_create(request):
return redirect("accounts:settings")
if request.method == "POST":
form = SLAConfigForm(request.POST, user=user)
form = SLAConfigForm(request.POST, request=request)
if form.is_valid():
sla_config = form.save()
@ -2273,7 +2273,7 @@ def sla_config_create(request):
else:
messages.error(request, "Please correct the errors below.")
else:
form = SLAConfigForm(user=user)
form = SLAConfigForm(request=request)
context = {
"form": form,
@ -2307,7 +2307,7 @@ def sla_config_edit(request, pk):
return redirect("complaints:sla_config_list")
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():
sla_config = form.save()
@ -2331,7 +2331,7 @@ def sla_config_edit(request, pk):
else:
messages.error(request, "Please correct the errors below.")
else:
form = SLAConfigForm(user=user, instance=sla_config)
form = SLAConfigForm(request=request, instance=sla_config)
context = {
"form": form,
@ -2452,7 +2452,7 @@ def escalation_rule_create(request):
return redirect("accounts:settings")
if request.method == "POST":
form = EscalationRuleForm(request.POST, user=user)
form = EscalationRuleForm(request.POST, request=request)
if form.is_valid():
escalation_rule = form.save()
@ -2475,7 +2475,7 @@ def escalation_rule_create(request):
else:
messages.error(request, "Please correct the errors below.")
else:
form = EscalationRuleForm(user=user)
form = EscalationRuleForm(request=request)
context = {
"form": form,
@ -2509,7 +2509,7 @@ def escalation_rule_edit(request, pk):
return redirect("complaints:escalation_rule_list")
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():
escalation_rule = form.save()
@ -2532,7 +2532,7 @@ def escalation_rule_edit(request, pk):
else:
messages.error(request, "Please correct the errors below.")
else:
form = EscalationRuleForm(user=user, instance=escalation_rule)
form = EscalationRuleForm(request=request, instance=escalation_rule)
context = {
"form": form,
@ -2652,7 +2652,7 @@ def complaint_threshold_create(request):
return redirect("accounts:settings")
if request.method == "POST":
form = ComplaintThresholdForm(request.POST, user=user)
form = ComplaintThresholdForm(request.POST, request=request)
if form.is_valid():
threshold = form.save()
@ -2675,7 +2675,7 @@ def complaint_threshold_create(request):
else:
messages.error(request, "Please correct the errors below.")
else:
form = ComplaintThresholdForm(user=user)
form = ComplaintThresholdForm(request=request)
context = {
"form": form,
@ -2709,7 +2709,7 @@ def complaint_threshold_edit(request, pk):
return redirect("complaints:complaint_threshold_list")
if request.method == "POST":
form = ComplaintThresholdForm(request.POST, user=user, instance=threshold)
form = ComplaintThresholdForm(request.POST, request=request, instance=threshold)
if form.is_valid():
threshold = form.save()
@ -2732,7 +2732,7 @@ def complaint_threshold_edit(request, pk):
else:
messages.error(request, "Please correct the errors below.")
else:
form = ComplaintThresholdForm(user=user, instance=threshold)
form = ComplaintThresholdForm(request=request, instance=threshold)
context = {
"form": form,

View File

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

View File

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

View File

@ -2072,7 +2072,7 @@ class Command(BaseCommand):
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:
self.stdout.write(f" ✓ Would create: HIS Integration")

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
"""
Integrations UI Views
"""
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
@ -18,14 +19,13 @@ class SurveyTemplateMappingViewSet(viewsets.ModelViewSet):
Provides CRUD operations for mapping patient types to survey templates.
"""
queryset = SurveyTemplateMapping.objects.select_related(
'hospital', 'survey_template'
).all()
queryset = SurveyTemplateMapping.objects.select_related("hospital", "survey_template").all()
serializer_class = SurveyTemplateMappingSerializer
filterset_fields = ['hospital', 'patient_type', 'is_active']
search_fields = ['hospital__name', 'patient_type', 'survey_template__name']
ordering_fields = ['hospital', 'patient_type', 'is_active']
ordering = ['hospital', 'patient_type', 'is_active']
filterset_fields = ["hospital", "patient_type", "is_active"]
search_fields = ["hospital__name", "patient_type", "survey_template__name"]
ordering_fields = ["hospital", "patient_type", "is_active"]
ordering = ["hospital", "patient_type", "is_active"]
def get_queryset(self):
"""
@ -34,73 +34,71 @@ class SurveyTemplateMappingViewSet(viewsets.ModelViewSet):
queryset = super().get_queryset()
user = self.request.user
# Superusers and PX Admins see all mappings
if user.is_superuser or user.is_px_admin():
# Superusers see all mappings
if user.is_superuser:
return queryset
# PX Admins filter by session hospital
if user.is_px_admin():
tenant_hospital = getattr(self.request, "tenant_hospital", None)
if tenant_hospital:
return queryset.filter(hospital=tenant_hospital)
# If no session hospital, show all (for management)
return queryset
# Hospital users filter by their assigned hospital
if user.hospital:
queryset = queryset.filter(hospital=user.hospital)
else:
# User without hospital assignment - no access
queryset = queryset.none()
if hasattr(user, "hospital") and user.hospital:
return queryset.filter(hospital=user.hospital)
return queryset
# User without hospital assignment - no access
return queryset.none()
def perform_create(self, serializer):
"""Add created_by information"""
serializer.save()
@action(detail=False, methods=['get'])
@action(detail=False, methods=["get"])
def by_hospital(self, request):
"""
Get all mappings for a specific hospital.
Query param: hospital_id
"""
hospital_id = request.query_params.get('hospital_id')
hospital_id = request.query_params.get("hospital_id")
if not hospital_id:
return Response(
{'error': 'hospital_id parameter is required'},
status=status.HTTP_400_BAD_REQUEST
)
return Response({"error": "hospital_id parameter is required"}, status=status.HTTP_400_BAD_REQUEST)
mappings = self.get_queryset().filter(hospital_id=hospital_id)
serializer = self.get_serializer(mappings, many=True)
return Response(serializer.data)
@action(detail=False, methods=['get'])
@action(detail=False, methods=["get"])
def by_patient_type(self, request):
"""
Get mapping for a specific patient type and hospital.
Query params: hospital_id, patient_type_code
"""
hospital_id = request.query_params.get('hospital_id')
patient_type_code = request.query_params.get('patient_type_code')
hospital_id = request.query_params.get("hospital_id")
patient_type_code = request.query_params.get("patient_type_code")
if not hospital_id or not patient_type_code:
return Response(
{'error': 'hospital_id and patient_type_code parameters are required'},
status=status.HTTP_400_BAD_REQUEST
{"error": "hospital_id and patient_type_code parameters are required"},
status=status.HTTP_400_BAD_REQUEST,
)
mapping = SurveyTemplateMapping.get_template_for_patient_type(
patient_type_code, hospital_id
)
mapping = SurveyTemplateMapping.get_template_for_patient_type(patient_type_code, hospital_id)
if not mapping:
return Response(
{'error': 'No mapping found'},
status=status.HTTP_404_NOT_FOUND
)
return Response({"error": "No mapping found"}, status=status.HTTP_404_NOT_FOUND)
serializer = self.get_serializer(mapping)
return Response(serializer.data)
@action(detail=False, methods=['post'])
@action(detail=False, methods=["post"])
def bulk_create(self, request):
"""
Create multiple mappings at once.
@ -118,13 +116,10 @@ class SurveyTemplateMappingViewSet(viewsets.ModelViewSet):
]
}
"""
mappings_data = request.data.get('mappings', [])
mappings_data = request.data.get("mappings", [])
if not mappings_data:
return Response(
{'error': 'mappings array is required'},
status=status.HTTP_400_BAD_REQUEST
)
return Response({"error": "mappings array is required"}, status=status.HTTP_400_BAD_REQUEST)
created_mappings = []
for mapping_data in mappings_data:
@ -134,8 +129,7 @@ class SurveyTemplateMappingViewSet(viewsets.ModelViewSet):
created_mappings.append(serializer.data)
return Response(
{'created': len(created_mappings), 'mappings': created_mappings},
status=status.HTTP_201_CREATED
{"created": len(created_mappings), "mappings": created_mappings}, status=status.HTTP_201_CREATED
)
@ -150,14 +144,21 @@ def survey_mapping_settings(request):
user = request.user
# Determine user's hospital context
user_hospital = None
if user.is_px_admin():
user_hospital = getattr(request, "tenant_hospital", None)
elif user.hospital:
user_hospital = user.hospital
# Get user's accessible hospitals based on role
if user.is_superuser:
# Superusers can see all hospitals
hospitals = Hospital.objects.filter(status='active')
hospitals = Hospital.objects.filter(status="active")
elif user.is_px_admin():
# PX Admins see all active hospitals for the dropdown
# They use session-based hospital selection (request.tenant_hospital)
hospitals = Hospital.objects.filter(status='active')
hospitals = Hospital.objects.filter(status="active")
elif user.hospital:
# Regular users can only see their assigned hospital
hospitals = Hospital.objects.filter(id=user.hospital.id)
@ -167,18 +168,22 @@ def survey_mapping_settings(request):
# Get all mappings based on user role
if user.is_superuser:
mappings = SurveyTemplateMapping.objects.select_related(
'hospital', 'survey_template'
).all()
# Superusers see all mappings
mappings = SurveyTemplateMapping.objects.select_related("hospital", "survey_template").all()
elif user.is_px_admin():
# PX Admins see mappings for all hospitals (they manage all)
mappings = SurveyTemplateMapping.objects.select_related(
'hospital', 'survey_template'
).all()
# PX Admins filter by session hospital
if user_hospital:
mappings = SurveyTemplateMapping.objects.filter(hospital=user_hospital).select_related(
"hospital", "survey_template"
)
else:
mappings = SurveyTemplateMapping.objects.filter(
hospital__in=hospitals
).select_related('hospital', 'survey_template')
# No session hospital - show all (for management)
mappings = SurveyTemplateMapping.objects.select_related("hospital", "survey_template").all()
else:
# Regular users see only their hospital's mappings
mappings = SurveyTemplateMapping.objects.filter(hospital__in=hospitals).select_related(
"hospital", "survey_template"
)
# Group mappings by hospital
mappings_by_hospital = {}
@ -192,10 +197,11 @@ def survey_mapping_settings(request):
mappings_by_hospital[hospital_name].append(mapping)
context = {
'hospitals': hospitals,
'mappings': mappings,
'mappings_by_hospital': mappings_by_hospital,
'page_title': _('Survey Template Mappings'),
"hospitals": hospitals,
"mappings": mappings,
"mappings_by_hospital": mappings_by_hospital,
"page_title": _("Survey Template Mappings"),
"user_hospital": user_hospital,
}
return render(request, 'integrations/survey_mapping_settings.html', context)
return render(request, "integrations/survey_mapping_settings.html", context)

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -4,6 +4,7 @@ Physician Rating Import Views
UI views for manual CSV upload of doctor ratings.
Similar to HIS Patient Import flow.
"""
import csv
import io
import logging
@ -45,22 +46,22 @@ def doctor_rating_import(request):
# Check permission
if not user.is_px_admin() and not user.is_hospital_admin():
messages.error(request, "You don't have permission to import doctor ratings.")
return redirect('physicians:physician_list')
return redirect("physicians:physician_list")
# Session storage for imported ratings
session_key = f'doctor_rating_import_{user.id}'
session_key = f"doctor_rating_import_{user.id}"
if request.method == 'POST':
form = DoctorRatingImportForm(request.POST, request.FILES, user=user)
if request.method == "POST":
form = DoctorRatingImportForm(request.POST, request.FILES, request=request)
if form.is_valid():
try:
hospital = form.cleaned_data['hospital']
csv_file = form.cleaned_data['csv_file']
skip_rows = form.cleaned_data['skip_header_rows']
hospital = form.cleaned_data["hospital"]
csv_file = form.cleaned_data["csv_file"]
skip_rows = form.cleaned_data["skip_header_rows"]
# Parse CSV
decoded_file = csv_file.read().decode('utf-8-sig')
decoded_file = csv_file.read().decode("utf-8-sig")
io_string = io.StringIO(decoded_file)
reader = csv.reader(io_string)
@ -72,39 +73,39 @@ def doctor_rating_import(request):
header = next(reader, None)
if not header:
messages.error(request, "CSV file is empty or has no data rows.")
return render(request, 'physicians/doctor_rating_import.html', {'form': form})
return render(request, "physicians/doctor_rating_import.html", {"form": form})
# Find column indices (handle different possible column names)
header = [h.strip().lower() for h in header]
col_map = {
'uhid': _find_column(header, ['uhid', 'file number', 'file_number', 'mrn', 'patient id']),
'patient_name': _find_column(header, ['patient name', 'patient_name', 'name']),
'gender': _find_column(header, ['gender', 'sex']),
'age': _find_column(header, ['full age', 'age', 'years']),
'nationality': _find_column(header, ['nationality', 'country']),
'mobile_no': _find_column(header, ['mobile no', 'mobile_no', 'mobile', 'phone', 'contact']),
'patient_type': _find_column(header, ['patient type', 'patient_type', 'type', 'visit type']),
'admit_date': _find_column(header, ['admit date', 'admit_date', 'admission date', 'visit date']),
'discharge_date': _find_column(header, ['discharge date', 'discharge_date']),
'doctor_name': _find_column(header, ['doctor name', 'doctor_name', 'physician name', 'physician']),
'rating': _find_column(header, ['rating', 'score', 'rate']),
'feedback': _find_column(header, ['feed back', 'feedback', 'comments', 'comment']),
'rating_date': _find_column(header, ['rating date', 'rating_date', 'date']),
'department': _find_column(header, ['department', 'dept', 'specialty']),
"uhid": _find_column(header, ["uhid", "file number", "file_number", "mrn", "patient id"]),
"patient_name": _find_column(header, ["patient name", "patient_name", "name"]),
"gender": _find_column(header, ["gender", "sex"]),
"age": _find_column(header, ["full age", "age", "years"]),
"nationality": _find_column(header, ["nationality", "country"]),
"mobile_no": _find_column(header, ["mobile no", "mobile_no", "mobile", "phone", "contact"]),
"patient_type": _find_column(header, ["patient type", "patient_type", "type", "visit type"]),
"admit_date": _find_column(header, ["admit date", "admit_date", "admission date", "visit date"]),
"discharge_date": _find_column(header, ["discharge date", "discharge_date"]),
"doctor_name": _find_column(header, ["doctor name", "doctor_name", "physician name", "physician"]),
"rating": _find_column(header, ["rating", "score", "rate"]),
"feedback": _find_column(header, ["feed back", "feedback", "comments", "comment"]),
"rating_date": _find_column(header, ["rating date", "rating_date", "date"]),
"department": _find_column(header, ["department", "dept", "specialty"]),
}
# Check required columns
if col_map['uhid'] is None:
if col_map["uhid"] is None:
messages.error(request, "Could not find 'UHID' column in CSV.")
return render(request, 'physicians/doctor_rating_import.html', {'form': form})
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.")
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.")
return render(request, 'physicians/doctor_rating_import.html', {'form': form})
return render(request, "physicians/doctor_rating_import.html", {"form": form})
# Process data rows
imported_ratings = []
@ -124,10 +125,10 @@ def doctor_rating_import(request):
continue
# Extract data
uhid = _get_cell(row, col_map['uhid'], '').strip()
patient_name = _get_cell(row, col_map['patient_name'], '').strip()
doctor_name_raw = _get_cell(row, col_map['doctor_name'], '').strip()
rating_str = _get_cell(row, col_map['rating'], '').strip()
uhid = _get_cell(row, col_map["uhid"], "").strip()
patient_name = _get_cell(row, col_map["patient_name"], "").strip()
doctor_name_raw = _get_cell(row, col_map["doctor_name"], "").strip()
rating_str = _get_cell(row, col_map["rating"], "").strip()
# Skip if missing required fields
if not uhid or not doctor_name_raw:
@ -144,39 +145,41 @@ def doctor_rating_import(request):
continue
# Extract optional fields
gender = _get_cell(row, col_map['gender'], '').strip()
age = _get_cell(row, col_map['age'], '').strip()
nationality = _get_cell(row, col_map['nationality'], '').strip()
mobile_no = _get_cell(row, col_map['mobile_no'], '').strip()
patient_type = _get_cell(row, col_map['patient_type'], '').strip()
admit_date = _get_cell(row, col_map['admit_date'], '').strip()
discharge_date = _get_cell(row, col_map['discharge_date'], '').strip()
feedback = _get_cell(row, col_map['feedback'], '').strip()
rating_date = _get_cell(row, col_map['rating_date'], '').strip()
department = _get_cell(row, col_map['department'], '').strip() or current_department
gender = _get_cell(row, col_map["gender"], "").strip()
age = _get_cell(row, col_map["age"], "").strip()
nationality = _get_cell(row, col_map["nationality"], "").strip()
mobile_no = _get_cell(row, col_map["mobile_no"], "").strip()
patient_type = _get_cell(row, col_map["patient_type"], "").strip()
admit_date = _get_cell(row, col_map["admit_date"], "").strip()
discharge_date = _get_cell(row, col_map["discharge_date"], "").strip()
feedback = _get_cell(row, col_map["feedback"], "").strip()
rating_date = _get_cell(row, col_map["rating_date"], "").strip()
department = _get_cell(row, col_map["department"], "").strip() or current_department
# Parse doctor name to extract ID
doctor_id, doctor_name_clean = DoctorRatingAdapter.parse_doctor_name(doctor_name_raw)
imported_ratings.append({
'row_num': row_num,
'uhid': uhid,
'patient_name': patient_name,
'doctor_name_raw': doctor_name_raw,
'doctor_id': doctor_id,
'doctor_name': doctor_name_clean,
'rating': rating,
'gender': gender,
'age': age,
'nationality': nationality,
'mobile_no': mobile_no,
'patient_type': patient_type,
'admit_date': admit_date,
'discharge_date': discharge_date,
'feedback': feedback,
'rating_date': rating_date,
'department': department,
})
imported_ratings.append(
{
"row_num": row_num,
"uhid": uhid,
"patient_name": patient_name,
"doctor_name_raw": doctor_name_raw,
"doctor_id": doctor_id,
"doctor_name": doctor_name_clean,
"rating": rating,
"gender": gender,
"age": age,
"nationality": nationality,
"mobile_no": mobile_no,
"patient_type": patient_type,
"admit_date": admit_date,
"discharge_date": discharge_date,
"feedback": feedback,
"rating_date": rating_date,
"department": department,
}
)
except Exception as e:
errors.append(f"Row {row_num}: {str(e)}")
@ -184,30 +187,30 @@ def doctor_rating_import(request):
# Store in session for review step
request.session[session_key] = {
'hospital_id': str(hospital.id),
'ratings': imported_ratings,
'errors': errors,
'total_count': len(imported_ratings)
"hospital_id": str(hospital.id),
"ratings": imported_ratings,
"errors": errors,
"total_count": len(imported_ratings),
}
# Log audit
AuditService.log_event(
event_type='doctor_rating_csv_import',
event_type="doctor_rating_csv_import",
description=f"Parsed {len(imported_ratings)} doctor ratings from CSV by {user.get_full_name()}",
user=user,
metadata={
'hospital': hospital.name,
'total_count': len(imported_ratings),
'error_count': len(errors)
}
"hospital": hospital.name,
"total_count": len(imported_ratings),
"error_count": len(errors),
},
)
if imported_ratings:
messages.success(
request,
f"Successfully parsed {len(imported_ratings)} doctor rating records. Please review before importing."
f"Successfully parsed {len(imported_ratings)} doctor rating records. Please review before importing.",
)
return redirect('physicians:doctor_rating_review')
return redirect("physicians:doctor_rating_review")
else:
messages.error(request, "No valid doctor rating records found in CSV.")
@ -215,12 +218,12 @@ def doctor_rating_import(request):
logger.error(f"Error processing Doctor Rating CSV: {str(e)}", exc_info=True)
messages.error(request, f"Error processing CSV: {str(e)}")
else:
form = DoctorRatingImportForm(user=user)
form = DoctorRatingImportForm(request=request)
context = {
'form': form,
"form": form,
}
return render(request, 'physicians/doctor_rating_import.html', context)
return render(request, "physicians/doctor_rating_import.html", context)
@login_required
@ -229,29 +232,27 @@ def doctor_rating_review(request):
Review imported doctor ratings before creating records.
"""
user = request.user
session_key = f'doctor_rating_import_{user.id}'
session_key = f"doctor_rating_import_{user.id}"
import_data = request.session.get(session_key)
if not import_data:
messages.error(request, "No import data found. Please upload CSV first.")
return redirect('physicians:doctor_rating_import')
return redirect("physicians:doctor_rating_import")
hospital = get_object_or_404(Hospital, id=import_data['hospital_id'])
ratings = import_data['ratings']
errors = import_data.get('errors', [])
hospital = get_object_or_404(Hospital, id=import_data["hospital_id"])
ratings = import_data["ratings"]
errors = import_data.get("errors", [])
# Check for staff matches
for r in ratings:
staff = DoctorRatingAdapter.find_staff_by_doctor_id(
r['doctor_id'], hospital, r['doctor_name']
)
r['staff_matched'] = staff is not None
r['staff_name'] = staff.get_full_name() if staff else None
staff = DoctorRatingAdapter.find_staff_by_doctor_id(r["doctor_id"], hospital, r["doctor_name"])
r["staff_matched"] = staff is not None
r["staff_name"] = staff.get_full_name() if staff else None
if request.method == 'POST':
action = request.POST.get('action')
if request.method == "POST":
action = request.POST.get("action")
if action == 'import':
if action == "import":
# Queue bulk import job
job = DoctorRatingImportJob.objects.create(
name=f"CSV Import - {hospital.name} - {len(ratings)} ratings",
@ -260,7 +261,7 @@ def doctor_rating_review(request):
created_by=user,
hospital=hospital,
total_records=len(ratings),
raw_data=ratings
raw_data=ratings,
)
# Queue the background task
@ -268,45 +269,38 @@ def doctor_rating_review(request):
# Log audit
AuditService.log_event(
event_type='doctor_rating_import_queued',
event_type="doctor_rating_import_queued",
description=f"Queued {len(ratings)} doctor ratings for import",
user=user,
metadata={
'job_id': str(job.id),
'hospital': hospital.name,
'total_records': len(ratings)
}
metadata={"job_id": str(job.id), "hospital": hospital.name, "total_records": len(ratings)},
)
# Clear session
del request.session[session_key]
messages.success(
request,
f"Import job queued for {len(ratings)} ratings. You can check the status below."
)
return redirect('physicians:doctor_rating_job_status', job_id=job.id)
messages.success(request, f"Import job queued for {len(ratings)} ratings. You can check the status below.")
return redirect("physicians:doctor_rating_job_status", job_id=job.id)
elif action == 'cancel':
elif action == "cancel":
del request.session[session_key]
messages.info(request, "Import cancelled.")
return redirect('physicians:doctor_rating_import')
return redirect("physicians:doctor_rating_import")
# Pagination
paginator = Paginator(ratings, 50)
page_number = request.GET.get('page')
page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number)
context = {
'hospital': hospital,
'ratings': ratings,
'page_obj': page_obj,
'errors': errors,
'total_count': len(ratings),
'matched_count': sum(1 for r in ratings if r['staff_matched']),
'unmatched_count': sum(1 for r in ratings if not r['staff_matched']),
"hospital": hospital,
"ratings": ratings,
"page_obj": page_obj,
"errors": errors,
"total_count": len(ratings),
"matched_count": sum(1 for r in ratings if r["staff_matched"]),
"unmatched_count": sum(1 for r in ratings if not r["staff_matched"]),
}
return render(request, 'physicians/doctor_rating_review.html', context)
return render(request, "physicians/doctor_rating_review.html", context)
@login_required
@ -320,7 +314,7 @@ def doctor_rating_job_status(request, job_id):
# Check permission
if not user.is_px_admin() and job.hospital != user.hospital:
messages.error(request, "You don't have permission to view this job.")
return redirect('physicians:physician_list')
return redirect("physicians:physician_list")
# Calculate progress circle stroke-dashoffset
# Circle circumference is 326.73 (2 * pi * r, where r=52)
@ -331,13 +325,13 @@ def doctor_rating_job_status(request, job_id):
stroke_dashoffset = circumference * (1 - progress / 100)
context = {
'job': job,
'progress': progress,
'stroke_dashoffset': stroke_dashoffset,
'is_complete': job.is_complete,
'results': job.results,
"job": job,
"progress": progress,
"stroke_dashoffset": stroke_dashoffset,
"is_complete": job.is_complete,
"results": job.results,
}
return render(request, 'physicians/doctor_rating_job_status.html', context)
return render(request, "physicians/doctor_rating_job_status.html", context)
@login_required
@ -355,12 +349,12 @@ def doctor_rating_job_list(request):
else:
jobs = DoctorRatingImportJob.objects.filter(created_by=user)
jobs = jobs.order_by('-created_at')[:50] # Last 50 jobs
jobs = jobs.order_by("-created_at")[:50] # Last 50 jobs
context = {
'jobs': jobs,
"jobs": jobs,
}
return render(request, 'physicians/doctor_rating_job_list.html', context)
return render(request, "physicians/doctor_rating_job_list.html", context)
@login_required
@ -371,9 +365,7 @@ def individual_ratings_list(request):
user = request.user
# Base queryset
queryset = PhysicianIndividualRating.objects.select_related(
'hospital', 'staff', 'staff__department'
)
queryset = PhysicianIndividualRating.objects.select_related("hospital", "staff", "staff__department")
# Apply RBAC
if not user.is_px_admin():
@ -383,13 +375,13 @@ def individual_ratings_list(request):
queryset = queryset.none()
# Filters
hospital_id = request.GET.get('hospital')
doctor_id = request.GET.get('doctor_id')
rating_min = request.GET.get('rating_min')
rating_max = request.GET.get('rating_max')
date_from = request.GET.get('date_from')
date_to = request.GET.get('date_to')
source = request.GET.get('source')
hospital_id = request.GET.get("hospital")
doctor_id = request.GET.get("doctor_id")
rating_min = request.GET.get("rating_min")
rating_max = request.GET.get("rating_max")
date_from = request.GET.get("date_from")
date_to = request.GET.get("date_to")
source = request.GET.get("source")
if hospital_id:
queryset = queryset.filter(hospital_id=hospital_id)
@ -407,41 +399,43 @@ def individual_ratings_list(request):
queryset = queryset.filter(source=source)
# Ordering
queryset = queryset.order_by('-rating_date')
queryset = queryset.order_by("-rating_date")
# Pagination
paginator = Paginator(queryset, 25)
page_number = request.GET.get('page')
page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number)
# Get hospitals for filter
from apps.organizations.models import Hospital
if user.is_px_admin():
hospitals = Hospital.objects.filter(status='active')
hospitals = Hospital.objects.filter(status="active")
else:
hospitals = Hospital.objects.filter(id=user.hospital.id) if user.hospital else Hospital.objects.none()
context = {
'page_obj': page_obj,
'hospitals': hospitals,
'sources': PhysicianIndividualRating.RatingSource.choices,
'filters': {
'hospital': hospital_id,
'doctor_id': doctor_id,
'rating_min': rating_min,
'rating_max': rating_max,
'date_from': date_from,
'date_to': date_to,
'source': source,
"page_obj": page_obj,
"hospitals": hospitals,
"sources": PhysicianIndividualRating.RatingSource.choices,
"filters": {
"hospital": hospital_id,
"doctor_id": doctor_id,
"rating_min": rating_min,
"rating_max": rating_max,
"date_from": date_from,
"date_to": date_to,
"source": source,
},
}
}
return render(request, 'physicians/individual_ratings_list.html', context)
return render(request, "physicians/individual_ratings_list.html", context)
# ============================================================================
# Helper Functions
# ============================================================================
def _find_column(header, possible_names):
"""Find column index by possible names."""
for name in possible_names:
@ -451,7 +445,7 @@ def _find_column(header, possible_names):
return None
def _get_cell(row, index, default=''):
def _get_cell(row, index, default=""):
"""Safely get cell value."""
if index is None or index >= len(row):
return default
@ -476,9 +470,9 @@ def _is_department_header(row, col_map):
# Check if other important columns are empty
# If UHID, Doctor Name, Rating are all empty, it's likely a header
uhid = _get_cell(row, col_map.get('uhid'), '').strip()
doctor_name = _get_cell(row, col_map.get('doctor_name'), '').strip()
rating = _get_cell(row, col_map.get('rating'), '').strip()
uhid = _get_cell(row, col_map.get("uhid"), "").strip()
doctor_name = _get_cell(row, col_map.get("doctor_name"), "").strip()
rating = _get_cell(row, col_map.get("rating"), "").strip()
# If these key fields are empty but first column has text, it's a department header
if not uhid and not doctor_name and not rating:
@ -491,6 +485,7 @@ def _is_department_header(row, col_map):
# AJAX Endpoints
# ============================================================================
@login_required
def api_job_progress(request, job_id):
"""AJAX endpoint to get job progress."""
@ -499,18 +494,20 @@ def api_job_progress(request, job_id):
# Check permission
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({
'job_id': str(job.id),
'status': job.status,
'progress_percentage': job.progress_percentage,
'processed_count': job.processed_count,
'total_records': job.total_records,
'success_count': job.success_count,
'failed_count': job.failed_count,
'is_complete': job.is_complete,
})
return JsonResponse(
{
"job_id": str(job.id),
"status": job.status,
"progress_percentage": job.progress_percentage,
"processed_count": job.processed_count,
"total_records": job.total_records,
"success_count": job.success_count,
"failed_count": job.failed_count,
"is_complete": job.is_complete,
}
)
@login_required
@ -523,38 +520,31 @@ def api_match_doctor(request):
- doctor_name: The doctor name
- staff_id: The staff ID to match to
"""
if request.method != 'POST':
return JsonResponse({'error': 'POST required'}, status=405)
if request.method != "POST":
return JsonResponse({"error": "POST required"}, status=405)
user = request.user
doctor_id = request.POST.get('doctor_id')
doctor_name = request.POST.get('doctor_name')
staff_id = request.POST.get('staff_id')
doctor_id = request.POST.get("doctor_id")
doctor_name = request.POST.get("doctor_name")
staff_id = request.POST.get("staff_id")
if not staff_id:
return JsonResponse({'error': 'staff_id required'}, status=400)
return JsonResponse({"error": "staff_id required"}, status=400)
try:
staff = Staff.objects.get(id=staff_id)
# Check permission
if not user.is_px_admin() and staff.hospital != user.hospital:
return JsonResponse({'error': 'Permission denied'}, status=403)
return JsonResponse({"error": "Permission denied"}, status=403)
# Update all unaggregated ratings for this doctor
count = PhysicianIndividualRating.objects.filter(
doctor_id=doctor_id,
is_aggregated=False
).update(staff=staff)
count = PhysicianIndividualRating.objects.filter(doctor_id=doctor_id, is_aggregated=False).update(staff=staff)
return JsonResponse({
'success': True,
'matched_count': count,
'staff_name': staff.get_full_name()
})
return JsonResponse({"success": True, "matched_count": count, "staff_name": staff.get_full_name()})
except Staff.DoesNotExist:
return JsonResponse({'error': 'Staff not found'}, status=404)
return JsonResponse({"error": "Staff not found"}, status=404)
except Exception as e:
logger.error(f"Error matching doctor: {str(e)}", exc_info=True)
return JsonResponse({'error': str(e)}, status=500)
return JsonResponse({"error": str(e)}, status=500)

View File

@ -93,6 +93,59 @@ def physician_list(request):
for physician in page_obj.object_list:
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")
if not user.is_px_admin() and 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)
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
@ -149,6 +202,15 @@ def physician_ratings_dashboard(request):
years = list(range(2024, current_year + 1))
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 = {
"years": years,
"departments": departments,
@ -470,6 +532,78 @@ def specialization_overview(request):
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
def department_overview(request):
"""

View File

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

View File

@ -4,6 +4,7 @@ QI Projects Console UI views
Provides full CRUD functionality for Quality Improvement projects,
task management, and template handling.
"""
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
@ -15,7 +16,7 @@ from apps.core.decorators import block_source_user
from apps.organizations.models import Hospital
from apps.px_action_center.models import PXAction
from .forms import ConvertToProjectForm, QIProjectForm, QIProjectTaskForm, QIProjectTemplateForm
from .forms import ConvertToProjectForm, QIProjectForm, QIProjectTaskForm, QIProjectTemplateForm, TaskTemplateFormSet
from .models import QIProject, QIProjectTask
@ -24,14 +25,16 @@ from .models import QIProject, QIProjectTask
def project_list(request):
"""QI Projects list view with filtering and pagination"""
# Exclude templates from the list
queryset = QIProject.objects.filter(is_template=False).select_related(
'hospital', 'department', 'project_lead'
).prefetch_related('team_members', 'related_actions')
queryset = (
QIProject.objects.filter(is_template=False)
.select_related("hospital", "department", "project_lead")
.prefetch_related("team_members", "related_actions")
)
# Apply RBAC filters
user = request.user
# Get selected hospital for PX Admins (from middleware)
selected_hospital = getattr(request, 'tenant_hospital', None)
selected_hospital = getattr(request, "tenant_hospital", None)
if user.is_px_admin():
# PX Admins see all, but filter by selected hospital if set
@ -41,53 +44,53 @@ def project_list(request):
queryset = queryset.filter(hospital=user.hospital)
# Apply filters
status_filter = request.GET.get('status')
status_filter = request.GET.get("status")
if status_filter:
queryset = queryset.filter(status=status_filter)
hospital_filter = request.GET.get('hospital')
hospital_filter = request.GET.get("hospital")
if hospital_filter:
queryset = queryset.filter(hospital_id=hospital_filter)
# Search
search_query = request.GET.get('search')
search_query = request.GET.get("search")
if search_query:
queryset = queryset.filter(
Q(name__icontains=search_query) |
Q(description__icontains=search_query) |
Q(name_ar__icontains=search_query)
Q(name__icontains=search_query)
| Q(description__icontains=search_query)
| Q(name_ar__icontains=search_query)
)
# Ordering
queryset = queryset.order_by('-created_at')
queryset = queryset.order_by("-created_at")
# Pagination
page_size = int(request.GET.get('page_size', 25))
page_size = int(request.GET.get("page_size", 25))
paginator = Paginator(queryset, page_size)
page_number = request.GET.get('page', 1)
page_number = request.GET.get("page", 1)
page_obj = paginator.get_page(page_number)
# Get hospitals for filter
hospitals = Hospital.objects.filter(status='active')
hospitals = Hospital.objects.filter(status="active")
if not user.is_px_admin() and user.hospital:
hospitals = hospitals.filter(id=user.hospital.id)
# Statistics
stats = {
'total': queryset.count(),
'active': queryset.filter(status='active').count(),
'completed': queryset.filter(status='completed').count(),
'pending': queryset.filter(status='pending').count(),
"total": queryset.count(),
"active": queryset.filter(status="active").count(),
"completed": queryset.filter(status="completed").count(),
"pending": queryset.filter(status="pending").count(),
}
context = {
'page_obj': page_obj,
'projects': page_obj.object_list,
'stats': stats,
'filters': request.GET,
"page_obj": page_obj,
"projects": page_obj.object_list,
"stats": stats,
"filters": request.GET,
}
return render(request, 'projects/project_list.html', context)
return render(request, "projects/project_list.html", context)
@block_source_user
@ -95,34 +98,32 @@ def project_list(request):
def project_detail(request, pk):
"""QI Project detail view with task management"""
project = get_object_or_404(
QIProject.objects.filter(is_template=False).select_related(
'hospital', 'department', 'project_lead'
).prefetch_related(
'team_members', 'related_actions', 'tasks'
),
pk=pk
QIProject.objects.filter(is_template=False)
.select_related("hospital", "department", "project_lead")
.prefetch_related("team_members", "related_actions", "tasks"),
pk=pk,
)
# Check permission
user = request.user
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
messages.error(request, _("You don't have permission to view this project."))
return redirect('projects:project_list')
return redirect("projects:project_list")
# Get tasks
tasks = project.tasks.all().order_by('order', 'created_at')
tasks = project.tasks.all().order_by("order", "created_at")
# Get related actions
related_actions = project.related_actions.all()
context = {
'project': project,
'tasks': tasks,
'related_actions': related_actions,
'can_edit': user.is_px_admin() or user.is_hospital_admin or user.is_department_manager,
"project": project,
"tasks": tasks,
"related_actions": related_actions,
"can_edit": user.is_px_admin() or user.is_hospital_admin or user.is_department_manager,
}
return render(request, 'projects/project_detail.html', context)
return render(request, "projects/project_detail.html", context)
@block_source_user
@ -134,10 +135,10 @@ def project_create(request, template_pk=None):
# Check permission (PX Admin, Hospital Admin, or Department Manager)
if not (user.is_px_admin() or user.is_hospital_admin or user.is_department_manager):
messages.error(request, _("You don't have permission to create projects."))
return redirect('projects:project_list')
return redirect("projects:project_list")
# Check for template parameter (from URL or GET)
template_id = template_pk or request.GET.get('template')
template_id = template_pk or request.GET.get("template")
initial_data = {}
template = None
@ -145,36 +146,36 @@ def project_create(request, template_pk=None):
try:
template = QIProject.objects.get(pk=template_id, is_template=True)
initial_data = {
'name': template.name,
'name_ar': template.name_ar,
'description': template.description,
'department': template.department,
'target_completion_date': template.target_completion_date,
"name": template.name,
"name_ar": template.name_ar,
"description": template.description,
"department": template.department,
"target_completion_date": template.target_completion_date,
}
if not user.is_px_admin() and user.hospital:
initial_data['hospital'] = user.hospital
initial_data["hospital"] = user.hospital
except QIProject.DoesNotExist:
pass
# Check for PX Action parameter (convert action to project)
action_id = request.GET.get('action')
action_id = request.GET.get("action")
if action_id:
try:
action = PXAction.objects.get(pk=action_id)
initial_data['name'] = f"QI Project: {action.title}"
initial_data['description'] = action.description
initial_data["name"] = f"QI Project: {action.title}"
initial_data["description"] = action.description
if not user.is_px_admin() and user.hospital:
initial_data['hospital'] = user.hospital
initial_data["hospital"] = user.hospital
else:
initial_data['hospital'] = action.hospital
initial_data["hospital"] = action.hospital
except PXAction.DoesNotExist:
pass
if request.method == 'POST':
form = QIProjectForm(request.POST, user=user)
if request.method == "POST":
form = QIProjectForm(request.POST, request=request)
# Check for template in POST data (hidden field)
template_id_post = request.POST.get('template_id')
template_id_post = request.POST.get("template_id")
if template_id_post:
try:
template = QIProject.objects.get(pk=template_id_post, is_template=True)
@ -196,7 +197,7 @@ def project_create(request, template_pk=None):
title=template_task.title,
description=template_task.description,
order=template_task.order,
status='pending'
status="pending",
)
task_count += 1
@ -211,21 +212,21 @@ def project_create(request, template_pk=None):
if template and task_count > 0:
messages.success(
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:
messages.success(request, _("QI Project created successfully."))
return redirect('projects:project_detail', pk=project.pk)
return redirect("projects:project_detail", pk=project.pk)
else:
form = QIProjectForm(user=user, initial=initial_data)
form = QIProjectForm(request=request, initial=initial_data)
context = {
'form': form,
'is_create': True,
'template': template,
"form": form,
"is_create": True,
"template": template,
}
return render(request, 'projects/project_form.html', context)
return render(request, "projects/project_form.html", context)
@block_source_user
@ -238,29 +239,29 @@ def project_edit(request, pk):
# Check permission
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
messages.error(request, _("You don't have permission to edit this project."))
return redirect('projects:project_list')
return redirect("projects:project_list")
# Check edit permission (PX Admin, Hospital Admin, or Department Manager)
if not (user.is_px_admin() or user.is_hospital_admin or user.is_department_manager):
messages.error(request, _("You don't have permission to edit projects."))
return redirect('projects:project_detail', pk=project.pk)
return redirect("projects:project_detail", pk=project.pk)
if request.method == 'POST':
form = QIProjectForm(request.POST, instance=project, user=user)
if request.method == "POST":
form = QIProjectForm(request.POST, instance=project, request=request)
if form.is_valid():
form.save()
messages.success(request, _("QI Project updated successfully."))
return redirect('projects:project_detail', pk=project.pk)
return redirect("projects:project_detail", pk=project.pk)
else:
form = QIProjectForm(instance=project, user=user)
form = QIProjectForm(instance=project, request=request)
context = {
'form': form,
'project': project,
'is_create': False,
"form": form,
"project": project,
"is_create": False,
}
return render(request, 'projects/project_form.html', context)
return render(request, "projects/project_form.html", context)
@block_source_user
@ -273,23 +274,23 @@ def project_delete(request, pk):
# Check permission (only PX Admin or Hospital Admin can delete)
if not (user.is_px_admin() or user.is_hospital_admin):
messages.error(request, _("You don't have permission to delete projects."))
return redirect('projects:project_detail', pk=project.pk)
return redirect("projects:project_detail", pk=project.pk)
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
messages.error(request, _("You don't have permission to delete this project."))
return redirect('projects:project_list')
return redirect("projects:project_list")
if request.method == 'POST':
if request.method == "POST":
project_name = project.name
project.delete()
messages.success(request, _('Project "%(name)s" deleted successfully.') % {'name': project_name})
return redirect('projects:project_list')
messages.success(request, _('Project "%(name)s" deleted successfully.') % {"name": project_name})
return redirect("projects:project_list")
context = {
'project': project,
"project": project,
}
return render(request, 'projects/project_delete_confirm.html', context)
return render(request, "projects/project_delete_confirm.html", context)
@block_source_user
@ -302,21 +303,21 @@ def project_save_as_template(request, pk):
# Check permission (only PX Admin or Hospital Admin can create templates)
if not (user.is_px_admin() or user.is_hospital_admin):
messages.error(request, _("You don't have permission to create templates."))
return redirect('projects:project_detail', pk=project.pk)
return redirect("projects:project_detail", pk=project.pk)
# Check hospital access
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
messages.error(request, _("You don't have permission to create templates from this project."))
return redirect('projects:project_list')
return redirect("projects:project_list")
if request.method == 'POST':
template_name = request.POST.get('template_name', '').strip()
template_description = request.POST.get('template_description', '').strip()
make_global = request.POST.get('make_global') == 'on'
if request.method == "POST":
template_name = request.POST.get("template_name", "").strip()
template_description = request.POST.get("template_description", "").strip()
make_global = request.POST.get("make_global") == "on"
if not template_name:
messages.error(request, _('Please provide a template name.'))
return redirect('projects:project_save_as_template', pk=project.pk)
messages.error(request, _("Please provide a template name."))
return redirect("projects:project_save_as_template", pk=project.pk)
# Create template from project
template = QIProject.objects.create(
@ -327,8 +328,8 @@ def project_save_as_template(request, pk):
# If global, hospital is None; otherwise use project's hospital
hospital=None if make_global else project.hospital,
department=project.department,
status='pending', # Default status for templates
created_by=user
status="pending", # Default status for templates
created_by=user,
)
# Copy tasks from project to template
@ -338,30 +339,29 @@ def project_save_as_template(request, pk):
title=task.title,
description=task.description,
order=task.order,
status='pending' # Reset status for template
status="pending", # Reset status for template
)
messages.success(
request,
_('Template "%(name)s" created successfully with %(count)d task(s).') % {
'name': template_name,
'count': project.tasks.count()
}
_('Template "%(name)s" created successfully with %(count)d task(s).')
% {"name": template_name, "count": project.tasks.count()},
)
return redirect('projects:template_list')
return redirect("projects:template_list")
context = {
'project': project,
'suggested_name': f"Template: {project.name}",
"project": project,
"suggested_name": f"Template: {project.name}",
}
return render(request, 'projects/project_save_as_template.html', context)
return render(request, "projects/project_save_as_template.html", context)
# =============================================================================
# Task Management Views
# =============================================================================
@block_source_user
@login_required
def task_create(request, project_pk):
@ -372,26 +372,26 @@ def task_create(request, project_pk):
# Check permission
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
messages.error(request, _("You don't have permission to add tasks to this project."))
return redirect('projects:project_detail', pk=project.pk)
return redirect("projects:project_detail", pk=project.pk)
if request.method == 'POST':
if request.method == "POST":
form = QIProjectTaskForm(request.POST, project=project)
if form.is_valid():
task = form.save(commit=False)
task.project = project
task.save()
messages.success(request, _("Task added successfully."))
return redirect('projects:project_detail', pk=project.pk)
return redirect("projects:project_detail", pk=project.pk)
else:
form = QIProjectTaskForm(project=project)
context = {
'form': form,
'project': project,
'is_create': True,
"form": form,
"project": project,
"is_create": True,
}
return render(request, 'projects/task_form.html', context)
return render(request, "projects/task_form.html", context)
@block_source_user
@ -405,30 +405,31 @@ def task_edit(request, project_pk, task_pk):
# Check permission
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
messages.error(request, _("You don't have permission to edit tasks in this project."))
return redirect('projects:project_detail', pk=project.pk)
return redirect("projects:project_detail", pk=project.pk)
if request.method == 'POST':
if request.method == "POST":
form = QIProjectTaskForm(request.POST, instance=task, project=project)
if form.is_valid():
task = form.save()
# If status changed to completed, set completed_date
if task.status == 'completed' and not task.completed_date:
if task.status == "completed" and not task.completed_date:
from django.utils import timezone
task.completed_date = timezone.now().date()
task.save()
messages.success(request, _("Task updated successfully."))
return redirect('projects:project_detail', pk=project.pk)
return redirect("projects:project_detail", pk=project.pk)
else:
form = QIProjectTaskForm(instance=task, project=project)
context = {
'form': form,
'project': project,
'task': task,
'is_create': False,
"form": form,
"project": project,
"task": task,
"is_create": False,
}
return render(request, 'projects/task_form.html', context)
return render(request, "projects/task_form.html", context)
@block_source_user
@ -442,19 +443,19 @@ def task_delete(request, project_pk, task_pk):
# Check permission
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
messages.error(request, _("You don't have permission to delete tasks in this project."))
return redirect('projects:project_detail', pk=project.pk)
return redirect("projects:project_detail", pk=project.pk)
if request.method == 'POST':
if request.method == "POST":
task.delete()
messages.success(request, _("Task deleted successfully."))
return redirect('projects:project_detail', pk=project.pk)
return redirect("projects:project_detail", pk=project.pk)
context = {
'project': project,
'task': task,
"project": project,
"task": task,
}
return render(request, 'projects/task_delete_confirm.html', context)
return render(request, "projects/task_delete_confirm.html", context)
@block_source_user
@ -468,26 +469,27 @@ def task_toggle_status(request, project_pk, task_pk):
# Check permission
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
messages.error(request, _("You don't have permission to update tasks in this project."))
return redirect('projects:project_detail', pk=project.pk)
return redirect("projects:project_detail", pk=project.pk)
from django.utils import timezone
if task.status == 'completed':
task.status = 'pending'
if task.status == "completed":
task.status = "pending"
task.completed_date = None
else:
task.status = 'completed'
task.status = "completed"
task.completed_date = timezone.now().date()
task.save()
messages.success(request, _("Task status updated."))
return redirect('projects:project_detail', pk=project.pk)
return redirect("projects:project_detail", pk=project.pk)
# =============================================================================
# Template Management Views
# =============================================================================
@block_source_user
@login_required
def template_list(request):
@ -497,35 +499,34 @@ def template_list(request):
# Only admins can manage templates
if not (user.is_px_admin() or user.is_hospital_admin):
messages.error(request, _("You don't have permission to view templates."))
return redirect('projects:project_list')
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
if not user.is_px_admin():
from django.db.models import Q
queryset = queryset.filter(
Q(hospital=user.hospital) | Q(hospital__isnull=True)
)
queryset = queryset.filter(Q(hospital=user.hospital) | Q(hospital__isnull=True))
# Search
search_query = request.GET.get('search')
search_query = request.GET.get("search")
if search_query:
queryset = queryset.filter(
Q(name__icontains=search_query) |
Q(description__icontains=search_query) |
Q(name_ar__icontains=search_query)
Q(name__icontains=search_query)
| Q(description__icontains=search_query)
| Q(name_ar__icontains=search_query)
)
queryset = queryset.order_by('name')
queryset = queryset.order_by("name")
context = {
'templates': queryset,
'can_create': user.is_px_admin() or user.is_hospital_admin,
'can_edit': user.is_px_admin() or user.is_hospital_admin,
"templates": queryset,
"can_create": user.is_px_admin() or user.is_hospital_admin,
"can_edit": user.is_px_admin() or user.is_hospital_admin,
}
return render(request, 'projects/template_list.html', context)
return render(request, "projects/template_list.html", context)
@block_source_user
@ -537,29 +538,29 @@ def template_detail(request, pk):
# Only admins can view templates
if not (user.is_px_admin() or user.is_hospital_admin):
messages.error(request, _("You don't have permission to view templates."))
return redirect('projects:project_list')
return redirect("projects:project_list")
template = get_object_or_404(
QIProject.objects.filter(is_template=True).select_related('hospital', 'department'),
pk=pk
QIProject.objects.filter(is_template=True).select_related("hospital", "department"), pk=pk
)
# Check permission for hospital-specific templates
if not user.is_px_admin():
from django.db.models import Q
if template.hospital and template.hospital != user.hospital:
messages.error(request, _("You don't have permission to view this template."))
return redirect('projects:template_list')
return redirect("projects:template_list")
# Get tasks
tasks = template.tasks.all().order_by('order', 'created_at')
tasks = template.tasks.all().order_by("order", "created_at")
context = {
'template': template,
'tasks': tasks,
"template": template,
"tasks": tasks,
}
return render(request, 'projects/template_detail.html', context)
return render(request, "projects/template_detail.html", context)
@block_source_user
@ -571,26 +572,37 @@ def template_create(request):
# Only admins can create templates
if not (user.is_px_admin() or user.is_hospital_admin):
messages.error(request, _("You don't have permission to create templates."))
return redirect('projects:project_list')
return redirect("projects:project_list")
if request.method == 'POST':
form = QIProjectTemplateForm(request.POST, user=user)
if request.method == "POST":
form = QIProjectTemplateForm(request.POST, request=request)
if form.is_valid():
template = form.save(commit=False)
template.is_template = True
template.created_by = user
template.save()
# Save task templates formset
formset = TaskTemplateFormSet(request.POST, instance=template, prefix='tasktemplate_set')
if formset.is_valid():
formset.save()
messages.success(request, _("Project template created successfully."))
return redirect('projects:template_list')
return redirect("projects:template_list")
else:
form = QIProjectTemplateForm(user=user)
# Form is invalid, show formset with errors
formset = TaskTemplateFormSet(request.POST, prefix='tasktemplate_set')
else:
form = QIProjectTemplateForm(request=request)
formset = TaskTemplateFormSet(prefix='tasktemplate_set')
context = {
'form': form,
'is_create': True,
"form": form,
"formset": formset,
"is_create": True,
}
return render(request, 'projects/template_form.html', context)
return render(request, "projects/template_form.html", context)
@block_source_user
@ -604,24 +616,28 @@ def template_edit(request, pk):
if not user.is_px_admin():
if template.hospital and template.hospital != user.hospital:
messages.error(request, _("You don't have permission to edit this template."))
return redirect('projects:template_list')
return redirect("projects:template_list")
if request.method == 'POST':
form = QIProjectTemplateForm(request.POST, instance=template, user=user)
if form.is_valid():
if request.method == "POST":
form = QIProjectTemplateForm(request.POST, instance=template, request=request)
formset = TaskTemplateFormSet(request.POST, instance=template, prefix='tasktemplate_set')
if form.is_valid() and formset.is_valid():
form.save()
formset.save()
messages.success(request, _("Project template updated successfully."))
return redirect('projects:template_list')
return redirect("projects:template_list")
else:
form = QIProjectTemplateForm(instance=template, user=user)
form = QIProjectTemplateForm(instance=template, request=request)
formset = TaskTemplateFormSet(instance=template, prefix='tasktemplate_set')
context = {
'form': form,
'template': template,
'is_create': False,
"form": form,
"formset": formset,
"template": template,
"is_create": False,
}
return render(request, 'projects/template_form.html', context)
return render(request, "projects/template_form.html", context)
@block_source_user
@ -635,25 +651,26 @@ def template_delete(request, pk):
if not user.is_px_admin():
if template.hospital and template.hospital != user.hospital:
messages.error(request, _("You don't have permission to delete this template."))
return redirect('projects:template_list')
return redirect("projects:template_list")
if request.method == 'POST':
if request.method == "POST":
template_name = template.name
template.delete()
messages.success(request, _('Template "%(name)s" deleted successfully.') % {'name': template_name})
return redirect('projects:template_list')
messages.success(request, _('Template "%(name)s" deleted successfully.') % {"name": template_name})
return redirect("projects:template_list")
context = {
'template': template,
"template": template,
}
return render(request, 'projects/template_delete_confirm.html', context)
return render(request, "projects/template_delete_confirm.html", context)
# =============================================================================
# PX Action Conversion View
# =============================================================================
@block_source_user
@login_required
def convert_action_to_project(request, action_pk):
@ -663,33 +680,33 @@ def convert_action_to_project(request, action_pk):
# Check permission
if not (user.is_px_admin() or user.is_hospital_admin or user.is_department_manager):
messages.error(request, _("You don't have permission to create projects."))
return redirect('px_action_center:action_detail', pk=action_pk)
return redirect("px_action_center:action_detail", pk=action_pk)
action = get_object_or_404(PXAction, pk=action_pk)
# Check hospital access
if not user.is_px_admin() and user.hospital and action.hospital != user.hospital:
messages.error(request, _("You don't have permission to convert this action."))
return redirect('px_action_center:action_detail', pk=action_pk)
return redirect("px_action_center:action_detail", pk=action_pk)
if request.method == 'POST':
form = ConvertToProjectForm(request.POST, user=user, action=action)
if request.method == "POST":
form = ConvertToProjectForm(request.POST, request=request, action=action)
if form.is_valid():
# Create project from template or blank
template = form.cleaned_data.get('template')
template = form.cleaned_data.get("template")
if template:
# Copy from template
project = QIProject.objects.create(
name=form.cleaned_data['project_name'],
name=form.cleaned_data["project_name"],
name_ar=template.name_ar,
description=template.description,
hospital=action.hospital,
department=template.department,
project_lead=form.cleaned_data['project_lead'],
target_completion_date=form.cleaned_data['target_completion_date'],
status='pending',
created_by=user
project_lead=form.cleaned_data["project_lead"],
target_completion_date=form.cleaned_data["target_completion_date"],
status="pending",
created_by=user,
)
# Copy tasks from template
for template_task in template.tasks.all():
@ -698,34 +715,34 @@ def convert_action_to_project(request, action_pk):
title=template_task.title,
description=template_task.description,
order=template_task.order,
status='pending'
status="pending",
)
else:
# Create blank project
project = QIProject.objects.create(
name=form.cleaned_data['project_name'],
name=form.cleaned_data["project_name"],
description=action.description,
hospital=action.hospital,
project_lead=form.cleaned_data['project_lead'],
target_completion_date=form.cleaned_data['target_completion_date'],
status='pending',
created_by=user
project_lead=form.cleaned_data["project_lead"],
target_completion_date=form.cleaned_data["target_completion_date"],
status="pending",
created_by=user,
)
# Link to the action
project.related_actions.add(action)
messages.success(request, _("PX Action converted to QI Project successfully."))
return redirect('projects:project_detail', pk=project.pk)
return redirect("projects:project_detail", pk=project.pk)
else:
initial_data = {
'project_name': f"QI Project: {action.title}",
"project_name": f"QI Project: {action.title}",
}
form = ConvertToProjectForm(user=user, action=action, initial=initial_data)
form = ConvertToProjectForm(request=request, action=action, initial=initial_data)
context = {
'form': form,
'action': action,
"form": form,
"action": action,
}
return render(request, 'projects/convert_action.html', context)
return render(request, "projects/convert_action.html", context)

View File

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

View File

@ -1,6 +1,7 @@
"""
PX Action Center UI views - Server-rendered templates for action center console
"""
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.contenttypes.models import ContentType
@ -39,14 +40,13 @@ def action_list(request):
"""
# Base queryset with optimizations
queryset = PXAction.objects.select_related(
'hospital', 'department', 'assigned_to',
'approved_by', 'closed_by', 'content_type'
"hospital", "department", "assigned_to", "approved_by", "closed_by", "content_type"
)
# Apply RBAC filters
user = request.user
# Get selected hospital for PX Admins (from middleware)
selected_hospital = getattr(request, 'tenant_hospital', None)
selected_hospital = getattr(request, "tenant_hospital", None)
if user.is_px_admin():
# PX Admins see all, but filter by selected hospital if set
@ -62,88 +62,85 @@ def action_list(request):
queryset = queryset.none()
# View filter (My Actions, Overdue, etc.)
view_filter = request.GET.get('view', 'all')
if view_filter == 'my_actions':
view_filter = request.GET.get("view", "all")
if view_filter == "my_actions":
queryset = queryset.filter(assigned_to=user)
elif view_filter == 'overdue':
elif view_filter == "overdue":
queryset = queryset.filter(is_overdue=True, status__in=[ActionStatus.OPEN, ActionStatus.IN_PROGRESS])
elif view_filter == 'escalated':
elif view_filter == "escalated":
queryset = queryset.filter(escalation_level__gt=0)
elif view_filter == 'pending_approval':
elif view_filter == "pending_approval":
queryset = queryset.filter(status=ActionStatus.PENDING_APPROVAL)
elif view_filter == 'from_surveys':
elif view_filter == "from_surveys":
queryset = queryset.filter(source_type=ActionSource.SURVEY)
elif view_filter == 'from_complaints':
elif view_filter == "from_complaints":
queryset = queryset.filter(source_type__in=[ActionSource.COMPLAINT, ActionSource.COMPLAINT_RESOLUTION])
elif view_filter == 'from_social':
elif view_filter == "from_social":
queryset = queryset.filter(source_type=ActionSource.SOCIAL_MEDIA)
# Apply filters from request
status_filter = request.GET.get('status')
status_filter = request.GET.get("status")
if status_filter:
queryset = queryset.filter(status=status_filter)
severity_filter = request.GET.get('severity')
severity_filter = request.GET.get("severity")
if severity_filter:
queryset = queryset.filter(severity=severity_filter)
priority_filter = request.GET.get('priority')
priority_filter = request.GET.get("priority")
if priority_filter:
queryset = queryset.filter(priority=priority_filter)
category_filter = request.GET.get('category')
category_filter = request.GET.get("category")
if category_filter:
queryset = queryset.filter(category=category_filter)
source_type_filter = request.GET.get('source_type')
source_type_filter = request.GET.get("source_type")
if source_type_filter:
queryset = queryset.filter(source_type=source_type_filter)
hospital_filter = request.GET.get('hospital')
hospital_filter = request.GET.get("hospital")
if hospital_filter:
queryset = queryset.filter(hospital_id=hospital_filter)
department_filter = request.GET.get('department')
department_filter = request.GET.get("department")
if department_filter:
queryset = queryset.filter(department_id=department_filter)
assigned_to_filter = request.GET.get('assigned_to')
assigned_to_filter = request.GET.get("assigned_to")
if assigned_to_filter:
queryset = queryset.filter(assigned_to_id=assigned_to_filter)
# Search
search_query = request.GET.get('search')
search_query = request.GET.get("search")
if search_query:
queryset = queryset.filter(
Q(title__icontains=search_query) |
Q(description__icontains=search_query)
)
queryset = queryset.filter(Q(title__icontains=search_query) | Q(description__icontains=search_query))
# Date range filters
date_from = request.GET.get('date_from')
date_from = request.GET.get("date_from")
if date_from:
queryset = queryset.filter(created_at__gte=date_from)
date_to = request.GET.get('date_to')
date_to = request.GET.get("date_to")
if date_to:
queryset = queryset.filter(created_at__lte=date_to)
# Ordering
order_by = request.GET.get('order_by', '-created_at')
order_by = request.GET.get("order_by", "-created_at")
queryset = queryset.order_by(order_by)
# Pagination
page_size = int(request.GET.get('page_size', 25))
page_size = int(request.GET.get("page_size", 25))
paginator = Paginator(queryset, page_size)
page_number = request.GET.get('page', 1)
page_number = request.GET.get("page", 1)
page_obj = paginator.get_page(page_number)
# Get filter options
hospitals = Hospital.objects.filter(status='active')
hospitals = Hospital.objects.filter(status="active")
if not user.is_px_admin() and user.hospital:
hospitals = hospitals.filter(id=user.hospital.id)
departments = Department.objects.filter(status='active')
departments = Department.objects.filter(status="active")
if not user.is_px_admin() and user.hospital:
departments = departments.filter(hospital=user.hospital)
@ -154,28 +151,28 @@ def action_list(request):
# Statistics
stats = {
'total': queryset.count(),
'open': queryset.filter(status=ActionStatus.OPEN).count(),
'in_progress': queryset.filter(status=ActionStatus.IN_PROGRESS).count(),
'overdue': queryset.filter(is_overdue=True).count(),
'pending_approval': queryset.filter(status=ActionStatus.PENDING_APPROVAL).count(),
'my_actions': queryset.filter(assigned_to=user).count(),
"total": queryset.count(),
"open": queryset.filter(status=ActionStatus.OPEN).count(),
"in_progress": queryset.filter(status=ActionStatus.IN_PROGRESS).count(),
"overdue": queryset.filter(is_overdue=True).count(),
"pending_approval": queryset.filter(status=ActionStatus.PENDING_APPROVAL).count(),
"my_actions": queryset.filter(assigned_to=user).count(),
}
context = {
'page_obj': page_obj,
'actions': page_obj.object_list,
'stats': stats,
'hospitals': hospitals,
'departments': departments,
'assignable_users': assignable_users,
'status_choices': ActionStatus.choices,
'source_choices': ActionSource.choices,
'filters': request.GET,
'current_view': view_filter,
"page_obj": page_obj,
"actions": page_obj.object_list,
"stats": stats,
"hospitals": hospitals,
"departments": departments,
"assignable_users": assignable_users,
"status_choices": ActionStatus.choices,
"source_choices": ActionSource.choices,
"filters": request.GET,
"current_view": view_filter,
}
return render(request, 'actions/action_list.html', context)
return render(request, "actions/action_list.html", context)
@login_required
@ -193,13 +190,9 @@ def action_detail(request, pk):
"""
action = get_object_or_404(
PXAction.objects.select_related(
'hospital', 'department', 'assigned_to',
'approved_by', 'closed_by', 'content_type'
).prefetch_related(
'logs__created_by',
'attachments__uploaded_by'
),
pk=pk
"hospital", "department", "assigned_to", "approved_by", "closed_by", "content_type"
).prefetch_related("logs__created_by", "attachments__uploaded_by"),
pk=pk,
)
# Check access
@ -207,19 +200,19 @@ def action_detail(request, pk):
if not user.is_px_admin():
if user.is_hospital_admin() and action.hospital != user.hospital:
messages.error(request, "You don't have permission to view this action.")
return redirect('actions:action_list')
return redirect("actions:action_list")
elif user.is_department_manager() and action.department != user.department:
messages.error(request, "You don't have permission to view this action.")
return redirect('actions:action_list')
return redirect("actions:action_list")
elif user.hospital and action.hospital != user.hospital:
messages.error(request, "You don't have permission to view this action.")
return redirect('actions:action_list')
return redirect("actions:action_list")
# Get logs (timeline)
logs = action.logs.all().order_by('-created_at')
logs = action.logs.all().order_by("-created_at")
# Get attachments
attachments = action.attachments.all().order_by('-created_at')
attachments = action.attachments.all().order_by("-created_at")
evidence_attachments = attachments.filter(is_evidence=True)
# Get assignable users
@ -239,18 +232,18 @@ def action_detail(request, pk):
sla_progress = 0
context = {
'action': action,
'logs': logs,
'attachments': attachments,
'evidence_attachments': evidence_attachments,
'assignable_users': assignable_users,
'status_choices': ActionStatus.choices,
'sla_progress': sla_progress,
'can_edit': user.is_px_admin() or user.is_hospital_admin() or action.assigned_to == user,
'can_approve': user.is_px_admin(),
"action": action,
"logs": logs,
"attachments": attachments,
"evidence_attachments": evidence_attachments,
"assignable_users": assignable_users,
"status_choices": ActionStatus.choices,
"sla_progress": sla_progress,
"can_edit": user.is_px_admin() or user.is_hospital_admin() or action.assigned_to == user,
"can_approve": user.is_px_admin(),
}
return render(request, 'actions/action_detail.html', context)
return render(request, "actions/action_detail.html", context)
@login_required
@ -263,33 +256,33 @@ def action_assign(request, pk):
user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()):
messages.error(request, "You don't have permission to assign 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:
messages.error(request, "Please select a user to assign.")
return redirect('actions:action_detail', pk=pk)
return redirect("actions:action_detail", pk=pk)
try:
assignee = User.objects.get(id=user_id)
action.assigned_to = assignee
action.assigned_at = timezone.now()
action.save(update_fields=['assigned_to', 'assigned_at'])
action.save(update_fields=["assigned_to", "assigned_at"])
# Create log
PXActionLog.objects.create(
action=action,
log_type='assignment',
log_type="assignment",
message=f"Assigned to {assignee.get_full_name()}",
created_by=request.user
created_by=request.user,
)
# Audit log
AuditService.log_event(
event_type='assignment',
event_type="assignment",
description=f"Action assigned to {assignee.get_full_name()}",
user=request.user,
content_object=action
content_object=action,
)
messages.success(request, f"Action assigned to {assignee.get_full_name()}.")
@ -297,7 +290,7 @@ def action_assign(request, pk):
except User.DoesNotExist:
messages.error(request, "User not found.")
return redirect('actions:action_detail', pk=pk)
return redirect("actions:action_detail", pk=pk)
@login_required
@ -310,14 +303,14 @@ def action_change_status(request, pk):
user = request.user
if not (user.is_px_admin() or user.is_hospital_admin() or action.assigned_to == user):
messages.error(request, "You don't have permission to change action status.")
return redirect('actions:action_detail', pk=pk)
return redirect("actions:action_detail", pk=pk)
new_status = request.POST.get('status')
note = request.POST.get('note', '')
new_status = request.POST.get("status")
note = request.POST.get("note", "")
if not new_status:
messages.error(request, "Please select a status.")
return redirect('actions:action_detail', pk=pk)
return redirect("actions:action_detail", pk=pk)
# Validate status transitions
if new_status == ActionStatus.PENDING_APPROVAL:
@ -325,12 +318,12 @@ def action_change_status(request, pk):
evidence_count = action.attachments.filter(is_evidence=True).count()
if evidence_count == 0:
messages.error(request, "Evidence is required before requesting approval.")
return redirect('actions:action_detail', pk=pk)
return redirect("actions:action_detail", pk=pk)
elif new_status == ActionStatus.APPROVED:
if not user.is_px_admin():
messages.error(request, "Only PX Admins can approve actions.")
return redirect('actions:action_detail', pk=pk)
return redirect("actions:action_detail", pk=pk)
action.approved_by = user
action.approved_at = timezone.now()
@ -345,24 +338,24 @@ def action_change_status(request, pk):
# Create log
PXActionLog.objects.create(
action=action,
log_type='status_change',
log_type="status_change",
message=note or f"Status changed from {old_status} to {new_status}",
created_by=user,
old_status=old_status,
new_status=new_status
new_status=new_status,
)
# Audit log
AuditService.log_event(
event_type='status_change',
event_type="status_change",
description=f"Action status changed from {old_status} to {new_status}",
user=user,
content_object=action,
metadata={'old_status': old_status, 'new_status': new_status}
metadata={"old_status": old_status, "new_status": new_status},
)
messages.success(request, f"Action status changed to {new_status}.")
return redirect('actions:action_detail', pk=pk)
return redirect("actions:action_detail", pk=pk)
@login_required
@ -371,21 +364,16 @@ def action_add_note(request, pk):
"""Add note to action"""
action = get_object_or_404(PXAction, pk=pk)
note = request.POST.get('note')
note = request.POST.get("note")
if not note:
messages.error(request, "Please enter a note.")
return redirect('actions:action_detail', pk=pk)
return redirect("actions:action_detail", pk=pk)
# Create log
PXActionLog.objects.create(
action=action,
log_type='note',
message=note,
created_by=request.user
)
PXActionLog.objects.create(action=action, log_type="note", message=note, created_by=request.user)
messages.success(request, "Note added successfully.")
return redirect('actions:action_detail', pk=pk)
return redirect("actions:action_detail", pk=pk)
@login_required
@ -398,34 +386,34 @@ def action_escalate(request, pk):
user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()):
messages.error(request, "You don't have permission to 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
action.escalation_level += 1
action.escalated_at = timezone.now()
action.save(update_fields=['escalation_level', 'escalated_at'])
action.save(update_fields=["escalation_level", "escalated_at"])
# Create log
PXActionLog.objects.create(
action=action,
log_type='escalation',
log_type="escalation",
message=f"Action escalated (Level {action.escalation_level}). Reason: {reason}",
created_by=user
created_by=user,
)
# Audit log
AuditService.log_event(
event_type='escalation',
event_type="escalation",
description=f"Action escalated to level {action.escalation_level}",
user=user,
content_object=action,
metadata={'reason': reason, 'escalation_level': action.escalation_level}
metadata={"reason": reason, "escalation_level": action.escalation_level},
)
messages.success(request, f"Action escalated to level {action.escalation_level}.")
return redirect('actions:action_detail', pk=pk)
return redirect("actions:action_detail", pk=pk)
@login_required
@ -437,11 +425,11 @@ def action_approve(request, pk):
# Check permission
if not request.user.is_px_admin():
messages.error(request, "Only PX Admins can approve actions.")
return redirect('actions:action_detail', pk=pk)
return redirect("actions:action_detail", pk=pk)
if action.status != ActionStatus.PENDING_APPROVAL:
messages.error(request, "Action is not pending approval.")
return redirect('actions:action_detail', pk=pk)
return redirect("actions:action_detail", pk=pk)
# Approve
action.status = ActionStatus.APPROVED
@ -452,23 +440,20 @@ def action_approve(request, pk):
# Create log
PXActionLog.objects.create(
action=action,
log_type='approval',
log_type="approval",
message=f"Action approved by {request.user.get_full_name()}",
created_by=request.user,
old_status=ActionStatus.PENDING_APPROVAL,
new_status=ActionStatus.APPROVED
new_status=ActionStatus.APPROVED,
)
# Audit log
AuditService.log_event(
event_type='approval',
description="Action approved",
user=request.user,
content_object=action
event_type="approval", description="Action approved", user=request.user, content_object=action
)
messages.success(request, "Action approved successfully.")
return redirect('actions:action_detail', pk=pk)
return redirect("actions:action_detail", pk=pk)
@login_required
@ -488,10 +473,10 @@ def action_create(request):
user = request.user
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager()):
messages.error(request, "You don't have permission to create actions.")
return redirect('actions:action_list')
return redirect("actions:action_list")
if request.method == 'POST':
form = ManualActionForm(request.POST, user=user)
if request.method == "POST":
form = ManualActionForm(request.POST, request=request)
if form.is_valid():
action = form.save(commit=False)
action.created_by = user
@ -508,47 +493,48 @@ def action_create(request):
# Create log
PXActionLog.objects.create(
action=action,
log_type='status_change',
log_type="status_change",
message=f"Action created manually from {action.get_source_type_display()}",
created_by=user
created_by=user,
)
# Audit log
AuditService.log_event(
event_type='action_created',
event_type="action_created",
description=f"Manual action created from {action.get_source_type_display()}",
user=user,
content_object=action
content_object=action,
)
# Notify assigned user
if action.assigned_to:
from apps.notifications.services import NotificationService
NotificationService.send_notification(
recipient=action.assigned_to,
title=f"New Action Assigned: {action.title}",
message=f"You have been assigned a new action. Due: {action.due_at.strftime('%Y-%m-%d %H:%M')}",
notification_type='action_assigned',
metadata={'link': f"/actions/{action.id}/"}
notification_type="action_assigned",
metadata={"link": f"/actions/{action.id}/"},
)
messages.success(request, "Action created successfully.")
return redirect('actions:action_detail', pk=action.id)
return redirect("actions:action_detail", pk=action.id)
else:
# Pre-fill hospital if user has one
initial = {}
if user.hospital:
initial['hospital'] = user.hospital.id
initial["hospital"] = user.hospital.id
form = ManualActionForm(user=user, initial=initial)
form = ManualActionForm(request=request)
context = {
'form': form,
'source_choices': ActionSource.choices,
'status_choices': ActionStatus.choices,
"form": form,
"source_choices": ActionSource.choices,
"status_choices": ActionStatus.choices,
}
return render(request, 'actions/action_create.html', context)
return render(request, "actions/action_create.html", context)
@login_required
@ -568,33 +554,23 @@ def action_create_from_ai(request, complaint_id):
# Check permission
user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()):
return JsonResponse({
'success': False,
'error': 'You do not have permission to create actions.'
}, status=403)
return JsonResponse({"success": False, "error": "You do not have permission to create actions."}, status=403)
# Get action data from POST
action_text = request.POST.get('action', '')
priority = request.POST.get('priority', 'medium')
category = request.POST.get('category', 'process_improvement')
action_text = request.POST.get("action", "")
priority = request.POST.get("priority", "medium")
category = request.POST.get("category", "process_improvement")
if not action_text:
return JsonResponse({
'success': False,
'error': 'Action description is required.'
}, status=400)
return JsonResponse({"success": False, "error": "Action description is required."}, status=400)
try:
# Map priority/severity
priority_map = {
'high': 'high',
'medium': 'medium',
'low': 'low'
}
priority_map = {"high": "high", "medium": "medium", "low": "low"}
# Create action
action = PXAction.objects.create(
source_type='complaint',
source_type="complaint",
content_type=ContentType.objects.get_for_model(Complaint),
object_id=complaint.id,
title=f"AI Suggested Action - {complaint.reference_number}",
@ -602,8 +578,8 @@ def action_create_from_ai(request, complaint_id):
hospital=complaint.hospital,
department=complaint.department,
category=category,
priority=priority_map.get(priority, 'medium'),
severity=priority_map.get(priority, 'medium'),
priority=priority_map.get(priority, "medium"),
severity=priority_map.get(priority, "medium"),
status=ActionStatus.OPEN,
assigned_to=complaint.assigned_to, # Assign to same person as complaint
assigned_at=timezone.now() if complaint.assigned_to else None,
@ -611,54 +587,51 @@ def action_create_from_ai(request, complaint_id):
# Set due date based on priority (SLA)
from datetime import timedelta
due_days = {
'high': 3,
'medium': 7,
'low': 14
}
due_days = {"high": 3, "medium": 7, "low": 14}
action.due_at = timezone.now() + timedelta(days=due_days.get(priority, 7))
action.save()
# Create log
PXActionLog.objects.create(
action=action,
log_type='status_change',
log_type="status_change",
message=f"Action created automatically from AI suggestion for complaint {complaint.reference_number}",
)
# Audit log
AuditService.log_event(
event_type='action_created',
event_type="action_created",
description=f"Action created automatically from AI suggestion",
user=user,
content_object=action,
metadata={
'complaint_id': str(complaint.id),
'complaint_reference': complaint.reference_number,
'source': 'ai_suggestion'
}
"complaint_id": str(complaint.id),
"complaint_reference": complaint.reference_number,
"source": "ai_suggestion",
},
)
# Notify assigned user
if action.assigned_to:
from apps.notifications.services import NotificationService
NotificationService.send_notification(
recipient=action.assigned_to,
title=f"New Action from AI: {action.title}",
message=f"AI suggested a new action for complaint {complaint.reference_number}. Due: {action.due_at.strftime('%Y-%m-%d')}",
notification_type='action_assigned',
metadata={'link': f"/actions/{action.id}/"}
notification_type="action_assigned",
metadata={"link": f"/actions/{action.id}/"},
)
return JsonResponse({
'success': True,
'action_id': str(action.id),
'action_url': f"/actions/{action.id}/",
'message': 'PX Action created successfully from AI suggestion.'
})
return JsonResponse(
{
"success": True,
"action_id": str(action.id),
"action_url": f"/actions/{action.id}/",
"message": "PX Action created successfully from AI suggestion.",
}
)
except Exception as e:
return JsonResponse({
'success': False,
'error': str(e)
}, status=500)
return JsonResponse({"success": False, "error": str(e)}, status=500)

View File

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

View File

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

View File

@ -7,6 +7,7 @@ This module contains tasks for:
- Survey-related background operations
- Bulk survey sending
"""
import logging
from celery import shared_task
@ -34,47 +35,41 @@ def analyze_survey_comment(survey_instance_id):
from apps.core.ai_service import AIService, AIServiceError
try:
survey = SurveyInstance.objects.select_related('patient', 'hospital').get(id=survey_instance_id)
survey = SurveyInstance.objects.select_related("patient", "hospital").get(id=survey_instance_id)
# Check if comment exists
if not survey.comment or not survey.comment.strip():
logger.info(f"No comment to analyze for survey {survey_instance_id}")
return {
'status': 'skipped',
'reason': 'no_comment'
}
return {"status": "skipped", "reason": "no_comment"}
# Check if already analyzed
if survey.comment_analyzed:
logger.info(f"Comment already analyzed for survey {survey_instance_id}")
return {
'status': 'skipped',
'reason': 'already_analyzed'
}
return {"status": "skipped", "reason": "already_analyzed"}
logger.info(f"Starting AI analysis for survey comment {survey_instance_id}")
# Analyze sentiment
try:
sentiment_analysis = AIService.classify_sentiment(survey.comment)
sentiment = sentiment_analysis.get('sentiment', 'neutral')
sentiment_score = sentiment_analysis.get('score', 0.0)
sentiment_confidence = sentiment_analysis.get('confidence', 0.0)
sentiment = sentiment_analysis.get("sentiment", "neutral")
sentiment_score = sentiment_analysis.get("score", 0.0)
sentiment_confidence = sentiment_analysis.get("confidence", 0.0)
except AIServiceError as e:
logger.error(f"Sentiment analysis failed for survey {survey_instance_id}: {str(e)}")
sentiment = 'neutral'
sentiment = "neutral"
sentiment_score = 0.0
sentiment_confidence = 0.0
# Analyze emotion
try:
emotion_analysis = AIService.analyze_emotion(survey.comment)
emotion = emotion_analysis.get('emotion', 'neutral')
emotion_intensity = emotion_analysis.get('intensity', 0.0)
emotion_confidence = emotion_analysis.get('confidence', 0.0)
emotion = emotion_analysis.get("emotion", "neutral")
emotion_intensity = emotion_analysis.get("intensity", 0.0)
emotion_confidence = emotion_analysis.get("confidence", 0.0)
except AIServiceError as e:
logger.error(f"Emotion analysis failed for survey {survey_instance_id}: {str(e)}")
emotion = 'neutral'
emotion = "neutral"
emotion_intensity = 0.0
emotion_confidence = 0.0
@ -106,64 +101,58 @@ def analyze_survey_comment(survey_instance_id):
messages=[
{
"role": "system",
"content": "You are a helpful assistant analyzing patient survey comments. Always respond with valid JSON."
"content": "You are a helpful assistant analyzing patient survey comments. Always respond with valid JSON.",
},
{
"role": "user",
"content": summary_prompt
}
{"role": "user", "content": summary_prompt},
],
response_format={"type": "json_object"}
response_format={"type": "json_object"},
)
# Parse the JSON response
import json
summary_data = json.loads(summary_result)
summary_en = summary_data.get('summary_en', '')
summary_ar = summary_data.get('summary_ar', '')
topics_en = summary_data.get('topics_en', [])
topics_ar = summary_data.get('topics_ar', [])
feedback_type = summary_data.get('feedback_type', 'neutral')
summary_en = summary_data.get("summary_en", "")
summary_ar = summary_data.get("summary_ar", "")
topics_en = summary_data.get("topics_en", [])
topics_ar = summary_data.get("topics_ar", [])
feedback_type = summary_data.get("feedback_type", "neutral")
except Exception as e:
logger.error(f"Summary generation failed for survey {survey_instance_id}: {str(e)}")
summary_en = survey.comment[:200] # Fallback to comment text
summary_ar = ''
summary_ar = ""
topics_en = []
topics_ar = []
feedback_type = sentiment # Fallback to sentiment
# Update survey with analysis results
survey.comment_analysis = {
'sentiment': sentiment,
'sentiment_score': sentiment_score,
'sentiment_confidence': sentiment_confidence,
'emotion': emotion,
'emotion_intensity': emotion_intensity,
'emotion_confidence': emotion_confidence,
'summary_en': summary_en,
'summary_ar': summary_ar,
'topics_en': topics_en,
'topics_ar': topics_ar,
'feedback_type': feedback_type,
'analyzed_at': timezone.now().isoformat()
"sentiment": sentiment,
"sentiment_score": sentiment_score,
"sentiment_confidence": sentiment_confidence,
"emotion": emotion,
"emotion_intensity": emotion_intensity,
"emotion_confidence": emotion_confidence,
"summary_en": summary_en,
"summary_ar": summary_ar,
"topics_en": topics_en,
"topics_ar": topics_ar,
"feedback_type": feedback_type,
"analyzed_at": timezone.now().isoformat(),
}
survey.comment_analyzed = True
survey.save(update_fields=['comment_analysis', 'comment_analyzed'])
survey.save(update_fields=["comment_analysis", "comment_analyzed"])
# Log audit
from apps.core.services import create_audit_log
create_audit_log(
event_type='survey_comment_analyzed',
event_type="survey_comment_analyzed",
description=f"Survey comment analyzed with AI: sentiment={sentiment}, emotion={emotion}",
content_object=survey,
metadata={
'sentiment': sentiment,
'emotion': emotion,
'feedback_type': feedback_type,
'topics': topics_en
}
metadata={"sentiment": sentiment, "emotion": emotion, "feedback_type": feedback_type, "topics": topics_en},
)
logger.info(
@ -174,25 +163,25 @@ def analyze_survey_comment(survey_instance_id):
)
return {
'status': 'success',
'survey_id': str(survey.id),
'sentiment': sentiment,
'sentiment_score': sentiment_score,
'sentiment_confidence': sentiment_confidence,
'emotion': emotion,
'emotion_intensity': emotion_intensity,
'emotion_confidence': emotion_confidence,
'summary_en': summary_en,
'summary_ar': summary_ar,
'topics_en': topics_en,
'topics_ar': topics_ar,
'feedback_type': feedback_type
"status": "success",
"survey_id": str(survey.id),
"sentiment": sentiment,
"sentiment_score": sentiment_score,
"sentiment_confidence": sentiment_confidence,
"emotion": emotion,
"emotion_intensity": emotion_intensity,
"emotion_confidence": emotion_confidence,
"summary_en": summary_en,
"summary_ar": summary_ar,
"topics_en": topics_en,
"topics_ar": topics_ar,
"feedback_type": feedback_type,
}
except SurveyInstance.DoesNotExist:
error_msg = f"SurveyInstance {survey_instance_id} not found"
logger.error(error_msg)
return {'status': 'error', 'reason': error_msg}
return {"status": "error", "reason": error_msg}
@shared_task
@ -213,97 +202,91 @@ def send_satisfaction_feedback(survey_instance_id, user_id):
from apps.surveys.models import SurveyInstance, SurveyTemplate
try:
survey = SurveyInstance.objects.select_related(
'patient', 'hospital', 'survey_template'
).get(id=survey_instance_id)
survey = SurveyInstance.objects.select_related("patient", "hospital", "survey_template").get(
id=survey_instance_id
)
# Get feedback survey template
try:
feedback_template = SurveyTemplate.objects.get(
hospital=survey.hospital,
survey_type='complaint_resolution',
is_active=True
hospital=survey.hospital, survey_type="complaint_resolution", is_active=True
)
except SurveyTemplate.DoesNotExist:
logger.warning(
f"No feedback survey template found for hospital {survey.hospital.name}"
)
return {'status': 'skipped', 'reason': 'no_template'}
logger.warning(f"No feedback survey template found for hospital {survey.hospital.name}")
return {"status": "skipped", "reason": "no_template"}
# Check if already sent
if survey.satisfaction_feedback_sent:
logger.info(f"Satisfaction feedback already sent for survey {survey_instance_id}")
return {'status': 'skipped', 'reason': 'already_sent'}
return {"status": "skipped", "reason": "already_sent"}
# Create feedback survey instance
feedback_survey = SurveyInstance.objects.create(
survey_template=feedback_template,
patient=survey.patient,
encounter_id=survey.encounter_id,
delivery_channel='sms',
delivery_channel="sms",
recipient_phone=survey.patient.phone,
recipient_email=survey.patient.email,
metadata={
'original_survey_id': str(survey.id),
'original_survey_title': survey.survey_template.name,
'original_score': float(survey.total_score) if survey.total_score else None,
'feedback_type': 'satisfaction'
}
"original_survey_id": str(survey.id),
"original_survey_title": survey.survey_template.name,
"original_score": float(survey.total_score) if survey.total_score else None,
"feedback_type": "satisfaction",
},
)
# Mark original survey as having feedback sent
survey.satisfaction_feedback_sent = True
survey.satisfaction_feedback_sent_at = timezone.now()
survey.satisfaction_feedback = feedback_survey
survey.save(update_fields=[
'satisfaction_feedback_sent',
'satisfaction_feedback_sent_at',
'satisfaction_feedback'
])
survey.save(
update_fields=["satisfaction_feedback_sent", "satisfaction_feedback_sent_at", "satisfaction_feedback"]
)
# Send survey invitation
from apps.notifications.services import NotificationService
notification_log = NotificationService.send_survey_invitation(
survey_instance=feedback_survey,
language='en' # TODO: Get from patient preference
language="en", # TODO: Get from patient preference
)
# Update feedback survey status
feedback_survey.status = 'sent'
feedback_survey.status = "sent"
feedback_survey.sent_at = timezone.now()
feedback_survey.save(update_fields=['status', 'sent_at'])
feedback_survey.save(update_fields=["status", "sent_at"])
# Log audit
from apps.core.services import create_audit_log
create_audit_log(
event_type='satisfaction_feedback_sent',
event_type="satisfaction_feedback_sent",
description=f"Satisfaction feedback survey sent for survey: {survey.survey_template.name}",
content_object=feedback_survey,
metadata={
'original_survey_id': str(survey.id),
'feedback_template': feedback_template.name,
'sent_by_user_id': user_id
}
"original_survey_id": str(survey.id),
"feedback_template": feedback_template.name,
"sent_by_user_id": user_id,
},
)
logger.info(
f"Satisfaction feedback survey sent for survey {survey_instance_id}"
)
logger.info(f"Satisfaction feedback survey sent for survey {survey_instance_id}")
return {
'status': 'sent',
'feedback_survey_id': str(feedback_survey.id),
'notification_log_id': str(notification_log.id)
"status": "sent",
"feedback_survey_id": str(feedback_survey.id),
"notification_log_id": str(notification_log.id),
}
except SurveyInstance.DoesNotExist:
error_msg = f"SurveyInstance {survey_instance_id} not found"
logger.error(error_msg)
return {'status': 'error', 'reason': error_msg}
return {"status": "error", "reason": error_msg}
except Exception as e:
error_msg = f"Error sending satisfaction feedback: {str(e)}"
logger.error(error_msg, exc_info=True)
return {'status': 'error', 'reason': error_msg}
return {"status": "error", "reason": error_msg}
@shared_task
@ -326,21 +309,19 @@ def create_action_from_negative_survey(survey_instance_id):
from django.contrib.contenttypes.models import ContentType
try:
survey = SurveyInstance.objects.select_related(
'survey_template',
'patient',
'hospital'
).get(id=survey_instance_id)
survey = SurveyInstance.objects.select_related("survey_template", "patient", "hospital").get(
id=survey_instance_id
)
# Verify survey is negative
if not survey.is_negative:
logger.info(f"Survey {survey_instance_id} is not negative, skipping action creation")
return {'status': 'skipped', 'reason': 'not_negative'}
return {"status": "skipped", "reason": "not_negative"}
# Check if action already created
if survey.metadata.get('px_action_created'):
if survey.metadata.get("px_action_created"):
logger.info(f"PX Action already created for survey {survey_instance_id}")
return {'status': 'skipped', 'reason': 'already_created'}
return {"status": "skipped", "reason": "already_created"}
# Calculate score for priority/severity determination
score = float(survey.total_score) if survey.total_score else 0.0
@ -360,19 +341,19 @@ def create_action_from_negative_survey(survey_instance_id):
priority = PriorityChoices.LOW
# Determine category based on survey template or journey stage
category = 'service_quality' # Default
if survey.survey_template.survey_type == 'post_discharge':
category = 'clinical_quality'
elif survey.survey_template.survey_type == 'inpatient_satisfaction':
category = 'service_quality'
category = "service_quality" # Default
if survey.survey_template.survey_type == "post_discharge":
category = "clinical_quality"
elif survey.survey_template.survey_type == "inpatient_satisfaction":
category = "service_quality"
elif survey.journey_instance and survey.journey_instance.stage:
stage = survey.journey_instance.stage.lower()
if 'admission' in stage or 'registration' in stage:
category = 'process_improvement'
elif 'treatment' in stage or 'procedure' in stage:
category = 'clinical_quality'
elif 'discharge' in stage or 'billing' in stage:
category = 'process_improvement'
if "admission" in stage or "registration" in stage:
category = "process_improvement"
elif "treatment" in stage or "procedure" in stage:
category = "clinical_quality"
elif "discharge" in stage or "billing" in stage:
category = "process_improvement"
# Build description
description_parts = [
@ -384,9 +365,7 @@ def create_action_from_negative_survey(survey_instance_id):
description_parts.append(f"Patient Comment: {survey.comment}")
if survey.journey_instance:
description_parts.append(
f"Journey Stage: {survey.journey_instance.stage}"
)
description_parts.append(f"Journey Stage: {survey.journey_instance.stage}")
if survey.encounter_id:
description_parts.append(f"Encounter ID: {survey.encounter_id}")
@ -397,7 +376,7 @@ def create_action_from_negative_survey(survey_instance_id):
survey_ct = ContentType.objects.get_for_model(SurveyInstance)
action = PXAction.objects.create(
source_type='survey',
source_type="survey",
content_type=survey_ct,
object_id=survey.id,
title=f"Negative Survey: {survey.survey_template.name} (Score: {score:.1f})",
@ -407,54 +386,55 @@ def create_action_from_negative_survey(survey_instance_id):
category=category,
priority=priority,
severity=severity,
status='open',
status="open",
metadata={
'source_survey_id': str(survey.id),
'source_survey_template': survey.survey_template.name,
'survey_score': score,
'is_negative': True,
'has_comment': bool(survey.comment),
'encounter_id': survey.encounter_id,
'auto_created': True
}
"source_survey_id": str(survey.id),
"source_survey_template": survey.survey_template.name,
"survey_score": score,
"is_negative": True,
"has_comment": bool(survey.comment),
"encounter_id": survey.encounter_id,
"auto_created": True,
},
)
# Create action log entry
PXActionLog.objects.create(
action=action,
log_type='note',
log_type="note",
message=(
f"Action automatically created from negative survey. "
f"Score: {score:.1f}, Template: {survey.survey_template.name}"
),
metadata={
'survey_id': str(survey.id),
'survey_score': score,
'auto_created': True,
'severity': severity,
'priority': priority
}
"survey_id": str(survey.id),
"survey_score": score,
"auto_created": True,
"severity": severity,
"priority": priority,
},
)
# Update survey metadata to track action creation
if not survey.metadata:
survey.metadata = {}
survey.metadata['px_action_created'] = True
survey.metadata['px_action_id'] = str(action.id)
survey.save(update_fields=['metadata'])
survey.metadata["px_action_created"] = True
survey.metadata["px_action_id"] = str(action.id)
survey.save(update_fields=["metadata"])
# Log audit
from apps.core.services import create_audit_log
create_audit_log(
event_type='px_action_created',
event_type="px_action_created",
description=f"PX Action created from negative survey: {survey.survey_template.name}",
content_object=action,
metadata={
'survey_id': str(survey.id),
'survey_template': survey.survey_template.name,
'survey_score': score,
'trigger': 'negative_survey'
}
"survey_id": str(survey.id),
"survey_template": survey.survey_template.name,
"survey_score": score,
"trigger": "negative_survey",
},
)
logger.info(
@ -463,23 +443,21 @@ def create_action_from_negative_survey(survey_instance_id):
)
return {
'status': 'action_created',
'action_id': str(action.id),
'survey_score': score,
'severity': severity,
'priority': priority
"status": "action_created",
"action_id": str(action.id),
"survey_score": score,
"severity": severity,
"priority": priority,
}
except SurveyInstance.DoesNotExist:
error_msg = f"SurveyInstance {survey_instance_id} not found"
logger.error(error_msg)
return {'status': 'error', 'reason': error_msg}
return {"status": "error", "reason": error_msg}
except Exception as e:
error_msg = f"Error creating action from negative survey: {str(e)}"
logger.error(error_msg, exc_info=True)
return {'status': 'error', 'reason': error_msg}
return {"status": "error", "reason": error_msg}
@shared_task(bind=True, max_retries=3)
@ -508,7 +486,7 @@ def send_bulk_surveys(self, job_id):
# Update status to processing
job.status = BulkSurveyJob.JobStatus.PROCESSING
job.started_at = timezone.now()
job.save(update_fields=['status', 'started_at'])
job.save(update_fields=["status", "started_at"])
logger.info(f"Starting bulk survey job {job_id} for {job.total_patients} patients")
@ -531,36 +509,35 @@ def send_bulk_surveys(self, job_id):
# Update progress periodically
if idx % 5 == 0:
job.processed_count = idx
job.save(update_fields=['processed_count'])
job.save(update_fields=["processed_count"])
# Get patient
patient_id = patient_info.get('patient_id')
file_number = patient_info.get('file_number', 'unknown')
patient_id = patient_info.get("patient_id")
file_number = patient_info.get("file_number", "unknown")
try:
patient = Patient.objects.get(id=patient_id)
except Patient.DoesNotExist:
failed_count += 1
failed_patients.append({
'file_number': file_number,
'reason': 'Patient not found'
})
failed_patients.append({"file_number": file_number, "reason": "Patient not found"})
continue
# Determine delivery channels
channels = []
if delivery_channel in ['sms', 'both'] and patient.phone:
channels.append('sms')
if delivery_channel in ['email', 'both'] and patient.email:
channels.append('email')
if delivery_channel in ["sms", "both"] and patient.phone:
channels.append("sms")
if delivery_channel in ["email", "both"] and patient.email:
channels.append("email")
if not channels:
failed_count += 1
failed_patients.append({
'file_number': file_number,
'patient_name': patient.get_full_name(),
'reason': 'No contact information'
})
failed_patients.append(
{
"file_number": file_number,
"patient_name": patient.get_full_name(),
"reason": "No contact information",
}
)
continue
# Create and send survey for each channel
@ -571,16 +548,16 @@ def send_bulk_surveys(self, job_id):
hospital=hospital,
delivery_channel=channel,
status=SurveyStatus.SENT,
recipient_phone=patient.phone if channel == 'sms' else '',
recipient_email=patient.email if channel == 'email' else '',
recipient_phone=patient.phone if channel == "sms" else "",
recipient_email=patient.email if channel == "email" else "",
metadata={
'sent_manually': True,
'sent_by': str(job.created_by.id) if job.created_by else None,
'custom_message': custom_message,
'recipient_type': 'bulk_import',
'his_file_number': file_number,
'bulk_job_id': str(job.id),
}
"sent_manually": True,
"sent_by": str(job.created_by.id) if job.created_by else None,
"custom_message": custom_message,
"recipient_type": "bulk_import",
"his_file_number": file_number,
"bulk_job_id": str(job.id),
},
)
# Send survey
@ -591,19 +568,18 @@ def send_bulk_surveys(self, job_id):
created_survey_ids.append(str(survey_instance.id))
else:
failed_count += 1
failed_patients.append({
'file_number': file_number,
'patient_name': patient.get_full_name(),
'reason': 'Delivery failed'
})
failed_patients.append(
{
"file_number": file_number,
"patient_name": patient.get_full_name(),
"reason": "Delivery failed",
}
)
survey_instance.delete()
except Exception as e:
failed_count += 1
failed_patients.append({
'file_number': patient_info.get('file_number', 'unknown'),
'reason': str(e)
})
failed_patients.append({"file_number": patient_info.get("file_number", "unknown"), "reason": str(e)})
logger.error(f"Error processing patient in bulk job {job_id}: {e}")
# Update job with final results
@ -611,10 +587,10 @@ def send_bulk_surveys(self, job_id):
job.success_count = success_count
job.failed_count = failed_count
job.results = {
'success_count': success_count,
'failed_count': failed_count,
'failed_patients': failed_patients[:50], # Limit stored failures
'survey_ids': created_survey_ids[:100], # Limit stored IDs
"success_count": success_count,
"failed_count": failed_count,
"failed_patients": failed_patients[:50], # Limit stored failures
"survey_ids": created_survey_ids[:100], # Limit stored IDs
}
# Determine final status
@ -631,31 +607,31 @@ def send_bulk_surveys(self, job_id):
# Log audit
AuditService.log_event(
event_type='bulk_survey_completed',
event_type="bulk_survey_completed",
description=f"Bulk survey job completed: {success_count} sent, {failed_count} failed",
user=job.created_by,
metadata={
'job_id': str(job.id),
'hospital': hospital.name,
'survey_template': survey_template.name,
'success_count': success_count,
'failed_count': failed_count
}
"job_id": str(job.id),
"hospital": hospital.name,
"survey_template": survey_template.name,
"success_count": success_count,
"failed_count": failed_count,
},
)
logger.info(f"Bulk survey job {job_id} completed: {success_count} success, {failed_count} failed")
return {
'status': 'success',
'job_id': str(job.id),
'success_count': success_count,
'failed_count': failed_count,
'total': len(patient_data_list)
"status": "success",
"job_id": str(job.id),
"success_count": success_count,
"failed_count": failed_count,
"total": len(patient_data_list),
}
except BulkSurveyJob.DoesNotExist:
logger.error(f"BulkSurveyJob {job_id} not found")
return {'status': 'error', 'error': 'Job not found'}
return {"status": "error", "error": "Job not found"}
except Exception as e:
logger.error(f"Error in bulk survey task {job_id}: {e}", exc_info=True)
@ -675,7 +651,7 @@ def send_bulk_surveys(self, job_id):
logger.info(f"Retrying bulk survey job {job_id} (attempt {self.request.retries + 1})")
raise self.retry(countdown=60 * (self.request.retries + 1))
return {'status': 'error', 'error': str(e)}
return {"status": "error", "error": str(e)}
@shared_task
@ -701,12 +677,12 @@ def send_scheduled_survey(survey_instance_id):
# Check if already sent
if survey.status != SurveyStatus.PENDING:
logger.warning(f"Survey {survey.id} already sent/cancelled (status: {survey.status})")
return {'status': 'skipped', 'reason': 'already_sent', 'survey_id': survey.id}
return {"status": "skipped", "reason": "already_sent", "survey_id": survey.id}
# Check if scheduled time has passed
if survey.scheduled_send_at and survey.scheduled_send_at > timezone.now():
logger.warning(f"Survey {survey.id} not due yet (scheduled: {survey.scheduled_send_at})")
return {'status': 'delayed', 'scheduled_at': survey.scheduled_send_at.isoformat(), 'survey_id': survey.id}
return {"status": "delayed", "scheduled_at": survey.scheduled_send_at.isoformat(), "survey_id": survey.id}
# Send survey
success = SurveyDeliveryService.deliver_survey(survey)
@ -716,19 +692,19 @@ def send_scheduled_survey(survey_instance_id):
survey.sent_at = timezone.now()
survey.save()
logger.info(f"Scheduled survey {survey.id} sent successfully")
return {'status': 'sent', 'survey_id': survey.id}
return {"status": "sent", "survey_id": survey.id}
else:
survey.status = SurveyStatus.FAILED
survey.save()
logger.error(f"Scheduled survey {survey.id} delivery failed")
return {'status': 'failed', 'survey_id': survey.id, 'reason': 'delivery_failed'}
return {"status": "failed", "survey_id": survey.id, "reason": "delivery_failed"}
except SurveyInstance.DoesNotExist:
logger.error(f"Survey {survey_instance_id} not found")
return {'status': 'error', 'reason': 'not_found'}
return {"status": "error", "reason": "not_found"}
except Exception as e:
logger.error(f"Error sending scheduled survey: {e}", exc_info=True)
return {'status': 'error', 'reason': str(e)}
return {"status": "error", "reason": str(e)}
@shared_task
@ -742,13 +718,13 @@ def send_pending_scheduled_surveys():
Returns:
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
overdue_surveys = SurveyInstance.objects.filter(
status=SurveyStatus.PENDING,
scheduled_send_at__lte=timezone.now()
)[:50] # Max 50 at a time
# Use sent_at__isnull=True since there's no PENDING status
overdue_surveys = SurveyInstance.objects.filter(sent_at__isnull=True, scheduled_send_at__lte=timezone.now())[
:50
] # Max 50 at a time
sent_count = 0
for survey in overdue_surveys:
@ -758,4 +734,4 @@ def send_pending_scheduled_surveys():
if sent_count > 0:
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")
if request.method == "POST":
form = SurveyTemplateForm(request.POST, user=user)
form = SurveyTemplateForm(request.POST, request=request)
formset = SurveyQuestionFormSet(request.POST)
if form.is_valid() and formset.is_valid():
@ -286,7 +286,7 @@ def survey_template_create(request):
messages.success(request, "Survey template created successfully.")
return redirect("surveys:template_detail", pk=template.pk)
else:
form = SurveyTemplateForm(user=user)
form = SurveyTemplateForm(request=request)
formset = SurveyQuestionFormSet()
context = {
@ -345,7 +345,7 @@ def survey_template_edit(request, pk):
return redirect("surveys:template_list")
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)
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.")
return redirect("surveys:template_detail", pk=template.pk)
else:
form = SurveyTemplateForm(instance=template, user=user)
form = SurveyTemplateForm(instance=template, request=request)
formset = SurveyQuestionFormSet(instance=template)
context = {
@ -786,7 +786,7 @@ def manual_survey_send(request):
return redirect("surveys:instance_list")
if request.method == "POST":
form = ManualSurveySendForm(user, request.POST)
form = ManualSurveySendForm(request=request, data=request.POST)
if form.is_valid():
try:
@ -877,7 +877,7 @@ def manual_survey_send(request):
messages.error(request, f"Error sending survey: {str(e)}")
return render(request, "surveys/manual_send.html", {"form": form})
else:
form = ManualSurveySendForm(user)
form = ManualSurveySendForm(request=request)
context = {
"form": form,
@ -905,7 +905,7 @@ def manual_survey_send_phone(request):
return redirect("surveys:instance_list")
if request.method == "POST":
form = ManualPhoneSurveySendForm(user, request.POST)
form = ManualPhoneSurveySendForm(request=request, data=request.POST)
if form.is_valid():
try:
@ -968,7 +968,7 @@ def manual_survey_send_phone(request):
messages.error(request, f"Error sending survey: {str(e)}")
return render(request, "surveys/manual_send_phone.html", {"form": form})
else:
form = ManualPhoneSurveySendForm(user)
form = ManualPhoneSurveySendForm(request=request)
context = {
"form": form,
@ -1005,7 +1005,7 @@ def manual_survey_send_csv(request):
return redirect("surveys:instance_list")
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():
try:
@ -1150,7 +1150,7 @@ def manual_survey_send_csv(request):
messages.error(request, f"Error processing CSV: {str(e)}")
return render(request, "surveys/manual_send_csv.html", {"form": form})
else:
form = BulkCSVSurveySendForm(user)
form = BulkCSVSurveySendForm(request=request)
context = {
"form": form,

View File

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

View File

@ -1,36 +1,46 @@
"""
Custom Celery Beat scheduler to fix Python 3.12 zoneinfo compatibility issue.
This patches the _default_now method to work with zoneinfo.ZoneInfo instead of pytz.
This patches the TzAwareCrontab class to work with zoneinfo.ZoneInfo instead of pytz.
The error occurs because django-celery-beat assumes pytz timezone objects
which have a `normalize()` method that zoneinfo.ZoneInfo doesn't have.
"""
from django_celery_beat.schedulers import DatabaseScheduler, ModelEntry
import functools
class PatchedModelEntry(ModelEntry):
"""
Custom model entry that fixes the zoneinfo.ZoneInfo compatibility issue.
def apply_tzcrontab_patch():
"""
Apply monkey-patch to django_celery_beat.tzcrontab.TzAwareCrontab
to fix zoneinfo.ZoneInfo compatibility.
def _default_now(self):
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()
from django_celery_beat import tzcrontab
original_init = tzcrontab.TzAwareCrontab.__init__
class PatchedDatabaseScheduler(DatabaseScheduler):
"""
Custom scheduler that fixes the zoneinfo.ZoneInfo compatibility issue
in django-celery-beat 2.1.0 with Python 3.12.
"""
@functools.wraps(original_init)
def patched_init(self, *args, **kwargs):
# Get the tz argument, default to None
tz = kwargs.get('tz', None)
Entry = PatchedModelEntry
# Check if it's a zoneinfo.ZoneInfo (no 'normalize' attribute)
if tz is not None and not hasattr(tz, 'normalize'):
# Replace with a patched nowfun that works with zoneinfo
def zoneinfo_aware_nowfunc():
"""Get current time in the scheduler's timezone using Django's timezone utility."""
from django.utils import timezone as django_timezone
now = django_timezone.now()
return now.astimezone(self.tz)
def _default_now(self):
"""
Return the current time in the configured timezone.
This fixes the AttributeError: 'zoneinfo.ZoneInfo' object has no attribute 'localize'
"""
from django.utils import timezone
return timezone.now()
# 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",
"litellm>=1.0.0",
"watchdog>=6.0.0",
"django-celery-beat>=2.1.0",
"django-celery-beat>=2.7.0",
"google-api-python-client>=2.187.0",
"tweepy>=4.16.0",
"google-auth-oauthlib>=1.2.3",

7429
rating_data.json Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -3,37 +3,84 @@
{% block title %}{% trans "Acknowledgement Checklist Items" %} - PX360{% endblock %}
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.section-card {
background: white;
border-radius: 1rem;
border: 2px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.section-card:hover {
border-color: #005696;
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
}
.section-header {
padding: 1rem 1.5rem;
border-bottom: 2px solid #e2e8f0;
background: linear-gradient(to right, #f8fafc, #f1f5f9);
display: flex;
align-items: center;
gap: 0.75rem;
}
.section-icon {
width: 40px;
height: 40px;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
}
</style>
{% endblock %}
{% block content %}
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
<!-- Page Header -->
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
<div class="page-header-gradient">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-14 h-14 bg-gradient-to-br from-purple-500 to-indigo-500 rounded-2xl shadow-lg shadow-purple-200">
<i data-lucide="check-square" class="w-8 h-8 text-white"></i>
<div class="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 text-navy">
<h1 class="text-2xl font-bold">
{% trans "Checklist Items" %}
</h1>
<p class="text-slate text-sm">
<p class="text-white/80 text-sm">
{% trans "Manage acknowledgement checklist items" %}
</p>
</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">
<a href="{% url 'accounts:acknowledgements:ack_dashboard' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-white/20 border border-white/30 text-white rounded-xl font-semibold hover:bg-white/30 transition">
<i data-lucide="arrow-left" class="w-4 h-4"></i>
{% trans "Back" %}
</a>
<a href="{% url 'accounts:acknowledgements:ack_checklist_create' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-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">
<a href="{% url 'accounts:acknowledgements:ack_checklist_create' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-white text-purple-700 rounded-xl font-semibold hover:bg-purple-50 transition shadow-lg">
<i data-lucide="plus" class="w-4 h-4"></i>
{% trans "New Item" %}
</a>
</div>
</div>
</div>
<!-- Filter Section -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-4 mb-6">
<div class="section-card mb-6">
<div class="p-4">
<form method="get" class="flex flex-wrap gap-4 items-end">
<div>
<label class="block text-xs font-semibold text-slate mb-1">{% trans "Category" %}</label>
@ -61,15 +108,16 @@
</a>
</form>
</div>
</div>
<!-- Checklist Items List -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
<div class="px-6 py-5 border-b border-slate-100 bg-gradient-to-r from-purple-50 to-transparent">
<h2 class="text-lg font-bold text-navy flex items-center gap-2">
<i data-lucide="list" class="w-5 h-5 text-purple-500"></i>
{% trans "All Checklist Items" %}
<div class="section-card">
<div class="section-header">
<div class="section-icon bg-purple-100">
<i data-lucide="list" class="w-5 h-5 text-purple-600"></i>
</div>
<h2 class="text-lg font-bold text-navy">{% trans "All Checklist Items" %}</h2>
<span class="ml-2 px-2 py-0.5 bg-slate-100 text-slate text-sm rounded-full">{{ items.count }}</span>
</h2>
</div>
<div class="p-6">
{% if items %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,48 +3,95 @@
{% block title %}{% trans "My Acknowledgements" %} - PX360{% endblock %}
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.section-card {
background: white;
border-radius: 1rem;
border: 2px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.section-card:hover {
border-color: #005696;
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
}
.section-header {
padding: 1rem 1.5rem;
border-bottom: 2px solid #e2e8f0;
background: linear-gradient(to right, #f8fafc, #f1f5f9);
display: flex;
align-items: center;
gap: 0.75rem;
}
.section-icon {
width: 40px;
height: 40px;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
}
</style>
{% endblock %}
{% block content %}
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
<!-- Page Header -->
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
<div class="page-header-gradient">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-14 h-14 bg-gradient-to-br from-blue to-indigo-500 rounded-2xl shadow-lg shadow-blue-200">
<i data-lucide="clipboard-signature" class="w-8 h-8 text-white"></i>
<div class="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 text-navy">{% trans "My Acknowledgements" %}</h1>
<p class="text-slate text-sm">{% trans "Review and sign required documents" %}</p>
<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 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">
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 border-2 border-slate-200 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition">
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-100 text-emerald-700 rounded-xl font-semibold">
<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-100 text-orange-700 rounded-xl font-semibold animate-pulse">
<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>
{% if pending > 0 %}
<!-- Alert Banner -->
<div class="bg-gradient-to-r from-orange-500 to-orange-600 rounded-2xl shadow-lg shadow-orange-200 p-6 mb-8 text-white">
<div class="section-card mb-6 border-orange-300 bg-gradient-to-r from-orange-500 to-orange-600">
<div class="p-6 text-white">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center">
<i data-lucide="bell" class="w-6 h-6"></i>
@ -59,10 +106,12 @@
</div>
</div>
</div>
</div>
{% endif %}
<!-- Progress Card -->
<div class="bg-white rounded-2xl shadow-sm border border-blue-100 p-6 mb-6">
<div class="section-card mb-6">
<div class="p-6">
<div class="flex justify-between items-center mb-3">
<span class="font-semibold text-navy">{% trans "Completion Progress" %}</span>
<span class="text-2xl font-bold text-blue">{{ progress }}%</span>
@ -71,16 +120,17 @@
<div class="bg-gradient-to-r from-emerald-400 to-emerald-500 h-3 rounded-full transition-all duration-500" style="width: {{ progress }}%"></div>
</div>
</div>
</div>
<!-- Pending Acknowledgements -->
{% if pending_acks %}
<div class="bg-white rounded-2xl shadow-sm border border-orange-200 overflow-hidden mb-6">
<div class="px-6 py-4 border-b border-orange-100 bg-gradient-to-r from-orange-50 to-transparent">
<h5 class="font-bold text-navy flex items-center gap-2">
<i data-lucide="alert-circle" class="w-5 h-5 text-orange-500"></i>
{% trans "Pending Signatures" %}
<div class="section-card mb-6 border-orange-200">
<div class="section-header border-orange-200 bg-gradient-to-r from-orange-50 to-transparent">
<div class="section-icon bg-orange-100">
<i data-lucide="alert-circle" class="w-5 h-5 text-orange-600"></i>
</div>
<h2 class="text-lg font-bold text-navy">{% trans "Pending Signatures" %}</h2>
<span class="bg-orange-100 text-orange-700 px-2 py-0.5 rounded-full text-sm">{{ pending_acks|length }}</span>
</h5>
</div>
<div class="divide-y divide-orange-50">
{% for item in pending_acks %}
@ -134,13 +184,13 @@
<!-- Completed Acknowledgements -->
{% if signed_acks %}
<div class="bg-white rounded-2xl shadow-sm border border-emerald-200 overflow-hidden">
<div class="px-6 py-4 border-b border-emerald-100 bg-gradient-to-r from-emerald-50 to-transparent">
<h5 class="font-bold text-navy flex items-center gap-2">
<i data-lucide="check-circle" class="w-5 h-5 text-emerald-500"></i>
{% trans "Completed" %}
<div class="section-card border-emerald-200">
<div class="section-header border-emerald-200 bg-gradient-to-r from-emerald-50 to-transparent">
<div class="section-icon bg-emerald-100">
<i data-lucide="check-circle" class="w-5 h-5 text-emerald-600"></i>
</div>
<h2 class="text-lg font-bold text-navy">{% trans "Completed" %}</h2>
<span class="bg-emerald-100 text-emerald-700 px-2 py-0.5 rounded-full text-sm">{{ signed_acks|length }}</span>
</h5>
</div>
<div class="divide-y divide-emerald-50">
{% for item in signed_acks %}
@ -181,6 +231,7 @@
{% endif %}
{% if not pending_acks and not signed_acks %}
<div class="section-card">
<div class="text-center py-12">
<div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i data-lucide="clipboard-check" class="w-8 h-8 text-slate-400"></i>
@ -188,6 +239,7 @@
<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 %}
</div>

View File

@ -3,39 +3,100 @@
{% block title %}{% trans "Action Plans" %} - PX360{% endblock %}
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.section-card {
background: white;
border-radius: 1rem;
border: 2px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.section-card:hover {
border-color: #005696;
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
}
.section-header {
padding: 1rem 1.5rem;
border-bottom: 2px solid #e2e8f0;
background: linear-gradient(to right, #f8fafc, #f1f5f9);
display: flex;
align-items: center;
gap: 0.75rem;
}
.section-icon {
width: 40px;
height: 40px;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
}
.section-icon-blue {
background: linear-gradient(135deg, #005696, #007bbd);
color: white;
}
.section-icon-orange {
background: linear-gradient(135deg, #f97316, #fb923c);
color: white;
}
.section-icon-gray {
background: linear-gradient(135deg, #64748b, #94a3b8);
color: white;
}
</style>
{% endblock %}
{% block content %}
<div class="mb-8">
<!-- Page Header -->
<div class="page-header-gradient">
<div class="flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold text-navy mb-2 flex items-center gap-3">
<i data-lucide="check-circle-2" class="w-8 h-8 text-orange-500"></i>
<h1 class="text-3xl font-bold mb-2 flex items-center gap-3">
<i data-lucide="check-circle-2" class="w-8 h-8"></i>
{% trans "Action Plans" %}
</h1>
<p class="text-slate">{% trans "Manage improvement action plans" %}</p>
<p class="opacity-90">{% trans "Manage improvement action plans" %}</p>
</div>
<a href="{% url 'actions:action_create' %}" class="bg-navy text-white px-6 py-3 rounded-2xl font-bold flex items-center gap-2 shadow-lg shadow-blue-200 hover:bg-blue transition">
<a href="{% url 'actions:action_create' %}" class="bg-white text-[#005696] px-6 py-3 rounded-xl font-bold flex items-center gap-2 shadow-lg hover:bg-gray-50 transition">
<i data-lucide="plus" class="w-5 h-5"></i> {% trans "Create Action Plan" %}
</a>
</div>
</div>
<!-- Filters -->
<div class="bg-white rounded-2xl shadow-sm border-2 border-slate-200 mb-6">
<div class="px-6 py-4 border-b-2 border-slate-200 flex justify-between items-center bg-gradient-to-r from-slate-50 to-slate-100">
<h3 class="font-bold text-gray-800 flex items-center gap-2">
<i data-lucide="filter" class="w-5 h-5 text-navy"></i>
{% trans "Filters" %}
</h3>
<!-- Filters Section -->
<div class="section-card mb-6">
<div class="section-header">
<div class="section-icon section-icon-blue">
<i data-lucide="filter" class="w-5 h-5"></i>
</div>
<h3 class="font-bold text-gray-800">{% trans "Filters" %}</h3>
</div>
<div class="p-6">
<form method="get" class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="md:col-span-2">
<label class="block text-sm font-semibold text-gray-700 mb-2">{% trans "Search" %}</label>
<input type="text" class="w-full px-4 py-3 border border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-navy focus:border-transparent transition" name="search" placeholder="{% trans 'Search action plans...' %}" value="{{ filters.search }}">
<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">
<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>
@ -45,7 +106,7 @@
</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">
<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>
@ -54,7 +115,7 @@
</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">
<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">
@ -66,10 +127,23 @@
</div>
</div>
<!-- Action Plans Section -->
<div class="section-card">
<div class="section-header">
<div class="section-icon section-icon-orange">
<i data-lucide="check-square" class="w-5 h-5"></i>
</div>
<h3 class="font-bold text-gray-800">{% trans "Action Plans" %}</h3>
<span class="ml-auto bg-gray-100 text-gray-600 px-3 py-1 rounded-full text-sm font-semibold">
{{ page_obj.paginator.count|default:actions|length }} {% trans "Total" %}
</span>
</div>
<div class="p-6">
<!-- Action Plans Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for action in actions %}
<div class="bg-white rounded-2xl shadow-sm border border-gray-50 overflow-hidden hover:shadow-md transition">
<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">
@ -100,14 +174,14 @@
<div class="space-y-2 text-sm">
{% if action.due_date %}
<div class="flex items-center gap-2 text-gray-600">
<i data-lucide="calendar" class="w-4 h-4"></i>
<i data-lucide="calendar" class="w-4 h-4 text-[#005696]"></i>
<span>{% trans "Due:" %} {{ action.due_date|date:"M d, Y" }}</span>
</div>
{% endif %}
{% if action.assigned_to %}
<div class="flex items-center gap-2 text-gray-600">
<i data-lucide="user" class="w-4 h-4"></i>
<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 %}
@ -115,18 +189,20 @@
</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">
<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>
{% 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>
<div class="bg-gray-50 rounded-2xl border-2 border-dashed border-gray-200 p-12 text-center">
<div class="section-icon section-icon-gray mx-auto mb-4" style="width: 64px; height: 64px;">
<i data-lucide="check-square" class="w-8 h-8"></i>
</div>
<h3 class="text-xl font-bold text-gray-800 mb-2">{% trans "No Action Plans Found" %}</h3>
<p class="text-gray-500 mb-6">{% trans "Get started by creating your first action plan" %}</p>
<a href="{% url 'actions:action_create' %}" class="inline-flex items-center gap-2 bg-navy text-white px-6 py-3 rounded-2xl font-bold shadow-lg shadow-blue-200 hover:bg-blue transition">
<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>
@ -140,20 +216,22 @@
<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" %}
<i data-lucide="chevron-left" class="w-4 h-4 inline"></i> {% trans "Previous" %}
</a>
{% endif %}
<span class="px-4 py-2 bg-navy text-white rounded-xl text-sm font-bold">
<span class="px-4 py-2 bg-[#005696] text-white rounded-xl text-sm font-bold">
{% blocktrans with current=page_obj.number total=page_obj.paginator.num_pages %}Page {{ current }} of {{ total }}{% endblocktrans %}
</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}&{{ filters|urlencode }}" class="px-4 py-2 border border-gray-200 rounded-xl text-sm font-semibold hover:bg-gray-50 transition">
{% trans "Next" %} <i data-lucide="chevron-right" class="w-4 h-4"></i>
{% trans "Next" %} <i data-lucide="chevron-right" class="w-4 h-4 inline"></i>
</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

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

View File

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

View File

@ -5,6 +5,42 @@
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.section-card {
background: white;
border-radius: 1rem;
border: 2px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.section-card:hover {
border-color: #005696;
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
}
.section-header {
padding: 1rem 1.5rem;
border-bottom: 2px solid #e2e8f0;
background: linear-gradient(to right, #f8fafc, #f1f5f9);
display: flex;
align-items: center;
gap: 0.75rem;
}
.section-icon {
width: 40px;
height: 40px;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
}
.status-completed { background-color: #dcfce7; color: #166534; }
.status-pending { background-color: #fef9c3; color: #854d0e; }
.status-generating { background-color: #e0f2fe; color: #075985; }
@ -16,29 +52,29 @@
{% endblock %}
{% block content %}
<!-- Header -->
<header class="mb-6">
<!-- Page Header -->
<div class="page-header-gradient">
<div class="flex justify-between items-start">
<div>
<h1 class="text-2xl font-bold text-navy flex items-center gap-3">
<h1 class="text-2xl font-bold flex items-center gap-3">
<i data-lucide="bar-chart-3" class="w-7 h-7"></i>
{% trans "KPI Reports" %}
</h1>
<p class="text-sm text-slate mt-1">{% trans "Monthly automated reports for MOH and internal KPIs" %}</p>
<p class="text-sm mt-1 opacity-75">{% trans "Monthly automated reports for MOH and internal KPIs" %}</p>
</div>
<div class="flex items-center gap-3">
<div class="relative group">
<i data-lucide="search" class="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate group-focus-within:text-navy"></i>
<i data-lucide="search" class="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-white/70 group-focus-within:text-white"></i>
<input type="text" id="searchInput" placeholder="{% trans 'Search KPI ID or indicator...' %}"
class="pl-10 pr-4 py-2.5 bg-slate-100 border-transparent border focus:border-navy/30 focus:bg-white rounded-xl text-sm outline-none w-64 transition-all">
class="pl-10 pr-4 py-2.5 bg-white/20 border-transparent border focus:border-white/50 focus:bg-white/30 rounded-xl text-sm outline-none w-64 transition-all text-white placeholder-white/70">
</div>
<a href="{% url 'analytics:kpi_report_generate' %}"
class="bg-navy text-white px-5 py-2.5 rounded-xl text-sm font-bold shadow-lg shadow-navy/20 hover:bg-blue flex items-center gap-2 transition">
class="bg-white text-[#005696] px-5 py-2.5 rounded-xl text-sm font-bold shadow-lg hover:bg-gray-100 flex items-center gap-2 transition">
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "Generate Report" %}
</a>
</div>
</div>
</header>
</div>
<!-- Statistics Cards -->
<div class="grid grid-cols-4 gap-6 mb-6">
@ -80,8 +116,20 @@
</div>
</div>
<!-- Filter Section -->
<div class="section-card mb-6">
<div class="section-header">
<div class="section-icon" style="background: linear-gradient(135deg, #005696, #007bbd);">
<i data-lucide="filter" style="width: 20px; height: 20px; color: white;"></i>
</div>
<h5 class="mb-0 fw-bold">{% trans "Filters" %}</h5>
<button onclick="toggleFilters()" class="ms-auto text-slate hover:text-navy transition p-2 rounded-lg hover:bg-white">
<i data-lucide="chevron-up" id="filterToggleIcon" class="w-5 h-5"></i>
</button>
</div>
<!-- Filter Tabs -->
<div class="bg-white px-6 py-4 rounded-t-2xl border-b flex items-center justify-between">
<div class="px-6 py-4 border-b flex items-center justify-between">
<div class="flex items-center gap-3">
<a href="?" class="filter-btn px-4 py-1.5 rounded-full text-xs font-semibold transition {% if not filters.status %}active{% endif %}">
{% trans "All Reports" %}
@ -95,10 +143,6 @@
<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>
</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>
@ -106,7 +150,7 @@
</div>
<!-- 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">
{% if filters.status %}
<input type="hidden" name="status" value="{{ filters.status }}">
@ -151,10 +195,12 @@
<a href="?" class="px-4 py-1.5 border rounded-lg text-xs font-semibold text-slate hover:bg-white">{% trans "Clear" %}</a>
</form>
</div>
</div>
<!-- Reports Grid -->
{% if reports %}
<div class="bg-white rounded-b-2xl shadow-sm border p-6">
<div class="section-card">
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{% for report in reports %}
<div class="card bg-white p-5 border border-slate-200 rounded-2xl hover:shadow-lg hover:-translate-y-1 transition-all duration-200 cursor-pointer group"
@ -294,8 +340,9 @@
</div>
{% endif %}
</div>
</div>
{% else %}
<div class="card text-center py-16">
<div class="section-card text-center py-16">
<i data-lucide="bar-chart-3" class="w-20 h-20 mx-auto text-slate-300 mb-4"></i>
<h3 class="text-lg font-bold text-navy mb-2">{% trans "No KPI Reports Found" %}</h3>
<p class="text-slate mb-6">{% trans "Generate your first KPI report to get started." %}</p>
@ -311,7 +358,14 @@
<script>
function toggleFilters() {
const filters = document.getElementById('advancedFilters');
const icon = document.getElementById('filterToggleIcon');
filters.classList.toggle('hidden');
if (filters.classList.contains('hidden')) {
icon.setAttribute('data-lucide', 'chevron-down');
} else {
icon.setAttribute('data-lucide', 'chevron-up');
}
lucide.createIcons();
}
// Search functionality

View File

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

View File

@ -3,42 +3,126 @@
{% block title %}{% trans "Send Appreciation" %} - {% endblock %}
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.form-section {
background: #fff;
border: 2px solid #e2e8f0;
border-radius: 1rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
transition: all 0.3s ease;
}
.form-section:hover {
border-color: #005696;
box-shadow: 0 4px 12px rgba(0, 86, 150, 0.1);
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: #1e293b;
margin-bottom: 0.5rem;
}
.form-control, .form-select {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
font-size: 0.875rem;
transition: all 0.2s ease;
}
.form-control:focus, .form-select:focus {
outline: none;
border-color: #005696;
box-shadow: 0 0 0 3px rgba(0, 86, 150, 0.1);
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: #005696;
color: white;
border-radius: 0.75rem;
font-weight: 600;
transition: all 0.2s ease;
border: none;
cursor: pointer;
}
.btn-primary:hover {
background: #007bbd;
}
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: white;
color: #64748b;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
font-weight: 600;
transition: all 0.2s ease;
}
.btn-secondary:hover {
background: #f1f5f9;
border-color: #005696;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="p-4">
<!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'appreciation:appreciation_list' %}">{% trans "Appreciation" %}</a></li>
<li class="breadcrumb-item active" aria-current="page">{% trans "Send Appreciation" %}</li>
<ol class="flex flex-wrap items-center gap-2 text-sm">
<li><a href="{% url 'appreciation:appreciation_list' %}" class="text-blue-600 hover:text-blue-800">{% trans "Appreciation" %}</a></li>
<li class="text-gray-400"><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
<li class="text-gray-600 font-medium" aria-current="page">{% trans "Send Appreciation" %}</li>
</ol>
</nav>
<!-- Send Appreciation Form -->
<div class="row">
<div class="col-lg-8">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h4 class="mb-0">
<i class="bi-send me-2"></i>
<!-- Header -->
<div class="page-header-gradient">
<h1 class="text-2xl font-bold flex items-center gap-3">
<i data-lucide="send" class="w-6 h-6"></i>
{% trans "Send Appreciation" %}
</h4>
</h1>
</div>
<div class="card-body">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Send Appreciation Form -->
<div class="lg:col-span-2">
<div class="form-section">
<form method="post" id="appreciationForm">
{% csrf_token %}
<!-- Recipient Type -->
<div class="row mb-3">
<div class="col-md-6">
<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-danger">*</span>
{% trans "Recipient Type" %} <span class="text-red-500">*</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">
<div>
<input type="hidden" name="hospital_id" value="{{ current_hospital.id }}">
<label class="form-label">{% trans "Hospital" %}</label>
<input type="text" class="form-control" value="{{ current_hospital.name }}" readonly>
@ -46,27 +130,27 @@
</div>
<!-- Recipient -->
<div class="mb-3">
<div class="mb-4">
<label for="recipient_id" class="form-label">
{% trans "Recipient" %} <span class="text-danger">*</span>
{% trans "Recipient" %} <span class="text-red-500">*</span>
</label>
<select class="form-select" id="recipient_id" name="recipient_id" required disabled>
<option value="">-- {% trans "Select Recipient" %} --</option>
</select>
<div class="form-text" id="recipientHelp">{% trans "Select a hospital first" %}</div>
<p class="text-sm text-gray-500 mt-1" id="recipientHelp">{% trans "Select a hospital first" %}</p>
</div>
<!-- Department (Optional) -->
<div class="mb-3">
<div class="mb-4">
<label for="department_id" class="form-label">{% trans "Department" %}</label>
<select class="form-select" id="department_id" name="department_id">
<option value="">-- {% trans "Select Department" %} --</option>
</select>
<div class="form-text">{% trans "Optional: Select if related to a specific department" %}</div>
<p class="text-sm text-gray-500 mt-1">{% trans "Optional: Select if related to a specific department" %}</p>
</div>
<!-- Category -->
<div class="mb-3">
<div class="mb-4">
<label for="category_id" class="form-label">{% trans "Category" %}</label>
<select class="form-select" id="category_id" name="category_id">
<option value="">-- {% trans "Select Category" %} --</option>
@ -79,9 +163,9 @@
</div>
<!-- Message (English) -->
<div class="mb-3">
<div class="mb-4">
<label for="message_en" class="form-label">
{% trans "Message (English)" %} <span class="text-danger">*</span>
{% trans "Message (English)" %} <span class="text-red-500">*</span>
</label>
<textarea
class="form-control"
@ -91,11 +175,11 @@
required
placeholder="{% trans 'Write your appreciation message here...' %}"
></textarea>
<div class="form-text">{% trans "Required: Appreciation message in English" %}</div>
<p class="text-sm text-gray-500 mt-1">{% trans "Required: Appreciation message in English" %}</p>
</div>
<!-- Message (Arabic) -->
<div class="mb-3">
<div class="mb-4">
<label for="message_ar" class="form-label">{% trans "Message (Arabic)" %}</label>
<textarea
class="form-control"
@ -105,11 +189,11 @@
dir="rtl"
placeholder="{% trans 'اكتب رسالة التقدير هنا...' %}"
></textarea>
<div class="form-text">{% trans "Optional: Appreciation message in Arabic" %}</div>
<p class="text-sm text-gray-500 mt-1">{% trans "Optional: Appreciation message in Arabic" %}</p>
</div>
<!-- Visibility -->
<div class="mb-3">
<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 %}
@ -121,104 +205,98 @@
</div>
<!-- Anonymous -->
<div class="mb-4">
<div class="form-check">
<div class="mb-6">
<div class="flex items-start gap-3">
<input
class="form-check-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"
>
<label class="form-check-label" for="is_anonymous">
<div>
<label class="text-sm font-medium text-gray-700" for="is_anonymous">
{% trans "Send anonymously" %}
</label>
<div class="form-text">{% trans "Your name will not be shown to the recipient" %}</div>
<p class="text-sm text-gray-500">{% trans "Your name will not be shown to the recipient" %}</p>
</div>
</div>
</div>
<!-- Submit Button -->
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="{% url 'appreciation:appreciation_list' %}" class="btn btn-outline-secondary">
<i class="bi-x me-2"></i>
<div class="flex flex-wrap justify-end gap-3">
<a href="{% url 'appreciation:appreciation_list' %}" class="btn-secondary">
<i data-lucide="x" class="w-4 h-4"></i>
{% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-success">
<i class="bi-send me-2"></i>
<button type="submit" class="btn-primary">
<i data-lucide="send" class="w-4 h-4"></i>
{% trans "Send Appreciation" %}
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<div class="lg:col-span-1">
<!-- Tips -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light">
<h6 class="card-title mb-0">
<i class="bi-lightbulb me-2"></i>
<div class="form-section">
<h6 class="font-semibold text-gray-800 mb-4 flex items-center gap-2">
<i data-lucide="lightbulb" class="w-4 h-4 text-yellow-500"></i>
{% trans "Tips for Writing Appreciation" %}
</h6>
</div>
<div class="card-body">
<ul class="list-unstyled mb-0">
<li class="mb-2">
<i class="bi-check text-success me-2"></i>
<ul class="space-y-2 text-sm">
<li class="flex items-start gap-2">
<i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5"></i>
{% trans "Be specific about what you appreciate" %}
</li>
<li class="mb-2">
<i class="bi-check text-success me-2"></i>
<li class="flex items-start gap-2">
<i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5"></i>
{% trans "Use the person's name when addressing them" %}
</li>
<li class="mb-2">
<i class="bi-check text-success me-2"></i>
<li class="flex items-start gap-2">
<i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5"></i>
{% trans "Mention the impact of their actions" %}
</li>
<li class="mb-2">
<i class="bi-check text-success me-2"></i>
<li class="flex items-start gap-2">
<i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5"></i>
{% trans "Be sincere and authentic" %}
</li>
<li>
<i class="bi-check text-success me-2"></i>
<li class="flex items-start gap-2">
<i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5"></i>
{% trans "Keep it positive and uplifting" %}
</li>
</ul>
</div>
</div>
<!-- Visibility Guide -->
<div class="card shadow-sm">
<div class="card-header bg-light">
<h6 class="card-title mb-0">
<i class="bi-info-circle me-2"></i>
<div class="form-section">
<h6 class="font-semibold text-gray-800 mb-4 flex items-center gap-2">
<i data-lucide="info" class="w-4 h-4 text-blue-500"></i>
{% trans "Visibility Levels" %}
</h6>
</div>
<div class="card-body">
<ul class="list-unstyled mb-0">
<li class="mb-2">
<strong>{% trans "Private:" %}</strong>
<p class="small text-muted mb-0">
<ul class="space-y-3 text-sm">
<li>
<strong class="text-gray-800">{% trans "Private:" %}</strong>
<p class="text-gray-500 text-xs">
{% trans "Only you and the recipient can see this appreciation" %}
</p>
</li>
<li class="mb-2">
<strong>{% trans "Department:" %}</strong>
<p class="small text-muted mb-0">
<li>
<strong class="text-gray-800">{% trans "Department:" %}</strong>
<p class="text-gray-500 text-xs">
{% trans "Visible to everyone in the selected department" %}
</p>
</li>
<li class="mb-2">
<strong>{% trans "Hospital:" %}</strong>
<p class="small text-muted mb-0">
<li>
<strong class="text-gray-800">{% trans "Hospital:" %}</strong>
<p class="text-gray-500 text-xs">
{% trans "Visible to everyone in the selected hospital" %}
</p>
</li>
<li>
<strong>{% trans "Public:" %}</strong>
<p class="small text-muted mb-0">
<strong class="text-gray-800">{% trans "Public:" %}</strong>
<p class="text-gray-500 text-xs">
{% trans "Visible to all PX360 users" %}
</p>
</li>
@ -227,7 +305,6 @@
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}

View File

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

View File

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

View File

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

View File

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

View File

@ -4,29 +4,80 @@
{% block title %}{% trans "Call Records" %} - PX360{% endblock %}
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.section-card {
background: white;
border-radius: 1rem;
border: 2px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.section-card:hover {
border-color: #005696;
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
}
.section-header {
padding: 1rem 1.5rem;
border-bottom: 2px solid #e2e8f0;
background: linear-gradient(to right, #f8fafc, #f1f5f9);
display: flex;
align-items: center;
gap: 0.75rem;
}
.section-icon {
width: 40px;
height: 40px;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
}
</style>
{% endblock %}
{% block content %}
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<!-- Page Header -->
<div class="page-header-gradient">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="section-icon bg-white/20">
<i data-lucide="phone-call" class="w-6 h-6"></i>
</div>
<div>
<h1 class="text-2xl font-bold text-navy">{% trans "Call Records" %}</h1>
<p class="text-slate text-sm mt-1">{% trans "Manage and analyze imported call center recordings" %}</p>
<h1 class="text-2xl font-bold mb-1">{% trans "Call Records" %}</h1>
<p class="opacity-90 mb-0">{% trans "Manage and analyze imported call center recordings" %}</p>
</div>
</div>
<div class="flex items-center gap-3">
<a href="{% url 'callcenter:export_call_records_template' %}"
class="px-4 py-2 border border-slate-200 text-slate rounded-xl font-semibold hover:bg-light transition flex items-center gap-2">
class="px-4 py-2 border border-white/30 text-white rounded-xl font-semibold hover:bg-white/20 transition flex items-center gap-2">
<i data-lucide="download" class="w-4 h-4"></i>
{% trans "Download Template" %}
</a>
<a href="{% url 'callcenter:import_call_records' %}"
class="px-4 py-2 bg-navy text-white rounded-xl font-semibold hover:bg-blue transition flex items-center gap-2">
class="px-4 py-2 bg-white text-blue-600 rounded-xl font-semibold hover:bg-blue-50 transition flex items-center gap-2">
<i data-lucide="upload" class="w-4 h-4"></i>
{% trans "Import CSV" %}
</a>
</div>
</div>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 mb-6">
<!-- Total Calls -->
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
<div class="flex items-center justify-between mb-4">
@ -89,7 +140,14 @@
</div>
<!-- Filters -->
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 mb-6">
<div class="section-card mb-6">
<div class="section-header">
<div class="section-icon bg-blue-100">
<i data-lucide="filter" class="w-5 h-5 text-blue-600"></i>
</div>
<h3 class="font-bold text-navy">{% trans "Filters" %}</h3>
</div>
<div class="p-5">
<form method="get" class="flex flex-wrap gap-4">
<div class="flex-1 min-w-48">
<label class="block text-xs font-bold text-slate uppercase mb-1.5">{% trans "Search" %}</label>
@ -140,9 +198,16 @@
</div>
</form>
</div>
</div>
<!-- Call Records Table -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
<div class="section-card">
<div class="section-header">
<div class="section-icon bg-blue-100">
<i data-lucide="list" class="w-5 h-5 text-blue-600"></i>
</div>
<h3 class="font-bold text-navy">{% trans "Call Records" %}</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-slate-50 border-b border-slate-200">
@ -256,9 +321,3 @@
{% endif %}
</div>
{% endblock %}
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>

View File

@ -6,6 +6,46 @@
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.section-card {
background: white;
border-radius: 1rem;
border: 2px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.section-card:hover {
border-color: #005696;
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
}
.section-header {
padding: 1rem 1.5rem;
border-bottom: 2px solid #e2e8f0;
background: linear-gradient(to right, #f8fafc, #f1f5f9);
display: flex;
align-items: center;
gap: 0.75rem;
}
.section-icon {
width: 40px;
height: 40px;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
}
.badge-status-open { background: #e0f2fe; color: #075985; }
.badge-status-in_progress { background: #fef9c3; color: #854d0e; }
.badge-status-resolved { background: #dcfce7; color: #166534; }
@ -18,21 +58,23 @@
{% endblock %}
{% block content %}
<!-- Header -->
<header class="mb-6">
<div class="flex justify-between items-start">
<div>
<h1 class="text-2xl font-bold text-navy flex items-center gap-3">
<i data-lucide="phone" class="w-7 h-7 text-blue"></i>
{% trans "Call Center Complaints" %}
</h1>
<p class="text-sm text-slate mt-1">{% trans "Complaints created via call center" %}</p>
<!-- Page Header -->
<div class="page-header-gradient">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="section-icon bg-white/20">
<i data-lucide="phone" class="w-6 h-6"></i>
</div>
<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">
<div>
<h1 class="text-2xl font-bold mb-1">{% trans "Call Center Complaints" %}</h1>
<p class="opacity-90 mb-0">{% trans "Complaints created via call center" %}</p>
</div>
</div>
<a href="{% url 'callcenter:create_complaint' %}" class="bg-white text-blue-600 px-5 py-2.5 rounded-xl text-sm font-bold hover:bg-blue-50 flex items-center gap-2 transition">
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "Create Complaint" %}
</a>
</div>
</header>
</div>
<!-- Statistics -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
@ -75,12 +117,12 @@
</div>
<!-- Filters -->
<div class="bg-white rounded-2xl shadow-sm border-2 border-slate-200 mb-6">
<div class="px-6 py-4 border-b-2 border-slate-200 flex justify-between items-center bg-gradient-to-r from-slate-50 to-slate-100">
<h3 class="font-bold text-navy flex items-center gap-2">
<i data-lucide="filter" class="w-5 h-5 text-navy"></i>
{% trans "Filters" %}
</h3>
<div class="section-card mb-6">
<div class="section-header">
<div class="section-icon bg-blue-100">
<i data-lucide="filter" class="w-5 h-5 text-blue-600"></i>
</div>
<h3 class="font-bold text-navy">{% trans "Filters" %}</h3>
</div>
<div class="p-5">
<form method="get" class="grid grid-cols-1 md:grid-cols-5 gap-4">
@ -120,12 +162,12 @@
</div>
<!-- Complaints Table -->
<div class="bg-white rounded-2xl shadow-sm border-2 border-slate-200 overflow-hidden">
<div class="px-6 py-4 border-b-2 border-slate-200 bg-gradient-to-r from-slate-50 to-slate-100 flex justify-between items-center">
<h3 class="font-bold text-navy flex items-center gap-2">
<i data-lucide="message-square-warning" class="w-5 h-5 text-red-500"></i>
{% trans "Complaints" %}
</h3>
<div class="section-card">
<div class="section-header">
<div class="section-icon bg-red-100">
<i data-lucide="message-square-warning" class="w-5 h-5 text-red-600"></i>
</div>
<h3 class="font-bold text-navy">{% trans "Complaints" %}</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full">

View File

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

View File

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

View File

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

View File

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

View File

@ -747,7 +747,10 @@
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
// Hospital field - could be select or hidden input depending on user role
const hospitalSelect = document.getElementById('hospitalSelect');
const hospitalField = hospitalSelect || document.querySelector('input[name="hospital"]');
const departmentSelect = document.getElementById('departmentSelect');
const staffSelect = document.getElementById('staffSelect');
const locationSelect = document.getElementById('locationSelect');
@ -756,6 +759,36 @@ document.addEventListener('DOMContentLoaded', function() {
const complaintTypeCards = document.querySelectorAll('.type-card');
const complaintTypeInput = document.getElementById('complaintTypeInput');
// Load departments on page load if hospital is already selected
function loadDepartments(hospitalId) {
if (!hospitalId || !departmentSelect) {
if (departmentSelect) {
departmentSelect.innerHTML = '<option value="">{% trans "Select Department" %}</option>';
}
return;
}
departmentSelect.innerHTML = '<option value="">{% trans "Loading..." %}</option>';
fetch('/organizations/api/departments/?hospital=' + hospitalId)
.then(response => response.json())
.then(data => {
const results = data.results || data;
departmentSelect.innerHTML = '<option value="">{% trans "Select Department" %}</option>';
results.forEach(dept => {
const option = document.createElement('option');
option.value = dept.id;
option.textContent = dept.name;
departmentSelect.appendChild(option);
});
})
.catch(error => {
console.error('Error loading departments:', error);
departmentSelect.innerHTML = '<option value="">{% trans "Error loading departments" %}</option>';
});
}
// Complaint type selection
complaintTypeCards.forEach(card => {
card.addEventListener('click', function() {
@ -767,7 +800,7 @@ document.addEventListener('DOMContentLoaded', function() {
});
});
// Hospital change handler
// Hospital change handler (only for select, not hidden fields)
if (hospitalSelect) {
hospitalSelect.addEventListener('change', function() {
const hospitalId = this.value;
@ -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
if (locationSelect) {
locationSelect.addEventListener('change', function() {

View File

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

View File

@ -3,41 +3,171 @@
{% block title %}{{ title }} - {% translate "Complaint Thresholds" %} - PX360{% endblock %}
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.form-section {
background: #fff;
border: 2px solid #e2e8f0;
border-radius: 1rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
transition: all 0.3s ease;
}
.form-section:hover {
border-color: #005696;
box-shadow: 0 4px 12px rgba(0, 86, 150, 0.1);
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: #1e293b;
margin-bottom: 0.5rem;
}
.form-control, .form-select {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
font-size: 0.875rem;
transition: all 0.2s ease;
}
.form-control:focus, .form-select:focus {
outline: none;
border-color: #005696;
box-shadow: 0 0 0 3px rgba(0, 86, 150, 0.1);
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: #005696;
color: white;
border-radius: 0.75rem;
font-weight: 600;
transition: all 0.2s ease;
border: none;
cursor: pointer;
}
.btn-primary:hover {
background: #007bbd;
}
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: white;
color: #64748b;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
font-weight: 600;
transition: all 0.2s ease;
}
.btn-secondary:hover {
background: #f1f5f9;
border-color: #005696;
}
.help-card {
background: #f8fafc;
border: 2px solid #e2e8f0;
border-radius: 1rem;
padding: 1.5rem;
}
.form-switch {
display: flex;
align-items: center;
gap: 0.75rem;
}
.form-switch-input {
width: 3rem;
height: 1.5rem;
appearance: none;
background: #cbd5e1;
border-radius: 1rem;
position: relative;
cursor: pointer;
transition: all 0.2s ease;
}
.form-switch-input:checked {
background: #005696;
}
.form-switch-input::after {
content: '';
position: absolute;
width: 1.25rem;
height: 1.25rem;
background: white;
border-radius: 50%;
top: 0.125rem;
left: 0.125rem;
transition: all 0.2s ease;
}
.form-switch-input:checked::after {
left: 1.625rem;
}
.invalid-feedback {
color: #dc2626;
font-size: 0.875rem;
margin-top: 0.25rem;
}
.form-text {
color: #64748b;
font-size: 0.875rem;
margin-top: 0.25rem;
}
</style>
{% endblock %}
{% block content %}
<div class="page-header">
<div class="page-header-content">
<!-- Gradient Header -->
<div class="page-header-gradient">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center">
<i data-lucide="gauge" class="w-6 h-6"></i>
</div>
<div>
<h1 class="page-title">
<i class="fas fa-chart-line"></i>
{{ title }}
</h1>
<p class="page-description">
{% if threshold %}
{% translate "Edit complaint threshold" %}
{% else %}
{% translate "Create new complaint threshold" %}
{% endif %}
<h1 class="text-2xl font-bold">{{ title }}</h1>
<p class="text-white/80">
{% if threshold %}{% translate "Edit complaint threshold" %}{% else %}{% translate "Create new complaint threshold" %}{% endif %}
</p>
</div>
<a href="{% url 'complaints:complaint_threshold_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i>
</div>
<a href="{% url 'complaints:complaint_threshold_list' %}" class="btn-secondary">
<i data-lucide="arrow-left" class="w-4 h-4"></i>
{% translate "Back to List" %}
</a>
</div>
</div>
<div class="page-content">
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-body">
<form method="post" class="row g-3">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2">
<div class="form-section">
<form method="post" class="space-y-6">
{% csrf_token %}
{% if not form.hospital.is_hidden %}
<div class="col-md-12">
<div>
<label for="id_hospital" class="form-label">
{% translate "Hospital" %} <span class="text-danger">*</span>
{% translate "Hospital" %} <span class="text-red-500">*</span>
</label>
<select name="hospital" id="id_hospital" class="form-select" required>
<option value="">{% translate "Select Hospital" %}</option>
@ -49,16 +179,17 @@
{% endfor %}
</select>
{% if form.hospital.errors %}
<div class="invalid-feedback d-block">
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
{{ form.hospital.errors.0 }}
</div>
{% endif %}
</div>
{% endif %}
<div class="col-md-12">
<div>
<label for="id_name" class="form-label">
{% translate "Threshold Name" %} <span class="text-danger">*</span>
{% translate "Threshold Name" %} <span class="text-red-500">*</span>
</label>
<input type="text"
name="name"
@ -68,15 +199,17 @@
required
placeholder="{% translate 'e.g., Daily Complaint Limit' %}">
{% if form.name.errors %}
<div class="invalid-feedback d-block">
<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="col-md-6">
<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-danger">*</span>
{% 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>
@ -88,15 +221,16 @@
{% endfor %}
</select>
{% if form.threshold_type.errors %}
<div class="invalid-feedback d-block">
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
{{ form.threshold_type.errors.0 }}
</div>
{% endif %}
</div>
<div class="col-md-6">
<div>
<label for="id_metric_type" class="form-label">
{% translate "Metric Type" %} <span class="text-danger">*</span>
{% translate "Metric Type" %} <span class="text-red-500">*</span>
</label>
<select name="metric_type" id="id_metric_type" class="form-select" required>
<option value="">{% translate "Select Metric" %}</option>
@ -108,15 +242,18 @@
{% endfor %}
</select>
{% if form.metric_type.errors %}
<div class="invalid-feedback d-block">
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
{{ form.metric_type.errors.0 }}
</div>
{% endif %}
</div>
</div>
<div class="col-md-6">
<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-danger">*</span>
{% translate "Threshold Value" %} <span class="text-red-500">*</span>
</label>
<input type="number"
name="threshold_value"
@ -127,15 +264,16 @@
required
placeholder="{% translate 'e.g., 10' %}">
{% if form.threshold_value.errors %}
<div class="invalid-feedback d-block">
<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 class="col-md-6">
<div>
<label for="id_action" class="form-label">
{% translate "Action to Take" %} <span class="text-danger">*</span>
{% 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>
@ -147,13 +285,15 @@
{% endfor %}
</select>
{% if form.action.errors %}
<div class="invalid-feedback d-block">
<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 class="col-md-12">
<div>
<label for="id_complaint_category" class="form-label">
{% translate "Complaint Category (Optional)" %}
</label>
@ -167,7 +307,8 @@
{% endfor %}
</select>
{% if form.complaint_category.errors %}
<div class="invalid-feedback d-block">
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
{{ form.complaint_category.errors.0 }}
</div>
{% else %}
@ -177,7 +318,7 @@
{% endif %}
</div>
<div class="col-md-12">
<div>
<label for="id_notify_emails" class="form-label">
{% translate "Notify Emails (Optional)" %}
</label>
@ -188,7 +329,8 @@
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">
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
{{ form.notify_emails.errors.0 }}
</div>
{% else %}
@ -198,129 +340,130 @@
{% endif %}
</div>
<div class="col-12">
<div class="form-check form-switch">
<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-check-input"
class="form-switch-input"
{% if form.is_active.value == 'on' or not form.is_active.value %}checked{% endif %}>
<label class="form-check-label" for="id_is_active">
<label for="id_is_active" class="text-sm font-semibold text-gray-700">
{% translate "Active" %}
</label>
</div>
<div class="form-text">
<div class="form-text ml-2">
{% translate "Only active thresholds will be monitored" %}
</div>
</div>
<div class="col-12">
<div>
<label for="id_description" class="form-label">
{% translate "Description" %}
</label>
<textarea name="description"
id="id_description"
class="form-control"
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 d-block">
<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="col-12">
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i>
<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 btn-outline-secondary">
<i class="fas fa-times"></i>
<a href="{% url 'complaints:complaint_threshold_list' %}" class="btn-secondary">
<i data-lucide="x" class="w-4 h-4"></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>
<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="card-subtitle mb-3 text-muted">
<h6 class="text-sm font-semibold text-gray-500 mb-3">
{% translate "Understanding Complaint Thresholds" %}
</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." %}
</p>
<h6 class="card-subtitle mb-2 text-muted">
<h6 class="text-sm font-semibold text-gray-500 mb-3">
{% translate "Threshold Types" %}
</h6>
<ul class="list-unstyled">
<li class="mb-2">
<i class="fas fa-calendar-day text-primary me-2"></i>
<ul class="space-y-2 text-sm text-gray-600">
<li class="flex items-center gap-2">
<i data-lucide="calendar" class="w-4 h-4 text-[#005696]"></i>
<strong>{% translate "Daily" %}</strong> - {% translate "Monitor daily complaint volume" %}
</li>
<li class="mb-2">
<i class="fas fa-calendar-week text-success me-2"></i>
<li class="flex items-center gap-2">
<i data-lucide="calendar-days" class="w-4 h-4 text-green-500"></i>
<strong>{% translate "Weekly" %}</strong> - {% translate "Monitor weekly complaint volume" %}
</li>
<li class="mb-2">
<i class="fas fa-calendar text-warning me-2"></i>
<li class="flex items-center gap-2">
<i data-lucide="calendar-range" class="w-4 h-4 text-orange-500"></i>
<strong>{% translate "Monthly" %}</strong> - {% translate "Monitor monthly complaint volume" %}
</li>
<li class="mb-2">
<i class="fas fa-tags text-info me-2"></i>
<li class="flex items-center gap-2">
<i data-lucide="tags" class="w-4 h-4 text-purple-500"></i>
<strong>{% translate "By Category" %}</strong> - {% translate "Monitor specific complaint categories" %}
</li>
</ul>
<hr>
<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" %}
</h6>
<ul class="list-unstyled">
<li class="mb-2">
<i class="fas fa-list-ol text-secondary me-2"></i>
<ul class="space-y-2 text-sm text-gray-600">
<li class="flex items-center gap-2">
<i data-lucide="list-ordered" class="w-4 h-4 text-gray-500"></i>
{% translate "Count" %} - {% translate "Number of complaints" %}
</li>
<li class="mb-2">
<i class="fas fa-percentage text-secondary me-2"></i>
<li class="flex items-center gap-2">
<i data-lucide="percent" class="w-4 h-4 text-gray-500"></i>
{% translate "Percentage" %} - {% translate "Percentage of total complaints" %}
</li>
</ul>
<hr>
<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" %}
</h6>
<ul class="list-unstyled">
<li class="mb-2">
<i class="fas fa-bell text-warning me-2"></i>
<ul class="space-y-2 text-sm text-gray-600">
<li class="flex items-center gap-2">
<i data-lucide="bell" class="w-4 h-4 text-orange-500"></i>
{% translate "Send Alert" %} - {% translate "Notify administrators" %}
</li>
<li class="mb-2">
<i class="fas fa-envelope text-info me-2"></i>
<li class="flex items-center gap-2">
<i data-lucide="mail" class="w-4 h-4 text-blue-500"></i>
{% translate "Send Email" %} - {% translate "Send email notifications" %}
</li>
<li class="mb-2">
<i class="fas fa-file-alt text-success me-2"></i>
<li class="flex items-center gap-2">
<i data-lucide="file-text" class="w-4 h-4 text-green-500"></i>
{% translate "Generate Report" %} - {% translate "Create detailed report" %}
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>
{% endblock %}

View File

@ -3,39 +3,209 @@
{% block title %}{% translate "Complaint Thresholds" %} - 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;
}
.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="page-header">
<div class="page-header-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="page-title">
<i class="fas fa-chart-line"></i>
<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="page-description">
{% translate "Configure thresholds for automatic alerts and reports" %}
</p>
<p class="text-white/90">{% 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>
<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="page-content">
<!-- Filters -->
<div class="card mb-4">
<div class="card-header">
<h5 class="card-title">
<i class="fas fa-filter"></i>
{% translate "Filters" %}
</h5>
<div class="section-card mb-6 animate-in">
<div class="section-header">
<div class="section-icon secondary">
<i data-lucide="filter" class="w-5 h-5"></i>
</div>
<div class="card-body">
<form method="get" class="row g-3">
<div class="col-md-4">
<label class="form-label">{% translate "Threshold Type" %}</label>
<select name="threshold_type" class="form-select">
<h2 class="text-lg font-bold text-navy m-0">{% translate "Filters" %}</h2>
</div>
<div class="p-6">
<form method="get" class="flex flex-wrap gap-4">
<div>
<label class="block text-sm font-semibold text-slate mb-1.5">{% translate "Threshold Type" %}</label>
<select name="threshold_type" class="px-4 py-2.5 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-blue bg-white">
<option value="">{% translate "All Types" %}</option>
<option value="daily" {% if filters.threshold_type == "daily" %}selected{% endif %}>{% translate "Daily" %}</option>
<option value="weekly" {% if filters.threshold_type == "weekly" %}selected{% endif %}>{% translate "Weekly" %}</option>
@ -43,23 +213,21 @@
<option value="category" {% if filters.threshold_type == "category" %}selected{% endif %}>{% translate "By Category" %}</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">{% translate "Status" %}</label>
<select name="is_active" class="form-select">
<div>
<label class="block text-sm font-semibold text-slate mb-1.5">{% translate "Status" %}</label>
<select name="is_active" class="px-4 py-2.5 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-blue bg-white">
<option value="">{% translate "All" %}</option>
<option value="true" {% if filters.is_active == "true" %}selected{% endif %}>{% translate "Active" %}</option>
<option value="false" {% if filters.is_active == "false" %}selected{% endif %}>{% translate "Inactive" %}</option>
</select>
</div>
<div class="col-12 text-end">
<button type="submit" class="btn btn-primary">
<i class="fas fa-search"></i>
<div class="flex items-end gap-2">
<button type="submit" class="btn-primary h-[46px]">
<i data-lucide="search" class="w-4 h-4"></i>
{% translate "Apply Filters" %}
</button>
<a href="{% url 'complaints:complaint_threshold_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-times"></i>
<a href="{% url 'complaints:complaint_threshold_list' %}" class="btn-secondary h-[46px]">
<i data-lucide="x" class="w-4 h-4"></i>
{% translate "Clear" %}
</a>
</div>
@ -68,11 +236,17 @@
</div>
<!-- Thresholds Table -->
<div class="card">
<div class="card-body">
<div class="section-card animate-in">
<div class="section-header">
<div class="section-icon primary">
<i data-lucide="trending-up" class="w-5 h-5"></i>
</div>
<h2 class="text-lg font-bold text-navy m-0">{% translate "All Thresholds" %}</h2>
</div>
<div class="p-0">
{% if thresholds %}
<div class="table-responsive">
<table class="table table-hover">
<div class="overflow-x-auto">
<table class="w-full data-table">
<thead>
<tr>
<th>{% translate "Hospital" %}</th>
@ -83,7 +257,7 @@
<th>{% translate "Category" %}</th>
<th>{% translate "Action" %}</th>
<th>{% translate "Status" %}</th>
<th>{% translate "Actions" %}</th>
<th class="text-right">{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
@ -94,10 +268,10 @@
</td>
<td>{{ threshold.name }}</td>
<td>
<span class="badge bg-info">{{ threshold.get_threshold_type_display }}</span>
<span class="badge badge-info">{{ threshold.get_threshold_type_display }}</span>
</td>
<td>
<span class="badge bg-secondary">{{ threshold.get_metric_type_display }}</span>
<span class="badge badge-secondary">{{ threshold.get_metric_type_display }}</span>
</td>
<td>
<strong>{{ threshold.threshold_value }}</strong>
@ -106,25 +280,31 @@
{% if threshold.complaint_category %}
{{ threshold.complaint_category.name }}
{% else %}
<span class="text-muted">{% translate "All Categories" %}</span>
<span class="text-slate-400">{% translate "All Categories" %}</span>
{% endif %}
</td>
<td>
<span class="badge bg-warning text-dark">{{ threshold.get_action_display }}</span>
<span class="badge badge-warning">{{ threshold.get_action_display }}</span>
</td>
<td>
{% if threshold.is_active %}
<span class="badge bg-success">{% translate "Active" %}</span>
<span class="badge badge-success">
<i data-lucide="check-circle" class="w-3 h-3"></i>
{% translate "Active" %}
</span>
{% else %}
<span class="badge bg-secondary">{% translate "Inactive" %}</span>
<span class="badge badge-secondary">
<i data-lucide="x-circle" class="w-3 h-3"></i>
{% translate "Inactive" %}
</span>
{% endif %}
</td>
<td>
<div class="btn-group">
<td class="text-right">
<div class="flex items-center justify-end gap-2">
<a href="{% url 'complaints:complaint_threshold_edit' threshold.id %}"
class="btn btn-sm btn-outline-primary"
class="p-2 text-blue hover:bg-blue-50 rounded-lg transition"
title="{% translate 'Edit' %}">
<i class="fas fa-edit"></i>
<i data-lucide="edit" class="w-4 h-4"></i>
</a>
<form method="post"
action="{% url 'complaints:complaint_threshold_delete' threshold.id %}"
@ -132,9 +312,9 @@
onsubmit="return confirm('{% translate "Are you sure you want to delete this threshold?" %}')">
{% csrf_token %}
<button type="submit"
class="btn btn-sm btn-outline-danger"
class="p-2 text-red-500 hover:bg-red-50 rounded-lg transition"
title="{% translate 'Delete' %}">
<i class="fas fa-trash"></i>
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</form>
</div>
@ -147,59 +327,42 @@
<!-- Pagination -->
{% if page_obj.has_other_pages %}
<nav class="mt-4">
<ul class="pagination justify-content-center">
<div class="p-4 border-t border-slate-200">
<div class="flex items-center justify-between">
<p class="text-sm text-slate">
{% blocktrans with start=page_obj.start_index end=page_obj.end_index total=page_obj.paginator.count %}
Showing {{ start }} to {{ end }} of {{ total }} thresholds
{% endblocktrans %}
</p>
<div class="flex gap-2">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
<i class="fas fa-angle-double-left"></i>
<a href="?page={{ page_obj.previous_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}"
class="px-4 py-2 border border-slate-200 rounded-lg hover:bg-slate-50 transition text-sm font-medium">
<i data-lucide="chevron-left" class="w-4 h-4 inline"></i>
{% translate "Previous" %}
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
<i class="fas fa-angle-left"></i>
</a>
</li>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<li class="page-item active">
<span class="page-link">{{ num }}</span>
</li>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<li class="page-item">
<a class="page-link" href="?page={{ num }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
{{ num }}
</a>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
<i class="fas fa-angle-right"></i>
<a href="?page={{ page_obj.next_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}"
class="px-4 py-2 border border-slate-200 rounded-lg hover:bg-slate-50 transition text-sm font-medium">
{% translate "Next" %}
<i data-lucide="chevron-right" class="w-4 h-4 inline"></i>
</a>
</li>
<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>
</div>
</div>
</div>
{% endif %}
{% else %}
<div class="text-center py-5">
<i class="fas fa-chart-line fa-3x text-muted mb-3"></i>
<p class="text-muted">
{% translate "No thresholds found. Create your first threshold to get started." %}
</p>
<a href="{% url 'complaints:complaint_threshold_create' %}" class="btn btn-primary">
<i class="fas fa-plus"></i>
<div class="text-center py-12">
<div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i data-lucide="trending-up" class="w-8 h-8 text-slate-400"></i>
</div>
<p class="text-slate font-medium">{% translate "No thresholds found" %}</p>
<p class="text-slate text-sm mt-1">{% translate "Create your first threshold to get started" %}</p>
<a href="{% url 'complaints:complaint_threshold_create' %}" class="btn-primary mt-4">
<i data-lucide="plus" class="w-4 h-4"></i>
{% translate "Create Threshold" %}
</a>
</div>
@ -207,4 +370,10 @@
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>
{% endblock %}

View File

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

View File

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

View File

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

View File

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

View File

@ -3,41 +3,186 @@
{% block title %}{{ title }} - {% translate "Escalation Rules" %} - PX360{% endblock %}
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.form-section {
background: #fff;
border: 2px solid #e2e8f0;
border-radius: 1rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
transition: all 0.3s ease;
}
.form-section:hover {
border-color: #005696;
box-shadow: 0 4px 12px rgba(0, 86, 150, 0.1);
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: #1e293b;
margin-bottom: 0.5rem;
}
.form-control, .form-select {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
font-size: 0.875rem;
transition: all 0.2s ease;
}
.form-control:focus, .form-select:focus {
outline: none;
border-color: #005696;
box-shadow: 0 0 0 3px rgba(0, 86, 150, 0.1);
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: #005696;
color: white;
border-radius: 0.75rem;
font-weight: 600;
transition: all 0.2s ease;
border: none;
cursor: pointer;
}
.btn-primary:hover {
background: #007bbd;
}
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: white;
color: #64748b;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
font-weight: 600;
transition: all 0.2s ease;
}
.btn-secondary:hover {
background: #f1f5f9;
border-color: #005696;
}
.help-card {
background: #f8fafc;
border: 2px solid #e2e8f0;
border-radius: 1rem;
padding: 1.5rem;
}
.form-switch {
display: flex;
align-items: center;
gap: 0.75rem;
}
.form-switch-input {
width: 3rem;
height: 1.5rem;
appearance: none;
background: #cbd5e1;
border-radius: 1rem;
position: relative;
cursor: pointer;
transition: all 0.2s ease;
}
.form-switch-input:checked {
background: #005696;
}
.form-switch-input::after {
content: '';
position: absolute;
width: 1.25rem;
height: 1.25rem;
background: white;
border-radius: 50%;
top: 0.125rem;
left: 0.125rem;
transition: all 0.2s ease;
}
.form-switch-input:checked::after {
left: 1.625rem;
}
.invalid-feedback {
color: #dc2626;
font-size: 0.875rem;
margin-top: 0.25rem;
}
.form-text {
color: #64748b;
font-size: 0.875rem;
margin-top: 0.25rem;
}
.alert-info {
background: #dbeafe;
border: 1px solid #93c5fd;
border-radius: 0.75rem;
padding: 1rem;
}
.alert-info ol {
color: #1e40af;
font-size: 0.875rem;
padding-left: 1.25rem;
}
.alert-info li {
margin-bottom: 0.25rem;
}
</style>
{% endblock %}
{% block content %}
<div class="page-header">
<div class="page-header-content">
<!-- Gradient Header -->
<div class="page-header-gradient">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center">
<i data-lucide="arrow-up-circle" class="w-6 h-6"></i>
</div>
<div>
<h1 class="page-title">
<i class="fas fa-arrow-up"></i>
{{ title }}
</h1>
<p class="page-description">
{% if escalation_rule %}
{% translate "Edit escalation rule" %}
{% else %}
{% translate "Create new escalation rule" %}
{% endif %}
<h1 class="text-2xl font-bold">{{ title }}</h1>
<p class="text-white/80">
{% if escalation_rule %}{% translate "Edit escalation rule" %}{% else %}{% translate "Create new escalation rule" %}{% endif %}
</p>
</div>
<a href="{% url 'complaints:escalation_rule_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i>
</div>
<a href="{% url 'complaints:escalation_rule_list' %}" class="btn-secondary">
<i data-lucide="arrow-left" class="w-4 h-4"></i>
{% translate "Back to List" %}
</a>
</div>
</div>
<div class="page-content">
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-body">
<form method="post" class="row g-3">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2">
<div class="form-section">
<form method="post" class="space-y-6">
{% csrf_token %}
{% if not form.hospital.is_hidden %}
<div class="col-md-12">
<div>
<label for="id_hospital" class="form-label">
{% translate "Hospital" %} <span class="text-danger">*</span>
{% translate "Hospital" %} <span class="text-red-500">*</span>
</label>
<select name="hospital" id="id_hospital" class="form-select" required>
<option value="">{% translate "Select Hospital" %}</option>
@ -49,16 +194,17 @@
{% endfor %}
</select>
{% if form.hospital.errors %}
<div class="invalid-feedback d-block">
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
{{ form.hospital.errors.0 }}
</div>
{% endif %}
</div>
{% endif %}
<div class="col-md-12">
<div>
<label for="id_name" class="form-label">
{% translate "Rule Name" %} <span class="text-danger">*</span>
{% translate "Rule Name" %} <span class="text-red-500">*</span>
</label>
<input type="text"
name="name"
@ -68,15 +214,17 @@
required
placeholder="{% translate 'e.g., Level 1 Escalation - High Priority' %}">
{% if form.name.errors %}
<div class="invalid-feedback d-block">
<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="col-md-6">
<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-danger">*</span>
{% 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>
@ -88,15 +236,16 @@
{% endfor %}
</select>
{% if form.escalation_level.errors %}
<div class="invalid-feedback d-block">
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
{{ form.escalation_level.errors.0 }}
</div>
{% endif %}
</div>
<div class="col-md-6">
<div>
<label for="id_trigger_hours" class="form-label">
{% translate "Trigger Hours" %} <span class="text-danger">*</span>
{% translate "Trigger Hours" %} <span class="text-red-500">*</span>
</label>
<input type="number"
name="trigger_hours"
@ -108,7 +257,8 @@
required
placeholder="{% translate 'e.g., 24' %}">
{% if form.trigger_hours.errors %}
<div class="invalid-feedback d-block">
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
{{ form.trigger_hours.errors.0 }}
</div>
{% else %}
@ -117,8 +267,10 @@
</div>
{% endif %}
</div>
</div>
<div class="col-md-6">
<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>
@ -132,13 +284,14 @@
{% endfor %}
</select>
{% if form.escalate_to_role.errors %}
<div class="invalid-feedback d-block">
<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 class="col-md-6">
<div>
<label for="id_escalate_to_user" class="form-label">
{% translate "Escalate To Specific User" %}
</label>
@ -152,7 +305,8 @@
{% endfor %}
</select>
{% if form.escalate_to_user.errors %}
<div class="invalid-feedback d-block">
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
{{ form.escalate_to_user.errors.0 }}
</div>
{% else %}
@ -161,8 +315,10 @@
</div>
{% endif %}
</div>
</div>
<div class="col-md-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="id_severity" class="form-label">
{% translate "Severity (Optional)" %}
</label>
@ -176,7 +332,8 @@
{% endfor %}
</select>
{% if form.severity.errors %}
<div class="invalid-feedback d-block">
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
{{ form.severity.errors.0 }}
</div>
{% else %}
@ -186,7 +343,7 @@
{% endif %}
</div>
<div class="col-md-6">
<div>
<label for="id_priority" class="form-label">
{% translate "Priority (Optional)" %}
</label>
@ -200,7 +357,8 @@
{% endfor %}
</select>
{% if form.priority.errors %}
<div class="invalid-feedback d-block">
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
{{ form.priority.errors.0 }}
</div>
{% else %}
@ -209,91 +367,89 @@
</div>
{% endif %}
</div>
</div>
<div class="col-12">
<div class="form-check form-switch">
<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-check-input"
class="form-switch-input"
{% if form.is_active.value == 'on' or not form.is_active.value %}checked{% endif %}>
<label class="form-check-label" for="id_is_active">
<label for="id_is_active" class="text-sm font-semibold text-gray-700">
{% translate "Active" %}
</label>
</div>
<div class="form-text">
<div class="form-text ml-2">
{% translate "Only active rules will be triggered" %}
</div>
</div>
<div class="col-12">
<div>
<label for="id_description" class="form-label">
{% translate "Description" %}
</label>
<textarea name="description"
id="id_description"
class="form-control"
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 d-block">
<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="col-12">
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i>
<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 btn-outline-secondary">
<i class="fas fa-times"></i>
<a href="{% url 'complaints:escalation_rule_list' %}" class="btn-secondary">
<i data-lucide="x" class="w-4 h-4"></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>
<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="card-subtitle mb-3 text-muted">
<h6 class="text-sm font-semibold text-gray-500 mb-3">
{% translate "Understanding Escalation Rules" %}
</h6>
<p class="card-text">
<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="list-unstyled">
<li class="mb-2">
<i class="fas fa-check text-success me-2"></i>
<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="mb-2">
<i class="fas fa-check text-success me-2"></i>
<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="mb-2">
<i class="fas fa-check text-success me-2"></i>
<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>
<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 "Escalation Flow" %}
</h6>
<div class="alert alert-info">
<ol class="mb-0">
<div class="alert-info">
<ol>
<li>{% translate "Complaint created" %}</li>
<li>{% translate "Trigger hours pass" %}</li>
<li>{% translate "Rule checks severity/priority" %}</li>
@ -304,6 +460,10 @@
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>
{% endblock %}

View File

@ -3,62 +3,265 @@
{% block title %}{% translate "Escalation Rules" %} - 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;
}
.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="page-header">
<div class="page-header-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="page-title">
<i class="fas fa-arrow-up"></i>
<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="page-description">
{% translate "Configure automatic complaint escalation based on time thresholds" %}
</p>
<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 btn-primary">
<i class="fas fa-plus"></i>
{% translate "Create Escalation Rule" %}
<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="page-content">
<!-- Filters -->
<div class="card mb-4">
<div class="card-header">
<h5 class="card-title">
<i class="fas fa-filter"></i>
{% translate "Filters" %}
</h5>
<div class="section-card mb-6 animate-in">
<div class="section-header">
<div class="section-icon secondary">
<i data-lucide="filter" class="w-5 h-5"></i>
</div>
<div class="card-body">
<form method="get" class="row g-3">
<div class="col-md-4">
<label class="form-label">{% translate "Escalation Level" %}</label>
<select name="escalation_level" class="form-select">
<h2 class="text-lg font-bold text-navy m-0">{% translate "Filters" %}</h2>
</div>
<div class="p-6">
<form method="get" class="flex flex-wrap gap-4">
<div>
<label class="block text-sm font-semibold text-slate mb-1.5">{% translate "Escalation Level" %}</label>
<select name="escalation_level" class="px-4 py-2.5 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-blue bg-white">
<option value="">{% translate "All Levels" %}</option>
<option value="1" {% if filters.escalation_level == "1" %}selected{% endif %}>1</option>
<option value="2" {% if filters.escalation_level == "2" %}selected{% endif %}>2</option>
<option value="3" {% if filters.escalation_level == "3" %}selected{% endif %}>3</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">{% translate "Status" %}</label>
<select name="is_active" class="form-select">
<div>
<label class="block text-sm font-semibold text-slate mb-1.5">{% translate "Status" %}</label>
<select name="is_active" class="px-4 py-2.5 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-blue bg-white">
<option value="">{% translate "All" %}</option>
<option value="true" {% if filters.is_active == "true" %}selected{% endif %}>{% translate "Active" %}</option>
<option value="false" {% if filters.is_active == "false" %}selected{% endif %}>{% translate "Inactive" %}</option>
</select>
</div>
<div class="col-12 text-end">
<button type="submit" class="btn btn-primary">
<i class="fas fa-search"></i>
<div class="flex items-end gap-2">
<button type="submit" class="btn-primary h-[46px]">
<i data-lucide="search" class="w-4 h-4"></i>
{% translate "Apply Filters" %}
</button>
<a href="{% url 'complaints:escalation_rule_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-times"></i>
<a href="{% url 'complaints:escalation_rule_list' %}" class="btn-secondary h-[46px]">
<i data-lucide="x" class="w-4 h-4"></i>
{% translate "Clear" %}
</a>
</div>
@ -67,11 +270,17 @@
</div>
<!-- Escalation Rules Table -->
<div class="card">
<div class="card-body">
<div class="section-card animate-in">
<div class="section-header">
<div class="section-icon primary">
<i data-lucide="arrow-up-circle" class="w-5 h-5"></i>
</div>
<h2 class="text-lg font-bold text-navy m-0">{% translate "All Escalation Rules" %}</h2>
</div>
<div class="p-0">
{% if escalation_rules %}
<div class="table-responsive">
<table class="table table-hover">
<div class="overflow-x-auto">
<table class="w-full data-table">
<thead>
<tr>
<th>{% translate "Hospital" %}</th>
@ -82,7 +291,7 @@
<th>{% translate "Severity" %}</th>
<th>{% translate "Priority" %}</th>
<th>{% translate "Status" %}</th>
<th>{% translate "Actions" %}</th>
<th class="text-right">{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
@ -93,7 +302,7 @@
</td>
<td>{{ rule.name }}</td>
<td>
<span class="badge bg-info">
<span class="badge badge-info">
{% translate "Level" %} {{ rule.escalation_level }}
</span>
</td>
@ -102,9 +311,9 @@
{% if rule.escalate_to_user %}
{{ rule.escalate_to_user.get_full_name }}
{% elif rule.escalate_to_role %}
<span class="badge bg-secondary">{{ rule.get_escalate_to_role_display }}</span>
<span class="badge badge-secondary">{{ rule.get_escalate_to_role_display }}</span>
{% else %}
<span class="text-muted">-</span>
<span class="text-slate-400">-</span>
{% endif %}
</td>
<td>
@ -113,7 +322,7 @@
{{ rule.get_severity_display }}
</span>
{% else %}
<span class="text-muted">{% translate "All" %}</span>
<span class="text-slate-400">{% translate "All" %}</span>
{% endif %}
</td>
<td>
@ -122,22 +331,28 @@
{{ rule.get_priority_display }}
</span>
{% else %}
<span class="text-muted">{% translate "All" %}</span>
<span class="text-slate-400">{% translate "All" %}</span>
{% endif %}
</td>
<td>
{% if rule.is_active %}
<span class="badge bg-success">{% translate "Active" %}</span>
<span class="badge badge-success">
<i data-lucide="check-circle" class="w-3 h-3"></i>
{% translate "Active" %}
</span>
{% else %}
<span class="badge bg-secondary">{% translate "Inactive" %}</span>
<span class="badge badge-secondary">
<i data-lucide="x-circle" class="w-3 h-3"></i>
{% translate "Inactive" %}
</span>
{% endif %}
</td>
<td>
<div class="btn-group">
<td class="text-right">
<div class="flex items-center justify-end gap-2">
<a href="{% url 'complaints:escalation_rule_edit' rule.id %}"
class="btn btn-sm btn-outline-primary"
class="p-2 text-blue hover:bg-blue-50 rounded-lg transition"
title="{% translate 'Edit' %}">
<i class="fas fa-edit"></i>
<i data-lucide="edit" class="w-4 h-4"></i>
</a>
<form method="post"
action="{% url 'complaints:escalation_rule_delete' rule.id %}"
@ -145,9 +360,9 @@
onsubmit="return confirm('{% translate "Are you sure you want to delete this escalation rule?" %}')">
{% csrf_token %}
<button type="submit"
class="btn btn-sm btn-outline-danger"
class="p-2 text-red-500 hover:bg-red-50 rounded-lg transition"
title="{% translate 'Delete' %}">
<i class="fas fa-trash"></i>
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</form>
</div>
@ -160,59 +375,42 @@
<!-- Pagination -->
{% if page_obj.has_other_pages %}
<nav class="mt-4">
<ul class="pagination justify-content-center">
<div class="p-4 border-t border-slate-200">
<div class="flex items-center justify-between">
<p class="text-sm text-slate">
{% blocktrans with start=page_obj.start_index end=page_obj.end_index total=page_obj.paginator.count %}
Showing {{ start }} to {{ end }} of {{ total }} rules
{% endblocktrans %}
</p>
<div class="flex gap-2">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
<i class="fas fa-angle-double-left"></i>
<a href="?page={{ page_obj.previous_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}"
class="px-4 py-2 border border-slate-200 rounded-lg hover:bg-slate-50 transition text-sm font-medium">
<i data-lucide="chevron-left" class="w-4 h-4 inline"></i>
{% translate "Previous" %}
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
<i class="fas fa-angle-left"></i>
</a>
</li>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<li class="page-item active">
<span class="page-link">{{ num }}</span>
</li>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<li class="page-item">
<a class="page-link" href="?page={{ num }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
{{ num }}
</a>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
<i class="fas fa-angle-right"></i>
<a href="?page={{ page_obj.next_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}"
class="px-4 py-2 border border-slate-200 rounded-lg hover:bg-slate-50 transition text-sm font-medium">
{% translate "Next" %}
<i data-lucide="chevron-right" class="w-4 h-4 inline"></i>
</a>
</li>
<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>
</div>
</div>
</div>
{% endif %}
{% else %}
<div class="text-center py-5">
<i class="fas fa-arrow-up fa-3x text-muted mb-3"></i>
<p class="text-muted">
{% translate "No escalation rules found. Create your first rule to get started." %}
</p>
<a href="{% url 'complaints:escalation_rule_create' %}" class="btn btn-primary">
<i class="fas fa-plus"></i>
<div class="text-center py-12">
<div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i data-lucide="arrow-up-circle" class="w-8 h-8 text-slate-400"></i>
</div>
<p class="text-slate font-medium">{% translate "No escalation rules found" %}</p>
<p class="text-slate text-sm mt-1">{% translate "Create your first rule to get started" %}</p>
<a href="{% url 'complaints:escalation_rule_create' %}" class="btn-primary mt-4">
<i data-lucide="plus" class="w-4 h-4"></i>
{% translate "Create Escalation Rule" %}
</a>
</div>
@ -220,4 +418,10 @@
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>
{% endblock %}

View File

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

View File

@ -5,113 +5,198 @@
{% block extra_css %}
<style>
.form-select {
@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;
.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-textarea {
@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;
.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 {
@apply w-5 h-5 text-navy border-slate-300 rounded focus:ring-navy;
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>
{% endblock %}
{% block content %}
<div class="p-6 max-w-4xl mx-auto">
<!-- Back Button -->
<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: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" %}
</a>
</div>
<!-- Header -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-navy">{{ title }}</h1>
<p class="text-slate mt-1">
<!-- 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 Card -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-8 max-w-3xl">
<!-- Form Section -->
<div class="form-section">
<form method="post" class="space-y-6">
{% csrf_token %}
<!-- Department -->
<div>
<label for="{{ form.department.id_for_label }}" class="block text-sm font-semibold text-navy mb-2">
<label for="{{ form.department.id_for_label }}" class="form-label">
{% 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>
<select name="{{ form.department.name }}" id="{{ form.department.id_for_label }}" class="form-select" 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">
<div class="invalid-feedback">
<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>
<p class="text-sm text-gray-500 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">
<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="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>
<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="text-red-500 text-sm mt-2 flex items-center gap-1">
<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-light/50 rounded-xl p-4 text-sm text-slate space-y-2">
<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-navy min-w-[80px]">{% trans "Primary:" %}</span>
<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-navy min-w-[80px]">{% trans "Secondary:" %}</span>
<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-navy min-w-[80px]">{% trans "Coordination:" %}</span>
<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-navy min-w-[80px]">{% trans "Investigating:" %}</span>
<span class="font-semibold text-[#005696] 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 class="flex items-start gap-3 p-4 bg-gray-50 rounded-xl border border-gray-200">
<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 %}>
<div>
<label for="{{ form.is_primary.id_for_label }}" class="text-sm font-semibold text-navy">
<label for="{{ form.is_primary.id_for_label }}" class="text-sm font-semibold text-gray-800">
{% trans "Mark as Primary Department" %}
</label>
<p class="text-slate text-xs mt-1">{% trans "The primary department takes lead responsibility for this complaint." %}</p>
<p class="text-gray-500 text-xs mt-1">{% trans "The primary department takes lead responsibility for this complaint." %}</p>
</div>
</div>
<!-- Assigned To -->
<div>
<label for="{{ form.assigned_to.id_for_label }}" class="block text-sm font-semibold text-navy mb-2">
{% trans "Assign To" %} <span class="text-slate font-normal">({% trans "Optional" %})</span>
<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="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">
<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 %}
@ -120,22 +205,22 @@
{% endfor %}
</select>
{% if form.assigned_to.errors %}
<div class="text-red-500 text-sm mt-2 flex items-center gap-1">
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4"></i>
{{ form.assigned_to.errors }}
</div>
{% endif %}
<p class="text-slate text-xs mt-2">{% trans "Optionally assign a specific user from this department." %}</p>
<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="block text-sm font-semibold text-navy mb-2">
{% trans "Notes" %} <span class="text-slate font-normal">({% trans "Optional" %})</span>
<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="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>
<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="text-red-500 text-sm mt-2 flex items-center gap-1">
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4"></i>
{{ form.notes.errors }}
</div>
@ -143,15 +228,23 @@
</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>
<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="px-8 py-3 border border-slate-200 rounded-xl text-slate hover:bg-light transition font-semibold">
<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>
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>
{% endblock %}

View File

@ -5,96 +5,177 @@
{% block extra_css %}
<style>
.form-select {
@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;
.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-textarea {
@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;
.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>
{% endblock %}
{% block content %}
<div class="p-6 max-w-4xl mx-auto">
<!-- Back Button -->
<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: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" %}
</a>
</div>
<!-- Header -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-navy">{{ title }}</h1>
<p class="text-slate mt-1">
<!-- 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 Card -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-8 max-w-3xl">
<!-- 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="block text-sm font-semibold text-navy mb-2">
<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="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>
<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="text-red-500 text-sm mt-2 flex items-center gap-1">
<div class="invalid-feedback">
<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>
<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="block text-sm font-semibold text-navy mb-2">
<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="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>
<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="text-red-500 text-sm mt-2 flex items-center gap-1">
<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-light/50 rounded-xl p-4 text-sm text-slate space-y-2">
<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-navy min-w-[120px]">{% trans "Accused/Involved:" %}</span>
<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-navy min-w-[120px]">{% trans "Witness:" %}</span>
<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-navy min-w-[120px]">{% trans "Responsible:" %}</span>
<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-navy min-w-[120px]">{% trans "Investigator:" %}</span>
<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-navy min-w-[120px]">{% trans "Support:" %}</span>
<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-navy min-w-[120px]">{% trans "Coordinator:" %}</span>
<span class="font-semibold text-[#005696] min-w-[120px]">{% trans "Coordinator:" %}</span>
<span>{% trans "Coordinating between departments" %}</span>
</p>
</div>
@ -102,12 +183,12 @@
<!-- 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 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="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>
<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="text-red-500 text-sm mt-2 flex items-center gap-1">
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4"></i>
{{ form.notes.errors }}
</div>
@ -115,15 +196,23 @@
</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>
<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="px-8 py-3 border border-slate-200 rounded-xl text-slate hover:bg-light transition font-semibold">
<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>
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>
{% endblock %}

View File

@ -4,42 +4,143 @@
{% 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 %}
<div class="max-w-2xl mx-auto p-6">
<!-- Header -->
<div class="max-w-2xl mx-auto">
<!-- Back Button -->
<div class="mb-6">
<div class="flex items-center gap-4">
<a href="{% url 'complaints:oncall_schedule_detail' schedule.id %}" class="text-[#64748b] hover:text-[#005696]">
<i data-lucide="arrow-left" class="w-6 h-6"></i>
<a href="{% url 'complaints:oncall_schedule_detail' schedule.id %}" class="btn-secondary">
<i data-lucide="arrow-left" class="w-4 h-4"></i>
{% trans "Back to Schedule" %}
</a>
<div>
<h1 class="text-2xl font-bold text-[#005696]">{{ title }}</h1>
<p class="text-[#64748b] mt-1">
</div>
<!-- Header -->
<div class="page-header-gradient">
<h1 class="text-2xl font-bold flex items-center gap-3">
<i data-lucide="user-cog" class="w-7 h-7"></i>
{{ 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>
</div>
<!-- Form -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<form method="post" class="p-6">
<div class="form-section">
<form method="post" class="space-y-6">
{% csrf_token %}
<!-- Admin Selection (only for new) -->
{% if not on_call_admin %}
<div class="mb-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<div class="form-section">
<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>
{% trans "Select Admin" %}
</h2>
<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>
</label>
<select name="admin_user" required
class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#005696] focus:border-transparent">
<select name="admin_user" required class="form-select">
<option value="">{% trans "Select an admin..." %}</option>
{% for admin in available_admins %}
<option value="{{ admin.id }}">
@ -50,26 +151,27 @@
{% endfor %}
</select>
{% 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." %}
</p>
{% endif %}
</div>
</div>
{% else %}
<div class="mb-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<div class="form-section">
<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>
{% trans "Admin" %}
</h2>
<div class="flex items-center gap-3 p-4 bg-gray-50 rounded-lg">
<div class="w-12 h-12 bg-[#eef6fb] rounded-full flex items-center justify-center">
<span class="text-[#005696] font-medium text-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-[#005696] rounded-full flex items-center justify-center">
<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 }}
</span>
</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>
</div>
</div>
@ -77,62 +179,61 @@
{% endif %}
<!-- Active Period -->
<div class="mb-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<div class="form-section">
<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>
{% trans "Active Period" %}
</h2>
<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">
<label class="form-label">
{% trans "Start Date (Optional)" %}
</label>
<input type="date" name="start_date"
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">
<p class="mt-1 text-xs text-[#64748b]">{% trans "Leave empty for immediate activation" %}</p>
class="form-control">
<p class="form-text">{% trans "Leave empty for immediate activation" %}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<label class="form-label">
{% trans "End Date (Optional)" %}
</label>
<input type="date" name="end_date"
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">
<p class="mt-1 text-xs text-[#64748b]">{% trans "Leave empty for permanent assignment" %}</p>
class="form-control">
<p class="form-text">{% trans "Leave empty for permanent assignment" %}</p>
</div>
</div>
</div>
<!-- Notification Settings -->
<div class="mb-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<div class="form-section">
<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>
{% trans "Notification Settings" %}
</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<label class="form-label">
{% trans "Notification Priority" %}
</label>
<select name="notification_priority"
class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#005696] focus:border-transparent">
<select name="notification_priority" class="form-select">
{% for i in "12345" %}
<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 %}
</option>
{% endfor %}
</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 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"
{% 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]">
<label for="is_active" class="text-sm font-medium text-gray-700">
class="w-5 h-5 text-[#005696] border-2 border-[#e2e8f0] rounded focus:ring-[#005696]">
<label for="is_active" class="text-sm font-semibold text-[#1e293b]">
{% trans "On-call assignment is active" %}
</label>
</div>
@ -140,40 +241,40 @@
</div>
<!-- Contact Preferences -->
<div class="mb-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<div class="form-section">
<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>
{% trans "Contact Preferences" %}
</h2>
<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"
{% 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]">
<label for="notify_email" class="text-sm font-medium text-gray-700">
class="w-5 h-5 text-[#005696] border-2 border-[#e2e8f0] rounded focus:ring-[#005696]">
<label for="notify_email" class="text-sm font-semibold text-[#1e293b]">
{% trans "Send email notifications" %}
</label>
</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"
{% if on_call_admin.notify_sms %}checked{% endif %}
class="w-5 h-5 text-[#005696] border-gray-300 rounded focus:ring-[#005696]">
<label for="notify_sms" class="text-sm font-medium text-gray-700">
class="w-5 h-5 text-[#005696] border-2 border-[#e2e8f0] rounded focus:ring-[#005696]">
<label for="notify_sms" class="text-sm font-semibold text-[#1e293b]">
{% trans "Send SMS notifications (for high priority complaints)" %}
</label>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<label class="form-label">
{% trans "SMS Phone Number (Optional)" %}
</label>
<input type="tel" name="sms_phone"
value="{{ on_call_admin.sms_phone|default:'' }}"
placeholder="+966501234567"
class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#005696] focus:border-transparent">
<p class="mt-1 text-xs text-[#64748b]">
class="form-control">
<p class="form-text">
{% trans "Leave empty to use the user's profile phone number" %}
</p>
</div>
@ -181,11 +282,11 @@
</div>
<!-- Info Box -->
<div class="bg-[#eef6fb] rounded-lg p-4 mb-6">
<div class="info-box">
<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>
<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">
{% 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>
@ -194,13 +295,14 @@
</div>
<!-- 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 %}"
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" %}
</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 %}>
<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 %}
</button>
</div>

View File

@ -4,41 +4,159 @@
{% 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 %}
<div class="max-w-3xl mx-auto p-6">
<!-- Header -->
<div class="max-w-3xl mx-auto">
<!-- Back Button -->
<div class="mb-6">
<div class="flex items-center gap-4">
<a href="{% url 'complaints:oncall_schedule_list' %}" class="text-[#64748b] hover:text-[#005696]">
<i data-lucide="arrow-left" class="w-6 h-6"></i>
<a href="{% url 'complaints:oncall_schedule_list' %}" class="btn-secondary">
<i data-lucide="arrow-left" class="w-4 h-4"></i>
{% trans "Back to Schedules" %}
</a>
<div>
<h1 class="text-2xl font-bold text-[#005696]">{{ title }}</h1>
<p class="text-[#64748b] mt-1">
</div>
<!-- Header -->
<div class="page-header-gradient">
<h1 class="text-2xl font-bold flex items-center gap-3">
<i data-lucide="calendar-clock" class="w-7 h-7"></i>
{{ 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>
</div>
<!-- Form -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<form method="post" class="p-6">
<div class="form-section">
<form method="post" class="space-y-6">
{% csrf_token %}
<!-- Scope Section -->
<div class="mb-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<div class="form-section">
<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>
{% trans "Schedule Scope" %}
</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<label class="form-label">
{% trans "Hospital (Optional)" %}
</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>
{% for hospital in hospitals %}
<option value="{{ hospital.id }}" {% if schedule.hospital_id == hospital.id %}selected{% endif %}>
@ -46,16 +164,16 @@
</option>
{% endfor %}
</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." %}
</p>
</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"
{% if not schedule or schedule.is_active %}checked{% endif %}
class="w-5 h-5 text-[#005696] border-gray-300 rounded focus:ring-[#005696]">
<label for="is_active" class="text-sm font-medium text-gray-700">
class="w-5 h-5 text-[#005696] border-2 border-[#e2e8f0] rounded focus:ring-[#005696]">
<label for="is_active" class="text-sm font-semibold text-[#1e293b]">
{% trans "Schedule is active" %}
</label>
</div>
@ -63,8 +181,8 @@
</div>
<!-- Working Hours Section -->
<div class="mb-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<div class="form-section">
<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>
{% trans "Working Hours Configuration" %}
</h2>
@ -72,56 +190,49 @@
<div class="space-y-4">
<!-- Working Days -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<label class="form-label">
{% trans "Working Days" %}
</label>
<div class="flex flex-wrap gap-3">
{% 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"
{% 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">{% trans "Mon" %}</span>
{% if schedule and 0 in schedule.get_working_days_list %}checked{% elif not schedule %}checked{% endif %}>
<span class="text-sm font-medium">{% trans "Mon" %}</span>
</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"
{% 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">{% trans "Tue" %}</span>
{% if schedule and 1 in schedule.get_working_days_list %}checked{% elif not schedule %}checked{% endif %}>
<span class="text-sm font-medium">{% trans "Tue" %}</span>
</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"
{% 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">{% trans "Wed" %}</span>
{% if schedule and 2 in schedule.get_working_days_list %}checked{% elif not schedule %}checked{% endif %}>
<span class="text-sm font-medium">{% trans "Wed" %}</span>
</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"
{% 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">{% trans "Thu" %}</span>
{% if schedule and 3 in schedule.get_working_days_list %}checked{% elif not schedule %}checked{% endif %}>
<span class="text-sm font-medium">{% trans "Thu" %}</span>
</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"
{% 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">{% trans "Fri" %}</span>
{% if schedule and 4 in schedule.get_working_days_list %}checked{% elif not schedule %}checked{% endif %}>
<span class="text-sm font-medium">{% trans "Fri" %}</span>
</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"
{% 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">{% trans "Sat" %}</span>
{% if schedule and 5 in schedule.get_working_days_list %}checked{% endif %}>
<span class="text-sm font-medium">{% trans "Sat" %}</span>
</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"
{% 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">{% trans "Sun" %}</span>
{% if schedule and 6 in schedule.get_working_days_list %}checked{% endif %}>
<span class="text-sm font-medium">{% trans "Sun" %}</span>
</label>
{% endwith %}
</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." %}
</p>
</div>
@ -129,29 +240,29 @@
<!-- Time Range -->
<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">
<label class="form-label">
{% trans "Work Start Time" %}
</label>
<input type="time" name="work_start_time"
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>
<label class="block text-sm font-medium text-gray-700 mb-2">
<label class="form-label">
{% trans "Work End Time" %}
</label>
<input type="time" name="work_end_time"
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>
<!-- Timezone -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<label class="form-label">
{% trans "Timezone" %}
</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 %}
<option value="{{ tz }}" {% if schedule.timezone == tz %}selected{% elif not schedule and tz == 'Asia/Riyadh' %}selected{% endif %}>
{{ tz }}
@ -163,11 +274,11 @@
</div>
<!-- Info Box -->
<div class="bg-[#eef6fb] rounded-lg p-4 mb-6">
<div class="info-box">
<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>
<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">
{% 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>
@ -176,12 +287,13 @@
</div>
<!-- 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' %}"
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" %}
</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 %}
</button>
</div>

View File

@ -4,22 +4,186 @@
{% 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 %}
<div class="p-6">
<!-- Header -->
<div class="mb-6">
<div class="px-4 py-6">
<!-- Page Header -->
<div class="page-header-gradient animate-in">
<div class="flex items-center justify-between">
<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>
</a>
<div>
<h1 class="text-2xl font-bold text-[#005696]">{{ title }}</h1>
<p class="text-[#64748b] mt-1">{% trans "Configure working hours and on-call admin assignments" %}</p>
<h1 class="text-2xl font-bold mb-2">
<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>
<a href="{% url 'complaints:oncall_schedule_create' %}"
class="px-4 py-2 bg-[#005696] text-white rounded-lg hover:bg-[#007bbd] transition-colors flex items-center gap-2">
<a href="{% url 'complaints:oncall_schedule_create' %}" class="btn-secondary">
<i data-lucide="plus" class="w-4 h-4"></i>
{% trans "Create Schedule" %}
</a>
@ -29,21 +193,21 @@
<!-- Schedules Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% 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="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
<div class="schedule-card animate-in">
<div class="schedule-card-header">
<div class="flex items-center gap-2">
{% 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 %}
<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" %}
</span>
{% endif %}
</div>
{% 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 %}
<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 %}
</div>
@ -51,12 +215,12 @@
<div class="space-y-4">
<!-- Working Hours -->
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-[#eef6fb] rounded-lg flex items-center justify-center">
<i data-lucide="clock" class="w-5 h-5 text-[#005696]"></i>
<div class="icon-box">
<i data-lucide="clock" class="w-5 h-5"></i>
</div>
<div>
<p class="text-sm text-[#64748b]">{% trans "Working Hours" %}</p>
<p class="font-medium text-gray-900">
<p class="text-sm text-slate font-medium">{% trans "Working Hours" %}</p>
<p class="font-bold text-gray-900">
{{ schedule.work_start_time|time:"H:i" }} - {{ schedule.work_end_time|time:"H:i" }}
</p>
</div>
@ -64,12 +228,12 @@
<!-- Working Days -->
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-[#eef6fb] rounded-lg flex items-center justify-center">
<i data-lucide="calendar" class="w-5 h-5 text-[#005696]"></i>
<div class="icon-box">
<i data-lucide="calendar" class="w-5 h-5"></i>
</div>
<div>
<p class="text-sm text-[#64748b]">{% trans "Working Days" %}</p>
<p class="font-medium text-gray-900 text-sm">
<p class="text-sm text-slate font-medium">{% trans "Working Days" %}</p>
<p class="font-bold text-gray-900 text-sm">
{% 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 %}
{% endfor %}
@ -79,41 +243,41 @@
<!-- Timezone -->
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-[#eef6fb] rounded-lg flex items-center justify-center">
<i data-lucide="globe" class="w-5 h-5 text-[#005696]"></i>
<div class="icon-box">
<i data-lucide="globe" class="w-5 h-5"></i>
</div>
<div>
<p class="text-sm text-[#64748b]">{% trans "Timezone" %}</p>
<p class="font-medium text-gray-900 text-sm">{{ schedule.timezone }}</p>
<p class="text-sm text-slate font-medium">{% trans "Timezone" %}</p>
<p class="font-bold text-gray-900 text-sm">{{ schedule.timezone }}</p>
</div>
</div>
<!-- On-Call Admins Count -->
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-blue-50 rounded-lg flex items-center justify-center">
<i data-lucide="users" class="w-5 h-5 text-[#007bbd]"></i>
<div class="icon-box">
<i data-lucide="users" class="w-5 h-5"></i>
</div>
<div>
<p class="text-sm text-[#64748b]">{% trans "On-Call Admins" %}</p>
<p class="font-medium text-[#007bbd]">{{ schedule.on_call_admins.count }}</p>
<p class="text-sm text-slate font-medium">{% trans "On-Call Admins" %}</p>
<p class="font-bold text-[#007bbd]">{{ schedule.on_call_admins.count }}</p>
</div>
</div>
</div>
<!-- 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 %}"
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" %}
</a>
<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>
</a>
<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?" %}')">
{% 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>
</button>
</form>
@ -122,16 +286,15 @@
</div>
{% empty %}
<div class="col-span-full">
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-12 text-center">
<div class="w-16 h-16 bg-gray-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>
<div class="section-card p-12 text-center animate-in">
<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-slate-400"></i>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">{% trans "No On-Call Schedules" %}</h3>
<p class="text-[#64748b] mb-6 max-w-md mx-auto">
<h3 class="text-lg font-bold text-navy mb-2">{% trans "No On-Call Schedules" %}</h3>
<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." %}
</p>
<a href="{% url 'complaints:oncall_schedule_create' %}"
class="px-6 py-3 bg-[#005696] text-white rounded-lg hover:bg-[#007bbd] transition-colors inline-flex items-center gap-2">
<a href="{% url 'complaints:oncall_schedule_create' %}" class="btn-primary">
<i data-lucide="plus" class="w-5 h-5"></i>
{% trans "Create Schedule" %}
</a>

View File

@ -3,45 +3,127 @@
{% 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 %}
<!-- Back Button -->
<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" %}
</a>
</div>
<!-- Header -->
<header class="mb-6">
<div class="flex items-center gap-2 text-sm text-slate mb-2">
<a href="{% url 'complaints:complaint_list' %}">{% trans "Complaints" %}</a>
<div class="page-header-gradient">
<div class="flex items-center gap-2 text-sm text-white/80 mb-2">
<a href="{% url 'complaints:complaint_list' %}" class="hover:text-white">{% trans "Complaints" %}</a>
<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>
<span class="font-bold text-navy">{% trans "Request Explanation" %}</span>
<span class="font-bold">{% trans "Request Explanation" %}</span>
</div>
<h1 class="text-2xl font-bold">{% trans "Request Explanation" %}</h1>
<p class="text-white/80 mt-1">{{ complaint.title }}</p>
</div>
<h1 class="text-2xl font-bold text-navy">{% trans "Request Explanation" %}</h1>
<p class="text-slate mt-1">{{ complaint.title }}</p>
</header>
<form method="post" class="space-y-6">
{% csrf_token %}
<!-- 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">
<h3 class="font-bold text-navy text-lg flex items-center gap-2">
<i data-lucide="users" class="w-5 h-5 text-blue"></i>
<h3 class="font-bold text-[#1e293b] text-lg flex items-center gap-2">
<i data-lucide="users" class="w-5 h-5 text-[#005696]"></i>
{% trans "Select Recipients" %}
</h3>
<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" %}
</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" %}
</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" %}
</button>
</div>
@ -50,7 +132,7 @@
{% if recipients %}
<div class="space-y-4">
{% 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">
<!-- Staff Checkbox -->
<div class="pt-1">
@ -58,7 +140,7 @@
name="selected_staff"
value="{{ 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
onchange="updateManagerCheckbox('{{ recipient.staff_id }}', '{{ recipient.manager_id }}')">
</div>
@ -66,51 +148,51 @@
<!-- Staff Info -->
<div class="flex-1">
<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 }}
</div>
<div>
<h4 class="font-bold text-navy">{{ recipient.staff_name }}</h4>
<span class="text-xs text-slate">{{ recipient.role }}</span>
<h4 class="font-bold text-[#1e293b]">{{ recipient.staff_name }}</h4>
<span class="text-xs text-[#64748b]">{{ recipient.role }}</span>
</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 }}
</span>
</div>
{% 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">
<!-- Manager Checkbox -->
<input type="checkbox"
name="selected_managers"
value="{{ 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
data-staff-id="{{ recipient.staff_id }}">
<div class="flex items-center gap-2">
<i data-lucide="user-check" class="w-4 h-4 text-blue"></i>
<span class="text-sm font-semibold text-slate">{% trans "Manager:" %}</span>
<span class="text-sm font-bold text-navy">{{ recipient.manager_name }}</span>
<i data-lucide="user-check" class="w-4 h-4 text-[#005696]"></i>
<span class="text-sm font-semibold text-[#64748b]">{% trans "Manager:" %}</span>
<span class="text-sm font-bold text-[#1e293b]">{{ recipient.manager_name }}</span>
</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)" %}
</p>
</div>
{% else %}
<div class="mt-3 pl-4 border-l-2 border-slate-200">
<p class="text-xs text-slate flex items-center gap-2">
<i data-lucide="alert-circle" class="w-4 h-4 text-orange-500"></i>
<div class="mt-3 pl-4 border-l-2 border-[#e2e8f0]">
<p class="text-xs text-[#64748b] flex items-center gap-2">
<i data-lucide="alert-circle" class="w-4 h-4 text-amber-500"></i>
{% trans "No manager assigned to this staff member" %}
</p>
</div>
{% endif %}
{% 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">
<i data-lucide="alert-triangle" class="w-4 h-4"></i>
{% trans "This staff member has no email address. Request cannot be sent." %}
@ -124,36 +206,36 @@
</div>
{% else %}
<div class="text-center py-12">
<i data-lucide="users" class="w-16 h-16 mx-auto text-slate-300 mb-4"></i>
<p class="text-slate font-medium">{% trans "No staff members are involved in this complaint." %}</p>
<i data-lucide="users" class="w-16 h-16 mx-auto text-[#e2e8f0] mb-4"></i>
<p class="text-[#64748b] font-medium">{% trans "No staff members are involved in this complaint." %}</p>
</div>
{% endif %}
</section>
<!-- Message -->
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
<h3 class="font-bold text-navy mb-4 text-lg flex items-center gap-2">
<i data-lucide="message-square" class="w-5 h-5 text-blue"></i>
<section class="form-section">
<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-[#005696]"></i>
{% trans "Additional Message" %}
</h3>
<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" %}
</label>
<textarea name="request_message"
id="request_message"
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>
</div>
</section>
<!-- Summary -->
<section class="bg-light/30 rounded-2xl p-6 border border-slate-100">
<div class="flex items-center justify-between">
<section class="form-section bg-[#f8fafc]">
<div class="flex items-center justify-between flex-wrap gap-4">
<div>
<h4 class="font-bold text-navy mb-1">{% trans "Summary" %}</h4>
<p class="text-sm text-slate">
<h4 class="font-bold text-[#1e293b] mb-1">{% trans "Summary" %}</h4>
<p class="text-sm text-[#64748b]">
<span id="staff-count">0</span> {% trans "staff will receive explanation links" %}
<span class="mx-2"></span>
<span id="manager-count">0</span> {% trans "managers will be notified" %}
@ -161,11 +243,11 @@
</div>
<div class="flex items-center gap-4">
<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" %}
</a>
<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">
<i data-lucide="send" class="w-5 h-5"></i>
{% trans "Send Requests" %}

View File

@ -3,15 +3,153 @@
{% 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 %}
<div class="page-header">
<div class="page-header-content">
<!-- Header -->
<div class="page-header-gradient">
<div class="flex items-center justify-between">
<div>
<h1 class="page-title">
<i class="fas fa-clock"></i>
<h1 class="text-2xl font-bold flex items-center gap-3">
<i data-lucide="clock" class="w-7 h-7"></i>
{{ title }}
</h1>
<p class="page-description">
<p class="text-white/80 mt-1">
{% if sla_config %}
{% translate "Edit SLA configuration" %}
{% else %}
@ -19,29 +157,28 @@
{% endif %}
</p>
</div>
<a href="{% url 'complaints:sla_config_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i>
<a href="{% url 'complaints:sla_config_list' %}" class="btn-secondary">
<i data-lucide="arrow-left" class="w-4 h-4"></i>
{% translate "Back to List" %}
</a>
</div>
</div>
<div class="page-content">
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-body">
<form method="post" class="row g-3">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2">
<div class="form-section">
<form method="post" class="space-y-6">
{% csrf_token %}
{% if request.user.is_px_admin %}
<div class="col-md-12">
<!-- Hospital (hidden for non-admins, visible for admins) -->
{% if not form.hospital.is_hidden %}
<div>
<label for="id_hospital" class="form-label">
{% translate "Hospital" %} <span class="text-danger">*</span>
{% translate "Hospital" %} <span class="text-red-500">*</span>
</label>
<select name="hospital" id="id_hospital" class="form-select" required>
<option value="">{% translate "Select Hospital" %}</option>
{% for hospital in form.hospital.field.queryset %}
{% for hospital in hospitals %}
<option value="{{ hospital.id }}"
{% if form.hospital.value == hospital.id|stringformat:"s" %}selected{% endif %}>
{{ hospital.name }}
@ -49,19 +186,46 @@
{% endfor %}
</select>
{% if form.hospital.errors %}
<div class="invalid-feedback d-block">
<div class="invalid-feedback">
{{ form.hospital.errors.0 }}
</div>
{% endif %}
</div>
{% else %}
{{ form.hospital }}
{% endif %}
<div class="col-md-6">
<label for="id_severity" class="form-label">
{% translate "Severity" %} <span class="text-danger">*</span>
<!-- Source -->
<div>
<label for="id_source" class="form-label">
{% translate "Complaint Source" %}
</label>
<select name="severity" id="id_severity" class="form-select" required>
<option value="">{% translate "Select Severity" %}</option>
<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 %}>
@ -70,18 +234,18 @@
{% endfor %}
</select>
{% if form.severity.errors %}
<div class="invalid-feedback d-block">
<div class="invalid-feedback">
{{ form.severity.errors.0 }}
</div>
{% endif %}
</div>
<div class="col-md-6">
<div>
<label for="id_priority" class="form-label">
{% translate "Priority" %} <span class="text-danger">*</span>
{% translate "Priority" %}
</label>
<select name="priority" id="id_priority" class="form-select" required>
<option value="">{% translate "Select Priority" %}</option>
<select name="priority" id="id_priority" class="form-select">
<option value="">{% translate "Any Priority" %}</option>
{% for value, label in form.priority.field.choices %}
<option value="{{ value }}"
{% if form.priority.value == value %}selected{% endif %}>
@ -90,15 +254,16 @@
{% endfor %}
</select>
{% if form.priority.errors %}
<div class="invalid-feedback d-block">
<div class="invalid-feedback">
{{ form.priority.errors.0 }}
</div>
{% endif %}
</div>
</div>
<div class="col-md-6">
<div>
<label for="id_sla_hours" class="form-label">
{% translate "SLA Deadline (Hours)" %} <span class="text-danger">*</span>
{% translate "SLA Deadline (Hours)" %} <span class="text-red-500">*</span>
</label>
<input type="number"
name="sla_hours"
@ -110,135 +275,226 @@
required
placeholder="{% translate 'e.g., 24' %}">
{% if form.sla_hours.errors %}
<div class="invalid-feedback d-block">
<div class="invalid-feedback">
{{ form.sla_hours.errors.0 }}
</div>
{% else %}
<div class="form-text">
{% translate "Hours after complaint creation before deadline" %}
{% translate "Total hours from complaint creation until SLA deadline" %}
</div>
{% endif %}
</div>
<div class="col-md-6">
<label for="id_warning_hours" class="form-label">
{% translate "Warning Threshold (Hours)" %} <span class="text-danger">*</span>
<!-- 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>
<input type="number"
name="warning_hours"
id="id_warning_hours"
name="first_reminder_hours_after"
id="id_first_reminder_hours_after"
class="form-control"
value="{{ form.warning_hours.value|default:'' }}"
min="1"
value="{{ form.first_reminder_hours_after.value|default:'' }}"
min="0"
step="0.5"
required
placeholder="{% translate 'e.g., 18' %}">
{% if form.warning_hours.errors %}
<div class="invalid-feedback d-block">
{{ form.warning_hours.errors.0 }}
placeholder="{% translate 'e.g., 12' %}">
{% if form.first_reminder_hours_after.errors %}
<div class="invalid-feedback">
{{ form.first_reminder_hours_after.errors.0 }}
</div>
{% else %}
<div class="form-text">
{% translate "Hours before deadline to send warning notification" %}
{% translate "Send first reminder X hours after complaint creation (0 = use legacy timing)" %}
</div>
{% endif %}
</div>
<div class="col-12">
<div class="form-check form-switch">
<div>
<label for="id_second_reminder_hours_after" class="form-label">
{% translate "Second Reminder (Hours After Creation)" %}
</label>
<input type="number"
name="second_reminder_hours_after"
id="id_second_reminder_hours_after"
class="form-control"
value="{{ form.second_reminder_hours_after.value|default:'' }}"
min="0"
step="0.5"
placeholder="{% translate 'e.g., 18' %}">
{% if form.second_reminder_hours_after.errors %}
<div class="invalid-feedback">
{{ form.second_reminder_hours_after.errors.0 }}
</div>
{% else %}
<div class="form-text">
{% 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>
{% endif %}
</div>
<div>
<div class="form-check mb-2">
<input type="checkbox"
name="is_active"
id="id_is_active"
name="second_reminder_enabled"
id="id_second_reminder_enabled"
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" %}
{% if form.second_reminder_enabled.value %}checked{% endif %}>
<label class="form-label mb-0" for="id_second_reminder_enabled">
{% translate "Enable Second Reminder" %}
</label>
</div>
<label for="id_second_reminder_hours_before" class="form-label">
{% translate "Second Reminder (Hours Before)" %}
</label>
<input type="number"
name="second_reminder_hours_before"
id="id_second_reminder_hours_before"
class="form-control"
value="{{ form.second_reminder_hours_before.value|default:'6' }}"
min="0"
step="0.5"
placeholder="{% translate 'e.g., 6' %}">
</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="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 configuration' %}">{{ form.description.value|default:'' }}</textarea>
{% if form.description.errors %}
<div class="invalid-feedback d-block">
{{ form.description.errors.0 }}
<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>
{% 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>
<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 btn-outline-secondary">
<i class="fas fa-times"></i>
<a href="{% url 'complaints:sla_config_list' %}" class="btn-secondary">
<i data-lucide="x" class="w-4 h-4"></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>
<div class="lg:col-span-1">
<div class="form-section bg-[#f8fafc]">
<h5 class="font-bold text-[#1e293b] flex items-center gap-2 mb-3">
<i data-lucide="info" class="w-5 h-5 text-[#005696]"></i>
{% translate "Help" %}
</h5>
<h6 class="card-subtitle mb-3 text-muted">
<h6 class="text-sm font-semibold text-[#64748b] mb-3">
{% translate "Understanding SLA Configuration" %}
</h6>
<p class="card-text">
{% translate "Service Level Agreements (SLAs) define the timeframes within which complaints should be resolved based on their severity and priority." %}
<p class="text-sm text-[#64748b] mb-4">
{% translate "SLA configurations define deadlines for complaints based on source, severity, and priority." %}
</p>
<ul class="list-unstyled">
<li class="mb-2">
<i class="fas fa-check text-success me-2"></i>
{% translate "High severity complaints typically have shorter SLAs" %}
<h6 class="text-sm font-semibold text-[#64748b] mb-3">
{% translate "Source-Based vs Severity/Priority-Based" %}
</h6>
<ul class="space-y-2 mb-4 text-sm text-[#64748b]">
<li class="flex items-start gap-2">
<i data-lucide="radio" class="w-4 h-4 text-blue-500 mt-1"></i>
<span><strong>{% translate "Source-Based:" %}</strong> {% translate "Use when different complaint sources (MOH, CCHI, Internal) need different SLAs" %}</span>
</li>
<li class="mb-2">
<i class="fas fa-check text-success me-2"></i>
{% translate "Warning threshold sends notifications before deadline" %}
</li>
<li class="mb-2">
<i class="fas fa-check text-success me-2"></i>
{% translate "Inactive configurations won't be applied" %}
<li class="flex items-start gap-2">
<i data-lucide="sliders" class="w-4 h-4 text-green-500 mt-1"></i>
<span><strong>{% translate "Severity/Priority-Based:" %}</strong> {% translate "Use for general SLA based on complaint characteristics" %}</span>
</li>
</ul>
<hr>
<h6 class="text-sm font-semibold text-[#64748b] mb-3">
{% translate "Reminder Timing" %}
</h6>
<ul class="space-y-2 mb-4 text-sm text-[#64748b]">
<li class="flex items-start gap-2">
<i data-lucide "clock" class="w-4 h-4 text-amber-500 mt-1"></i>
<span><strong>{% translate "Source-Based Timing:" %}</strong> {% translate "Set reminder hours after creation (e.g., remind 12h after creation for 24h SLA)" %}</span>
</li>
<li class="flex items-start gap-2">
<i data-lucide="history" class="w-4 h-4 text-slate-400 mt-1"></i>
<span><strong>{% translate "Legacy Timing:" %}</strong> {% translate "Set reminder hours before deadline (e.g., remind 6h before deadline)" %}</span>
</li>
</ul>
<h6 class="card-subtitle mb-2 text-muted">
<h6 class="text-sm font-semibold text-[#64748b] mb-3">
{% translate "Best Practices" %}
</h6>
<ul class="list-unstyled">
<li class="mb-2">
<i class="fas fa-lightbulb text-warning me-2"></i>
{% translate "Set warning threshold at least 4-6 hours before deadline" %}
<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="mb-2">
<i class="fas fa-lightbulb text-warning me-2"></i>
{% translate "Consider hospital-specific requirements" %}
<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>
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>
{% endblock %}

View File

@ -3,39 +3,239 @@
{% block title %}{% translate "SLA Configurations" %} - 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;
}
.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="page-header">
<div class="page-header-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="page-title">
<i class="fas fa-clock"></i>
<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="page-description">
{% translate "Manage Service Level Agreement settings for complaint deadlines" %}
</p>
<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 btn-primary">
<i class="fas fa-plus"></i>
<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="page-content">
<!-- Filters -->
<div class="card mb-4">
<div class="card-header">
<h5 class="card-title">
<i class="fas fa-filter"></i>
{% translate "Filters" %}
</h5>
<div class="section-card mb-6 animate-in">
<div class="section-header">
<div class="section-icon secondary">
<i data-lucide="filter" class="w-5 h-5"></i>
</div>
<div class="card-body">
<form method="get" class="row g-3">
<div class="col-md-3">
<label class="form-label">{% translate "Severity" %}</label>
<select name="severity" class="form-select">
<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>
{% for value, label in severity_choices %}
<option value="{{ value }}" {% if filters.severity == value %}selected{% endif %}>
@ -44,10 +244,9 @@
{% endfor %}
</select>
</div>
<div class="col-md-3">
<label class="form-label">{% translate "Priority" %}</label>
<select name="priority" class="form-select">
<div>
<label class="block text-sm font-semibold text-slate mb-1.5">{% 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">
<option value="">{% translate "All Priorities" %}</option>
{% for value, label in priority_choices %}
<option value="{{ value }}" {% if filters.priority == value %}selected{% endif %}>
@ -56,23 +255,21 @@
{% endfor %}
</select>
</div>
<div class="col-md-3">
<label class="form-label">{% translate "Status" %}</label>
<select name="is_active" class="form-select">
<div>
<label class="block text-sm font-semibold text-slate mb-1.5">{% translate "Status" %}</label>
<select name="is_active" class="px-4 py-2.5 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-blue bg-white">
<option value="">{% translate "All" %}</option>
<option value="true" {% if filters.is_active == "true" %}selected{% endif %}>{% translate "Active" %}</option>
<option value="false" {% if filters.is_active == "false" %}selected{% endif %}>{% translate "Inactive" %}</option>
</select>
</div>
<div class="col-12 text-end">
<button type="submit" class="btn btn-primary">
<i class="fas fa-search"></i>
<div class="flex items-end gap-2">
<button type="submit" class="btn-primary h-[46px]">
<i data-lucide="search" class="w-4 h-4"></i>
{% translate "Apply Filters" %}
</button>
<a href="{% url 'complaints:sla_config_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-times"></i>
<a href="{% url 'complaints:sla_config_list' %}" class="btn-secondary h-[46px]">
<i data-lucide="x" class="w-4 h-4"></i>
{% translate "Clear" %}
</a>
</div>
@ -81,11 +278,17 @@
</div>
<!-- SLA Configurations Table -->
<div class="card">
<div class="card-body">
<div class="section-card animate-in">
<div class="section-header">
<div class="section-icon primary">
<i data-lucide="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 %}
<div class="table-responsive">
<table class="table table-hover">
<div class="overflow-x-auto">
<table class="w-full data-table">
<thead>
<tr>
<th>{% translate "Hospital" %}</th>
@ -94,7 +297,7 @@
<th>{% translate "SLA Hours" %}</th>
<th>{% translate "Warning Hours" %}</th>
<th>{% translate "Status" %}</th>
<th>{% translate "Actions" %}</th>
<th class="text-right">{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
@ -117,17 +320,23 @@
<td>{{ config.warning_hours }}h</td>
<td>
{% 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 %}
<span class="badge bg-secondary">{% translate "Inactive" %}</span>
<span class="badge badge-secondary">
<i data-lucide="x-circle" class="w-3 h-3"></i>
{% translate "Inactive" %}
</span>
{% endif %}
</td>
<td>
<div class="btn-group">
<td class="text-right">
<div class="flex items-center justify-end gap-2">
<a href="{% url 'complaints: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' %}">
<i class="fas fa-edit"></i>
<i data-lucide="edit" class="w-4 h-4"></i>
</a>
<form method="post"
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?" %}')">
{% csrf_token %}
<button type="submit"
class="btn btn-sm btn-outline-danger"
class="p-2 text-red-500 hover:bg-red-50 rounded-lg transition"
title="{% translate 'Delete' %}">
<i class="fas fa-trash"></i>
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</form>
</div>
@ -150,59 +359,42 @@
<!-- Pagination -->
{% if page_obj.has_other_pages %}
<nav class="mt-4">
<ul class="pagination justify-content-center">
<div class="p-4 border-t border-slate-200">
<div class="flex items-center justify-between">
<p class="text-sm text-slate">
{% blocktrans with start=page_obj.start_index end=page_obj.end_index total=page_obj.paginator.count %}
Showing {{ start }} to {{ end }} of {{ total }} configurations
{% endblocktrans %}
</p>
<div class="flex gap-2">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
<i class="fas fa-angle-double-left"></i>
<a href="?page={{ page_obj.previous_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}"
class="px-4 py-2 border border-slate-200 rounded-lg hover:bg-slate-50 transition text-sm font-medium">
<i data-lucide="chevron-left" class="w-4 h-4 inline"></i>
{% translate "Previous" %}
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
<i class="fas fa-angle-left"></i>
</a>
</li>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<li class="page-item active">
<span class="page-link">{{ num }}</span>
</li>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<li class="page-item">
<a class="page-link" href="?page={{ num }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
{{ num }}
</a>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
<i class="fas fa-angle-right"></i>
<a href="?page={{ page_obj.next_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}"
class="px-4 py-2 border border-slate-200 rounded-lg hover:bg-slate-50 transition text-sm font-medium">
{% translate "Next" %}
<i data-lucide="chevron-right" class="w-4 h-4 inline"></i>
</a>
</li>
<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>
</div>
</div>
</div>
{% endif %}
{% else %}
<div class="text-center py-5">
<i class="fas fa-clock fa-3x text-muted mb-3"></i>
<p class="text-muted">
{% translate "No SLA configurations found. Create your first configuration to get started." %}
</p>
<a href="{% url 'complaints:sla_config_create' %}" class="btn btn-primary">
<i class="fas fa-plus"></i>
<div class="text-center py-12">
<div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i data-lucide="clock" class="w-8 h-8 text-slate-400"></i>
</div>
<p class="text-slate font-medium">{% translate "No SLA configurations found" %}</p>
<p class="text-slate text-sm mt-1">{% translate "Create your first configuration to get started" %}</p>
<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" %}
</a>
</div>
@ -210,4 +402,10 @@
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>
{% endblock %}

View File

@ -12,19 +12,62 @@
--hh-slate: #64748b;
}
.page-header {
background: linear-gradient(135deg, var(--hh-navy) 0%, #0069a8 50%, var(--hh-blue) 100%);
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 2rem 2.5rem;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 2rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.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 {
background: white;
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;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
@ -33,7 +76,7 @@
.template-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px -8px rgba(0, 0, 0, 0.1);
border-color: var(--hh-blue);
border-color: #005696;
}
.usage-badge {
@ -69,6 +112,28 @@
box-shadow: 0 8px 16px rgba(0, 86, 150, 0.3);
}
.btn-secondary {
background: white;
color: #475569;
padding: 0.625rem 1.25rem;
border-radius: 0.75rem;
font-weight: 600;
border: 2px solid #e2e8f0;
cursor: pointer;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
font-size: 0.875rem;
}
.btn-secondary:hover {
background: #f1f5f9;
border-color: #cbd5e1;
transform: translateY(-1px);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
@ -83,7 +148,7 @@
{% block content %}
<div class="px-4 py-6">
<!-- Page Header -->
<div class="page-header animate-in">
<div class="page-header-gradient animate-in">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold mb-2">
@ -92,7 +157,7 @@
</h1>
<p class="text-white/90">{% trans "Pre-defined templates for common complaints" %}</p>
</div>
<a href="{% url 'complaints:template_create' %}" class="inline-flex items-center gap-2 bg-white text-navy px-5 py-2.5 rounded-xl font-bold hover:bg-light transition shadow-lg">
<a href="{% url 'complaints:template_create' %}" class="btn-secondary">
<i data-lucide="plus" class="w-4 h-4"></i>
{% trans "New Template" %}
</a>
@ -100,7 +165,14 @@
</div>
<!-- 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">
<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">{% trans "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">{% trans "Status" %}</label>
@ -124,6 +196,7 @@
</div>
</form>
</div>
</div>
<!-- Templates Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 animate-in">
@ -184,13 +257,13 @@
</div>
{% empty %}
<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">
<i data-lucide="file-template" class="w-8 h-8 text-slate-400"></i>
</div>
<p class="text-slate font-medium">{% trans "No templates found" %}</p>
<p class="text-slate text-sm mt-1">{% trans "Create your first template to get started" %}</p>
<a href="{% url 'complaints:template_create' %}" class="inline-flex items-center gap-2 mt-4 btn-primary">
<a href="{% url 'complaints:template_create' %}" class="btn-primary mt-4">
<i data-lucide="plus" class="w-4 h-4"></i>
{% trans "Create Template" %}
</a>

View File

@ -1,10 +1,56 @@
{% extends "layouts/public_base.html" %}
{% load i18n %}
{% load i18n static %}
{% block title %}{% trans "Submit Feedback" %} - PX360{% endblock %}
{% block extra_css %}
<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 */
.selection-card {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
@ -216,17 +262,34 @@
{% endblock %}
{% 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">
<!-- Welcome Card -->
<div class="glass-card rounded-3xl shadow-2xl p-8 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">
<i data-lucide="heart-handshake" class="w-10 h-10 text-white"></i>
<div class="rounded-3xl shadow-2xl overflow-hidden mb-8 text-center animate-fade-in">
<!-- Logo Banner - Full Width White -->
<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>
<!-- Selection Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8" id="selectionCards">

View File

@ -1,39 +1,92 @@
{% extends "layouts/base.html" %}
{% load i18n static %}
{% block title %}{% trans "Select Hospital" %} - PX360{% endblock %}
{% block content %}
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!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="text-center mb-8">
<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 mx-auto">
<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-3xl font-bold text-navy mb-3">
{% trans "Select Hospital" %}
</h1>
<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:" %}
<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>
<!-- Hospital Selection Form -->
<form method="post" class="space-y-6" id="hospitalForm">
<!-- Body -->
<div class="p-8">
{% if messages %}
<div class="mb-6 space-y-3">
{% for message in messages %}
<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">
{% if message.tags == 'error' or message.tags == 'danger' %}
<i data-lucide="alert-triangle" class="w-5 h-5 flex-shrink-0 mt-0.5"></i>
{% elif message.tags == 'warning' %}
<i data-lucide="alert-circle" class="w-5 h-5 flex-shrink-0 mt-0.5"></i>
{% elif message.tags == 'success' %}
<i data-lucide="check-circle" class="w-5 h-5 flex-shrink-0 mt-0.5"></i>
{% else %}
<i data-lucide="info" class="w-5 h-5 flex-shrink-0 mt-0.5"></i>
{% endif %}
<p class="text-sm flex-1">{{ message }}</p>
</div>
{% endfor %}
</div>
{% endif %}
<form method="post" 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">
<!-- Hospital Selection Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-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
class="hospital-card cursor-pointer rounded-xl border-2 p-4
{% if hospital_id_str == selected_hospital_id %}
border-blue bg-blue-50/50 shadow-md selected
border-blue selected bg-blue-50/50
{% else %}
border-slate-200 bg-white hover:border-blue/50 hover:-translate-y-1
border-gray-200 hover:border-blue/50 hover:shadow-md
{% endif %}"
onclick="selectHospital('{{ hospital_id_str }}')"
data-hospital-id="{{ hospital_id_str }}"
@ -44,80 +97,42 @@
name="hospital_id"
value="{{ hospital.id }}"
{% if hospital_id_str == selected_hospital_id %}checked{% endif %}
class="hospital-radio sr-only"
class="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
<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 %}
border-blue bg-blue
bg-gradient-to-br from-blue to-navy
{% else %}
border-slate-300
bg-gray-100
{% endif %}">
<i data-lucide="check" class="w-3.5 h-3.5 text-white transition-opacity
{% if hospital_id_str == selected_hospital_id %}
opacity-100
{% else %}
opacity-0
{% endif %}"></i>
</div>
</div>
<!-- Hospital Icon -->
<div class="hospital-icon w-14 h-14 rounded-xl flex items-center justify-center mb-4 transition-all
{% if hospital_id_str == selected_hospital_id %}
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
<i data-lucide="building-2" class="w-6 h-6
{% if hospital_id_str == selected_hospital_id %}
text-white
{% else %}
text-slate-500 group-hover:text-blue
text-gray-500
{% endif %}"></i>
</div>
<!-- Hospital Name -->
<h3 class="hospital-name text-lg font-bold mb-2 transition-colors pr-8
{% if hospital_id_str == selected_hospital_id %}
text-blue
{% else %}
text-navy group-hover:text-blue
{% endif %}">
<!-- 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>
<!-- Location -->
{% if hospital.city %}
<div class="flex items-center gap-2 text-slate text-sm">
<i data-lucide="map-pin" class="w-4 h-4 text-slate/70"></i>
<span>
{{ hospital.city }}
{% if hospital.country %}, {{ hospital.country }}{% endif %}
</span>
</div>
{% else %}
<div class="flex items-center gap-2 text-slate/50 text-sm">
<i data-lucide="map-pin" class="w-4 h-4"></i>
<span>{% trans "Location not specified" %}</span>
</div>
{% endif %}
<!-- Selected Badge -->
<div class="selected-badge mt-4 pt-4 border-t transition-all
{% if hospital_id_str == selected_hospital_id %}
block border-blue/20
{% else %}
hidden border-slate-100
{% endif %}">
<span class="inline-flex items-center gap-1.5 text-sm font-semibold text-blue">
<i data-lucide="check-circle-2" class="w-4 h-4"></i>
{% trans "Currently Selected" %}
</span>
<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>
@ -126,8 +141,8 @@
</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">
<div class="bg-amber-50 border border-amber-200 rounded-xl p-8 text-center mb-6">
<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">
@ -139,43 +154,28 @@
</div>
{% endif %}
<!-- Action Buttons -->
<!-- Submit Button -->
{% if hospitals %}
<div class="bg-white rounded-2xl border border-slate-200 p-6 mt-8">
<div class="flex flex-col sm:flex-row justify-between items-center gap-4">
<a href="/" class="w-full sm:w-auto px-6 py-3 border border-slate-200 text-slate rounded-xl font-semibold hover:bg-slate-50 transition flex items-center justify-center gap-2">
<i data-lucide="arrow-left" class="w-5 h-5"></i>
{% trans "Back to Dashboard" %}
</a>
<button type="submit" class="w-full sm:w-auto px-8 py-3 bg-gradient-to-r from-blue to-navy text-white rounded-xl font-semibold hover:from-navy hover:to-blue transition flex items-center justify-center gap-2 shadow-lg shadow-blue/20">
<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">
<i data-lucide="check" class="w-5 h-5"></i>
{% trans "Continue" %}
{% trans "Continue to Dashboard" %}
</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>
</div>
<!-- Footer -->
<div class="bg-gray-50 px-8 py-4 text-center border-t border-gray-100">
<p class="text-xs text-gray-500">
{% trans "You must select a hospital to access the system" %}
</p>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Initialize Lucide icons
lucide.createIcons();
});
function selectHospital(hospitalId) {
// Check the radio button
@ -184,95 +184,63 @@ function selectHospital(hospitalId) {
radio.checked = true;
}
// Update all cards visual state
// Update all cards
document.querySelectorAll('.hospital-card').forEach(card => {
const cardHospitalId = card.dataset.hospitalId;
const isSelected = cardHospitalId === hospitalId;
const cardId = card.dataset.hospitalId;
const isSelected = cardId === hospitalId;
// Update card border and background
// Update card styling
if (isSelected) {
card.classList.remove('border-slate-200', 'bg-white');
card.classList.add('border-blue', 'bg-blue-50/50', 'shadow-md', 'selected');
card.classList.add('border-blue', 'selected', 'bg-blue-50/50');
card.classList.remove('border-gray-200');
} else {
card.classList.remove('border-blue', 'bg-blue-50/50', 'shadow-md', 'selected');
card.classList.add('border-slate-200', 'bg-white');
card.classList.remove('border-blue', 'selected', 'bg-blue-50/50');
card.classList.add('border-gray-200');
}
// Update selection indicator (check circle)
const indicator = card.querySelector('.selection-indicator');
if (indicator) {
// Update icon container
const iconContainer = card.querySelector('.w-12');
const icon = iconContainer?.querySelector('i');
if (iconContainer && icon) {
if (isSelected) {
indicator.classList.remove('border-slate-300');
indicator.classList.add('border-blue', 'bg-blue');
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 {
indicator.classList.remove('border-blue', 'bg-blue');
indicator.classList.add('border-slate-300');
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 checkIcon = card.querySelector('.selection-indicator i');
if (checkIcon) {
if (isSelected) {
checkIcon.classList.remove('opacity-0');
checkIcon.classList.add('opacity-100');
} else {
checkIcon.classList.remove('opacity-100');
checkIcon.classList.add('opacity-0');
}
}
// Update hospital icon
const iconContainer = card.querySelector('.hospital-icon');
if (iconContainer) {
if (isSelected) {
iconContainer.classList.remove('bg-slate-100');
iconContainer.classList.add('bg-gradient-to-br', 'from-blue', 'to-navy', 'ring-4', 'ring-blue/20');
} else {
iconContainer.classList.remove('bg-gradient-to-br', 'from-blue', 'to-navy', 'ring-4', 'ring-blue/20');
iconContainer.classList.add('bg-slate-100');
}
}
// Update icon color
const icon = card.querySelector('.hospital-icon i');
if (icon) {
if (isSelected) {
icon.classList.remove('text-slate-500');
icon.classList.add('text-white');
} else {
icon.classList.remove('text-white');
icon.classList.add('text-slate-500');
}
}
// Update hospital name color
const name = card.querySelector('.hospital-name');
if (name) {
if (isSelected) {
name.classList.remove('text-navy');
name.classList.add('text-blue');
} else {
name.classList.remove('text-blue');
name.classList.add('text-navy');
}
}
// Update selected badge
const badge = card.querySelector('.selected-badge');
if (badge) {
if (isSelected) {
badge.classList.remove('hidden');
badge.classList.add('block', 'border-blue/20');
} else {
badge.classList.remove('block', 'border-blue/20');
badge.classList.add('hidden');
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();
}
}
});
// Re-render icons
lucide.createIcons();
}
</script>
{% endblock %}
</body>
</html>

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 %}
<!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">
<p>{% trans "Dear" %} {{ staff_name }},</p>
{% block title %}{% trans "Explanation Request - Al Hammadi Hospital" %}{% endblock %}
<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 %}
{% block hero_title %}{% trans "Explanation Request" %}{% endblock %}
{% block hero_subtitle %}{% trans "Please review the complaint details and submit your response" %}{% endblock %}
{% block content %}
<!-- Greeting -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding-bottom: 20px;">
<p style="margin: 0; font-size: 16px; color: #1e293b; line-height: 1.6;">
{% trans "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 %}
<div class="custom-message">
<strong>{% trans "Note from PX Team:" %}</strong>
<p>{{ custom_message }}</p>
</div>
<!-- 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 %}
<div class="complaint-box">
<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>
<!-- 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 %}
<div class="info-row" style="display: block;">
<div class="info-label">{% trans "Description:" %}</div>
<div class="info-value" style="margin-top: 5px;">{{ description }}</div>
</div>
<tr>
<td valign="top" style="padding: 8px 0; font-size: 14px; color: #64748b; font-weight: 500;">
{% trans "Description:" %}
</td>
<td style="padding: 8px 0; font-size: 14px; color: #1e293b; line-height: 1.6;">
{{ description }}
</td>
</tr>
{% endif %}
</div>
</table>
</td>
</tr>
</table>
<div style="text-align: center;">
<a href="{{ explanation_url }}" class="button">{% trans "Submit Your Explanation" %}</a>
</div>
<div class="note">
<strong>{% trans "Important Information:" %}</strong>
<ul style="margin-top: 10px; padding-left: 20px;">
<!-- Important Information -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<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>
</div>
</td>
</tr>
</table>
{% endblock %}
<p>{% trans "If you have any questions or concerns, please contact the PX team directly." %}</p>
{% block cta_url %}{{ explanation_url }}{% endblock %}
{% block cta_text %}{% trans "Submit Your Explanation" %}{% endblock %}
<p>{% trans "Thank you for your cooperation." %}</p>
</div>
{% 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 %}
<div class="footer">
<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>
{% block footer_address %}
PX360 Complaint Management System<br>
Al Hammadi Hospital
{% endblock %}

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