Merge remote-tracking branch 'origin/main'

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,11 +7,13 @@ internal format for sending surveys based on PatientType.
Simplified Flow: Simplified Flow:
1. Parse HIS patient data 1. Parse HIS patient data
2. Determine survey type from PatientType 2. Determine survey type from PatientType
3. Create survey instance 3. Create survey instance with PENDING status
4. Send survey via SMS 4. Queue delayed send task
5. Survey sent after delay (e.g., 1 hour for OPD)
""" """
from datetime import datetime from datetime import datetime, timedelta
from typing import Dict, Optional, Tuple from typing import Dict, Optional, Tuple
import logging
from django.utils import timezone from django.utils import timezone
@ -19,6 +21,8 @@ from apps.organizations.models import Hospital, Patient
from apps.surveys.models import SurveyTemplate, SurveyInstance, SurveyStatus from apps.surveys.models import SurveyTemplate, SurveyInstance, SurveyStatus
from apps.integrations.models import InboundEvent from apps.integrations.models import InboundEvent
logger = logging.getLogger(__name__)
class HISAdapter: class HISAdapter:
""" """
@ -191,6 +195,54 @@ class HISAdapter:
return survey_template return survey_template
@staticmethod
def get_delay_for_patient_type(patient_type: str, hospital) -> int:
"""
Get delay hours from SurveyTemplateMapping.
Falls back to default delays if no mapping found.
Args:
patient_type: HIS PatientType code (1, 2, 3, 4, O, E)
hospital: Hospital instance
Returns:
Delay in hours
"""
from apps.integrations.models import SurveyTemplateMapping
# Try to get mapping with delay (hospital-specific)
mapping = SurveyTemplateMapping.objects.filter(
patient_type=patient_type,
hospital=hospital,
is_active=True
).first()
if mapping and mapping.send_delay_hours:
return mapping.send_delay_hours
# Fallback to global mapping
mapping = SurveyTemplateMapping.objects.filter(
patient_type=patient_type,
hospital__isnull=True,
is_active=True
).first()
if mapping and mapping.send_delay_hours:
return mapping.send_delay_hours
# Default delays by patient type
default_delays = {
'1': 24, # Inpatient - 24 hours
'2': 1, # OPD - 1 hour
'3': 2, # EMS - 2 hours
'O': 1, # OPD - 1 hour
'E': 2, # EMS - 2 hours
'4': 4, # Daycase - 4 hours
}
return default_delays.get(patient_type, 1) # Default 1 hour
@staticmethod @staticmethod
def create_and_send_survey( def create_and_send_survey(
patient: Patient, patient: Patient,
@ -199,7 +251,9 @@ class HISAdapter:
survey_template: SurveyTemplate survey_template: SurveyTemplate
) -> Optional[SurveyInstance]: ) -> Optional[SurveyInstance]:
""" """
Create survey instance and send via SMS. Create survey instance and queue for delayed sending.
NEW: Survey is created with PENDING status and sent after delay.
Args: Args:
patient: Patient instance patient: Patient instance
@ -210,9 +264,11 @@ class HISAdapter:
Returns: Returns:
SurveyInstance or None if failed SurveyInstance or None if failed
""" """
from apps.surveys.tasks import send_scheduled_survey
admission_id = patient_data.get("AdmissionID") admission_id = patient_data.get("AdmissionID")
discharge_date_str = patient_data.get("DischargeDate") discharge_date_str = patient_data.get("DischargeDate")
discharge_date = HISAdapter.parse_date(discharge_date_str) if discharge_date_str else None patient_type = patient_data.get("PatientType")
# Check if survey already sent for this admission # Check if survey already sent for this admission
existing_survey = SurveyInstance.objects.filter( existing_survey = SurveyInstance.objects.filter(
@ -222,42 +278,48 @@ class HISAdapter:
).first() ).first()
if existing_survey: if existing_survey:
logger.info(f"Survey already exists for admission {admission_id}")
return existing_survey return existing_survey
# Create survey instance # Get delay from SurveyTemplateMapping
delay_hours = HISAdapter.get_delay_for_patient_type(patient_type, hospital)
# Calculate scheduled send time
scheduled_send_at = timezone.now() + timedelta(hours=delay_hours)
# Create survey with PENDING status (NOT SENT)
survey = SurveyInstance.objects.create( survey = SurveyInstance.objects.create(
survey_template=survey_template, survey_template=survey_template,
patient=patient, patient=patient,
hospital=hospital, hospital=hospital,
status=SurveyStatus.SENT, # Set to SENT as it will be sent immediately status=SurveyStatus.PENDING, # Changed from SENT
delivery_channel="SMS", # Send via SMS delivery_channel="SMS",
recipient_phone=patient.phone, recipient_phone=patient.phone,
recipient_email=patient.email, recipient_email=patient.email,
scheduled_send_at=scheduled_send_at,
metadata={ metadata={
'admission_id': admission_id, 'admission_id': admission_id,
'patient_type': patient_data.get("PatientType"), 'patient_type': patient_type,
'hospital_id': patient_data.get("HospitalID"), 'hospital_id': patient_data.get("HospitalID"),
'insurance_company': patient_data.get("InsuranceCompanyName"), 'insurance_company': patient_data.get("InsuranceCompanyName"),
'is_vip': patient_data.get("IsVIP") == "1" 'is_vip': patient_data.get("IsVIP") == "1",
'discharge_date': discharge_date_str,
'scheduled_send_at': scheduled_send_at.isoformat(),
'delay_hours': delay_hours,
} }
) )
# Send survey via SMS # Queue delayed send task
try: send_scheduled_survey.apply_async(
from apps.surveys.services import SurveyDeliveryService args=[str(survey.id)],
delivery_success = SurveyDeliveryService.deliver_survey(survey) countdown=delay_hours * 3600 # Convert to seconds
)
logger.info(
f"Survey {survey.id} created for {patient_type}, "
f"will send in {delay_hours}h at {scheduled_send_at}"
)
if delivery_success:
return survey
else:
import logging
logger = logging.getLogger(__name__)
logger.warning(f"Survey created but SMS delivery failed for survey {survey.id}")
return survey
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Error sending survey SMS: {str(e)}", exc_info=True)
return survey return survey
@staticmethod @staticmethod
@ -269,7 +331,8 @@ class HISAdapter:
1. Extract patient data 1. Extract patient data
2. Get or create patient and hospital 2. Get or create patient and hospital
3. Determine survey type from PatientType 3. Determine survey type from PatientType
4. Create and send survey via SMS 4. Create survey with PENDING status
5. Queue delayed send task
Args: Args:
his_data: HIS data in real format his_data: HIS data in real format
@ -282,7 +345,7 @@ class HISAdapter:
'message': '', 'message': '',
'patient': None, 'patient': None,
'survey': None, 'survey': None,
'survey_sent': False 'survey_queued': False
} }
try: try:
@ -327,16 +390,16 @@ class HISAdapter:
result['message'] = f"No survey template found for patient type '{patient_type}'" result['message'] = f"No survey template found for patient type '{patient_type}'"
return result return result
# Create and send survey # Create and queue survey (delayed sending)
survey = HISAdapter.create_and_send_survey( survey = HISAdapter.create_and_send_survey(
patient, hospital, patient_data, survey_template patient, hospital, patient_data, survey_template
) )
if survey: if survey:
from apps.surveys.models import SurveyStatus # Survey is queued with PENDING status
survey_sent = survey.status == SurveyStatus.SENT survey_queued = survey.status == SurveyStatus.PENDING
else: else:
survey_sent = False survey_queued = False
result.update({ result.update({
'success': True, 'success': True,
@ -344,13 +407,12 @@ class HISAdapter:
'patient': patient, 'patient': patient,
'patient_type': patient_type, 'patient_type': patient_type,
'survey': survey, 'survey': survey,
'survey_sent': survey_sent, 'survey_queued': survey_queued,
'scheduled_send_at': survey.scheduled_send_at.isoformat() if survey and survey.scheduled_send_at else None,
'survey_url': survey.get_survey_url() if survey else None 'survey_url': survey.get_survey_url() if survey else None
}) })
except Exception as e: except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Error processing HIS data: {str(e)}", exc_info=True) logger.error(f"Error processing HIS data: {str(e)}", exc_info=True)
result['message'] = f"Error processing HIS data: {str(e)}" result['message'] = f"Error processing HIS data: {str(e)}"
result['success'] = False result['success'] = False

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
""" """
Physicians Console UI views - Server-rendered templates for physician management Physicians Console UI views - Server-rendered templates for physician management
""" """
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db.models import Avg, Count, Q, Sum from django.db.models import Avg, Count, Q, Sum
@ -26,9 +27,11 @@ def physician_list(request):
""" """
# Base queryset with optimizations - only show staff marked as physicians # Base queryset with optimizations - only show staff marked as physicians
# Include both: staff with physician=True (from rating imports) OR staff_type='physician' # Include both: staff with physician=True (from rating imports) OR staff_type='physician'
queryset = Staff.objects.filter( queryset = (
Q(physician=True) | Q(staff_type=Staff.StaffType.PHYSICIAN) Staff.objects.filter(Q(physician=True) | Q(staff_type=Staff.StaffType.PHYSICIAN))
).select_related('hospital', 'department').distinct() .select_related("hospital", "department")
.distinct()
)
# Apply RBAC filters # Apply RBAC filters
user = request.user user = request.user
@ -40,50 +43,48 @@ def physician_list(request):
queryset = queryset.none() queryset = queryset.none()
# Apply filters # Apply filters
hospital_filter = request.GET.get('hospital') hospital_filter = request.GET.get("hospital")
if hospital_filter: if hospital_filter:
queryset = queryset.filter(hospital_id=hospital_filter) queryset = queryset.filter(hospital_id=hospital_filter)
department_filter = request.GET.get('department') department_filter = request.GET.get("department")
if department_filter: if department_filter:
queryset = queryset.filter(department_id=department_filter) queryset = queryset.filter(department_id=department_filter)
specialization_filter = request.GET.get('specialization') specialization_filter = request.GET.get("specialization")
if specialization_filter: if specialization_filter:
queryset = queryset.filter(specialization__icontains=specialization_filter) queryset = queryset.filter(specialization__icontains=specialization_filter)
status_filter = request.GET.get('status', 'active') status_filter = request.GET.get("status", "active")
if status_filter: if status_filter:
queryset = queryset.filter(status=status_filter) queryset = queryset.filter(status=status_filter)
# Search # Search
search_query = request.GET.get('search') search_query = request.GET.get("search")
if search_query: if search_query:
queryset = queryset.filter( queryset = queryset.filter(
Q(first_name__icontains=search_query) | Q(first_name__icontains=search_query)
Q(last_name__icontains=search_query) | | Q(last_name__icontains=search_query)
Q(license_number__icontains=search_query) | | Q(license_number__icontains=search_query)
Q(specialization__icontains=search_query) | Q(specialization__icontains=search_query)
) )
# Ordering # Ordering
order_by = request.GET.get('order_by', 'last_name') order_by = request.GET.get("order_by", "last_name")
queryset = queryset.order_by(order_by) queryset = queryset.order_by(order_by)
# Pagination # Pagination
page_size = int(request.GET.get('page_size', 25)) page_size = int(request.GET.get("page_size", 25))
paginator = Paginator(queryset, page_size) paginator = Paginator(queryset, page_size)
page_number = request.GET.get('page', 1) page_number = request.GET.get("page", 1)
page_obj = paginator.get_page(page_number) page_obj = paginator.get_page(page_number)
# Get current month ratings for displayed physicians # Get current month ratings for displayed physicians
now = timezone.now() now = timezone.now()
physician_ids = [p.id for p in page_obj.object_list] physician_ids = [p.id for p in page_obj.object_list]
current_ratings = PhysicianMonthlyRating.objects.filter( current_ratings = PhysicianMonthlyRating.objects.filter(
staff_id__in=physician_ids, staff_id__in=physician_ids, year=now.year, month=now.month
year=now.year, ).select_related("staff")
month=now.month
).select_related('staff')
# Create rating lookup # Create rating lookup
ratings_dict = {r.staff_id: r for r in current_ratings} ratings_dict = {r.staff_id: r for r in current_ratings}
@ -92,162 +93,38 @@ def physician_list(request):
for physician in page_obj.object_list: for physician in page_obj.object_list:
physician.current_rating = ratings_dict.get(physician.id) physician.current_rating = ratings_dict.get(physician.id)
# Get filter options departments = Department.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')
if not user.is_px_admin() and user.hospital: if not user.is_px_admin() and user.hospital:
departments = departments.filter(hospital=user.hospital) departments = departments.filter(hospital=user.hospital)
# Get unique specializations (only from physicians)
specializations = Staff.objects.filter(
Q(physician=True) | Q(staff_type=Staff.StaffType.PHYSICIAN)
).values_list('specialization', flat=True).distinct().order_by('specialization')
# Statistics
stats = {
'total': queryset.count(),
'active': queryset.filter(status='active').count(),
}
context = { context = {
'page_obj': page_obj, "page_obj": page_obj,
'physicians': page_obj.object_list, "physicians": page_obj.object_list,
'stats': stats, "departments": departments,
'hospitals': hospitals, "filters": request.GET,
'departments': departments,
'specializations': specializations,
'filters': request.GET,
'current_year': now.year,
'current_month': now.month,
} }
return render(request, 'physicians/physician_list.html', context) return render(request, "physicians/physician_list.html", context)
@login_required
def physician_detail(request, pk):
"""
Physician detail view with performance metrics.
Features:
- Full physician details
- Current month rating
- Year-to-date performance
- Monthly ratings history (last 12 months)
- Performance trends
"""
physician = get_object_or_404(
Staff.objects.select_related('hospital', 'department'),
pk=pk
)
# Check permission
user = request.user
if not user.is_px_admin() and user.hospital:
if physician.hospital != user.hospital:
from django.http import Http404
raise Http404("Physician not found")
now = timezone.now()
current_year = now.year
current_month = now.month
# Get current month rating
current_month_rating = PhysicianMonthlyRating.objects.filter(
staff=physician,
year=current_year,
month=current_month
).first()
# Get previous month rating
prev_month = current_month - 1 if current_month > 1 else 12
prev_year = current_year if current_month > 1 else current_year - 1
previous_month_rating = PhysicianMonthlyRating.objects.filter(
staff=physician,
year=prev_year,
month=prev_month
).first()
# Get year-to-date stats
ytd_ratings = PhysicianMonthlyRating.objects.filter(
staff=physician,
year=current_year
)
ytd_stats = ytd_ratings.aggregate(
avg_rating=Avg('average_rating'),
total_surveys=Count('id')
)
# Get last 12 months ratings
ratings_history = PhysicianMonthlyRating.objects.filter(
staff=physician
).order_by('-year', '-month')[:12]
# Get best and worst months from all ratings (not just last 12 months)
all_ratings = PhysicianMonthlyRating.objects.filter(staff=physician)
best_month = all_ratings.order_by('-average_rating').first()
worst_month = all_ratings.order_by('average_rating').first()
# Determine trend
trend = 'stable'
trend_percentage = 0
if current_month_rating and previous_month_rating:
diff = float(current_month_rating.average_rating - previous_month_rating.average_rating)
if previous_month_rating.average_rating > 0:
trend_percentage = (diff / float(previous_month_rating.average_rating)) * 100
if diff > 0.1:
trend = 'improving'
elif diff < -0.1:
trend = 'declining'
context = {
'physician': physician,
'current_month_rating': current_month_rating,
'previous_month_rating': previous_month_rating,
'ytd_average': ytd_stats['avg_rating'],
'ytd_surveys': ytd_stats['total_surveys'],
'ratings_history': ratings_history,
'best_month': best_month,
'worst_month': worst_month,
'trend': trend,
'trend_percentage': abs(trend_percentage),
'current_year': current_year,
'current_month': current_month,
}
return render(request, 'physicians/physician_detail.html', context)
@login_required @login_required
def leaderboard(request): def leaderboard(request):
""" """
Physician leaderboard view. Physician leaderboard - Top rated physicians.
Features: Features:
- Top-rated physicians for selected period - Ranked list of physicians by rating
- Filters (hospital, department, month/year) - Filters (hospital, department, year, month)
- Ranking with trends - Statistics summary
- Performance distribution
""" """
# Get parameters
now = timezone.now() now = timezone.now()
year = int(request.GET.get('year', now.year)) year = int(request.GET.get("year", now.year))
month = int(request.GET.get('month', now.month)) month = int(request.GET.get("month", now.month))
hospital_filter = request.GET.get('hospital')
department_filter = request.GET.get('department')
limit = int(request.GET.get('limit', 20))
# Build queryset - only include staff marked as physicians # Base queryset for ratings
queryset = PhysicianMonthlyRating.objects.filter( queryset = PhysicianMonthlyRating.objects.filter(
year=year, year=year, month=month, staff__physician=True
month=month, ).select_related("staff", "staff__hospital", "staff__department")
staff__physician=True
).select_related('staff', 'staff__hospital', 'staff__department')
# Apply RBAC filters # Apply RBAC filters
user = request.user user = request.user
@ -255,54 +132,21 @@ def leaderboard(request):
queryset = queryset.filter(staff__hospital=user.hospital) queryset = queryset.filter(staff__hospital=user.hospital)
# Apply filters # Apply filters
hospital_filter = request.GET.get("hospital")
if hospital_filter: if hospital_filter:
queryset = queryset.filter(staff__hospital_id=hospital_filter) queryset = queryset.filter(staff__hospital_id=hospital_filter)
department_filter = request.GET.get("department")
if department_filter: if department_filter:
queryset = queryset.filter(staff__department_id=department_filter) queryset = queryset.filter(staff__department_id=department_filter)
# Order by rating # Order by rating (highest first)
queryset = queryset.order_by('-average_rating')[:limit] queryset = queryset.order_by("-average_rating", "-total_surveys")
# Get previous month for trend # Get top 50 for leaderboard
prev_month = month - 1 if month > 1 else 12 leaderboard = queryset[:50]
prev_year = year if month > 1 else year - 1
# Build leaderboard with trends departments = Department.objects.filter(status="active")
leaderboard = []
for rank, rating in enumerate(queryset, start=1):
# Get previous month rating for trend
prev_rating = PhysicianMonthlyRating.objects.filter(
staff=rating.staff,
year=prev_year,
month=prev_month
).first()
trend = 'stable'
trend_value = 0
if prev_rating:
diff = float(rating.average_rating - prev_rating.average_rating)
trend_value = diff
if diff > 0.1:
trend = 'up'
elif diff < -0.1:
trend = 'down'
leaderboard.append({
'rank': rank,
'rating': rating,
'physician': rating.staff,
'trend': trend,
'trend_value': trend_value,
'prev_rating': prev_rating
})
# Get filter options
hospitals = Hospital.objects.filter(status='active')
if not user.is_px_admin() and user.hospital:
hospitals = hospitals.filter(id=user.hospital.id)
departments = Department.objects.filter(status='active')
if not user.is_px_admin() and user.hospital: if not user.is_px_admin() and user.hospital:
departments = departments.filter(hospital=user.hospital) departments = departments.filter(hospital=user.hospital)
@ -312,9 +156,7 @@ def leaderboard(request):
all_ratings = all_ratings.filter(staff__hospital=user.hospital) all_ratings = all_ratings.filter(staff__hospital=user.hospital)
stats = all_ratings.aggregate( stats = all_ratings.aggregate(
total_physicians=Count('id'), total_physicians=Count("id"), average_rating=Avg("average_rating"), total_surveys=Sum("total_surveys")
average_rating=Avg('average_rating'),
total_surveys=Count('total_surveys')
) )
# Distribution # Distribution
@ -324,22 +166,16 @@ def leaderboard(request):
poor = all_ratings.filter(average_rating__lt=2.5).count() poor = all_ratings.filter(average_rating__lt=2.5).count()
context = { context = {
'leaderboard': leaderboard, "leaderboard": leaderboard,
'year': year, "year": year,
'month': month, "month": month,
'hospitals': hospitals, "departments": departments,
'departments': departments, "filters": request.GET,
'filters': request.GET, "stats": stats,
'stats': stats, "distribution": {"excellent": excellent, "good": good, "average": average, "poor": poor},
'distribution': {
'excellent': excellent,
'good': good,
'average': average,
'poor': poor
}
} }
return render(request, 'physicians/leaderboard.html', context) return render(request, "physicians/leaderboard.html", context)
@login_required @login_required
@ -356,33 +192,32 @@ def physician_ratings_dashboard(request):
- Top physicians table - Top physicians table
""" """
now = timezone.now() now = timezone.now()
year = int(request.GET.get('year', now.year)) year = int(request.GET.get("year", now.year))
month = int(request.GET.get('month', now.month)) month = int(request.GET.get("month", now.month))
hospital_filter = request.GET.get('hospital') hospital_filter = request.GET.get("hospital")
department_filter = request.GET.get('department') department_filter = request.GET.get("department")
# Get filter options
user = request.user
hospitals = Hospital.objects.filter(status='active')
departments = Department.objects.filter(status='active')
if not user.is_px_admin() and user.hospital:
hospitals = hospitals.filter(id=user.hospital.id)
departments = departments.filter(hospital=user.hospital)
# Get available years (2024 to current year) # Get available years (2024 to current year)
current_year = now.year current_year = now.year
years = list(range(2024, current_year + 1)) years = list(range(2024, current_year + 1))
years.reverse() # Most recent first years.reverse() # Most recent first
# Get departments for filter dropdown
from apps.organizations.models import Department
if request.user.is_px_admin():
departments = Department.objects.filter(status="active").order_by("name")
elif request.user.hospital:
departments = Department.objects.filter(hospital=request.user.hospital, status="active").order_by("name")
else:
departments = Department.objects.none()
context = { context = {
'years': years, "years": years,
'hospitals': hospitals, "departments": departments,
'departments': departments, "filters": request.GET,
'filters': request.GET,
} }
return render(request, 'physicians/physician_ratings_dashboard.html', context) return render(request, "physicians/physician_ratings_dashboard.html", context)
@login_required @login_required
@ -394,15 +229,15 @@ def physician_ratings_dashboard_api(request):
""" """
try: try:
now = timezone.now() now = timezone.now()
year = int(request.GET.get('year', now.year)) year = int(request.GET.get("year", now.year))
month = int(request.GET.get('month', now.month)) month = int(request.GET.get("month", now.month))
hospital_filter = request.GET.get('hospital') hospital_filter = request.GET.get("hospital")
department_filter = request.GET.get('department') department_filter = request.GET.get("department")
# Base queryset - only include staff marked as physicians # Base queryset - only include staff marked as physicians
queryset = PhysicianMonthlyRating.objects.filter( queryset = PhysicianMonthlyRating.objects.filter(staff__physician=True).select_related(
staff__physician=True "staff", "staff__hospital", "staff__department"
).select_related('staff', 'staff__hospital', 'staff__department') )
# Apply RBAC filters # Apply RBAC filters
user = request.user user = request.user
@ -420,9 +255,9 @@ def physician_ratings_dashboard_api(request):
# 1. Statistics # 1. Statistics
stats = current_period.aggregate( stats = current_period.aggregate(
total_physicians=Count('id', distinct=True), total_physicians=Count("id", distinct=True),
average_rating=Avg('average_rating'), average_rating=Avg("average_rating"),
total_surveys=Sum('total_surveys') total_surveys=Sum("total_surveys"),
) )
excellent_count = current_period.filter(average_rating__gte=4.5).count() excellent_count = current_period.filter(average_rating__gte=4.5).count()
@ -437,15 +272,16 @@ def physician_ratings_dashboard_api(request):
y -= 1 y -= 1
period_data = queryset.filter(year=y, month=m).aggregate( period_data = queryset.filter(year=y, month=m).aggregate(
avg=Avg('average_rating'), avg=Avg("average_rating"), surveys=Sum("total_surveys")
surveys=Sum('total_surveys')
) )
trend_data.append({ trend_data.append(
'period': f'{y}-{m:02d}', {
'average_rating': float(period_data['avg'] or 0), "period": f"{y}-{m:02d}",
'total_surveys': period_data['surveys'] or 0 "average_rating": float(period_data["avg"] or 0),
}) "total_surveys": period_data["surveys"] or 0,
}
)
# 3. Rating Distribution # 3. Rating Distribution
excellent = current_period.filter(average_rating__gte=4.5).count() excellent = current_period.filter(average_rating__gte=4.5).count()
@ -453,85 +289,83 @@ def physician_ratings_dashboard_api(request):
average = current_period.filter(average_rating__gte=2.5, average_rating__lt=3.5).count() average = current_period.filter(average_rating__gte=2.5, average_rating__lt=3.5).count()
poor = current_period.filter(average_rating__lt=2.5).count() poor = current_period.filter(average_rating__lt=2.5).count()
distribution = { distribution = {"excellent": excellent, "good": good, "average": average, "poor": poor}
'excellent': excellent,
'good': good,
'average': average,
'poor': poor
}
# 4. Department Comparison (top 10) # 4. Department Comparison (top 10)
dept_data = current_period.values('staff__department__name').annotate( dept_data = (
average_rating=Avg('average_rating'), current_period.values("staff__department__name")
total_surveys=Sum('total_surveys'), .annotate(
physician_count=Count('id', distinct=True) average_rating=Avg("average_rating"),
).filter(staff__department__isnull=False).order_by('-average_rating')[:10] total_surveys=Sum("total_surveys"),
physician_count=Count("id", distinct=True),
)
.filter(staff__department__isnull=False)
.order_by("-average_rating")[:10]
)
departments = [ departments = [
{ {
'name': item['staff__department__name'] or 'Unknown', "name": item["staff__department__name"] or "Unknown",
'average_rating': float(item['average_rating'] or 0), "average_rating": float(item["average_rating"] or 0),
'total_surveys': item['total_surveys'] or 0 "total_surveys": item["total_surveys"] or 0,
} }
for item in dept_data for item in dept_data
] ]
# 5. Sentiment Analysis # 5. Sentiment Analysis
sentiment = current_period.aggregate( sentiment = current_period.aggregate(
positive=Sum('positive_count'), positive=Sum("positive_count"), neutral=Sum("neutral_count"), negative=Sum("negative_count")
neutral=Sum('neutral_count'),
negative=Sum('negative_count')
) )
total_sentiment = (sentiment['positive'] or 0) + (sentiment['neutral'] or 0) + (sentiment['negative'] or 0) total_sentiment = (sentiment["positive"] or 0) + (sentiment["neutral"] or 0) + (sentiment["negative"] or 0)
if total_sentiment > 0: if total_sentiment > 0:
sentiment_pct = { sentiment_pct = {
'positive': ((sentiment['positive'] or 0) / total_sentiment) * 100, "positive": ((sentiment["positive"] or 0) / total_sentiment) * 100,
'neutral': ((sentiment['neutral'] or 0) / total_sentiment) * 100, "neutral": ((sentiment["neutral"] or 0) / total_sentiment) * 100,
'negative': ((sentiment['negative'] or 0) / total_sentiment) * 100 "negative": ((sentiment["negative"] or 0) / total_sentiment) * 100,
} }
else: else:
sentiment_pct = {'positive': 0, 'neutral': 0, 'negative': 0} sentiment_pct = {"positive": 0, "neutral": 0, "negative": 0}
# 6. Top 10 Physicians # 6. Top 10 Physicians
top_physicians = current_period.select_related( top_physicians = current_period.select_related("staff", "staff__hospital", "staff__department").order_by(
'staff', 'staff__hospital', 'staff__department' "-average_rating", "-total_surveys"
).order_by('-average_rating', '-total_surveys')[:10] )[:10]
physicians_list = [ physicians_list = [
{ {
'id': rating.staff.id, "id": rating.staff.id,
'name': rating.staff.get_full_name(), "name": rating.staff.get_full_name(),
'license_number': rating.staff.license_number, "license_number": rating.staff.license_number,
'specialization': rating.staff.specialization or '-', "specialization": rating.staff.specialization or "-",
'department': rating.staff.department.name if rating.staff.department else '-', "department": rating.staff.department.name if rating.staff.department else "-",
'hospital': rating.staff.hospital.name if rating.staff.hospital else '-', "hospital": rating.staff.hospital.name if rating.staff.hospital else "-",
'rating': float(rating.average_rating), "rating": float(rating.average_rating),
'surveys': rating.total_surveys "surveys": rating.total_surveys,
} }
for rating in top_physicians for rating in top_physicians
] ]
return JsonResponse({ return JsonResponse(
'statistics': { {
'total_physicians': stats['total_physicians'] or 0, "statistics": {
'average_rating': float(stats['average_rating'] or 0), "total_physicians": stats["total_physicians"] or 0,
'total_surveys': stats['total_surveys'] or 0, "average_rating": float(stats["average_rating"] or 0),
'excellent_count': excellent_count "total_surveys": stats["total_surveys"] or 0,
"excellent_count": excellent_count,
}, },
'trend': trend_data, "trend": trend_data,
'distribution': distribution, "distribution": distribution,
'departments': departments, "departments": departments,
'sentiment': sentiment_pct, "sentiment": sentiment_pct,
'top_physicians': physicians_list "top_physicians": physicians_list,
}) }
)
except Exception as e: except Exception as e:
import traceback import traceback
return JsonResponse({
'error': str(e), return JsonResponse({"error": str(e), "traceback": traceback.format_exc()}, status=500)
'traceback': traceback.format_exc()
}, status=500)
@login_required @login_required
@ -546,9 +380,9 @@ def ratings_list(request):
- Pagination - Pagination
""" """
# Base queryset - only include staff marked as physicians # Base queryset - only include staff marked as physicians
queryset = PhysicianMonthlyRating.objects.filter( queryset = PhysicianMonthlyRating.objects.filter(staff__physician=True).select_related(
staff__physician=True "staff", "staff__hospital", "staff__department"
).select_related('staff', 'staff__hospital', 'staff__department') )
# Apply RBAC filters # Apply RBAC filters
user = request.user user = request.user
@ -556,67 +390,61 @@ def ratings_list(request):
queryset = queryset.filter(staff__hospital=user.hospital) queryset = queryset.filter(staff__hospital=user.hospital)
# Apply filters # Apply filters
physician_filter = request.GET.get('physician') physician_filter = request.GET.get("physician")
if physician_filter: if physician_filter:
queryset = queryset.filter(staff_id=physician_filter) queryset = queryset.filter(staff_id=physician_filter)
hospital_filter = request.GET.get('hospital') hospital_filter = request.GET.get("hospital")
if hospital_filter: if hospital_filter:
queryset = queryset.filter(staff__hospital_id=hospital_filter) queryset = queryset.filter(staff__hospital_id=hospital_filter)
department_filter = request.GET.get('department') department_filter = request.GET.get("department")
if department_filter: if department_filter:
queryset = queryset.filter(staff__department_id=department_filter) queryset = queryset.filter(staff__department_id=department_filter)
year_filter = request.GET.get('year') year_filter = request.GET.get("year")
if year_filter: if year_filter:
queryset = queryset.filter(year=int(year_filter)) queryset = queryset.filter(year=int(year_filter))
month_filter = request.GET.get('month') month_filter = request.GET.get("month")
if month_filter: if month_filter:
queryset = queryset.filter(month=int(month_filter)) queryset = queryset.filter(month=int(month_filter))
# Search # Search
search_query = request.GET.get('search') search_query = request.GET.get("search")
if search_query: if search_query:
queryset = queryset.filter( queryset = queryset.filter(
Q(staff__first_name__icontains=search_query) | Q(staff__first_name__icontains=search_query)
Q(staff__last_name__icontains=search_query) | | Q(staff__last_name__icontains=search_query)
Q(staff__license_number__icontains=search_query) | Q(staff__license_number__icontains=search_query)
) )
# Ordering # Ordering
order_by = request.GET.get('order_by', '-year,-month,-average_rating') order_by = request.GET.get("order_by", "-year,-month,-average_rating")
queryset = queryset.order_by(*order_by.split(',')) queryset = queryset.order_by(*order_by.split(","))
# Pagination # Pagination
page_size = int(request.GET.get('page_size', 25)) page_size = int(request.GET.get("page_size", 25))
paginator = Paginator(queryset, page_size) paginator = Paginator(queryset, page_size)
page_number = request.GET.get('page', 1) page_number = request.GET.get("page", 1)
page_obj = paginator.get_page(page_number) page_obj = paginator.get_page(page_number)
# Get filter options departments = Department.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')
if not user.is_px_admin() and user.hospital: if not user.is_px_admin() and user.hospital:
departments = departments.filter(hospital=user.hospital) departments = departments.filter(hospital=user.hospital)
# Get available years # Get available years
years = PhysicianMonthlyRating.objects.values_list('year', flat=True).distinct().order_by('-year') years = PhysicianMonthlyRating.objects.values_list("year", flat=True).distinct().order_by("-year")
context = { context = {
'page_obj': page_obj, "page_obj": page_obj,
'ratings': page_obj.object_list, "ratings": page_obj.object_list,
'hospitals': hospitals, "departments": departments,
'departments': departments, "years": years,
'years': years, "filters": request.GET,
'filters': request.GET,
} }
return render(request, 'physicians/ratings_list.html', context) return render(request, "physicians/ratings_list.html", context)
@login_required @login_required
@ -632,16 +460,14 @@ def specialization_overview(request):
""" """
# Get parameters # Get parameters
now = timezone.now() now = timezone.now()
year = int(request.GET.get('year', now.year)) year = int(request.GET.get("year", now.year))
month = int(request.GET.get('month', now.month)) month = int(request.GET.get("month", now.month))
hospital_filter = request.GET.get('hospital') hospital_filter = request.GET.get("hospital")
# Base queryset - only include staff marked as physicians # Base queryset - only include staff marked as physicians
queryset = PhysicianMonthlyRating.objects.filter( queryset = PhysicianMonthlyRating.objects.filter(year=year, month=month, staff__physician=True).select_related(
year=year, "staff", "staff__hospital", "staff__department"
month=month, )
staff__physician=True
).select_related('staff', 'staff__hospital', 'staff__department')
# Apply RBAC filters # Apply RBAC filters
user = request.user user = request.user
@ -655,60 +481,127 @@ def specialization_overview(request):
# Aggregate by specialization # Aggregate by specialization
specialization_data = {} specialization_data = {}
for rating in queryset: for rating in queryset:
spec = rating.staff.specialization spec = rating.staff.specialization
if spec not in specialization_data: if spec not in specialization_data:
specialization_data[spec] = { specialization_data[spec] = {
'specialization': spec, "specialization": spec,
'physicians': [], "physicians": [],
'total_physicians': 0, "total_physicians": 0,
'total_surveys': 0, "total_surveys": 0,
'total_positive': 0, "total_positive": 0,
'total_neutral': 0, "total_neutral": 0,
'total_negative': 0, "total_negative": 0,
'ratings_sum': 0, "ratings_sum": 0,
} }
specialization_data[spec]['physicians'].append(rating) specialization_data[spec]["physicians"].append(rating)
specialization_data[spec]['total_physicians'] += 1 specialization_data[spec]["total_physicians"] += 1
specialization_data[spec]['total_surveys'] += rating.total_surveys specialization_data[spec]["total_surveys"] += rating.total_surveys
specialization_data[spec]['total_positive'] += rating.positive_count specialization_data[spec]["total_positive"] += rating.positive_count
specialization_data[spec]['total_neutral'] += rating.neutral_count specialization_data[spec]["total_neutral"] += rating.neutral_count
specialization_data[spec]['total_negative'] += rating.negative_count specialization_data[spec]["total_negative"] += rating.negative_count
specialization_data[spec]['ratings_sum'] += float(rating.average_rating) specialization_data[spec]["ratings_sum"] += float(rating.average_rating)
# Calculate averages # Calculate averages
specializations = [] specializations = []
for spec, data in specialization_data.items(): for spec, data in specialization_data.items():
avg_rating = data['ratings_sum'] / data['total_physicians'] if data['total_physicians'] > 0 else 0 avg_rating = data["ratings_sum"] / data["total_physicians"] if data["total_physicians"] > 0 else 0
specializations.append({ specializations.append(
'specialization': spec, {
'total_physicians': data['total_physicians'], "specialization": spec,
'average_rating': round(avg_rating, 2), "total_physicians": data["total_physicians"],
'total_surveys': data['total_surveys'], "average_rating": round(avg_rating, 2),
'positive_count': data['total_positive'], "total_surveys": data["total_surveys"],
'neutral_count': data['total_neutral'], "positive_count": data["total_positive"],
'negative_count': data['total_negative'], "neutral_count": data["total_neutral"],
'physicians': sorted(data['physicians'], key=lambda x: x.average_rating, reverse=True) "negative_count": data["total_negative"],
}) "physicians": sorted(data["physicians"], key=lambda x: x.average_rating, reverse=True),
}
)
# Sort by average rating # Sort by average rating
specializations.sort(key=lambda x: x['average_rating'], reverse=True) specializations.sort(key=lambda x: x["average_rating"], reverse=True)
# Get filter options
hospitals = Hospital.objects.filter(status='active')
if not user.is_px_admin() and user.hospital:
hospitals = hospitals.filter(id=user.hospital.id)
context = { context = {
'specializations': specializations, "specializations": specializations,
'year': year, "year": year,
'month': month, "month": month,
'hospitals': hospitals, "filters": request.GET,
'filters': request.GET,
} }
return render(request, 'physicians/specialization_overview.html', context) return render(request, "physicians/specialization_overview.html", context)
@login_required
def physician_detail(request, pk):
"""
Physician detail view - shows detailed information and ratings for a specific physician.
Features:
- Physician profile information
- Monthly ratings history
- Rating trends over time
- Statistics and achievements
"""
from django.shortcuts import get_object_or_404
# Get the physician (staff member marked as physician)
physician = get_object_or_404(
Staff.objects.filter(Q(physician=True) | Q(staff_type=Staff.StaffType.PHYSICIAN))
.select_related('hospital', 'department'),
pk=pk
)
# Check permissions
user = request.user
if not user.is_px_admin() and user.hospital and physician.hospital != user.hospital:
from django.core.exceptions import PermissionDenied
raise PermissionDenied("You don't have permission to view this physician.")
# Get all monthly ratings for this physician
ratings = PhysicianMonthlyRating.objects.filter(
staff=physician
).order_by('-year', '-month')
# Get current month rating
now = timezone.now()
current_rating = ratings.filter(year=now.year, month=now.month).first()
# Calculate statistics
stats = ratings.aggregate(
total_months=Count('id'),
average_rating=Avg('average_rating'),
total_surveys=Sum('total_surveys'),
total_positive=Sum('positive_count'),
total_neutral=Sum('neutral_count'),
total_negative=Sum('negative_count'),
)
# Get trend data (last 12 months)
trend_data = []
for i in range(11, -1, -1):
m = now.month - i
y = now.year
if m <= 0:
m += 12
y -= 1
rating = ratings.filter(year=y, month=m).first()
trend_data.append({
'period': f"{y}-{m:02d}",
'rating': float(rating.average_rating) if rating else None,
'surveys': rating.total_surveys if rating else 0,
})
context = {
'physician': physician,
'current_rating': current_rating,
'ratings': ratings[:12], # Last 12 months
'stats': stats,
'trend_data': trend_data,
}
return render(request, 'physicians/physician_detail.html', context)
@login_required @login_required
@ -724,16 +617,14 @@ def department_overview(request):
""" """
# Get parameters # Get parameters
now = timezone.now() now = timezone.now()
year = int(request.GET.get('year', now.year)) year = int(request.GET.get("year", now.year))
month = int(request.GET.get('month', now.month)) month = int(request.GET.get("month", now.month))
hospital_filter = request.GET.get('hospital') hospital_filter = request.GET.get("hospital")
# Base queryset - only include staff marked as physicians # Base queryset - only include staff marked as physicians
queryset = PhysicianMonthlyRating.objects.filter( queryset = PhysicianMonthlyRating.objects.filter(year=year, month=month, staff__physician=True).select_related(
year=year, "staff", "staff__hospital", "staff__department"
month=month, )
staff__physician=True
).select_related('staff', 'staff__hospital', 'staff__department')
# Apply RBAC filters # Apply RBAC filters
user = request.user user = request.user
@ -754,53 +645,49 @@ def department_overview(request):
dept_key = str(dept.id) dept_key = str(dept.id)
if dept_key not in department_data: if dept_key not in department_data:
department_data[dept_key] = { department_data[dept_key] = {
'department': dept, "department": dept,
'physicians': [], "physicians": [],
'total_physicians': 0, "total_physicians": 0,
'total_surveys': 0, "total_surveys": 0,
'total_positive': 0, "total_positive": 0,
'total_neutral': 0, "total_neutral": 0,
'total_negative': 0, "total_negative": 0,
'ratings_sum': 0, "ratings_sum": 0,
} }
department_data[dept_key]['physicians'].append(rating) department_data[dept_key]["physicians"].append(rating)
department_data[dept_key]['total_physicians'] += 1 department_data[dept_key]["total_physicians"] += 1
department_data[dept_key]['total_surveys'] += rating.total_surveys department_data[dept_key]["total_surveys"] += rating.total_surveys
department_data[dept_key]['total_positive'] += rating.positive_count department_data[dept_key]["total_positive"] += rating.positive_count
department_data[dept_key]['total_neutral'] += rating.neutral_count department_data[dept_key]["total_neutral"] += rating.neutral_count
department_data[dept_key]['total_negative'] += rating.negative_count department_data[dept_key]["total_negative"] += rating.negative_count
department_data[dept_key]['ratings_sum'] += float(rating.average_rating) department_data[dept_key]["ratings_sum"] += float(rating.average_rating)
# Calculate averages # Calculate averages
departments = [] departments = []
for dept_key, data in department_data.items(): for dept_key, data in department_data.items():
avg_rating = data['ratings_sum'] / data['total_physicians'] if data['total_physicians'] > 0 else 0 avg_rating = data["ratings_sum"] / data["total_physicians"] if data["total_physicians"] > 0 else 0
departments.append({ departments.append(
'department': data['department'], {
'total_physicians': data['total_physicians'], "department": data["department"],
'average_rating': round(avg_rating, 2), "total_physicians": data["total_physicians"],
'total_surveys': data['total_surveys'], "average_rating": round(avg_rating, 2),
'positive_count': data['total_positive'], "total_surveys": data["total_surveys"],
'neutral_count': data['total_neutral'], "positive_count": data["total_positive"],
'negative_count': data['total_negative'], "neutral_count": data["total_neutral"],
'physicians': sorted(data['physicians'], key=lambda x: x.average_rating, reverse=True) "negative_count": data["total_negative"],
}) "physicians": sorted(data["physicians"], key=lambda x: x.average_rating, reverse=True),
}
)
# Sort by average rating # Sort by average rating
departments.sort(key=lambda x: x['average_rating'], reverse=True) departments.sort(key=lambda x: x["average_rating"], reverse=True)
# Get filter options
hospitals = Hospital.objects.filter(status='active')
if not user.is_px_admin() and user.hospital:
hospitals = hospitals.filter(id=user.hospital.id)
context = { context = {
'departments': departments, "departments": departments,
'year': year, "year": year,
'month': month, "month": month,
'hospitals': hospitals, "filters": request.GET,
'filters': request.GET,
} }
return render(request, 'physicians/department_overview.html', context) return render(request, "physicians/department_overview.html", context)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

1281
data.json

File diff suppressed because it is too large Load Diff

245
docs/SETUP_COMPLETE.md Normal file
View File

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

362
docs/SETUP_GUIDE.md Normal file
View File

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

View File

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

7429
rating_data.json Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,138 +1,108 @@
<!DOCTYPE html> {% extends 'emails/base_email_template.html' %}
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Welcome to PX360</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background-color: #ffffff;
border-radius: 8px;
padding: 40px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
margin-bottom: 30px;
}
.logo {
font-size: 28px;
font-weight: bold;
color: #0d6efd;
}
h1 {
color: #0d6efd;
font-size: 24px;
margin-bottom: 20px;
}
.greeting {
font-size: 18px;
margin-bottom: 20px;
}
.content {
margin-bottom: 30px;
}
.button {
display: inline-block;
background-color: #0d6efd;
color: #ffffff !important;
text-decoration: none;
padding: 14px 30px;
border-radius: 6px;
font-weight: bold;
font-size: 16px;
margin: 20px 0;
}
.button:hover {
background-color: #0b5ed7;
}
.button-container {
text-align: center;
margin: 30px 0;
}
.expiry-notice {
background-color: #fff3cd;
border: 1px solid #ffc107;
border-radius: 6px;
padding: 15px;
margin: 20px 0;
font-size: 14px;
}
.expiry-notice strong {
color: #856404;
}
.footer {
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #eee;
font-size: 12px;
color: #666;
text-align: center;
}
.link-fallback {
font-size: 12px;
color: #666;
word-break: break-all;
margin-top: 15px;
}
ul {
padding-left: 20px;
}
li {
margin-bottom: 10px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">PX360</div>
<p style="color: #666; margin-top: 5px;">Patient Experience Platform</p>
</div>
<h1>Welcome to PX360!</h1> {% 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"> {% block hero_title %}Welcome to PX360!{% endblock %}
<p>You have been invited to join PX360, our comprehensive Patient Experience management platform. To complete your account setup, please click the button below:</p>
<div class="button-container"> {% block hero_subtitle %}Your comprehensive Patient Experience management platform{% endblock %}
<a href="{{ activation_url }}" class="button">Complete Account Setup</a>
</div>
<p>During the onboarding process, you will:</p> {% block content %}
<ul> <!-- Greeting -->
<li>Learn about PX360 features and your role responsibilities</li> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<li>Review and acknowledge important policies and guidelines</li> <tr>
<li>Set up your username and password</li> <td style="padding-bottom: 20px;">
<li>Complete your profile information</li> <p style="margin: 0; font-size: 16px; color: #1e293b; line-height: 1.6;">
</ul> Hello <strong>{{ user.first_name|default:user.email }}</strong>,
</div>
<div class="expiry-notice">
<strong>⏰ Important:</strong> This invitation link will expire on <strong>{{ expires_at|date:"F j, Y, g:i a" }}</strong>. Please complete your registration before this date.
</div>
<p class="link-fallback">
If the button above doesn't work, copy and paste this link into your browser:<br>
<a href="{{ activation_url }}">{{ activation_url }}</a>
</p> </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"> <!-- What You'll Do -->
<p>This is an automated message from PX360. Please do not reply to this email.</p> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-top: 25px;">
<p>If you did not expect this invitation or have questions, please contact your system administrator.</p> <tr>
<p>&copy; {{ "now"|date:"Y" }} PX360 - Patient Experience Platform</p> <td>
</div> <h3 style="margin: 0 0 15px 0; font-size: 18px; font-weight: 600; color: #005696;">
</div> During the onboarding process, you will:
</body> </h3>
</html> </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 title %}{% trans "Provisional Accounts" %}{% endblock %}
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.section-card {
background: white;
border-radius: 1rem;
border: 2px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.section-card:hover {
border-color: #005696;
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
}
.section-header {
padding: 1rem 1.5rem;
border-bottom: 2px solid #e2e8f0;
background: linear-gradient(to right, #f8fafc, #f1f5f9);
display: flex;
align-items: center;
gap: 0.75rem;
}
.section-icon {
width: 40px;
height: 40px;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen"> <div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
<!-- Header --> <!-- Page Header -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-8"> <div class="page-header-gradient">
<div class="flex 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 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"> <div class="section-icon bg-white/20">
<i data-lucide="user-plus" class="w-8 h-8 text-white"></i> <i data-lucide="user-plus" class="w-6 h-6 text-white"></i>
</div> </div>
<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> <i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i>
{% trans "Back to Dashboard" %} {% trans "Back to Dashboard" %}
</a> </a>
<h1 class="text-3xl font-bold text-navy"> <h1 class="text-2xl font-bold">
{% trans "Provisional Accounts" %} {% trans "Provisional Accounts" %}
</h1> </h1>
<p class="text-slate"> <p class="text-white/80">{% trans "View accounts pending activation" %}</p>
{% trans "View accounts pending activation" %} </div>
</p> </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> </div>
</div> </div>
<!-- Accounts List --> <!-- Accounts List -->
<div class="bg-white rounded-2xl shadow-sm border border-blue-100 overflow-hidden"> <div class="section-card">
<div class="section-header">
<div class="section-icon bg-gradient-to-br from-blue to-purple-500">
<i data-lucide="users" class="w-5 h-5 text-white"></i>
</div>
<h3 class="font-bold text-navy text-lg">{% trans "Pending Accounts" %}</h3>
</div>
{% if provisional_accounts %} {% if provisional_accounts %}
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full"> <table class="w-full">
@ -80,7 +173,7 @@
{% endif %} {% endif %}
</td> </td>
<td class="px-6 py-4"> <td class="px-6 py-4">
<span class="text-slate text-sm">{{ account.invited_at|date:"M d, Y" }}</span> <span class="text-slate text-sm">{{ account.created_at|date:"M d, Y" }}</span>
</td> </td>
<td class="px-6 py-4"> <td class="px-6 py-4">
{% if account.invitation_expires_at %} {% if account.invitation_expires_at %}
@ -99,7 +192,7 @@
<i data-lucide="check" class="w-3 h-3"></i> <i data-lucide="check" class="w-3 h-3"></i>
{% trans "Active" %} {% trans "Active" %}
</span> </span>
elif account.invitation_expires_at and account.invitation_expires_at < now %} {% elif account.invitation_expires_at and account.invitation_expires_at < now %}
<span class="inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-bold bg-red-100 text-red-700"> <span class="inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-bold bg-red-100 text-red-700">
<i data-lucide="alert-circle" class="w-3 h-3"></i> <i data-lucide="alert-circle" class="w-3 h-3"></i>
{% trans "Expired" %} {% trans "Expired" %}
@ -112,12 +205,23 @@
</td> </td>
<td class="px-6 py-4"> <td class="px-6 py-4">
<div class="flex gap-2"> <div class="flex gap-2">
<button class="px-3 py-2 text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-lg transition font-medium text-sm" title="{% trans 'Resend Invite' %}"> <a href="{% url 'accounts:provisional-user-progress' account.id %}" class="px-3 py-2 text-emerald-600 bg-emerald-50 hover:bg-emerald-100 rounded-lg transition font-medium text-sm" title="{% trans 'View Progress' %}">
<i data-lucide="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> <i data-lucide="mail" class="w-4 h-4"></i>
</button> </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> <i data-lucide="user-x" class="w-4 h-4"></i>
</button> </button>
</form>
</div> </div>
</td> </td>
</tr> </tr>
@ -137,7 +241,139 @@
</div> </div>
</div> </div>
<!-- Create User Modal -->
<div id="createUserModal" class="fixed inset-0 bg-black/50 z-50 hidden items-center justify-center">
<div class="bg-white rounded-2xl w-full max-w-2xl m-4 shadow-2xl">
<div class="bg-gradient-to-r from-navy to-blue p-6 rounded-t-2xl">
<div class="flex justify-between items-center">
<h3 class="text-xl font-bold text-white flex items-center gap-2">
<i data-lucide="user-plus" class="w-6 h-6"></i>
{% trans "Create New User" %}
</h3>
<button onclick="closeCreateModal()" class="text-white/80 hover:text-white transition">
<i data-lucide="x" class="w-6 h-6"></i>
</button>
</div>
</div>
<form method="post" class="p-6 space-y-6">
{% csrf_token %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
{% trans "Email" %} <span class="text-red-500">*</span>
</label>
<input type="email" name="email" required
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-navy focus:ring-2 focus:ring-navy/20 transition"
placeholder="user@example.com">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
{% trans "Employee ID" %}
</label>
<input type="text" name="employee_id"
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-navy focus:ring-2 focus:ring-navy/20 transition"
placeholder="EMP001">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
{% trans "First Name" %} <span class="text-red-500">*</span>
</label>
<input type="text" name="first_name" required
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-navy focus:ring-2 focus:ring-navy/20 transition"
placeholder="John">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
{% trans "Last Name" %} <span class="text-red-500">*</span>
</label>
<input type="text" name="last_name" required
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-navy focus:ring-2 focus:ring-navy/20 transition"
placeholder="Doe">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
{% trans "Hospital" %}
</label>
<select name="hospital"
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-navy focus:ring-2 focus:ring-navy/20 transition bg-white">
<option value="">{% trans "Select Hospital" %}</option>
{% for hospital in hospitals %}
<option value="{{ hospital.id }}">{{ hospital.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
{% trans "Department" %}
</label>
<select name="department"
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-navy focus:ring-2 focus:ring-navy/20 transition bg-white">
<option value="">{% trans "Select Department" %}</option>
{% for department in departments %}
<option value="{{ department.id }}">{{ department.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
{% trans "Roles" %} <span class="text-red-500">*</span>
</label>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
{% for role in roles %}
<label class="flex items-center gap-2 p-3 border-2 border-gray-200 rounded-xl cursor-pointer hover:border-navy hover:bg-blue-50 transition">
<input type="checkbox" name="roles" value="{{ role.name }}" class="w-4 h-4 text-navy rounded focus:ring-navy">
<span class="text-sm">{{ role.display_name }}</span>
</label>
{% endfor %}
</div>
</div>
<div class="flex gap-3 pt-4 border-t">
<button type="button" onclick="closeCreateModal()"
class="flex-1 px-6 py-3 border-2 border-gray-300 text-gray-700 rounded-xl font-medium hover:bg-gray-50 transition">
{% trans "Cancel" %}
</button>
<button type="submit"
class="flex-1 px-6 py-3 bg-gradient-to-r from-navy to-blue text-white rounded-xl font-medium hover:shadow-lg transition">
{% trans "Create User" %}
</button>
</div>
</form>
</div>
</div>
<script> <script>
function openCreateModal() {
document.getElementById('createUserModal').classList.remove('hidden');
document.getElementById('createUserModal').classList.add('flex');
lucide.createIcons();
}
function closeCreateModal() {
document.getElementById('createUserModal').classList.add('hidden');
document.getElementById('createUserModal').classList.remove('flex');
}
// Close modal when clicking outside
document.getElementById('createUserModal').addEventListener('click', function(e) {
if (e.target === this) {
closeCreateModal();
}
});
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons(); lucide.createIcons();
}); });

View File

@ -1,134 +1,97 @@
<!DOCTYPE html> {% extends 'emails/base_email_template.html' %}
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reminder: Complete Your PX360 Account Setup</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background-color: #ffffff;
border-radius: 8px;
padding: 40px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
margin-bottom: 30px;
}
.logo {
font-size: 28px;
font-weight: bold;
color: #0d6efd;
}
h1 {
color: #fd7e14;
font-size: 24px;
margin-bottom: 20px;
}
.greeting {
font-size: 18px;
margin-bottom: 20px;
}
.content {
margin-bottom: 30px;
}
.button {
display: inline-block;
background-color: #0d6efd;
color: #ffffff !important;
text-decoration: none;
padding: 14px 30px;
border-radius: 6px;
font-weight: bold;
font-size: 16px;
margin: 20px 0;
}
.button:hover {
background-color: #0b5ed7;
}
.button-container {
text-align: center;
margin: 30px 0;
}
.expiry-notice {
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 6px;
padding: 15px;
margin: 20px 0;
font-size: 14px;
}
.expiry-notice strong {
color: #721c24;
}
.footer {
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #eee;
font-size: 12px;
color: #666;
text-align: center;
}
.link-fallback {
font-size: 12px;
color: #666;
word-break: break-all;
margin-top: 15px;
}
.reminder-icon {
font-size: 48px;
text-align: center;
margin-bottom: 20px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">PX360</div>
<p style="color: #666; margin-top: 5px;">Patient Experience Platform</p>
</div>
<div class="reminder-icon"></div> {% 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"> {% block hero_subtitle %}Your PX360 account invitation is still active{% endblock %}
<p>We noticed that you haven't completed your PX360 account setup yet. Your invitation is still active, and we'd love to have you on board!</p>
<p>Click the button below to continue where you left off:</p> {% block content %}
<!-- Greeting -->
<div class="button-container"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<a href="{{ activation_url }}" class="button">Complete Account Setup</a> <tr>
</div> <td style="padding-bottom: 20px;">
</div> <p style="margin: 0; font-size: 16px; color: #1e293b; line-height: 1.6;">
Hello <strong>{{ user.first_name|default:user.email }}</strong>,
<div class="expiry-notice">
<strong>⚠️ Time Sensitive:</strong> Your invitation link will expire on <strong>{{ expires_at|date:"F j, Y, g:i a" }}</strong>. Please complete your registration before this date to avoid requesting a new invitation.
</div>
<p class="link-fallback">
If the button above doesn't work, copy and paste this link into your browser:<br>
<a href="{{ activation_url }}">{{ activation_url }}</a>
</p> </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"> <!-- Quick Benefits -->
<p>This is an automated reminder from PX360. Please do not reply to this email.</p> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-top: 25px;">
<p>If you have already completed your registration, please disregard this message.</p> <tr>
<p>If you have questions, please contact your system administrator.</p> <td>
<p>&copy; {{ "now"|date:"Y" }} PX360 - Patient Experience Platform</p> <h3 style="margin: 0 0 15px 0; font-size: 18px; font-weight: 600; color: #005696;">
</div> Why Complete Your Setup?
</div> </h3>
</body> </td>
</html> </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" %} {% trans "Hospital Notifications" %}
</button> </button>
{% endif %} {% endif %}
{% if user.is_px_admin %}
<button class="settings-tab px-6 py-3 rounded-xl font-medium text-sm transition flex items-center gap-2" data-tab="users">
<i data-lucide="users" class="w-4 h-4"></i>
{% trans "Users" %}
</button>
{% endif %}
</div> </div>
</div> </div>
@ -715,6 +721,150 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
<!-- Users Management Tab (PX Admin only) -->
{% if user.is_px_admin %}
<div class="settings-content hidden" id="users-content">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div class="lg:col-span-2">
<h5 class="text-xl font-bold text-gray-800 mb-6 flex items-center gap-2">
<i data-lucide="users" class="w-5 h-5 text-navy"></i>
{% trans "User Management" %}
</h5>
<!-- Quick Stats -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="bg-gradient-to-br from-navy to-blue text-white p-4 rounded-xl text-center">
<i data-lucide="users" class="w-8 h-8 mx-auto mb-2"></i>
<h6 class="font-bold text-2xl mb-0">{{ total_users_count|default:0 }}</h6>
<span class="text-xs text-white/80">{% trans "Total Users" %}</span>
</div>
<div class="bg-gradient-to-br from-emerald-500 to-emerald-600 text-white p-4 rounded-xl text-center">
<i data-lucide="user-check" class="w-8 h-8 mx-auto mb-2"></i>
<h6 class="font-bold text-2xl mb-0">{{ active_users_count|default:0 }}</h6>
<span class="text-xs text-white/80">{% trans "Active Users" %}</span>
</div>
<div class="bg-gradient-to-br from-orange-500 to-orange-600 text-white p-4 rounded-xl text-center">
<i data-lucide="user-plus" class="w-8 h-8 mx-auto mb-2"></i>
<h6 class="font-bold text-2xl mb-0">{{ provisional_users_count|default:0 }}</h6>
<span class="text-xs text-white/80">{% trans "Pending" %}</span>
</div>
<div class="bg-gradient-to-br from-red-500 to-red-600 text-white p-4 rounded-xl text-center">
<i data-lucide="user-x" class="w-8 h-8 mx-auto mb-2"></i>
<h6 class="font-bold text-2xl mb-0">{{ inactive_users_count|default:0 }}</h6>
<span class="text-xs text-white/80">{% trans "Inactive" %}</span>
</div>
</div>
<!-- Action Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<a href="{% url 'accounts:provisional-user-list' %}" class="block bg-white border-2 border-blue-200 rounded-xl p-4 hover:border-blue-400 hover:shadow-md transition group">
<div class="flex items-center gap-3 mb-2">
<div class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center group-hover:bg-blue-200 transition">
<i data-lucide="list" class="w-6 h-6 text-blue-600"></i>
</div>
<div>
<h6 class="font-bold text-gray-800">{% trans "Provisional Users" %}</h6>
<p class="text-xs text-gray-500">{% trans "Manage pending accounts" %}</p>
</div>
</div>
<p class="text-sm text-gray-600">{% trans "View and manage pending user accounts awaiting activation. Create new users and track their onboarding progress." %}</p>
</a>
<a href="{% url 'accounts:bulk-invite-users' %}" class="block bg-white border-2 border-emerald-200 rounded-xl p-4 hover:border-emerald-400 hover:shadow-md transition group">
<div class="flex items-center gap-3 mb-2">
<div class="w-12 h-12 bg-emerald-100 rounded-xl flex items-center justify-center group-hover:bg-emerald-200 transition">
<i data-lucide="upload" class="w-6 h-6 text-emerald-600"></i>
</div>
<div>
<h6 class="font-bold text-gray-800">{% trans "Bulk Invite" %}</h6>
<p class="text-xs text-gray-500">{% trans "Import via CSV" %}</p>
</div>
</div>
<p class="text-sm text-gray-600">{% trans "Import multiple users at once using a CSV file. Great for onboarding entire departments." %}</p>
</a>
<a href="{% url 'accounts:export-provisional-users' %}" class="block bg-white border-2 border-amber-200 rounded-xl p-4 hover:border-amber-400 hover:shadow-md transition group">
<div class="flex items-center gap-3 mb-2">
<div class="w-12 h-12 bg-amber-100 rounded-xl flex items-center justify-center group-hover:bg-amber-200 transition">
<i data-lucide="download" class="w-6 h-6 text-amber-600"></i>
</div>
<div>
<h6 class="font-bold text-gray-800">{% trans "Export Users" %}</h6>
<p class="text-xs text-gray-500">{% trans "Download CSV report" %}</p>
</div>
</div>
<p class="text-sm text-gray-600">{% trans "Download user data as CSV for reporting and compliance purposes." %}</p>
</a>
<a href="{% url 'accounts:provisional-user-list' %}" class="block bg-white border-2 border-purple-200 rounded-xl p-4 hover:border-purple-400 hover:shadow-md transition group">
<div class="flex items-center gap-3 mb-2">
<div class="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center group-hover:bg-purple-200 transition">
<i data-lucide="bar-chart-2" class="w-6 h-6 text-purple-600"></i>
</div>
<div>
<h6 class="font-bold text-gray-800">{% trans "Onboarding Dashboard" %}</h6>
<p class="text-xs text-gray-500">{% trans "View analytics" %}</p>
</div>
</div>
<p class="text-sm text-gray-600">{% trans "Access the full onboarding dashboard with detailed statistics and user progress tracking." %}</p>
</a>
</div>
</div>
<div>
<div class="bg-light rounded-2xl p-6">
<h6 class="font-bold text-gray-800 mb-4 flex items-center gap-2">
<i data-lucide="lightbulb" class="w-5 h-5 text-navy"></i>
{% trans "User Management Tips" %}
</h6>
<div class="space-y-4 text-sm text-gray-600">
<div class="flex gap-3">
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center flex-shrink-0">
<span class="font-bold text-blue-600">1</span>
</div>
<div>
<p class="font-medium text-gray-800">{% trans "Bulk Invite for Efficiency" %}</p>
<p class="text-xs mt-1">{% trans "Use CSV upload when adding multiple users to save time and ensure consistency." %}</p>
</div>
</div>
<div class="flex gap-3">
<div class="w-8 h-8 bg-emerald-100 rounded-lg flex items-center justify-center flex-shrink-0">
<span class="font-bold text-emerald-600">2</span>
</div>
<div>
<p class="font-medium text-gray-800">{% trans "Resend Expired Invitations" %}>
<p class="text-xs mt-1">{% trans "Monitor pending users and resend invitations to those who haven't activated their accounts." %}</p>
</div>
</div>
<div class="flex gap-3">
<div class="w-8 h-8 bg-orange-100 rounded-lg flex items-center justify-center flex-shrink-0">
<span class="font-bold text-orange-600">3</span>
</div>
<div>
<p class="font-medium text-gray-800">{% trans "Review Inactive Accounts" %}</p>
<p class="text-xs mt-1">{% trans "Regularly audit and deactivate inactive accounts to maintain security and compliance." %}</p>
</div>
</div>
<div class="flex gap-3">
<div class="w-8 h-8 bg-purple-100 rounded-lg flex items-center justify-center flex-shrink-0">
<span class="font-bold text-purple-600">4</span>
</div>
<div>
<p class="font-medium text-gray-800">{% trans "Export for Compliance" %}</p>
<p class="text-xs mt-1">{% trans "Export user data regularly for audit trails and compliance reporting requirements." %}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

@ -3,39 +3,100 @@
{% block title %}{% trans "Action Plans" %} - PX360{% endblock %} {% block title %}{% trans "Action Plans" %} - PX360{% endblock %}
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.section-card {
background: white;
border-radius: 1rem;
border: 2px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.section-card:hover {
border-color: #005696;
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
}
.section-header {
padding: 1rem 1.5rem;
border-bottom: 2px solid #e2e8f0;
background: linear-gradient(to right, #f8fafc, #f1f5f9);
display: flex;
align-items: center;
gap: 0.75rem;
}
.section-icon {
width: 40px;
height: 40px;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
}
.section-icon-blue {
background: linear-gradient(135deg, #005696, #007bbd);
color: white;
}
.section-icon-orange {
background: linear-gradient(135deg, #f97316, #fb923c);
color: white;
}
.section-icon-gray {
background: linear-gradient(135deg, #64748b, #94a3b8);
color: white;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="mb-8"> <!-- Page Header -->
<div class="page-header-gradient">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<div> <div>
<h1 class="text-3xl font-bold text-navy mb-2 flex items-center gap-3"> <h1 class="text-3xl font-bold mb-2 flex items-center gap-3">
<i data-lucide="check-circle-2" class="w-8 h-8 text-orange-500"></i> <i data-lucide="check-circle-2" class="w-8 h-8"></i>
{% trans "Action Plans" %} {% trans "Action Plans" %}
</h1> </h1>
<p class="text-slate">{% trans "Manage improvement action plans" %}</p> <p class="opacity-90">{% trans "Manage improvement action plans" %}</p>
</div> </div>
<a href="{% url 'actions:action_create' %}" class="bg-navy text-white px-6 py-3 rounded-2xl font-bold flex items-center gap-2 shadow-lg shadow-blue-200 hover:bg-blue transition"> <a href="{% url 'actions:action_create' %}" class="bg-white text-[#005696] px-6 py-3 rounded-xl font-bold flex items-center gap-2 shadow-lg hover:bg-gray-50 transition">
<i data-lucide="plus" class="w-5 h-5"></i> {% trans "Create Action Plan" %} <i data-lucide="plus" class="w-5 h-5"></i> {% trans "Create Action Plan" %}
</a> </a>
</div> </div>
</div> </div>
<!-- Filters --> <!-- Filters Section -->
<div class="bg-white rounded-2xl shadow-sm border-2 border-slate-200 mb-6"> <div class="section-card mb-6">
<div class="px-6 py-4 border-b-2 border-slate-200 flex justify-between items-center bg-gradient-to-r from-slate-50 to-slate-100"> <div class="section-header">
<h3 class="font-bold text-gray-800 flex items-center gap-2"> <div class="section-icon section-icon-blue">
<i data-lucide="filter" class="w-5 h-5 text-navy"></i> <i data-lucide="filter" class="w-5 h-5"></i>
{% trans "Filters" %} </div>
</h3> <h3 class="font-bold text-gray-800">{% trans "Filters" %}</h3>
</div> </div>
<div class="p-6"> <div class="p-6">
<form method="get" class="grid grid-cols-1 md:grid-cols-4 gap-4"> <form method="get" class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="md:col-span-2"> <div class="md:col-span-2">
<label class="block text-sm font-semibold text-gray-700 mb-2">{% trans "Search" %}</label> <label class="block text-sm font-semibold text-gray-700 mb-2">{% trans "Search" %}</label>
<input type="text" class="w-full px-4 py-3 border border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-navy focus:border-transparent transition" name="search" placeholder="{% trans 'Search action plans...' %}" value="{{ filters.search }}"> <input type="text" class="w-full px-4 py-3 border border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-[#005696] focus:border-transparent transition" name="search" placeholder="{% trans 'Search action plans...' %}" value="{{ filters.search }}">
</div> </div>
<div> <div>
<label class="block text-sm font-semibold text-gray-700 mb-2">{% trans "Status" %}</label> <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="">{% trans "All Status" %}</option>
<option value="pending" {% if filters.status == 'pending' %}selected{% endif %}>{% trans "Pending" %}</option> <option value="pending" {% if filters.status == 'pending' %}selected{% endif %}>{% trans "Pending" %}</option>
<option value="in_progress" {% if filters.status == 'in_progress' %}selected{% endif %}>{% trans "In Progress" %}</option> <option value="in_progress" {% if filters.status == 'in_progress' %}selected{% endif %}>{% trans "In Progress" %}</option>
@ -45,7 +106,7 @@
</div> </div>
<div> <div>
<label class="block text-sm font-semibold text-gray-700 mb-2">{% trans "Priority" %}</label> <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="">{% trans "All Priorities" %}</option>
<option value="high" {% if filters.priority == 'high' %}selected{% endif %}>{% trans "High" %}</option> <option value="high" {% if filters.priority == 'high' %}selected{% endif %}>{% trans "High" %}</option>
<option value="medium" {% if filters.priority == 'medium' %}selected{% endif %}>{% trans "Medium" %}</option> <option value="medium" {% if filters.priority == 'medium' %}selected{% endif %}>{% trans "Medium" %}</option>
@ -54,7 +115,7 @@
</div> </div>
<div class="md:col-span-2 flex items-end"> <div class="md:col-span-2 flex items-end">
<div class="flex gap-2 w-full"> <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" %} <i data-lucide="search" class="w-4 h-4"></i> {% trans "Filter" %}
</button> </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"> <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>
</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 --> <!-- Action Plans Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for action in actions %} {% 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="p-6">
<div class="flex justify-between items-start mb-4"> <div class="flex justify-between items-start mb-4">
<div class="flex gap-2"> <div class="flex gap-2">
@ -100,14 +174,14 @@
<div class="space-y-2 text-sm"> <div class="space-y-2 text-sm">
{% if action.due_date %} {% if action.due_date %}
<div class="flex items-center gap-2 text-gray-600"> <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> <span>{% trans "Due:" %} {{ action.due_date|date:"M d, Y" }}</span>
</div> </div>
{% endif %} {% endif %}
{% if action.assigned_to %} {% if action.assigned_to %}
<div class="flex items-center gap-2 text-gray-600"> <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> <span>{{ action.assigned_to.get_full_name|default:action.assigned_to.username }}</span>
</div> </div>
{% endif %} {% endif %}
@ -115,18 +189,20 @@
</div> </div>
<div class="px-6 py-4 bg-gray-50 border-t border-gray-100 flex justify-between items-center"> <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> {% trans "View Details" %} <i data-lucide="arrow-right" class="w-4 h-4"></i>
</a> </a>
</div> </div>
</div> </div>
{% empty %} {% empty %}
<div class="col-span-full"> <div class="col-span-full">
<div class="bg-white rounded-2xl shadow-sm border border-gray-50 p-12 text-center"> <div class="bg-gray-50 rounded-2xl border-2 border-dashed border-gray-200 p-12 text-center">
<i data-lucide="check-square" class="w-16 h-16 mx-auto mb-4 text-gray-300"></i> <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> <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> <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" %} <i data-lucide="plus" class="w-5 h-5"></i> {% trans "Create Action Plan" %}
</a> </a>
</div> </div>
@ -140,20 +216,22 @@
<div class="flex gap-2"> <div class="flex gap-2">
{% if page_obj.has_previous %} {% 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"> <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> </a>
{% endif %} {% 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 %} {% blocktrans with current=page_obj.number total=page_obj.paginator.num_pages %}Page {{ current }} of {{ total }}{% endblocktrans %}
</span> </span>
{% if page_obj.has_next %} {% 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"> <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> </a>
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
</div>
</div>
{% endblock %} {% endblock %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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