Merge remote-tracking branch 'origin/main'
# Conflicts: # data.json
This commit is contained in:
commit
3c2593de78
@ -68,9 +68,14 @@ SMS_API_RETRY_DELAY=2
|
||||
# Admin URL (change in production)
|
||||
ADMIN_URL=admin/
|
||||
|
||||
# Integration APIs (Stubs - Replace with actual credentials)
|
||||
HIS_API_URL=
|
||||
# Integration APIs
|
||||
# HIS API - Hospital Information System for fetching patient discharge data
|
||||
HIS_API_URL=https://his.alhammadi.med.sa/SSRCE/API/FetchPatientVisitTimeStamps
|
||||
HIS_API_USERNAME=your_his_username
|
||||
HIS_API_PASSWORD=your_his_password
|
||||
HIS_API_KEY=
|
||||
|
||||
# Other Integration APIs (Stubs - Replace with actual credentials)
|
||||
MOH_API_URL=
|
||||
MOH_API_KEY=
|
||||
CHI_API_URL=
|
||||
|
||||
1
.~lock.MOHStatisticsDetails.csv#
Normal file
1
.~lock.MOHStatisticsDetails.csv#
Normal file
@ -0,0 +1 @@
|
||||
,ismail,ismail-Latitude-5500,11.03.2026 02:32,/home/ismail/.local/share/onlyoffice;
|
||||
220
ADMIN_COMPLAINT_NOTIFICATION_FIX.md
Normal file
220
ADMIN_COMPLAINT_NOTIFICATION_FIX.md
Normal 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
|
||||
144
EMAIL_SIMPLIFICATION_SUMMARY.md
Normal file
144
EMAIL_SIMPLIFICATION_SUMMARY.md
Normal 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
|
||||
452
EMAIL_TEMPLATE_SYSTEM_SUMMARY.md
Normal file
452
EMAIL_TEMPLATE_SYSTEM_SUMMARY.md
Normal 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
@ -1,6 +1,7 @@
|
||||
"""
|
||||
Accounts views and viewsets
|
||||
"""
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.decorators import login_required
|
||||
@ -55,19 +56,19 @@ class CustomTokenObtainPairView(TokenObtainPairView):
|
||||
|
||||
# Log successful login and add redirect info
|
||||
if response.status_code == 200:
|
||||
username = request.data.get('username')
|
||||
username = request.data.get("username")
|
||||
try:
|
||||
user = User.objects.get(username=username)
|
||||
AuditService.log_from_request(
|
||||
event_type='user_login',
|
||||
event_type="user_login",
|
||||
description=f"User {user.email} logged in",
|
||||
request=request,
|
||||
content_object=user
|
||||
content_object=user,
|
||||
)
|
||||
|
||||
# Add redirect URL to response data
|
||||
response_data = response.data
|
||||
response_data['redirect_url'] = self.get_redirect_url(user)
|
||||
response_data["redirect_url"] = self.get_redirect_url(user)
|
||||
response.data = response_data
|
||||
|
||||
except User.DoesNotExist:
|
||||
@ -81,23 +82,25 @@ class CustomTokenObtainPairView(TokenObtainPairView):
|
||||
"""
|
||||
# Check if user is a Source User first
|
||||
from apps.px_sources.models import SourceUser
|
||||
|
||||
if SourceUser.objects.filter(user=user).exists():
|
||||
return '/px_sources/dashboard/'
|
||||
|
||||
return "/px_sources/dashboard/"
|
||||
|
||||
# PX Admins need to select a hospital first
|
||||
if user.is_px_admin():
|
||||
from apps.organizations.models import Hospital
|
||||
|
||||
# Check if there's already a hospital in session
|
||||
# Since we don't have access to request here, frontend should handle this
|
||||
# Return to hospital selector URL
|
||||
return '/health/select-hospital/'
|
||||
return "/health/select-hospital/"
|
||||
|
||||
# Users without hospital assignment get error page
|
||||
if not user.hospital:
|
||||
return '/health/no-hospital/'
|
||||
return "/health/no-hospital/"
|
||||
|
||||
# All other users go to dashboard
|
||||
return '/'
|
||||
return "/"
|
||||
|
||||
|
||||
class UserViewSet(viewsets.ModelViewSet):
|
||||
@ -109,26 +112,27 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||
- Create/Update/Delete: PX Admins only
|
||||
- Users can update their own profile
|
||||
"""
|
||||
|
||||
queryset = User.objects.all()
|
||||
permission_classes = [IsAuthenticated]
|
||||
filterset_fields = ['is_active', 'hospital', 'department', 'groups']
|
||||
search_fields = ['username', 'email', 'first_name', 'last_name', 'employee_id']
|
||||
ordering_fields = ['date_joined', 'email', 'last_name']
|
||||
ordering = ['-date_joined']
|
||||
filterset_fields = ["is_active", "hospital", "department", "groups"]
|
||||
search_fields = ["username", "email", "first_name", "last_name", "employee_id"]
|
||||
ordering_fields = ["date_joined", "email", "last_name"]
|
||||
ordering = ["-date_joined"]
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Return appropriate serializer based on action"""
|
||||
if self.action == 'create':
|
||||
if self.action == "create":
|
||||
return UserCreateSerializer
|
||||
elif self.action in ['update', 'partial_update']:
|
||||
elif self.action in ["update", "partial_update"]:
|
||||
return UserUpdateSerializer
|
||||
return UserSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
"""Set permissions based on action"""
|
||||
if self.action in ['create', 'destroy']:
|
||||
if self.action in ["create", "destroy"]:
|
||||
return [IsPXAdmin()]
|
||||
elif self.action in ['update', 'partial_update']:
|
||||
elif self.action in ["update", "partial_update"]:
|
||||
return [IsOwnerOrPXAdmin()]
|
||||
return [IsAuthenticated()]
|
||||
|
||||
@ -139,15 +143,15 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||
|
||||
# PX Admins see all users
|
||||
if user.is_px_admin():
|
||||
return queryset.select_related('hospital', 'department')
|
||||
return queryset.select_related("hospital", "department")
|
||||
|
||||
# Hospital Admins see users in their hospital
|
||||
if user.is_hospital_admin() and user.hospital:
|
||||
return queryset.filter(hospital=user.hospital).select_related('hospital', 'department')
|
||||
return queryset.filter(hospital=user.hospital).select_related("hospital", "department")
|
||||
|
||||
# Department Managers see users in their department
|
||||
if user.is_department_manager() and user.department:
|
||||
return queryset.filter(department=user.department).select_related('hospital', 'department')
|
||||
return queryset.filter(department=user.department).select_related("hospital", "department")
|
||||
|
||||
# Source Users see only themselves
|
||||
if user.is_source_user():
|
||||
@ -160,29 +164,23 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||
"""Log user creation"""
|
||||
user = serializer.save()
|
||||
AuditService.log_from_request(
|
||||
event_type='other',
|
||||
description=f"User {user.email} created",
|
||||
request=self.request,
|
||||
content_object=user
|
||||
event_type="other", description=f"User {user.email} created", request=self.request, content_object=user
|
||||
)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""Log user update"""
|
||||
user = serializer.save()
|
||||
AuditService.log_from_request(
|
||||
event_type='other',
|
||||
description=f"User {user.email} updated",
|
||||
request=self.request,
|
||||
content_object=user
|
||||
event_type="other", description=f"User {user.email} updated", request=self.request, content_object=user
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['get'], permission_classes=[IsAuthenticated])
|
||||
@action(detail=False, methods=["get"], permission_classes=[IsAuthenticated])
|
||||
def me(self, request):
|
||||
"""Get current user profile"""
|
||||
serializer = self.get_serializer(request.user)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=['put'], permission_classes=[IsAuthenticated])
|
||||
@action(detail=False, methods=["put"], permission_classes=[IsAuthenticated])
|
||||
def update_profile(self, request):
|
||||
"""Update current user profile"""
|
||||
serializer = UserUpdateSerializer(request.user, data=request.data, partial=True)
|
||||
@ -190,76 +188,76 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||
serializer.save()
|
||||
|
||||
AuditService.log_from_request(
|
||||
event_type='other',
|
||||
event_type="other",
|
||||
description=f"User {request.user.email} updated their profile",
|
||||
request=request,
|
||||
content_object=request.user
|
||||
content_object=request.user,
|
||||
)
|
||||
|
||||
return Response(UserSerializer(request.user).data)
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[IsAuthenticated])
|
||||
@action(detail=False, methods=["post"], permission_classes=[IsAuthenticated])
|
||||
def change_password(self, request):
|
||||
"""Change user password"""
|
||||
serializer = ChangePasswordSerializer(data=request.data, context={'request': request})
|
||||
serializer = ChangePasswordSerializer(data=request.data, context={"request": request})
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Change password
|
||||
request.user.set_password(serializer.validated_data['new_password'])
|
||||
request.user.set_password(serializer.validated_data["new_password"])
|
||||
request.user.save()
|
||||
|
||||
AuditService.log_from_request(
|
||||
event_type='other',
|
||||
event_type="other",
|
||||
description=f"User {request.user.email} changed their password",
|
||||
request=request,
|
||||
content_object=request.user
|
||||
content_object=request.user,
|
||||
)
|
||||
|
||||
return Response({'message': 'Password changed successfully'}, status=status.HTTP_200_OK)
|
||||
return Response({"message": "Password changed successfully"}, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[IsPXAdmin])
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsPXAdmin])
|
||||
def assign_role(self, request, pk=None):
|
||||
"""Assign role to user (PX Admin only)"""
|
||||
user = self.get_object()
|
||||
role_id = request.data.get('role_id')
|
||||
role_id = request.data.get("role_id")
|
||||
|
||||
try:
|
||||
role = Role.objects.get(id=role_id)
|
||||
user.groups.add(role.group)
|
||||
|
||||
AuditService.log_from_request(
|
||||
event_type='role_change',
|
||||
event_type="role_change",
|
||||
description=f"Role {role.display_name} assigned to user {user.email}",
|
||||
request=request,
|
||||
content_object=user,
|
||||
metadata={'role': role.name}
|
||||
metadata={"role": role.name},
|
||||
)
|
||||
|
||||
return Response({'message': f'Role {role.display_name} assigned successfully'})
|
||||
return Response({"message": f"Role {role.display_name} assigned successfully"})
|
||||
except Role.DoesNotExist:
|
||||
return Response({'error': 'Role not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
return Response({"error": "Role not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[IsPXAdmin])
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsPXAdmin])
|
||||
def remove_role(self, request, pk=None):
|
||||
"""Remove role from user (PX Admin only)"""
|
||||
user = self.get_object()
|
||||
role_id = request.data.get('role_id')
|
||||
role_id = request.data.get("role_id")
|
||||
|
||||
try:
|
||||
role = Role.objects.get(id=role_id)
|
||||
user.groups.remove(role.group)
|
||||
|
||||
AuditService.log_from_request(
|
||||
event_type='role_change',
|
||||
event_type="role_change",
|
||||
description=f"Role {role.display_name} removed from user {user.email}",
|
||||
request=request,
|
||||
content_object=user,
|
||||
metadata={'role': role.name}
|
||||
metadata={"role": role.name},
|
||||
)
|
||||
|
||||
return Response({'message': f'Role {role.display_name} removed successfully'})
|
||||
return Response({"message": f"Role {role.display_name} removed successfully"})
|
||||
except Role.DoesNotExist:
|
||||
return Response({'error': 'Role not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
return Response({"error": "Role not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
class RoleViewSet(viewsets.ModelViewSet):
|
||||
@ -270,282 +268,269 @@ class RoleViewSet(viewsets.ModelViewSet):
|
||||
- List/Retrieve: Authenticated users
|
||||
- Create/Update/Delete: PX Admins only
|
||||
"""
|
||||
|
||||
queryset = Role.objects.all()
|
||||
serializer_class = RoleSerializer
|
||||
permission_classes = [IsPXAdminOrReadOnly]
|
||||
filterset_fields = ['name', 'level']
|
||||
search_fields = ['name', 'display_name', 'description']
|
||||
ordering_fields = ['level', 'name']
|
||||
ordering = ['-level', 'name']
|
||||
filterset_fields = ["name", "level"]
|
||||
search_fields = ["name", "display_name", "description"]
|
||||
ordering_fields = ["level", "name"]
|
||||
ordering = ["-level", "name"]
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().select_related('group')
|
||||
return super().get_queryset().select_related("group")
|
||||
|
||||
|
||||
# ==================== Settings Views ====================
|
||||
|
||||
|
||||
@login_required
|
||||
def user_settings(request):
|
||||
"""
|
||||
User settings page for managing notification preferences, profile, and security.
|
||||
"""
|
||||
user = request.user
|
||||
|
||||
if request.method == 'POST':
|
||||
|
||||
if request.method == "POST":
|
||||
# Get form type
|
||||
form_type = request.POST.get('form_type', 'preferences')
|
||||
|
||||
if form_type == 'preferences':
|
||||
form_type = request.POST.get("form_type", "preferences")
|
||||
|
||||
if form_type == "preferences":
|
||||
# Update notification preferences
|
||||
user.notification_email_enabled = request.POST.get('notification_email_enabled', 'off') == 'on'
|
||||
user.notification_sms_enabled = request.POST.get('notification_sms_enabled', 'off') == 'on'
|
||||
user.preferred_notification_channel = request.POST.get('preferred_notification_channel', 'email')
|
||||
user.explanation_notification_channel = request.POST.get('explanation_notification_channel', 'email')
|
||||
user.phone = request.POST.get('phone', '')
|
||||
user.language = request.POST.get('language', 'en')
|
||||
messages.success(request, _('Notification preferences updated successfully.'))
|
||||
|
||||
elif form_type == 'profile':
|
||||
user.notification_email_enabled = request.POST.get("notification_email_enabled", "off") == "on"
|
||||
user.notification_sms_enabled = request.POST.get("notification_sms_enabled", "off") == "on"
|
||||
user.preferred_notification_channel = request.POST.get("preferred_notification_channel", "email")
|
||||
user.explanation_notification_channel = request.POST.get("explanation_notification_channel", "email")
|
||||
user.phone = request.POST.get("phone", "")
|
||||
user.language = request.POST.get("language", "en")
|
||||
messages.success(request, _("Notification preferences updated successfully."))
|
||||
|
||||
elif form_type == "profile":
|
||||
# Update profile information
|
||||
user.first_name = request.POST.get('first_name', '')
|
||||
user.last_name = request.POST.get('last_name', '')
|
||||
user.phone = request.POST.get('phone', '')
|
||||
user.bio = request.POST.get('bio', '')
|
||||
|
||||
user.first_name = request.POST.get("first_name", "")
|
||||
user.last_name = request.POST.get("last_name", "")
|
||||
user.phone = request.POST.get("phone", "")
|
||||
user.bio = request.POST.get("bio", "")
|
||||
|
||||
# Handle avatar upload
|
||||
if request.FILES.get('avatar'):
|
||||
user.avatar = request.FILES.get('avatar')
|
||||
|
||||
messages.success(request, _('Profile updated successfully.'))
|
||||
|
||||
elif form_type == 'password':
|
||||
if request.FILES.get("avatar"):
|
||||
user.avatar = request.FILES.get("avatar")
|
||||
|
||||
messages.success(request, _("Profile updated successfully."))
|
||||
|
||||
elif form_type == "password":
|
||||
# Change password
|
||||
current_password = request.POST.get('current_password')
|
||||
new_password = request.POST.get('new_password')
|
||||
confirm_password = request.POST.get('confirm_password')
|
||||
|
||||
current_password = request.POST.get("current_password")
|
||||
new_password = request.POST.get("new_password")
|
||||
confirm_password = request.POST.get("confirm_password")
|
||||
|
||||
if not user.check_password(current_password):
|
||||
messages.error(request, _('Current password is incorrect.'))
|
||||
messages.error(request, _("Current password is incorrect."))
|
||||
elif new_password != confirm_password:
|
||||
messages.error(request, _('New passwords do not match.'))
|
||||
messages.error(request, _("New passwords do not match."))
|
||||
elif len(new_password) < 8:
|
||||
messages.error(request, _('Password must be at least 8 characters long.'))
|
||||
messages.error(request, _("Password must be at least 8 characters long."))
|
||||
else:
|
||||
user.set_password(new_password)
|
||||
messages.success(request, _('Password changed successfully. Please login again.'))
|
||||
|
||||
messages.success(request, _("Password changed successfully. Please login again."))
|
||||
|
||||
# Re-authenticate user with new password
|
||||
from django.contrib.auth import update_session_auth_hash
|
||||
|
||||
update_session_auth_hash(request, user)
|
||||
|
||||
|
||||
user.save()
|
||||
|
||||
|
||||
# Log the update
|
||||
AuditService.log_from_request(
|
||||
event_type='other',
|
||||
description=f"User {user.email} updated settings",
|
||||
request=request,
|
||||
content_object=user
|
||||
event_type="other", description=f"User {user.email} updated settings", request=request, content_object=user
|
||||
)
|
||||
|
||||
return redirect('accounts:settings')
|
||||
|
||||
|
||||
return redirect("accounts:settings")
|
||||
|
||||
context = {
|
||||
'user': user,
|
||||
'notification_channels': [
|
||||
('email', _('Email')),
|
||||
('sms', _('SMS')),
|
||||
('both', _('Both'))
|
||||
],
|
||||
'languages': [
|
||||
('en', _('English')),
|
||||
('ar', _('Arabic'))
|
||||
]
|
||||
"user": user,
|
||||
"notification_channels": [("email", _("Email")), ("sms", _("SMS")), ("both", _("Both"))],
|
||||
"languages": [("en", _("English")), ("ar", _("Arabic"))],
|
||||
}
|
||||
|
||||
return render(request, 'accounts/settings.html', context)
|
||||
|
||||
# Add user statistics for PX Admin
|
||||
if user.is_px_admin():
|
||||
User = get_user_model()
|
||||
context["total_users_count"] = User.objects.count()
|
||||
context["active_users_count"] = User.objects.filter(is_active=True, is_provisional=False).count()
|
||||
context["provisional_users_count"] = User.objects.filter(is_provisional=True).count()
|
||||
context["inactive_users_count"] = User.objects.filter(is_active=False).count()
|
||||
|
||||
return render(request, "accounts/settings.html", context)
|
||||
|
||||
|
||||
# ==================== Onboarding ViewSets ====================
|
||||
|
||||
|
||||
class AcknowledgementContentViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for AcknowledgementContent model.
|
||||
|
||||
|
||||
Permissions:
|
||||
- List/Retrieve: Authenticated users
|
||||
- Create/Update/Delete: PX Admins only
|
||||
"""
|
||||
|
||||
queryset = AcknowledgementContent.objects.all()
|
||||
serializer_class = AcknowledgementContentSerializer
|
||||
permission_classes = [CanManageAcknowledgementContent]
|
||||
filterset_fields = ['role', 'is_active']
|
||||
search_fields = ['code', 'title_en', 'title_ar', 'description_en', 'description_ar']
|
||||
ordering_fields = ['role', 'order']
|
||||
ordering = ['role', 'order']
|
||||
filterset_fields = ["role", "is_active"]
|
||||
search_fields = ["code", "title_en", "title_ar", "description_en", "description_ar"]
|
||||
ordering_fields = ["role", "order"]
|
||||
ordering = ["role", "order"]
|
||||
|
||||
|
||||
class AcknowledgementChecklistItemViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for AcknowledgementChecklistItem model.
|
||||
|
||||
|
||||
Permissions:
|
||||
- List/Retrieve: Authenticated users
|
||||
- Create/Update/Delete: PX Admins only
|
||||
"""
|
||||
|
||||
queryset = AcknowledgementChecklistItem.objects.all()
|
||||
serializer_class = AcknowledgementChecklistItemSerializer
|
||||
permission_classes = [CanManageAcknowledgementContent]
|
||||
filterset_fields = ['role', 'content', 'is_required', 'is_active']
|
||||
search_fields = ['code', 'text_en', 'text_ar', 'description_en', 'description_ar']
|
||||
ordering_fields = ['role', 'order']
|
||||
ordering = ['role', 'order']
|
||||
|
||||
filterset_fields = ["role", "content", "is_required", "is_active"]
|
||||
search_fields = ["code", "text_en", "text_ar", "description_en", "description_ar"]
|
||||
ordering_fields = ["role", "order"]
|
||||
ordering = ["role", "order"]
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().select_related('content')
|
||||
return super().get_queryset().select_related("content")
|
||||
|
||||
|
||||
class UserAcknowledgementViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
ViewSet for UserAcknowledgement model.
|
||||
|
||||
|
||||
Permissions:
|
||||
- Users can view their own acknowledgements
|
||||
- PX Admins can view all
|
||||
"""
|
||||
|
||||
queryset = UserAcknowledgement.objects.all()
|
||||
serializer_class = UserAcknowledgementSerializer
|
||||
permission_classes = [IsOnboardingOwnerOrAdmin]
|
||||
filterset_fields = ['user', 'checklist_item', 'is_acknowledged']
|
||||
ordering_fields = ['-acknowledged_at']
|
||||
ordering = ['-acknowledged_at']
|
||||
|
||||
filterset_fields = ["user", "checklist_item", "is_acknowledged"]
|
||||
ordering_fields = ["-acknowledged_at"]
|
||||
ordering = ["-acknowledged_at"]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
user = self.request.user
|
||||
|
||||
|
||||
# PX Admins see all
|
||||
if user.is_px_admin():
|
||||
return queryset.select_related('user', 'checklist_item')
|
||||
|
||||
return queryset.select_related("user", "checklist_item")
|
||||
|
||||
# Others see only their own
|
||||
return queryset.filter(user=user).select_related('user', 'checklist_item')
|
||||
|
||||
@action(detail=True, methods=['get'], permission_classes=[IsAuthenticated])
|
||||
return queryset.filter(user=user).select_related("user", "checklist_item")
|
||||
|
||||
@action(detail=True, methods=["get"], permission_classes=[IsAuthenticated])
|
||||
def download_pdf(self, request, pk=None):
|
||||
"""
|
||||
Download PDF for a specific acknowledgement
|
||||
"""
|
||||
from django.http import FileResponse, Http404
|
||||
import os
|
||||
|
||||
|
||||
acknowledgement = self.get_object()
|
||||
|
||||
|
||||
# Check if PDF exists
|
||||
if not acknowledgement.pdf_file:
|
||||
return Response(
|
||||
{'error': 'PDF not available for this acknowledgement'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
return Response({"error": "PDF not available for this acknowledgement"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Check file exists
|
||||
if not os.path.exists(acknowledgement.pdf_file.path):
|
||||
return Response(
|
||||
{'error': 'PDF file not found'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
return Response({"error": "PDF file not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Return file
|
||||
try:
|
||||
response = FileResponse(
|
||||
open(acknowledgement.pdf_file.path, 'rb'),
|
||||
content_type='application/pdf'
|
||||
)
|
||||
|
||||
response = FileResponse(open(acknowledgement.pdf_file.path, "rb"), content_type="application/pdf")
|
||||
|
||||
# Generate filename
|
||||
filename = f"acknowledgement_{acknowledgement.id}_{acknowledgement.user.employee_id or acknowledgement.user.username}.pdf"
|
||||
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||||
|
||||
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||
|
||||
return response
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{'error': f'Error downloading PDF: {str(e)}'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
return Response({"error": f"Error downloading PDF: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
# ==================== Onboarding Actions for UserViewSet ====================
|
||||
|
||||
|
||||
def onboarding_create_provisional(self, request):
|
||||
"""Create provisional user"""
|
||||
from .services import OnboardingService, EmailService
|
||||
|
||||
|
||||
serializer = ProvisionalUserSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
|
||||
# Create provisional user
|
||||
user_data = serializer.validated_data.copy()
|
||||
roles = user_data.pop('roles', [])
|
||||
|
||||
roles = user_data.pop("roles", [])
|
||||
|
||||
user = OnboardingService.create_provisional_user(user_data)
|
||||
|
||||
|
||||
# Assign roles
|
||||
for role_name in roles:
|
||||
from .models import Role as RoleModel
|
||||
|
||||
try:
|
||||
role = RoleModel.objects.get(name=role_name)
|
||||
user.groups.add(role.group)
|
||||
except RoleModel.DoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
# Send invitation email
|
||||
EmailService.send_invitation_email(user, request)
|
||||
|
||||
return Response(
|
||||
UserSerializer(user).data,
|
||||
status=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
return Response(UserSerializer(user).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
def onboarding_resend_invitation(self, request, pk=None):
|
||||
"""Resend invitation email"""
|
||||
from .services import EmailService
|
||||
|
||||
|
||||
user = self.get_object()
|
||||
|
||||
|
||||
if not user.is_provisional:
|
||||
return Response(
|
||||
{'error': 'User is not a provisional user'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
return Response({"error": "User is not a provisional user"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
EmailService.send_reminder_email(user, request)
|
||||
|
||||
return Response({'message': 'Invitation email resent successfully'})
|
||||
|
||||
return Response({"message": "Invitation email resent successfully"})
|
||||
|
||||
|
||||
def onboarding_progress(self, request):
|
||||
"""Get current user's onboarding progress"""
|
||||
from .services import OnboardingService
|
||||
|
||||
|
||||
user = request.user
|
||||
|
||||
|
||||
# Get checklist items
|
||||
required_items = OnboardingService.get_checklist_items(user).filter(is_required=True)
|
||||
acknowledged_items = UserAcknowledgement.objects.filter(
|
||||
user=user,
|
||||
checklist_item__in=required_items,
|
||||
is_acknowledged=True
|
||||
user=user, checklist_item__in=required_items, is_acknowledged=True
|
||||
)
|
||||
|
||||
|
||||
progress = {
|
||||
'current_step': user.current_wizard_step,
|
||||
'completed_steps': user.wizard_completed_steps,
|
||||
'progress_percentage': OnboardingService.get_user_progress_percentage(user),
|
||||
'total_required_items': required_items.count(),
|
||||
'acknowledged_items': acknowledged_items.count()
|
||||
"current_step": user.current_wizard_step,
|
||||
"completed_steps": user.wizard_completed_steps,
|
||||
"progress_percentage": OnboardingService.get_user_progress_percentage(user),
|
||||
"total_required_items": required_items.count(),
|
||||
"acknowledged_items": acknowledged_items.count(),
|
||||
}
|
||||
|
||||
|
||||
serializer = WizardProgressSerializer(progress)
|
||||
return Response(serializer.data)
|
||||
|
||||
@ -553,7 +538,7 @@ def onboarding_progress(self, request):
|
||||
def onboarding_content(self, request):
|
||||
"""Get wizard content for current user"""
|
||||
from .services import OnboardingService
|
||||
|
||||
|
||||
content = OnboardingService.get_wizard_content(request.user)
|
||||
serializer = AcknowledgementContentSerializer(content, many=True)
|
||||
return Response(serializer.data)
|
||||
@ -562,156 +547,125 @@ def onboarding_content(self, request):
|
||||
def onboarding_checklist(self, request):
|
||||
"""Get checklist items for current user"""
|
||||
from .services import OnboardingService
|
||||
|
||||
|
||||
items = OnboardingService.get_checklist_items(request.user)
|
||||
|
||||
|
||||
# Include acknowledgement status
|
||||
from django.db import models
|
||||
acknowledged_ids = UserAcknowledgement.objects.filter(
|
||||
user=request.user,
|
||||
is_acknowledged=True
|
||||
).values_list('checklist_item_id', flat=True)
|
||||
|
||||
|
||||
acknowledged_ids = UserAcknowledgement.objects.filter(user=request.user, is_acknowledged=True).values_list(
|
||||
"checklist_item_id", flat=True
|
||||
)
|
||||
|
||||
data = []
|
||||
for item in items:
|
||||
item_data = AcknowledgementChecklistItemSerializer(item).data
|
||||
item_data['is_acknowledged'] = item.id in acknowledged_ids
|
||||
item_data["is_acknowledged"] = item.id in acknowledged_ids
|
||||
data.append(item_data)
|
||||
|
||||
|
||||
return Response(data)
|
||||
|
||||
|
||||
def onboarding_acknowledge(self, request):
|
||||
"""Acknowledge a checklist item"""
|
||||
from .services import OnboardingService
|
||||
|
||||
|
||||
serializer = AcknowledgeItemSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
|
||||
try:
|
||||
item = AcknowledgementChecklistItem.objects.get(
|
||||
id=serializer.validated_data['checklist_item_id']
|
||||
)
|
||||
item = AcknowledgementChecklistItem.objects.get(id=serializer.validated_data["checklist_item_id"])
|
||||
except AcknowledgementChecklistItem.DoesNotExist:
|
||||
return Response(
|
||||
{'error': 'Checklist item not found'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
return Response({"error": "Checklist item not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Acknowledge item
|
||||
OnboardingService.acknowledge_item(
|
||||
request.user,
|
||||
item,
|
||||
signature=serializer.validated_data.get('signature', ''),
|
||||
request=request
|
||||
request.user, item, signature=serializer.validated_data.get("signature", ""), request=request
|
||||
)
|
||||
|
||||
return Response({'message': 'Item acknowledged successfully'})
|
||||
|
||||
return Response({"message": "Item acknowledged successfully"})
|
||||
|
||||
|
||||
def onboarding_complete(self, request):
|
||||
"""Complete wizard and activate account"""
|
||||
from .services import OnboardingService, EmailService
|
||||
|
||||
|
||||
serializer = AccountActivationSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
|
||||
# Complete wizard
|
||||
success = OnboardingService.complete_wizard(
|
||||
request.user,
|
||||
serializer.validated_data['username'],
|
||||
serializer.validated_data['password'],
|
||||
serializer.validated_data['signature'],
|
||||
request=request
|
||||
serializer.validated_data["username"],
|
||||
serializer.validated_data["password"],
|
||||
serializer.validated_data["signature"],
|
||||
request=request,
|
||||
)
|
||||
|
||||
|
||||
if not success:
|
||||
return Response(
|
||||
{'error': 'Failed to complete wizard. Please ensure all required items are acknowledged.'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
{"error": "Failed to complete wizard. Please ensure all required items are acknowledged."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
# Notify admins
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
admin_users = User.objects.filter(groups__name='PX Admin')
|
||||
admin_users = User.objects.filter(groups__name="PX Admin")
|
||||
EmailService.send_completion_notification(request.user, admin_users, request)
|
||||
|
||||
return Response({'message': 'Account activated successfully'})
|
||||
|
||||
return Response({"message": "Account activated successfully"})
|
||||
|
||||
|
||||
def onboarding_status(self, request, pk=None):
|
||||
"""Get onboarding status for a specific user"""
|
||||
user = self.get_object()
|
||||
|
||||
|
||||
status_data = {
|
||||
'user': UserSerializer(user).data,
|
||||
'is_provisional': user.is_provisional,
|
||||
'acknowledgement_completed': user.acknowledgement_completed,
|
||||
'acknowledgement_completed_at': user.acknowledgement_completed_at,
|
||||
'current_wizard_step': user.current_wizard_step,
|
||||
'invitation_expires_at': user.invitation_expires_at,
|
||||
'progress_percentage': user.get_onboarding_progress_percentage()
|
||||
"user": UserSerializer(user).data,
|
||||
"is_provisional": user.is_provisional,
|
||||
"acknowledgement_completed": user.acknowledgement_completed,
|
||||
"acknowledgement_completed_at": user.acknowledgement_completed_at,
|
||||
"current_wizard_step": user.current_wizard_step,
|
||||
"invitation_expires_at": user.invitation_expires_at,
|
||||
"progress_percentage": user.get_onboarding_progress_percentage(),
|
||||
}
|
||||
|
||||
|
||||
return Response(status_data)
|
||||
|
||||
|
||||
# Add onboarding actions to UserViewSet with proper function names
|
||||
UserViewSet.onboarding_create_provisional = action(
|
||||
detail=False,
|
||||
methods=['post'],
|
||||
permission_classes=[CanManageOnboarding],
|
||||
url_path='onboarding/create-provisional'
|
||||
detail=False, methods=["post"], permission_classes=[CanManageOnboarding], url_path="onboarding/create-provisional"
|
||||
)(onboarding_create_provisional)
|
||||
|
||||
UserViewSet.onboarding_resend_invitation = action(
|
||||
detail=True,
|
||||
methods=['post'],
|
||||
permission_classes=[CanManageOnboarding],
|
||||
url_path='onboarding/resend-invitation'
|
||||
detail=True, methods=["post"], permission_classes=[CanManageOnboarding], url_path="onboarding/resend-invitation"
|
||||
)(onboarding_resend_invitation)
|
||||
|
||||
UserViewSet.onboarding_progress = action(
|
||||
detail=False,
|
||||
methods=['get'],
|
||||
permission_classes=[IsProvisionalUser],
|
||||
url_path='onboarding/progress'
|
||||
detail=False, methods=["get"], permission_classes=[IsProvisionalUser], url_path="onboarding/progress"
|
||||
)(onboarding_progress)
|
||||
|
||||
UserViewSet.onboarding_content = action(
|
||||
detail=False,
|
||||
methods=['get'],
|
||||
permission_classes=[IsProvisionalUser],
|
||||
url_path='onboarding/content'
|
||||
detail=False, methods=["get"], permission_classes=[IsProvisionalUser], url_path="onboarding/content"
|
||||
)(onboarding_content)
|
||||
|
||||
UserViewSet.onboarding_checklist = action(
|
||||
detail=False,
|
||||
methods=['get'],
|
||||
permission_classes=[IsProvisionalUser],
|
||||
url_path='onboarding/checklist'
|
||||
detail=False, methods=["get"], permission_classes=[IsProvisionalUser], url_path="onboarding/checklist"
|
||||
)(onboarding_checklist)
|
||||
|
||||
UserViewSet.onboarding_acknowledge = action(
|
||||
detail=False,
|
||||
methods=['post'],
|
||||
permission_classes=[IsProvisionalUser],
|
||||
url_path='onboarding/acknowledge'
|
||||
detail=False, methods=["post"], permission_classes=[IsProvisionalUser], url_path="onboarding/acknowledge"
|
||||
)(onboarding_acknowledge)
|
||||
|
||||
UserViewSet.onboarding_complete = action(
|
||||
detail=False,
|
||||
methods=['post'],
|
||||
permission_classes=[IsProvisionalUser],
|
||||
url_path='onboarding/complete'
|
||||
detail=False, methods=["post"], permission_classes=[IsProvisionalUser], url_path="onboarding/complete"
|
||||
)(onboarding_complete)
|
||||
|
||||
UserViewSet.onboarding_status = action(
|
||||
detail=True,
|
||||
methods=['get'],
|
||||
permission_classes=[CanViewOnboarding],
|
||||
url_path='onboarding/status'
|
||||
detail=True, methods=["get"], permission_classes=[CanViewOnboarding], url_path="onboarding/status"
|
||||
)(onboarding_status)
|
||||
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"""
|
||||
Analytics Console UI views
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
@ -25,14 +26,14 @@ import json
|
||||
def serialize_queryset_values(queryset):
|
||||
"""Properly serialize QuerySet values to JSON string."""
|
||||
if queryset is None:
|
||||
return '[]'
|
||||
return "[]"
|
||||
data = list(queryset)
|
||||
result = []
|
||||
for item in data:
|
||||
row = {}
|
||||
for key, value in item.items():
|
||||
# Convert UUID to string
|
||||
if hasattr(value, 'hex'): # UUID object
|
||||
if hasattr(value, "hex"): # UUID object
|
||||
row[key] = str(value)
|
||||
# Convert Python None to JavaScript null
|
||||
elif value is None:
|
||||
@ -60,11 +61,11 @@ def analytics_dashboard(request):
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from django.db.models.functions import TruncDate, TruncMonth
|
||||
|
||||
|
||||
user = request.user
|
||||
|
||||
# Get hospital filter
|
||||
hospital_filter = request.GET.get('hospital')
|
||||
hospital_filter = request.GET.get("hospital")
|
||||
if hospital_filter:
|
||||
hospital = Hospital.objects.filter(id=hospital_filter).first()
|
||||
elif user.hospital:
|
||||
@ -75,7 +76,7 @@ def analytics_dashboard(request):
|
||||
# Base querysets
|
||||
complaints_queryset = Complaint.objects.all()
|
||||
actions_queryset = PXAction.objects.all()
|
||||
surveys_queryset = SurveyInstance.objects.filter(status='completed')
|
||||
surveys_queryset = SurveyInstance.objects.filter(status="completed")
|
||||
feedback_queryset = Feedback.objects.all()
|
||||
|
||||
if hospital:
|
||||
@ -86,117 +87,128 @@ def analytics_dashboard(request):
|
||||
|
||||
# ============ COMPLAINTS KPIs ============
|
||||
total_complaints = complaints_queryset.count()
|
||||
open_complaints = complaints_queryset.filter(status='open').count()
|
||||
in_progress_complaints = complaints_queryset.filter(status='in_progress').count()
|
||||
resolved_complaints = complaints_queryset.filter(status='resolved').count()
|
||||
closed_complaints = complaints_queryset.filter(status='closed').count()
|
||||
open_complaints = complaints_queryset.filter(status="open").count()
|
||||
in_progress_complaints = complaints_queryset.filter(status="in_progress").count()
|
||||
resolved_complaints = complaints_queryset.filter(status="resolved").count()
|
||||
closed_complaints = complaints_queryset.filter(status="closed").count()
|
||||
overdue_complaints = complaints_queryset.filter(is_overdue=True).count()
|
||||
|
||||
|
||||
# Complaint sources
|
||||
complaint_sources = complaints_queryset.values('source').annotate(count=Count('id')).order_by('-count')[:6]
|
||||
complaint_sources = complaints_queryset.values("source").annotate(count=Count("id")).order_by("-count")[:6]
|
||||
|
||||
# Complaint domains (Level 1)
|
||||
top_domains = complaints_queryset.filter(domain__isnull=False).values('domain__name_en').annotate(count=Count('id')).order_by('-count')[:5]
|
||||
top_domains = (
|
||||
complaints_queryset.filter(domain__isnull=False)
|
||||
.values("domain__name_en")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")[:5]
|
||||
)
|
||||
|
||||
# Complaint categories (Level 2)
|
||||
top_categories = complaints_queryset.filter(category__isnull=False).values('category__name_en').annotate(count=Count('id')).order_by('-count')[:5]
|
||||
top_categories = (
|
||||
complaints_queryset.filter(category__isnull=False)
|
||||
.values("category__name_en")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")[:5]
|
||||
)
|
||||
|
||||
# Complaint severity
|
||||
severity_breakdown = complaints_queryset.values('severity').annotate(count=Count('id')).order_by('-count')
|
||||
|
||||
severity_breakdown = complaints_queryset.values("severity").annotate(count=Count("id")).order_by("-count")
|
||||
|
||||
# Status breakdown
|
||||
status_breakdown = complaints_queryset.values('status').annotate(count=Count('id')).order_by('-count')
|
||||
status_breakdown = complaints_queryset.values("status").annotate(count=Count("id")).order_by("-count")
|
||||
|
||||
# ============ ACTIONS KPIs ============
|
||||
total_actions = actions_queryset.count()
|
||||
open_actions = actions_queryset.filter(status='open').count()
|
||||
in_progress_actions = actions_queryset.filter(status='in_progress').count()
|
||||
approved_actions = actions_queryset.filter(status='approved').count()
|
||||
closed_actions = actions_queryset.filter(status='closed').count()
|
||||
open_actions = actions_queryset.filter(status="open").count()
|
||||
in_progress_actions = actions_queryset.filter(status="in_progress").count()
|
||||
approved_actions = actions_queryset.filter(status="approved").count()
|
||||
closed_actions = actions_queryset.filter(status="closed").count()
|
||||
overdue_actions = actions_queryset.filter(is_overdue=True).count()
|
||||
|
||||
|
||||
# Action sources
|
||||
action_sources = actions_queryset.values('source_type').annotate(count=Count('id')).order_by('-count')[:6]
|
||||
|
||||
action_sources = actions_queryset.values("source_type").annotate(count=Count("id")).order_by("-count")[:6]
|
||||
|
||||
# Action categories
|
||||
action_categories = actions_queryset.exclude(category='').values('category').annotate(count=Count('id')).order_by('-count')[:5]
|
||||
action_categories = (
|
||||
actions_queryset.exclude(category="").values("category").annotate(count=Count("id")).order_by("-count")[:5]
|
||||
)
|
||||
|
||||
# ============ SURVEYS KPIs ============
|
||||
total_surveys = surveys_queryset.count()
|
||||
avg_survey_score = surveys_queryset.aggregate(avg=Avg('total_score'))['avg'] or 0
|
||||
avg_survey_score = surveys_queryset.aggregate(avg=Avg("total_score"))["avg"] or 0
|
||||
negative_surveys = surveys_queryset.filter(is_negative=True).count()
|
||||
|
||||
|
||||
# Survey completion rate
|
||||
all_surveys = SurveyInstance.objects.all()
|
||||
if hospital:
|
||||
all_surveys = all_surveys.filter(survey_template__hospital=hospital)
|
||||
total_sent = all_surveys.count()
|
||||
completed_surveys = all_surveys.filter(status='completed').count()
|
||||
completed_surveys = all_surveys.filter(status="completed").count()
|
||||
completion_rate = (completed_surveys / total_sent * 100) if total_sent > 0 else 0
|
||||
|
||||
|
||||
# Survey types
|
||||
survey_types = all_surveys.values('survey_template__survey_type').annotate(count=Count('id')).order_by('-count')[:5]
|
||||
survey_types = all_surveys.values("survey_template__survey_type").annotate(count=Count("id")).order_by("-count")[:5]
|
||||
|
||||
# ============ FEEDBACK KPIs ============
|
||||
total_feedback = feedback_queryset.count()
|
||||
compliments = feedback_queryset.filter(feedback_type='compliment').count()
|
||||
suggestions = feedback_queryset.filter(feedback_type='suggestion').count()
|
||||
|
||||
compliments = feedback_queryset.filter(feedback_type="compliment").count()
|
||||
suggestions = feedback_queryset.filter(feedback_type="suggestion").count()
|
||||
|
||||
# Sentiment analysis
|
||||
sentiment_breakdown = feedback_queryset.values('sentiment').annotate(count=Count('id')).order_by('-count')
|
||||
|
||||
sentiment_breakdown = feedback_queryset.values("sentiment").annotate(count=Count("id")).order_by("-count")
|
||||
|
||||
# Feedback categories
|
||||
feedback_categories = feedback_queryset.values('category').annotate(count=Count('id')).order_by('-count')[:5]
|
||||
|
||||
feedback_categories = feedback_queryset.values("category").annotate(count=Count("id")).order_by("-count")[:5]
|
||||
|
||||
# Average rating
|
||||
avg_rating = feedback_queryset.filter(rating__isnull=False).aggregate(avg=Avg('rating'))['avg'] or 0
|
||||
avg_rating = feedback_queryset.filter(rating__isnull=False).aggregate(avg=Avg("rating"))["avg"] or 0
|
||||
|
||||
# ============ TRENDS (Last 30 days) ============
|
||||
thirty_days_ago = timezone.now() - timedelta(days=30)
|
||||
|
||||
|
||||
# Complaint trends
|
||||
complaint_trend = complaints_queryset.filter(
|
||||
created_at__gte=thirty_days_ago
|
||||
).annotate(
|
||||
day=TruncDate('created_at')
|
||||
).values('day').annotate(count=Count('id')).order_by('day')
|
||||
|
||||
complaint_trend = (
|
||||
complaints_queryset.filter(created_at__gte=thirty_days_ago)
|
||||
.annotate(day=TruncDate("created_at"))
|
||||
.values("day")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("day")
|
||||
)
|
||||
|
||||
# Survey score trend
|
||||
survey_score_trend = surveys_queryset.filter(
|
||||
completed_at__gte=thirty_days_ago
|
||||
).annotate(
|
||||
day=TruncDate('completed_at')
|
||||
).values('day').annotate(avg_score=Avg('total_score')).order_by('day')
|
||||
survey_score_trend = (
|
||||
surveys_queryset.filter(completed_at__gte=thirty_days_ago)
|
||||
.annotate(day=TruncDate("completed_at"))
|
||||
.values("day")
|
||||
.annotate(avg_score=Avg("total_score"))
|
||||
.order_by("day")
|
||||
)
|
||||
|
||||
# ============ DEPARTMENT RANKINGS ============
|
||||
department_rankings = Department.objects.filter(
|
||||
status='active'
|
||||
).annotate(
|
||||
avg_score=Avg(
|
||||
'journey_instances__surveys__total_score',
|
||||
filter=Q(journey_instances__surveys__status='completed')
|
||||
),
|
||||
survey_count=Count(
|
||||
'journey_instances__surveys',
|
||||
filter=Q(journey_instances__surveys__status='completed')
|
||||
),
|
||||
complaint_count=Count('complaints'),
|
||||
action_count=Count('px_actions')
|
||||
).filter(
|
||||
survey_count__gt=0
|
||||
).order_by('-avg_score')[:7]
|
||||
department_rankings = (
|
||||
Department.objects.filter(status="active")
|
||||
.annotate(
|
||||
avg_score=Avg(
|
||||
"journey_instances__surveys__total_score", filter=Q(journey_instances__surveys__status="completed")
|
||||
),
|
||||
survey_count=Count("journey_instances__surveys", filter=Q(journey_instances__surveys__status="completed")),
|
||||
complaint_count=Count("complaints"),
|
||||
action_count=Count("px_actions"),
|
||||
)
|
||||
.filter(survey_count__gt=0)
|
||||
.order_by("-avg_score")[:7]
|
||||
)
|
||||
|
||||
# ============ TIME-BASED CALCULATIONS ============
|
||||
# Average resolution time (complaints)
|
||||
resolved_with_time = complaints_queryset.filter(
|
||||
status__in=['resolved', 'closed'],
|
||||
resolved_at__isnull=False,
|
||||
created_at__isnull=False
|
||||
status__in=["resolved", "closed"], resolved_at__isnull=False, created_at__isnull=False
|
||||
)
|
||||
if resolved_with_time.exists():
|
||||
avg_resolution_hours = resolved_with_time.annotate(
|
||||
resolution_time=F('resolved_at') - F('created_at')
|
||||
).aggregate(avg=Avg('resolution_time'))['avg']
|
||||
resolution_time=F("resolved_at") - F("created_at")
|
||||
).aggregate(avg=Avg("resolution_time"))["avg"]
|
||||
if avg_resolution_hours:
|
||||
avg_resolution_hours = avg_resolution_hours.total_seconds() / 3600
|
||||
else:
|
||||
@ -206,14 +218,12 @@ def analytics_dashboard(request):
|
||||
|
||||
# Average action completion time
|
||||
closed_actions_with_time = actions_queryset.filter(
|
||||
status='closed',
|
||||
closed_at__isnull=False,
|
||||
created_at__isnull=False
|
||||
status="closed", closed_at__isnull=False, created_at__isnull=False
|
||||
)
|
||||
if closed_actions_with_time.exists():
|
||||
avg_action_days = closed_actions_with_time.annotate(
|
||||
completion_time=F('closed_at') - F('created_at')
|
||||
).aggregate(avg=Avg('completion_time'))['avg']
|
||||
avg_action_days = closed_actions_with_time.annotate(completion_time=F("closed_at") - F("created_at")).aggregate(
|
||||
avg=Avg("completion_time")
|
||||
)["avg"]
|
||||
if avg_action_days:
|
||||
avg_action_days = avg_action_days.days
|
||||
else:
|
||||
@ -224,17 +234,13 @@ def analytics_dashboard(request):
|
||||
# ============ SLA COMPLIANCE ============
|
||||
total_with_sla = complaints_queryset.filter(due_at__isnull=False).count()
|
||||
resolved_within_sla = complaints_queryset.filter(
|
||||
status__in=['resolved', 'closed'],
|
||||
resolved_at__lte=F('due_at')
|
||||
status__in=["resolved", "closed"], resolved_at__lte=F("due_at")
|
||||
).count()
|
||||
sla_compliance = (resolved_within_sla / total_with_sla * 100) if total_with_sla > 0 else 0
|
||||
|
||||
# ============ NPS CALCULATION ============
|
||||
# NPS = % Promoters (9-10) - % Detractors (0-6)
|
||||
nps_surveys = surveys_queryset.filter(
|
||||
survey_template__survey_type='nps',
|
||||
total_score__isnull=False
|
||||
)
|
||||
nps_surveys = surveys_queryset.filter(survey_template__survey_type="nps", total_score__isnull=False)
|
||||
if nps_surveys.exists():
|
||||
promoters = nps_surveys.filter(total_score__gte=9).count()
|
||||
detractors = nps_surveys.filter(total_score__lte=6).count()
|
||||
@ -243,76 +249,52 @@ def analytics_dashboard(request):
|
||||
else:
|
||||
nps_score = 0
|
||||
|
||||
# Get hospitals for filter
|
||||
hospitals = Hospital.objects.filter(status='active')
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
hospitals = hospitals.filter(id=user.hospital.id)
|
||||
|
||||
# Build comprehensive KPI data
|
||||
kpis = {
|
||||
# Complaints
|
||||
'total_complaints': total_complaints,
|
||||
'open_complaints': open_complaints,
|
||||
'in_progress_complaints': in_progress_complaints,
|
||||
'resolved_complaints': resolved_complaints,
|
||||
'closed_complaints': closed_complaints,
|
||||
'overdue_complaints': overdue_complaints,
|
||||
'avg_resolution_hours': round(avg_resolution_hours, 1),
|
||||
'sla_compliance': round(sla_compliance, 1),
|
||||
|
||||
# Actions
|
||||
'total_actions': total_actions,
|
||||
'open_actions': open_actions,
|
||||
'in_progress_actions': in_progress_actions,
|
||||
'approved_actions': approved_actions,
|
||||
'closed_actions': closed_actions,
|
||||
'overdue_actions': overdue_actions,
|
||||
'avg_action_days': round(avg_action_days, 1),
|
||||
|
||||
# Surveys
|
||||
'total_surveys': total_surveys,
|
||||
'avg_survey_score': round(avg_survey_score, 2),
|
||||
'nps_score': round(nps_score, 1),
|
||||
'negative_surveys': negative_surveys,
|
||||
'completion_rate': round(completion_rate, 1),
|
||||
|
||||
# Feedback
|
||||
'total_feedback': total_feedback,
|
||||
'compliments': compliments,
|
||||
'suggestions': suggestions,
|
||||
'avg_rating': round(avg_rating, 2),
|
||||
"total_complaints": total_complaints,
|
||||
"open_complaints": open_complaints,
|
||||
"in_progress_complaints": in_progress_complaints,
|
||||
"resolved_complaints": resolved_complaints,
|
||||
"closed_complaints": closed_complaints,
|
||||
"overdue_complaints": overdue_complaints,
|
||||
"avg_resolution_hours": round(avg_resolution_hours, 1),
|
||||
"sla_compliance": round(sla_compliance, 1),
|
||||
"total_actions": total_actions,
|
||||
"open_actions": open_actions,
|
||||
"in_progress_actions": in_progress_actions,
|
||||
"approved_actions": approved_actions,
|
||||
"closed_actions": closed_actions,
|
||||
"overdue_actions": overdue_actions,
|
||||
"avg_action_days": round(avg_action_days, 1),
|
||||
"total_surveys": total_surveys,
|
||||
"avg_survey_score": round(avg_survey_score, 2),
|
||||
"nps_score": round(nps_score, 1),
|
||||
"negative_surveys": negative_surveys,
|
||||
"completion_rate": round(completion_rate, 1),
|
||||
"total_feedback": total_feedback,
|
||||
"compliments": compliments,
|
||||
"suggestions": suggestions,
|
||||
"avg_rating": round(avg_rating, 2),
|
||||
}
|
||||
|
||||
context = {
|
||||
'kpis': kpis,
|
||||
'hospitals': hospitals,
|
||||
'selected_hospital': hospital,
|
||||
|
||||
# Complaint analytics - serialize properly for JSON
|
||||
'complaint_sources': serialize_queryset_values(complaint_sources),
|
||||
'top_domains': serialize_queryset_values(top_domains),
|
||||
'top_categories': serialize_queryset_values(top_categories),
|
||||
'severity_breakdown': serialize_queryset_values(severity_breakdown),
|
||||
'status_breakdown': serialize_queryset_values(status_breakdown),
|
||||
'complaint_trend': serialize_queryset_values(complaint_trend),
|
||||
|
||||
# Action analytics
|
||||
'action_sources': serialize_queryset_values(action_sources),
|
||||
'action_categories': serialize_queryset_values(action_categories),
|
||||
|
||||
# Survey analytics
|
||||
'survey_types': serialize_queryset_values(survey_types),
|
||||
'survey_score_trend': serialize_queryset_values(survey_score_trend),
|
||||
|
||||
# Feedback analytics
|
||||
'sentiment_breakdown': serialize_queryset_values(sentiment_breakdown),
|
||||
'feedback_categories': serialize_queryset_values(feedback_categories),
|
||||
|
||||
# Department rankings
|
||||
'department_rankings': department_rankings,
|
||||
"kpis": kpis,
|
||||
"selected_hospital": hospital,
|
||||
"complaint_sources": serialize_queryset_values(complaint_sources),
|
||||
"top_domains": serialize_queryset_values(top_domains),
|
||||
"top_categories": serialize_queryset_values(top_categories),
|
||||
"severity_breakdown": serialize_queryset_values(severity_breakdown),
|
||||
"status_breakdown": serialize_queryset_values(status_breakdown),
|
||||
"complaint_trend": serialize_queryset_values(complaint_trend),
|
||||
"action_sources": serialize_queryset_values(action_sources),
|
||||
"action_categories": serialize_queryset_values(action_categories),
|
||||
"survey_types": serialize_queryset_values(survey_types),
|
||||
"survey_score_trend": serialize_queryset_values(survey_score_trend),
|
||||
"sentiment_breakdown": serialize_queryset_values(sentiment_breakdown),
|
||||
"feedback_categories": serialize_queryset_values(feedback_categories),
|
||||
"department_rankings": department_rankings,
|
||||
}
|
||||
|
||||
return render(request, 'analytics/dashboard.html', context)
|
||||
return render(request, "analytics/dashboard.html", context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@ -320,34 +302,34 @@ def analytics_dashboard(request):
|
||||
def kpi_list(request):
|
||||
"""KPI definitions list view"""
|
||||
queryset = KPI.objects.all()
|
||||
|
||||
|
||||
# Apply filters
|
||||
category_filter = request.GET.get('category')
|
||||
category_filter = request.GET.get("category")
|
||||
if category_filter:
|
||||
queryset = queryset.filter(category=category_filter)
|
||||
|
||||
is_active = request.GET.get('is_active')
|
||||
if is_active == 'true':
|
||||
|
||||
is_active = request.GET.get("is_active")
|
||||
if is_active == "true":
|
||||
queryset = queryset.filter(is_active=True)
|
||||
elif is_active == 'false':
|
||||
elif is_active == "false":
|
||||
queryset = queryset.filter(is_active=False)
|
||||
|
||||
|
||||
# Ordering
|
||||
queryset = queryset.order_by('category', 'name')
|
||||
|
||||
queryset = queryset.order_by("category", "name")
|
||||
|
||||
# Pagination
|
||||
page_size = int(request.GET.get('page_size', 25))
|
||||
page_size = int(request.GET.get("page_size", 25))
|
||||
paginator = Paginator(queryset, page_size)
|
||||
page_number = request.GET.get('page', 1)
|
||||
page_number = request.GET.get("page", 1)
|
||||
page_obj = paginator.get_page(page_number)
|
||||
|
||||
|
||||
context = {
|
||||
'page_obj': page_obj,
|
||||
'kpis': page_obj.object_list,
|
||||
'filters': request.GET,
|
||||
"page_obj": page_obj,
|
||||
"kpis": page_obj.object_list,
|
||||
"filters": request.GET,
|
||||
}
|
||||
|
||||
return render(request, 'analytics/kpi_list.html', context)
|
||||
|
||||
return render(request, "analytics/kpi_list.html", context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@ -355,7 +337,7 @@ def kpi_list(request):
|
||||
def command_center(request):
|
||||
"""
|
||||
PX Command Center - Unified Dashboard
|
||||
|
||||
|
||||
Comprehensive dashboard showing all PX360 metrics:
|
||||
- Complaints, Surveys, Actions KPIs
|
||||
- Interactive charts with ApexCharts
|
||||
@ -363,53 +345,52 @@ def command_center(request):
|
||||
- Export to Excel/PDF
|
||||
"""
|
||||
user = request.user
|
||||
|
||||
|
||||
# Get filter parameters
|
||||
filters = {
|
||||
'date_range': request.GET.get('date_range', '30d'),
|
||||
'hospital': request.GET.get('hospital', ''),
|
||||
'department': request.GET.get('department', ''),
|
||||
'kpi_category': request.GET.get('kpi_category', ''),
|
||||
'custom_start': request.GET.get('custom_start', ''),
|
||||
'custom_end': request.GET.get('custom_end', ''),
|
||||
"date_range": request.GET.get("date_range", "30d"),
|
||||
"hospital": request.GET.get("hospital", ""),
|
||||
"department": request.GET.get("department", ""),
|
||||
"kpi_category": request.GET.get("kpi_category", ""),
|
||||
"custom_start": request.GET.get("custom_start", ""),
|
||||
"custom_end": request.GET.get("custom_end", ""),
|
||||
}
|
||||
|
||||
|
||||
# Get hospitals for filter
|
||||
hospitals = Hospital.objects.filter(status='active')
|
||||
hospitals = Hospital.objects.filter(status="active")
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
hospitals = hospitals.filter(id=user.hospital.id)
|
||||
|
||||
|
||||
# Get departments for filter
|
||||
departments = Department.objects.filter(status='active')
|
||||
if filters.get('hospital'):
|
||||
departments = departments.filter(hospital_id=filters['hospital'])
|
||||
departments = Department.objects.filter(status="active")
|
||||
if filters.get("hospital"):
|
||||
departments = departments.filter(hospital_id=filters["hospital"])
|
||||
elif not user.is_px_admin() and user.hospital:
|
||||
departments = departments.filter(hospital=user.hospital)
|
||||
|
||||
|
||||
# Get initial KPIs
|
||||
custom_start = None
|
||||
custom_end = None
|
||||
if filters['custom_start'] and filters['custom_end']:
|
||||
custom_start = datetime.strptime(filters['custom_start'], '%Y-%m-%d')
|
||||
custom_end = datetime.strptime(filters['custom_end'], '%Y-%m-%d')
|
||||
|
||||
if filters["custom_start"] and filters["custom_end"]:
|
||||
custom_start = datetime.strptime(filters["custom_start"], "%Y-%m-%d")
|
||||
custom_end = datetime.strptime(filters["custom_end"], "%Y-%m-%d")
|
||||
|
||||
kpis = UnifiedAnalyticsService.get_all_kpis(
|
||||
user=user,
|
||||
date_range=filters['date_range'],
|
||||
hospital_id=filters['hospital'] if filters['hospital'] else None,
|
||||
department_id=filters['department'] if filters['department'] else None,
|
||||
date_range=filters["date_range"],
|
||||
hospital_id=filters["hospital"] if filters["hospital"] else None,
|
||||
department_id=filters["department"] if filters["department"] else None,
|
||||
custom_start=custom_start,
|
||||
custom_end=custom_end
|
||||
custom_end=custom_end,
|
||||
)
|
||||
|
||||
|
||||
context = {
|
||||
'filters': filters,
|
||||
'hospitals': hospitals,
|
||||
'departments': departments,
|
||||
'kpis': kpis,
|
||||
"filters": filters,
|
||||
"departments": departments,
|
||||
"kpis": kpis,
|
||||
}
|
||||
|
||||
return render(request, 'analytics/command_center.html', context)
|
||||
|
||||
return render(request, "analytics/command_center.html", context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@ -417,39 +398,39 @@ def command_center(request):
|
||||
def command_center_api(request):
|
||||
"""
|
||||
API endpoint for Command Center data
|
||||
|
||||
|
||||
Returns JSON data for KPIs, charts, and tables based on filters.
|
||||
Used by JavaScript to dynamically update dashboard.
|
||||
"""
|
||||
if request.method != 'GET':
|
||||
return JsonResponse({'error': 'Only GET requests allowed'}, status=405)
|
||||
|
||||
if request.method != "GET":
|
||||
return JsonResponse({"error": "Only GET requests allowed"}, status=405)
|
||||
|
||||
user = request.user
|
||||
|
||||
|
||||
# Get filter parameters
|
||||
date_range = request.GET.get('date_range', '30d')
|
||||
hospital_id = request.GET.get('hospital')
|
||||
department_id = request.GET.get('department')
|
||||
kpi_category = request.GET.get('kpi_category')
|
||||
custom_start_str = request.GET.get('custom_start')
|
||||
custom_end_str = request.GET.get('custom_end')
|
||||
|
||||
date_range = request.GET.get("date_range", "30d")
|
||||
hospital_id = request.GET.get("hospital")
|
||||
department_id = request.GET.get("department")
|
||||
kpi_category = request.GET.get("kpi_category")
|
||||
custom_start_str = request.GET.get("custom_start")
|
||||
custom_end_str = request.GET.get("custom_end")
|
||||
|
||||
# Parse custom dates
|
||||
custom_start = None
|
||||
custom_end = None
|
||||
if custom_start_str and custom_end_str:
|
||||
try:
|
||||
custom_start = datetime.strptime(custom_start_str, '%Y-%m-%d')
|
||||
custom_end = datetime.strptime(custom_end_str, '%Y-%m-%d')
|
||||
custom_start = datetime.strptime(custom_start_str, "%Y-%m-%d")
|
||||
custom_end = datetime.strptime(custom_end_str, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
# Handle hospital_id (can be integer or UUID string)
|
||||
hospital_id = hospital_id if hospital_id else None
|
||||
|
||||
|
||||
# Handle department_id (UUID string)
|
||||
department_id = department_id if department_id else None
|
||||
|
||||
|
||||
# Get KPIs
|
||||
kpis = UnifiedAnalyticsService.get_all_kpis(
|
||||
user=user,
|
||||
@ -458,51 +439,62 @@ def command_center_api(request):
|
||||
department_id=department_id,
|
||||
kpi_category=kpi_category,
|
||||
custom_start=custom_start,
|
||||
custom_end=custom_end
|
||||
custom_end=custom_end,
|
||||
)
|
||||
|
||||
|
||||
# Ensure numeric KPIs are proper Python types for JSON serialization
|
||||
numeric_kpis = [
|
||||
'total_complaints', 'open_complaints', 'overdue_complaints',
|
||||
'high_severity_complaints', 'resolved_complaints',
|
||||
'total_actions', 'open_actions', 'overdue_actions', 'escalated_actions', 'resolved_actions',
|
||||
'total_surveys', 'negative_surveys', 'avg_survey_score',
|
||||
'negative_social_mentions', 'low_call_ratings', 'total_sentiment_analyses'
|
||||
"total_complaints",
|
||||
"open_complaints",
|
||||
"overdue_complaints",
|
||||
"high_severity_complaints",
|
||||
"resolved_complaints",
|
||||
"total_actions",
|
||||
"open_actions",
|
||||
"overdue_actions",
|
||||
"escalated_actions",
|
||||
"resolved_actions",
|
||||
"total_surveys",
|
||||
"negative_surveys",
|
||||
"avg_survey_score",
|
||||
"negative_social_mentions",
|
||||
"low_call_ratings",
|
||||
"total_sentiment_analyses",
|
||||
]
|
||||
|
||||
|
||||
for key in numeric_kpis:
|
||||
if key in kpis:
|
||||
value = kpis[key]
|
||||
if value is None:
|
||||
kpis[key] = 0.0 if key == 'avg_survey_score' else 0
|
||||
kpis[key] = 0.0 if key == "avg_survey_score" else 0
|
||||
elif isinstance(value, (int, float)):
|
||||
# Already a number - ensure floats for specific fields
|
||||
if key == 'avg_survey_score':
|
||||
if key == "avg_survey_score":
|
||||
kpis[key] = float(value)
|
||||
else:
|
||||
# Try to convert to number
|
||||
try:
|
||||
kpis[key] = float(value)
|
||||
except (ValueError, TypeError):
|
||||
kpis[key] = 0.0 if key == 'avg_survey_score' else 0
|
||||
|
||||
kpis[key] = 0.0 if key == "avg_survey_score" else 0
|
||||
|
||||
# Handle nested trend data
|
||||
if 'complaints_trend' in kpis and isinstance(kpis['complaints_trend'], dict):
|
||||
trend = kpis['complaints_trend']
|
||||
trend['current'] = int(trend.get('current', 0))
|
||||
trend['previous'] = int(trend.get('previous', 0))
|
||||
trend['percentage_change'] = float(trend.get('percentage_change', 0))
|
||||
|
||||
if "complaints_trend" in kpis and isinstance(kpis["complaints_trend"], dict):
|
||||
trend = kpis["complaints_trend"]
|
||||
trend["current"] = int(trend.get("current", 0))
|
||||
trend["previous"] = int(trend.get("previous", 0))
|
||||
trend["percentage_change"] = float(trend.get("percentage_change", 0))
|
||||
|
||||
# Get chart data
|
||||
chart_types = [
|
||||
'complaints_trend',
|
||||
'complaints_by_category',
|
||||
'survey_satisfaction_trend',
|
||||
'survey_distribution',
|
||||
'department_performance',
|
||||
'physician_leaderboard'
|
||||
"complaints_trend",
|
||||
"complaints_by_category",
|
||||
"survey_satisfaction_trend",
|
||||
"survey_distribution",
|
||||
"department_performance",
|
||||
"physician_leaderboard",
|
||||
]
|
||||
|
||||
|
||||
charts = {}
|
||||
for chart_type in chart_types:
|
||||
charts[chart_type] = UnifiedAnalyticsService.get_chart_data(
|
||||
@ -512,64 +504,60 @@ def command_center_api(request):
|
||||
hospital_id=hospital_id,
|
||||
department_id=department_id,
|
||||
custom_start=custom_start,
|
||||
custom_end=custom_end
|
||||
custom_end=custom_end,
|
||||
)
|
||||
|
||||
|
||||
# Get table data
|
||||
tables = {}
|
||||
|
||||
|
||||
# Overdue complaints table
|
||||
complaints_qs = Complaint.objects.filter(is_overdue=True)
|
||||
if hospital_id:
|
||||
complaints_qs = complaints_qs.filter(hospital_id=hospital_id)
|
||||
if department_id:
|
||||
complaints_qs = complaints_qs.filter(department_id=department_id)
|
||||
|
||||
|
||||
# Apply role-based filtering
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
complaints_qs = complaints_qs.filter(hospital=user.hospital)
|
||||
if user.is_department_manager() and user.department:
|
||||
complaints_qs = complaints_qs.filter(department=user.department)
|
||||
|
||||
tables['overdue_complaints'] = list(
|
||||
complaints_qs.select_related('hospital', 'department', 'patient', 'source')
|
||||
.order_by('due_at')[:20]
|
||||
|
||||
tables["overdue_complaints"] = list(
|
||||
complaints_qs.select_related("hospital", "department", "patient", "source")
|
||||
.order_by("due_at")[:20]
|
||||
.values(
|
||||
'id',
|
||||
'title',
|
||||
'severity',
|
||||
'due_at',
|
||||
'complaint_source_type',
|
||||
hospital_name=F('hospital__name'),
|
||||
department_name=F('department__name'),
|
||||
patient_full_name=Concat('patient__first_name', Value(' '), 'patient__last_name'),
|
||||
source_name=F('source__name_en'),
|
||||
assigned_to_full_name=Concat('assigned_to__first_name', Value(' '), 'assigned_to__last_name')
|
||||
"id",
|
||||
"title",
|
||||
"severity",
|
||||
"due_at",
|
||||
"complaint_source_type",
|
||||
hospital_name=F("hospital__name"),
|
||||
department_name=F("department__name"),
|
||||
patient_full_name=Concat("patient__first_name", Value(" "), "patient__last_name"),
|
||||
source_name=F("source__name_en"),
|
||||
assigned_to_full_name=Concat("assigned_to__first_name", Value(" "), "assigned_to__last_name"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Physician leaderboard table
|
||||
physician_data = charts.get('physician_leaderboard', {}).get('metadata', [])
|
||||
tables['physician_leaderboard'] = [
|
||||
physician_data = charts.get("physician_leaderboard", {}).get("metadata", [])
|
||||
tables["physician_leaderboard"] = [
|
||||
{
|
||||
'physician_id': p['physician_id'],
|
||||
'name': p['name'],
|
||||
'specialization': p['specialization'],
|
||||
'department': p['department'],
|
||||
'rating': float(p['rating']) if p['rating'] is not None else 0.0,
|
||||
'surveys': int(p['surveys']) if p['surveys'] is not None else 0,
|
||||
'positive': int(p['positive']) if p['positive'] is not None else 0,
|
||||
'neutral': int(p['neutral']) if p['neutral'] is not None else 0,
|
||||
'negative': int(p['negative']) if p['negative'] is not None else 0
|
||||
"physician_id": p["physician_id"],
|
||||
"name": p["name"],
|
||||
"specialization": p["specialization"],
|
||||
"department": p["department"],
|
||||
"rating": float(p["rating"]) if p["rating"] is not None else 0.0,
|
||||
"surveys": int(p["surveys"]) if p["surveys"] is not None else 0,
|
||||
"positive": int(p["positive"]) if p["positive"] is not None else 0,
|
||||
"neutral": int(p["neutral"]) if p["neutral"] is not None else 0,
|
||||
"negative": int(p["negative"]) if p["negative"] is not None else 0,
|
||||
}
|
||||
for p in physician_data
|
||||
]
|
||||
|
||||
return JsonResponse({
|
||||
'kpis': kpis,
|
||||
'charts': charts,
|
||||
'tables': tables
|
||||
})
|
||||
|
||||
return JsonResponse({"kpis": kpis, "charts": charts, "tables": tables})
|
||||
|
||||
|
||||
@block_source_user
|
||||
@ -577,40 +565,40 @@ def command_center_api(request):
|
||||
def export_command_center(request, export_format):
|
||||
"""
|
||||
Export Command Center data to Excel or PDF
|
||||
|
||||
|
||||
Args:
|
||||
export_format: 'excel' or 'pdf'
|
||||
|
||||
|
||||
Returns:
|
||||
HttpResponse with file download
|
||||
"""
|
||||
if export_format not in ['excel', 'pdf']:
|
||||
return JsonResponse({'error': 'Invalid export format'}, status=400)
|
||||
|
||||
if export_format not in ["excel", "pdf"]:
|
||||
return JsonResponse({"error": "Invalid export format"}, status=400)
|
||||
|
||||
user = request.user
|
||||
|
||||
|
||||
# Get filter parameters
|
||||
date_range = request.GET.get('date_range', '30d')
|
||||
hospital_id = request.GET.get('hospital')
|
||||
department_id = request.GET.get('department')
|
||||
kpi_category = request.GET.get('kpi_category')
|
||||
custom_start_str = request.GET.get('custom_start')
|
||||
custom_end_str = request.GET.get('custom_end')
|
||||
|
||||
date_range = request.GET.get("date_range", "30d")
|
||||
hospital_id = request.GET.get("hospital")
|
||||
department_id = request.GET.get("department")
|
||||
kpi_category = request.GET.get("kpi_category")
|
||||
custom_start_str = request.GET.get("custom_start")
|
||||
custom_end_str = request.GET.get("custom_end")
|
||||
|
||||
# Parse custom dates
|
||||
custom_start = None
|
||||
custom_end = None
|
||||
if custom_start_str and custom_end_str:
|
||||
try:
|
||||
custom_start = datetime.strptime(custom_start_str, '%Y-%m-%d')
|
||||
custom_end = datetime.strptime(custom_end_str, '%Y-%m-%d')
|
||||
custom_start = datetime.strptime(custom_start_str, "%Y-%m-%d")
|
||||
custom_end = datetime.strptime(custom_end_str, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
# Handle hospital_id and department_id (can be integer or UUID string)
|
||||
hospital_id = hospital_id if hospital_id else None
|
||||
department_id = department_id if department_id else None
|
||||
|
||||
|
||||
# Get all data
|
||||
kpis = UnifiedAnalyticsService.get_all_kpis(
|
||||
user=user,
|
||||
@ -619,18 +607,18 @@ def export_command_center(request, export_format):
|
||||
department_id=department_id,
|
||||
kpi_category=kpi_category,
|
||||
custom_start=custom_start,
|
||||
custom_end=custom_end
|
||||
custom_end=custom_end,
|
||||
)
|
||||
|
||||
|
||||
chart_types = [
|
||||
'complaints_trend',
|
||||
'complaints_by_category',
|
||||
'survey_satisfaction_trend',
|
||||
'survey_distribution',
|
||||
'department_performance',
|
||||
'physician_leaderboard'
|
||||
"complaints_trend",
|
||||
"complaints_by_category",
|
||||
"survey_satisfaction_trend",
|
||||
"survey_distribution",
|
||||
"department_performance",
|
||||
"physician_leaderboard",
|
||||
]
|
||||
|
||||
|
||||
charts = {}
|
||||
for chart_type in chart_types:
|
||||
charts[chart_type] = UnifiedAnalyticsService.get_chart_data(
|
||||
@ -640,77 +628,64 @@ def export_command_center(request, export_format):
|
||||
hospital_id=hospital_id,
|
||||
department_id=department_id,
|
||||
custom_start=custom_start,
|
||||
custom_end=custom_end
|
||||
custom_end=custom_end,
|
||||
)
|
||||
|
||||
|
||||
# Get table data
|
||||
tables = {}
|
||||
|
||||
|
||||
# Overdue complaints
|
||||
complaints_qs = Complaint.objects.filter(is_overdue=True)
|
||||
if hospital_id:
|
||||
complaints_qs = complaints_qs.filter(hospital_id=hospital_id)
|
||||
if department_id:
|
||||
complaints_qs = complaints_qs.filter(department_id=department_id)
|
||||
|
||||
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
complaints_qs = complaints_qs.filter(hospital=user.hospital)
|
||||
if user.is_department_manager() and user.department:
|
||||
complaints_qs = complaints_qs.filter(department=user.department)
|
||||
|
||||
tables['overdue_complaints'] = {
|
||||
'headers': ['ID', 'Title', 'Patient', 'Severity', 'Hospital', 'Department', 'Due Date'],
|
||||
'rows': list(
|
||||
complaints_qs.select_related('hospital', 'department', 'patient')
|
||||
.order_by('due_at')[:100]
|
||||
|
||||
tables["overdue_complaints"] = {
|
||||
"headers": ["ID", "Title", "Patient", "Severity", "Hospital", "Department", "Due Date"],
|
||||
"rows": list(
|
||||
complaints_qs.select_related("hospital", "department", "patient")
|
||||
.order_by("due_at")[:100]
|
||||
.annotate(
|
||||
patient_full_name=Concat('patient__first_name', Value(' '), 'patient__last_name'),
|
||||
hospital_name=F('hospital__name'),
|
||||
department_name=F('department__name')
|
||||
patient_full_name=Concat("patient__first_name", Value(" "), "patient__last_name"),
|
||||
hospital_name=F("hospital__name"),
|
||||
department_name=F("department__name"),
|
||||
)
|
||||
.values_list(
|
||||
'id',
|
||||
'title',
|
||||
'patient_full_name',
|
||||
'severity',
|
||||
'hospital_name',
|
||||
'department_name',
|
||||
'due_at'
|
||||
)
|
||||
)
|
||||
.values_list("id", "title", "patient_full_name", "severity", "hospital_name", "department_name", "due_at")
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# Physician leaderboard
|
||||
physician_data = charts.get('physician_leaderboard', {}).get('metadata', [])
|
||||
tables['physician_leaderboard'] = {
|
||||
'headers': ['Name', 'Specialization', 'Department', 'Rating', 'Surveys', 'Positive', 'Neutral', 'Negative'],
|
||||
'rows': [
|
||||
physician_data = charts.get("physician_leaderboard", {}).get("metadata", [])
|
||||
tables["physician_leaderboard"] = {
|
||||
"headers": ["Name", "Specialization", "Department", "Rating", "Surveys", "Positive", "Neutral", "Negative"],
|
||||
"rows": [
|
||||
[
|
||||
p['name'],
|
||||
p['specialization'],
|
||||
p['department'],
|
||||
str(p['rating']),
|
||||
str(p['surveys']),
|
||||
str(p['positive']),
|
||||
str(p['neutral']),
|
||||
str(p['negative'])
|
||||
p["name"],
|
||||
p["specialization"],
|
||||
p["department"],
|
||||
str(p["rating"]),
|
||||
str(p["surveys"]),
|
||||
str(p["positive"]),
|
||||
str(p["neutral"]),
|
||||
str(p["negative"]),
|
||||
]
|
||||
for p in physician_data
|
||||
]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# Prepare export data
|
||||
export_data = ExportService.prepare_dashboard_data(
|
||||
user=user,
|
||||
kpis=kpis,
|
||||
charts=charts,
|
||||
tables=tables
|
||||
)
|
||||
|
||||
export_data = ExportService.prepare_dashboard_data(user=user, kpis=kpis, charts=charts, tables=tables)
|
||||
|
||||
# Export based on format
|
||||
if export_format == 'excel':
|
||||
if export_format == "excel":
|
||||
return ExportService.export_to_excel(export_data)
|
||||
elif export_format == 'pdf':
|
||||
elif export_format == "pdf":
|
||||
return ExportService.export_to_pdf(export_data)
|
||||
|
||||
return JsonResponse({'error': 'Export failed'}, status=500)
|
||||
|
||||
return JsonResponse({"error": "Export failed"}, status=500)
|
||||
|
||||
@ -140,10 +140,6 @@ def appreciation_list(request):
|
||||
page_number = request.GET.get('page', 1)
|
||||
page_obj = paginator.get_page(page_number)
|
||||
|
||||
# Get filter options
|
||||
hospitals = Hospital.objects.filter(status='active')
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
hospitals = hospitals.filter(id=user.hospital.id)
|
||||
|
||||
departments = Department.objects.filter(status='active')
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
@ -172,7 +168,6 @@ def appreciation_list(request):
|
||||
'page_obj': page_obj,
|
||||
'appreciations': page_obj.object_list,
|
||||
'stats': stats,
|
||||
'hospitals': hospitals,
|
||||
'departments': departments,
|
||||
'categories': categories,
|
||||
'status_choices': AppreciationStatus.choices,
|
||||
@ -334,7 +329,6 @@ def appreciation_send(request):
|
||||
categories = categories.filter(Q(hospital_id=request.user.hospital.id) | Q(hospital__isnull=True))
|
||||
|
||||
context = {
|
||||
'hospitals': hospitals,
|
||||
'categories': categories,
|
||||
'visibility_choices': AppreciationVisibility.choices,
|
||||
}
|
||||
@ -410,10 +404,6 @@ def leaderboard_view(request):
|
||||
page_number = request.GET.get('page', 1)
|
||||
page_obj = paginator.get_page(page_number)
|
||||
|
||||
# Get filter options
|
||||
hospitals = Hospital.objects.filter(status='active')
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
hospitals = hospitals.filter(id=user.hospital.id)
|
||||
|
||||
departments = Department.objects.filter(status='active')
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
@ -426,7 +416,6 @@ def leaderboard_view(request):
|
||||
context = {
|
||||
'page_obj': page_obj,
|
||||
'leaderboard': page_obj.object_list,
|
||||
'hospitals': hospitals,
|
||||
'departments': departments,
|
||||
'months': months,
|
||||
'years': years,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
397
apps/complaints/management/commands/test_sla_reminders.py
Normal file
397
apps/complaints/management/commands/test_sla_reminders.py
Normal 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
@ -40,7 +40,7 @@ class AIService:
|
||||
|
||||
|
||||
# Default configuration
|
||||
DEFAULT_MODEL = "openrouter/z-ai/glm-4.7"
|
||||
DEFAULT_MODEL = "openrouter/nvidia/nemotron-3-super-120b-a12b:free"
|
||||
# DEFAULT_MODEL = "openrouter/xiaomi/mimo-v2-flash:free"
|
||||
DEFAULT_TEMPERATURE = 0.3
|
||||
DEFAULT_MAX_TOKENS = 500
|
||||
@ -521,7 +521,7 @@ class AIService:
|
||||
|
||||
# Build kwargs
|
||||
kwargs = {
|
||||
"model": "openrouter/xiaomi/mimo-v2-flash",
|
||||
"model": "openrouter/nvidia/nemotron-3-super-120b-a12b:free",
|
||||
"messages": messages
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"""
|
||||
Context processors for global template variables
|
||||
"""
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
|
||||
@ -25,59 +26,36 @@ def sidebar_counts(request):
|
||||
|
||||
# Source Users only see their own created complaints
|
||||
if user.is_source_user():
|
||||
complaint_count = Complaint.objects.filter(
|
||||
created_by=user,
|
||||
status__in=['open', 'in_progress']
|
||||
).count()
|
||||
complaint_count = Complaint.objects.filter(created_by=user, status__in=["open", "in_progress"]).count()
|
||||
return {
|
||||
'complaint_count': complaint_count,
|
||||
'feedback_count': 0,
|
||||
'action_count': 0,
|
||||
'current_hospital': None,
|
||||
'is_px_admin': False,
|
||||
'is_source_user': True,
|
||||
"complaint_count": complaint_count,
|
||||
"feedback_count": 0,
|
||||
"action_count": 0,
|
||||
"current_hospital": None,
|
||||
"is_px_admin": False,
|
||||
"is_source_user": True,
|
||||
}
|
||||
|
||||
# Filter based on user role and tenant_hospital
|
||||
if user.is_px_admin():
|
||||
# PX Admins use their selected hospital from session
|
||||
hospital = getattr(request, 'tenant_hospital', None)
|
||||
hospital = getattr(request, "tenant_hospital", None)
|
||||
if hospital:
|
||||
complaint_count = Complaint.objects.filter(
|
||||
hospital=hospital,
|
||||
status__in=['open', 'in_progress']
|
||||
).count()
|
||||
feedback_count = Feedback.objects.filter(
|
||||
hospital=hospital,
|
||||
status__in=['submitted', 'reviewed']
|
||||
).count()
|
||||
action_count = PXAction.objects.filter(
|
||||
hospital=hospital,
|
||||
status__in=['open', 'in_progress']
|
||||
).count()
|
||||
complaint_count = Complaint.objects.filter(hospital=hospital, status__in=["open", "in_progress"]).count()
|
||||
feedback_count = Feedback.objects.filter(hospital=hospital, status__in=["submitted", "reviewed"]).count()
|
||||
action_count = PXAction.objects.filter(hospital=hospital, status__in=["open", "in_progress"]).count()
|
||||
else:
|
||||
complaint_count = 0
|
||||
feedback_count = 0
|
||||
action_count = 0
|
||||
# Count provisional users for PX Admin
|
||||
from apps.accounts.models import User
|
||||
provisional_user_count = User.objects.filter(
|
||||
is_provisional=True,
|
||||
acknowledgement_completed=False
|
||||
).count()
|
||||
|
||||
provisional_user_count = User.objects.filter(is_provisional=True, acknowledgement_completed=False).count()
|
||||
elif user.hospital:
|
||||
complaint_count = Complaint.objects.filter(
|
||||
hospital=user.hospital,
|
||||
status__in=['open', 'in_progress']
|
||||
).count()
|
||||
feedback_count = Feedback.objects.filter(
|
||||
hospital=user.hospital,
|
||||
status__in=['submitted', 'reviewed']
|
||||
).count()
|
||||
action_count = PXAction.objects.filter(
|
||||
hospital=user.hospital,
|
||||
status__in=['open', 'in_progress']
|
||||
).count()
|
||||
complaint_count = Complaint.objects.filter(hospital=user.hospital, status__in=["open", "in_progress"]).count()
|
||||
feedback_count = Feedback.objects.filter(hospital=user.hospital, status__in=["submitted", "reviewed"]).count()
|
||||
action_count = PXAction.objects.filter(hospital=user.hospital, status__in=["open", "in_progress"]).count()
|
||||
# provisional_user_count = 0
|
||||
else:
|
||||
complaint_count = 0
|
||||
@ -85,12 +63,12 @@ def sidebar_counts(request):
|
||||
action_count = 0
|
||||
|
||||
return {
|
||||
'complaint_count': complaint_count,
|
||||
'feedback_count': feedback_count,
|
||||
'action_count': action_count,
|
||||
'current_hospital': getattr(request, 'tenant_hospital', None),
|
||||
'is_px_admin': request.user.is_authenticated and request.user.is_px_admin(),
|
||||
'is_source_user': False,
|
||||
"complaint_count": complaint_count,
|
||||
"feedback_count": feedback_count,
|
||||
"action_count": action_count,
|
||||
"current_hospital": getattr(request, "tenant_hospital", None),
|
||||
"is_px_admin": request.user.is_authenticated and request.user.is_px_admin(),
|
||||
"is_source_user": False,
|
||||
}
|
||||
|
||||
|
||||
@ -103,25 +81,24 @@ def hospital_context(request):
|
||||
if not request.user.is_authenticated:
|
||||
return {}
|
||||
|
||||
hospital = getattr(request, 'tenant_hospital', None)
|
||||
|
||||
hospital = getattr(request, "tenant_hospital", None)
|
||||
|
||||
# Get list of hospitals for PX Admin switcher
|
||||
hospitals_list = []
|
||||
if request.user.is_px_admin():
|
||||
from apps.organizations.models import Hospital
|
||||
hospitals_list = list(
|
||||
Hospital.objects.filter(status='active').order_by('name').values('id', 'name', 'code')
|
||||
)
|
||||
|
||||
hospitals_list = list(Hospital.objects.filter(status="active").order_by("name").values("id", "name", "code"))
|
||||
|
||||
# Source user context
|
||||
is_source_user = request.user.is_source_user()
|
||||
source_user_profile = getattr(request, 'source_user_profile', None)
|
||||
source_user_profile = getattr(request, "source_user_profile", None)
|
||||
|
||||
return {
|
||||
'current_hospital': hospital,
|
||||
'is_px_admin': request.user.is_px_admin(),
|
||||
'is_source_user': is_source_user,
|
||||
'source_user_profile': source_user_profile,
|
||||
'hospitals_list': hospitals_list,
|
||||
# 'provisional_user_count': provisional_user_count,
|
||||
"current_hospital": hospital,
|
||||
"is_px_admin": request.user.is_px_admin(),
|
||||
"is_source_user": is_source_user,
|
||||
"source_user_profile": source_user_profile,
|
||||
"hospitals_list": hospitals_list,
|
||||
"show_hospital_selector": False,
|
||||
}
|
||||
|
||||
@ -3,135 +3,131 @@ Form mixins for tenant-aware forms.
|
||||
|
||||
Provides mixins to handle hospital field visibility based on user role.
|
||||
"""
|
||||
|
||||
from django import forms
|
||||
from apps.organizations.models import Hospital
|
||||
|
||||
|
||||
class HospitalFieldMixin:
|
||||
"""
|
||||
Mixin to handle hospital field visibility based on user role.
|
||||
|
||||
- PX Admins: See dropdown with all active hospitals
|
||||
- Others: Hidden field, auto-set to user's hospital
|
||||
|
||||
Mixin to handle hospital field - always hidden, auto-set based on user context.
|
||||
|
||||
- PX Admins: Hidden field, auto-set from session (request.tenant_hospital)
|
||||
- Others: Hidden field, auto-set from user's hospital (User.hospital)
|
||||
|
||||
Usage:
|
||||
class MyForm(HospitalFieldMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = MyModel
|
||||
fields = ['hospital', ...]
|
||||
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Hospital field is automatically configured
|
||||
|
||||
|
||||
In views:
|
||||
form = MyForm(user=request.user)
|
||||
form = MyForm(request=request)
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.user = kwargs.pop('user', None)
|
||||
self.request = kwargs.pop("request", None)
|
||||
self.user = self.request.user if self.request else None
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if self.user and 'hospital' in self.fields:
|
||||
|
||||
if self.user and "hospital" in self.fields:
|
||||
self._setup_hospital_field()
|
||||
|
||||
|
||||
def _setup_hospital_field(self):
|
||||
"""Configure hospital field based on user role."""
|
||||
hospital_field = self.fields['hospital']
|
||||
|
||||
"""Configure hospital field - always hidden, auto-set based on user context."""
|
||||
hospital_field = self.fields["hospital"]
|
||||
|
||||
hospital_field.widget = forms.HiddenInput()
|
||||
hospital_field.required = False
|
||||
|
||||
hospital = None
|
||||
|
||||
if self.user.is_px_admin():
|
||||
# PX Admin: Show dropdown with all active hospitals
|
||||
hospital_field.queryset = Hospital.objects.filter(status='active').order_by('name')
|
||||
hospital_field.required = True
|
||||
# Update widget attrs instead of replacing widget to preserve choices
|
||||
hospital_field.widget.attrs.update({
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white'
|
||||
})
|
||||
hospital = getattr(self.request, "tenant_hospital", None)
|
||||
else:
|
||||
# Regular user: Hide field and set default
|
||||
hospital_field.widget = forms.HiddenInput()
|
||||
hospital_field.required = False
|
||||
|
||||
# Set initial value to user's hospital
|
||||
if self.user.hospital:
|
||||
hospital_field.initial = self.user.hospital
|
||||
# Limit queryset to just user's hospital (for validation)
|
||||
hospital_field.queryset = Hospital.objects.filter(id=self.user.hospital.id)
|
||||
else:
|
||||
# User has no hospital - empty queryset
|
||||
hospital_field.queryset = Hospital.objects.none()
|
||||
|
||||
hospital = self.user.hospital
|
||||
|
||||
if hospital:
|
||||
hospital_field.initial = hospital
|
||||
hospital_field.queryset = Hospital.objects.filter(id=hospital.id)
|
||||
else:
|
||||
hospital_field.queryset = Hospital.objects.none()
|
||||
|
||||
def clean_hospital(self):
|
||||
"""
|
||||
Ensure non-PX admins can only use their own hospital.
|
||||
PX Admins can select any hospital.
|
||||
Auto-set hospital based on user context.
|
||||
"""
|
||||
hospital = self.cleaned_data.get('hospital')
|
||||
|
||||
hospital = self.cleaned_data.get("hospital")
|
||||
|
||||
if not self.user:
|
||||
return hospital
|
||||
|
||||
|
||||
if self.user.is_px_admin():
|
||||
# PX Admin must select a hospital
|
||||
hospital = getattr(self.request, "tenant_hospital", None)
|
||||
if not hospital:
|
||||
raise forms.ValidationError("Please select a hospital.")
|
||||
raise forms.ValidationError("No hospital selected. Please select a hospital first.")
|
||||
return hospital
|
||||
else:
|
||||
# Non-PX admins: Force user's hospital
|
||||
if self.user.hospital:
|
||||
return self.user.hospital
|
||||
else:
|
||||
raise forms.ValidationError(
|
||||
"You do not have a hospital assigned. Please contact your administrator."
|
||||
)
|
||||
raise forms.ValidationError("You do not have a hospital assigned. Please contact your administrator.")
|
||||
|
||||
|
||||
class DepartmentFieldMixin:
|
||||
"""
|
||||
Mixin to handle department field filtering based on user's hospital.
|
||||
|
||||
|
||||
- Filters departments to only show those in the selected/current hospital
|
||||
- Works with HospitalFieldMixin
|
||||
|
||||
|
||||
Usage:
|
||||
class MyForm(HospitalFieldMixin, DepartmentFieldMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = MyModel
|
||||
fields = ['hospital', 'department', ...]
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if 'department' in self.fields:
|
||||
|
||||
if "department" in self.fields:
|
||||
self._setup_department_field()
|
||||
|
||||
|
||||
def _setup_department_field(self):
|
||||
"""Configure department field with hospital-based filtering."""
|
||||
from apps.organizations.models import Department
|
||||
|
||||
department_field = self.fields['department']
|
||||
|
||||
|
||||
department_field = self.fields["department"]
|
||||
|
||||
# Get the hospital (either from form data or user's hospital)
|
||||
hospital = None
|
||||
if self.data.get('hospital'):
|
||||
if self.data.get("hospital"):
|
||||
try:
|
||||
hospital = Hospital.objects.get(id=self.data['hospital'])
|
||||
hospital = Hospital.objects.get(id=self.data["hospital"])
|
||||
except Hospital.DoesNotExist:
|
||||
pass
|
||||
elif self.initial.get('hospital'):
|
||||
hospital = self.initial['hospital']
|
||||
elif self.instance and self.instance.pk and self.instance.hospital:
|
||||
hospital = self.instance.hospital
|
||||
elif self.initial.get("hospital"):
|
||||
hospital = self.initial["hospital"]
|
||||
elif self.instance and self.instance.pk:
|
||||
# Only access hospital if instance is saved (has pk)
|
||||
try:
|
||||
hospital = self.instance.hospital
|
||||
except Exception:
|
||||
# Hospital not set (RelatedObjectDoesNotExist or similar)
|
||||
pass
|
||||
elif self.user and self.user.is_px_admin():
|
||||
hospital = getattr(self.request, "tenant_hospital", None)
|
||||
elif self.user and self.user.hospital:
|
||||
hospital = self.user.hospital
|
||||
|
||||
|
||||
if hospital:
|
||||
# Filter departments to user's hospital
|
||||
department_field.queryset = Department.objects.filter(
|
||||
hospital=hospital,
|
||||
status='active'
|
||||
).order_by('name')
|
||||
department_field.queryset = Department.objects.filter(hospital=hospital, status="active").order_by("name")
|
||||
else:
|
||||
# No hospital context - empty queryset
|
||||
department_field.queryset = Department.objects.none()
|
||||
|
||||
2267
apps/core/management/commands/setup_dev_environment.py
Normal file
2267
apps/core/management/commands/setup_dev_environment.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,9 @@
|
||||
"""
|
||||
Tenant-aware middleware for multi-tenancy
|
||||
"""
|
||||
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
|
||||
|
||||
@ -11,17 +14,28 @@ class TenantMiddleware(MiddlewareMixin):
|
||||
This middleware ensures that:
|
||||
- authenticated users have their tenant_hospital set from their profile
|
||||
- PX admins can switch between hospitals via session
|
||||
- PX admins without a selected hospital are redirected to select-hospital
|
||||
- Source Users have their source context available
|
||||
- All requests have tenant context available
|
||||
"""
|
||||
|
||||
EXEMPT_PATHS = [
|
||||
"/core/select-hospital/",
|
||||
"/accounts/logout/",
|
||||
"/accounts/password_reset/",
|
||||
"/accounts/password_reset/done/",
|
||||
"/accounts/reset/",
|
||||
"/api/",
|
||||
"/health/",
|
||||
"/admin/",
|
||||
"/__debug__/",
|
||||
]
|
||||
|
||||
def process_request(self, request):
|
||||
"""Set tenant hospital context on each request."""
|
||||
if request.user and request.user.is_authenticated:
|
||||
# Store user's role for quick access
|
||||
request.user_roles = request.user.get_role_names()
|
||||
|
||||
# Set source user context
|
||||
request.source_user = None
|
||||
request.source_user_profile = None
|
||||
if request.user.is_source_user():
|
||||
@ -30,24 +44,19 @@ class TenantMiddleware(MiddlewareMixin):
|
||||
request.source_user = profile
|
||||
request.source_user_profile = profile
|
||||
|
||||
# PX Admins can switch hospitals via session
|
||||
if request.user.is_px_admin():
|
||||
hospital_id = request.session.get('selected_hospital_id')
|
||||
hospital_id = request.session.get("selected_hospital_id")
|
||||
if hospital_id:
|
||||
from apps.organizations.models import Hospital
|
||||
|
||||
try:
|
||||
# Validate that the hospital exists
|
||||
request.tenant_hospital = Hospital.objects.get(id=hospital_id)
|
||||
except Hospital.DoesNotExist:
|
||||
# Invalid hospital ID, fall back to default
|
||||
request.tenant_hospital = None
|
||||
# Clear invalid session data
|
||||
request.session.pop('selected_hospital_id', None)
|
||||
request.session.pop("selected_hospital_id", None)
|
||||
else:
|
||||
# No hospital selected yet
|
||||
request.tenant_hospital = None
|
||||
else:
|
||||
# Non-PX Admin users use their assigned hospital
|
||||
request.tenant_hospital = request.user.hospital
|
||||
else:
|
||||
request.tenant_hospital = None
|
||||
@ -56,3 +65,24 @@ class TenantMiddleware(MiddlewareMixin):
|
||||
request.source_user_profile = None
|
||||
|
||||
return None
|
||||
|
||||
def process_view(self, request, view_func, view_args, view_kwargs):
|
||||
"""Redirect PX admins without hospital to select-hospital page."""
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return None
|
||||
|
||||
if not request.user.is_px_admin():
|
||||
return None
|
||||
|
||||
if request.tenant_hospital:
|
||||
return None
|
||||
|
||||
path = request.path
|
||||
for exempt_path in self.EXEMPT_PATHS:
|
||||
if path.startswith(exempt_path):
|
||||
return None
|
||||
|
||||
if request.headers.get("x-requested-with") == "XMLHttpRequest":
|
||||
return None
|
||||
|
||||
return HttpResponseRedirect(reverse("core:select_hospital"))
|
||||
|
||||
@ -1083,7 +1083,6 @@ def admin_evaluation(request):
|
||||
).distinct().select_related('hospital', 'department')
|
||||
|
||||
context = {
|
||||
'hospitals': hospitals,
|
||||
'departments': departments,
|
||||
'staff_list': staff_queryset,
|
||||
'selected_hospital_id': hospital_id,
|
||||
|
||||
@ -1,161 +1,111 @@
|
||||
"""
|
||||
Feedback forms - Forms for feedback management
|
||||
"""
|
||||
|
||||
from django import forms
|
||||
|
||||
from apps.organizations.models import Department, Hospital, Patient, Staff
|
||||
|
||||
from apps.core.form_mixins import HospitalFieldMixin
|
||||
from .models import Feedback, FeedbackResponse, FeedbackStatus, FeedbackType, FeedbackCategory
|
||||
|
||||
|
||||
class FeedbackForm(forms.ModelForm):
|
||||
class FeedbackForm(HospitalFieldMixin, forms.ModelForm):
|
||||
"""Form for creating and editing feedback"""
|
||||
|
||||
class Meta:
|
||||
model = Feedback
|
||||
fields = [
|
||||
'patient',
|
||||
'is_anonymous',
|
||||
'contact_name',
|
||||
'contact_email',
|
||||
'contact_phone',
|
||||
'hospital',
|
||||
'department',
|
||||
'staff',
|
||||
'feedback_type',
|
||||
'title',
|
||||
'message',
|
||||
'category',
|
||||
'subcategory',
|
||||
'rating',
|
||||
'priority',
|
||||
'encounter_id',
|
||||
"patient",
|
||||
"is_anonymous",
|
||||
"contact_name",
|
||||
"contact_email",
|
||||
"contact_phone",
|
||||
"hospital",
|
||||
"department",
|
||||
"staff",
|
||||
"feedback_type",
|
||||
"title",
|
||||
"message",
|
||||
"category",
|
||||
"subcategory",
|
||||
"rating",
|
||||
"priority",
|
||||
"encounter_id",
|
||||
]
|
||||
widgets = {
|
||||
'patient': forms.Select(attrs={
|
||||
'class': 'form-select',
|
||||
'id': 'id_patient'
|
||||
}),
|
||||
'is_anonymous': forms.CheckboxInput(attrs={
|
||||
'class': 'form-check-input',
|
||||
'id': 'id_is_anonymous'
|
||||
}),
|
||||
'contact_name': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Enter contact name'
|
||||
}),
|
||||
'contact_email': forms.EmailInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Enter email address'
|
||||
}),
|
||||
'contact_phone': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Enter phone number'
|
||||
}),
|
||||
'hospital': forms.Select(attrs={
|
||||
'class': 'form-select',
|
||||
'required': True
|
||||
}),
|
||||
'department': forms.Select(attrs={
|
||||
'class': 'form-select'
|
||||
}),
|
||||
'staff': forms.Select(attrs={
|
||||
'class': 'form-select'
|
||||
}),
|
||||
'feedback_type': forms.Select(attrs={
|
||||
'class': 'form-select',
|
||||
'required': True
|
||||
}),
|
||||
'title': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Enter feedback title',
|
||||
'required': True
|
||||
}),
|
||||
'message': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 5,
|
||||
'placeholder': 'Enter your feedback message...',
|
||||
'required': True
|
||||
}),
|
||||
'category': forms.Select(attrs={
|
||||
'class': 'form-select',
|
||||
'required': True
|
||||
}),
|
||||
'subcategory': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Enter subcategory (optional)'
|
||||
}),
|
||||
'rating': forms.NumberInput(attrs={
|
||||
'class': 'form-control',
|
||||
'min': 1,
|
||||
'max': 5,
|
||||
'placeholder': 'Rate from 1 to 5'
|
||||
}),
|
||||
'priority': forms.Select(attrs={
|
||||
'class': 'form-select'
|
||||
}),
|
||||
'encounter_id': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Enter encounter ID (optional)'
|
||||
}),
|
||||
"patient": forms.Select(attrs={"class": "form-select", "id": "id_patient"}),
|
||||
"is_anonymous": forms.CheckboxInput(attrs={"class": "form-check-input", "id": "id_is_anonymous"}),
|
||||
"contact_name": forms.TextInput(attrs={"class": "form-control", "placeholder": "Enter contact name"}),
|
||||
"contact_email": forms.EmailInput(attrs={"class": "form-control", "placeholder": "Enter email address"}),
|
||||
"contact_phone": forms.TextInput(attrs={"class": "form-control", "placeholder": "Enter phone number"}),
|
||||
"hospital": forms.Select(attrs={"class": "form-select", "required": True}),
|
||||
"department": forms.Select(attrs={"class": "form-select"}),
|
||||
"staff": forms.Select(attrs={"class": "form-select"}),
|
||||
"feedback_type": forms.Select(attrs={"class": "form-select", "required": True}),
|
||||
"title": forms.TextInput(
|
||||
attrs={"class": "form-control", "placeholder": "Enter feedback title", "required": True}
|
||||
),
|
||||
"message": forms.Textarea(
|
||||
attrs={
|
||||
"class": "form-control",
|
||||
"rows": 5,
|
||||
"placeholder": "Enter your feedback message...",
|
||||
"required": True,
|
||||
}
|
||||
),
|
||||
"category": forms.Select(attrs={"class": "form-select", "required": True}),
|
||||
"subcategory": forms.TextInput(
|
||||
attrs={"class": "form-control", "placeholder": "Enter subcategory (optional)"}
|
||||
),
|
||||
"rating": forms.NumberInput(
|
||||
attrs={"class": "form-control", "min": 1, "max": 5, "placeholder": "Rate from 1 to 5"}
|
||||
),
|
||||
"priority": forms.Select(attrs={"class": "form-select"}),
|
||||
"encounter_id": forms.TextInput(
|
||||
attrs={"class": "form-control", "placeholder": "Enter encounter ID (optional)"}
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
user = kwargs.pop('user', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Filter hospitals based on user permissions
|
||||
if user:
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
self.fields['hospital'].queryset = Hospital.objects.filter(
|
||||
id=user.hospital.id,
|
||||
status='active'
|
||||
)
|
||||
else:
|
||||
self.fields['hospital'].queryset = Hospital.objects.filter(status='active')
|
||||
# Hospital field is configured by HospitalFieldMixin (always hidden)
|
||||
# Filter departments and staff based on hospital
|
||||
hospital = None
|
||||
if self.instance.pk and hasattr(self.instance, "hospital") and self.instance.hospital_id:
|
||||
hospital = self.instance.hospital
|
||||
elif self.user and self.user.is_px_admin():
|
||||
hospital = getattr(self.request, "tenant_hospital", None)
|
||||
elif self.user and self.user.hospital:
|
||||
hospital = self.user.hospital
|
||||
|
||||
# Set initial hospital if user has one
|
||||
if user and user.hospital and not self.instance.pk:
|
||||
self.fields['hospital'].initial = user.hospital
|
||||
|
||||
# Filter departments and physicians based on selected hospital
|
||||
if self.instance.pk and hasattr(self.instance, 'hospital') and self.instance.hospital_id:
|
||||
self.fields['department'].queryset = Department.objects.filter(
|
||||
hospital=self.instance.hospital,
|
||||
status='active'
|
||||
)
|
||||
self.fields['staff'].queryset = Staff.objects.filter(
|
||||
hospital=self.instance.hospital,
|
||||
status='active'
|
||||
)
|
||||
if hospital:
|
||||
self.fields["department"].queryset = Department.objects.filter(hospital=hospital, status="active")
|
||||
self.fields["staff"].queryset = Staff.objects.filter(hospital=hospital, status="active")
|
||||
else:
|
||||
self.fields['department'].queryset = Department.objects.none()
|
||||
self.fields['staff'].queryset = Staff.objects.none()
|
||||
self.fields["department"].queryset = Department.objects.none()
|
||||
self.fields["staff"].queryset = Staff.objects.none()
|
||||
|
||||
# Make patient optional if anonymous
|
||||
if self.data.get('is_anonymous'):
|
||||
self.fields['patient'].required = False
|
||||
if self.data.get("is_anonymous"):
|
||||
self.fields["patient"].required = False
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
is_anonymous = cleaned_data.get('is_anonymous')
|
||||
patient = cleaned_data.get('patient')
|
||||
contact_name = cleaned_data.get('contact_name')
|
||||
is_anonymous = cleaned_data.get("is_anonymous")
|
||||
patient = cleaned_data.get("patient")
|
||||
contact_name = cleaned_data.get("contact_name")
|
||||
|
||||
# Validate anonymous feedback
|
||||
if is_anonymous:
|
||||
if not contact_name:
|
||||
raise forms.ValidationError(
|
||||
"Contact name is required for anonymous feedback."
|
||||
)
|
||||
raise forms.ValidationError("Contact name is required for anonymous feedback.")
|
||||
else:
|
||||
if not patient:
|
||||
raise forms.ValidationError(
|
||||
"Please select a patient or mark as anonymous."
|
||||
)
|
||||
raise forms.ValidationError("Please select a patient or mark as anonymous.")
|
||||
|
||||
# Validate rating
|
||||
rating = cleaned_data.get('rating')
|
||||
rating = cleaned_data.get("rating")
|
||||
if rating is not None and (rating < 1 or rating > 5):
|
||||
raise forms.ValidationError("Rating must be between 1 and 5.")
|
||||
|
||||
@ -167,21 +117,13 @@ class FeedbackResponseForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = FeedbackResponse
|
||||
fields = ['response_type', 'message', 'is_internal']
|
||||
fields = ["response_type", "message", "is_internal"]
|
||||
widgets = {
|
||||
'response_type': forms.Select(attrs={
|
||||
'class': 'form-select',
|
||||
'required': True
|
||||
}),
|
||||
'message': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 4,
|
||||
'placeholder': 'Enter your response...',
|
||||
'required': True
|
||||
}),
|
||||
'is_internal': forms.CheckboxInput(attrs={
|
||||
'class': 'form-check-input'
|
||||
}),
|
||||
"response_type": forms.Select(attrs={"class": "form-select", "required": True}),
|
||||
"message": forms.Textarea(
|
||||
attrs={"class": "form-control", "rows": 4, "placeholder": "Enter your response...", "required": True}
|
||||
),
|
||||
"is_internal": forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||
}
|
||||
|
||||
|
||||
@ -190,111 +132,86 @@ class FeedbackFilterForm(forms.Form):
|
||||
|
||||
search = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Search by title, message, patient...'
|
||||
})
|
||||
widget=forms.TextInput(attrs={"class": "form-control", "placeholder": "Search by title, message, patient..."}),
|
||||
)
|
||||
|
||||
feedback_type = forms.ChoiceField(
|
||||
required=False,
|
||||
choices=[('', 'All Types')] + list(FeedbackType.choices),
|
||||
widget=forms.Select(attrs={'class': 'form-select'})
|
||||
choices=[("", "All Types")] + list(FeedbackType.choices),
|
||||
widget=forms.Select(attrs={"class": "form-select"}),
|
||||
)
|
||||
|
||||
status = forms.ChoiceField(
|
||||
required=False,
|
||||
choices=[('', 'All Statuses')] + list(FeedbackStatus.choices),
|
||||
widget=forms.Select(attrs={'class': 'form-select'})
|
||||
choices=[("", "All Statuses")] + list(FeedbackStatus.choices),
|
||||
widget=forms.Select(attrs={"class": "form-select"}),
|
||||
)
|
||||
|
||||
category = forms.ChoiceField(
|
||||
required=False,
|
||||
choices=[('', 'All Categories')] + list(FeedbackCategory.choices),
|
||||
widget=forms.Select(attrs={'class': 'form-select'})
|
||||
choices=[("", "All Categories")] + list(FeedbackCategory.choices),
|
||||
widget=forms.Select(attrs={"class": "form-select"}),
|
||||
)
|
||||
|
||||
sentiment = forms.ChoiceField(
|
||||
required=False,
|
||||
choices=[
|
||||
('', 'All Sentiments'),
|
||||
('positive', 'Positive'),
|
||||
('neutral', 'Neutral'),
|
||||
('negative', 'Negative'),
|
||||
("", "All Sentiments"),
|
||||
("positive", "Positive"),
|
||||
("neutral", "Neutral"),
|
||||
("negative", "Negative"),
|
||||
],
|
||||
widget=forms.Select(attrs={'class': 'form-select'})
|
||||
widget=forms.Select(attrs={"class": "form-select"}),
|
||||
)
|
||||
|
||||
priority = forms.ChoiceField(
|
||||
required=False,
|
||||
choices=[
|
||||
('', 'All Priorities'),
|
||||
('low', 'Low'),
|
||||
('medium', 'Medium'),
|
||||
('high', 'High'),
|
||||
('urgent', 'Urgent'),
|
||||
("", "All Priorities"),
|
||||
("low", "Low"),
|
||||
("medium", "Medium"),
|
||||
("high", "High"),
|
||||
("urgent", "Urgent"),
|
||||
],
|
||||
widget=forms.Select(attrs={'class': 'form-select'})
|
||||
widget=forms.Select(attrs={"class": "form-select"}),
|
||||
)
|
||||
|
||||
hospital = forms.ModelChoiceField(
|
||||
required=False,
|
||||
queryset=Hospital.objects.filter(status='active'),
|
||||
widget=forms.Select(attrs={'class': 'form-select'}),
|
||||
empty_label='All Hospitals'
|
||||
queryset=Hospital.objects.filter(status="active"),
|
||||
widget=forms.Select(attrs={"class": "form-select"}),
|
||||
empty_label="All Hospitals",
|
||||
)
|
||||
|
||||
department = forms.ModelChoiceField(
|
||||
required=False,
|
||||
queryset=Department.objects.filter(status='active'),
|
||||
widget=forms.Select(attrs={'class': 'form-select'}),
|
||||
empty_label='All Departments'
|
||||
queryset=Department.objects.filter(status="active"),
|
||||
widget=forms.Select(attrs={"class": "form-select"}),
|
||||
empty_label="All Departments",
|
||||
)
|
||||
|
||||
rating_min = forms.IntegerField(
|
||||
required=False,
|
||||
min_value=1,
|
||||
max_value=5,
|
||||
widget=forms.NumberInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Min rating'
|
||||
})
|
||||
widget=forms.NumberInput(attrs={"class": "form-control", "placeholder": "Min rating"}),
|
||||
)
|
||||
|
||||
rating_max = forms.IntegerField(
|
||||
required=False,
|
||||
min_value=1,
|
||||
max_value=5,
|
||||
widget=forms.NumberInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Max rating'
|
||||
})
|
||||
widget=forms.NumberInput(attrs={"class": "form-control", "placeholder": "Max rating"}),
|
||||
)
|
||||
|
||||
date_from = forms.DateField(
|
||||
required=False,
|
||||
widget=forms.DateInput(attrs={
|
||||
'class': 'form-control',
|
||||
'type': 'date'
|
||||
})
|
||||
)
|
||||
date_from = forms.DateField(required=False, widget=forms.DateInput(attrs={"class": "form-control", "type": "date"}))
|
||||
|
||||
date_to = forms.DateField(
|
||||
required=False,
|
||||
widget=forms.DateInput(attrs={
|
||||
'class': 'form-control',
|
||||
'type': 'date'
|
||||
})
|
||||
)
|
||||
date_to = forms.DateField(required=False, widget=forms.DateInput(attrs={"class": "form-control", "type": "date"}))
|
||||
|
||||
is_featured = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
|
||||
)
|
||||
is_featured = forms.BooleanField(required=False, widget=forms.CheckboxInput(attrs={"class": "form-check-input"}))
|
||||
|
||||
requires_follow_up = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
|
||||
required=False, widget=forms.CheckboxInput(attrs={"class": "form-check-input"})
|
||||
)
|
||||
|
||||
|
||||
@ -302,38 +219,29 @@ class FeedbackStatusChangeForm(forms.Form):
|
||||
"""Form for changing feedback status"""
|
||||
|
||||
status = forms.ChoiceField(
|
||||
choices=FeedbackStatus.choices,
|
||||
widget=forms.Select(attrs={
|
||||
'class': 'form-select',
|
||||
'required': True
|
||||
})
|
||||
choices=FeedbackStatus.choices, widget=forms.Select(attrs={"class": "form-select", "required": True})
|
||||
)
|
||||
|
||||
note = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 3,
|
||||
'placeholder': 'Add a note about this status change (optional)...'
|
||||
})
|
||||
widget=forms.Textarea(
|
||||
attrs={
|
||||
"class": "form-control",
|
||||
"rows": 3,
|
||||
"placeholder": "Add a note about this status change (optional)...",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class FeedbackAssignForm(forms.Form):
|
||||
"""Form for assigning feedback to a user"""
|
||||
|
||||
user_id = forms.UUIDField(
|
||||
widget=forms.Select(attrs={
|
||||
'class': 'form-select',
|
||||
'required': True
|
||||
})
|
||||
)
|
||||
user_id = forms.UUIDField(widget=forms.Select(attrs={"class": "form-select", "required": True}))
|
||||
|
||||
note = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 2,
|
||||
'placeholder': 'Add a note about this assignment (optional)...'
|
||||
})
|
||||
widget=forms.Textarea(
|
||||
attrs={"class": "form-control", "rows": 2, "placeholder": "Add a note about this assignment (optional)..."}
|
||||
),
|
||||
)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"""
|
||||
Feedback views - Server-rendered templates for feedback management
|
||||
"""
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.paginator import Paginator
|
||||
@ -42,15 +43,14 @@ def feedback_list(request):
|
||||
"""
|
||||
# Base queryset with optimizations
|
||||
queryset = Feedback.objects.select_related(
|
||||
'patient', 'hospital', 'department', 'staff',
|
||||
'assigned_to', 'reviewed_by', 'acknowledged_by', 'closed_by'
|
||||
"patient", "hospital", "department", "staff", "assigned_to", "reviewed_by", "acknowledged_by", "closed_by"
|
||||
).filter(is_deleted=False)
|
||||
|
||||
# Apply RBAC filters
|
||||
user = request.user
|
||||
# Get selected hospital for PX Admins (from middleware)
|
||||
selected_hospital = getattr(request, 'tenant_hospital', None)
|
||||
|
||||
selected_hospital = getattr(request, "tenant_hospital", None)
|
||||
|
||||
if user.is_px_admin():
|
||||
# PX Admins see all, but filter by selected hospital if set
|
||||
if selected_hospital:
|
||||
@ -65,95 +65,90 @@ def feedback_list(request):
|
||||
queryset = queryset.none()
|
||||
|
||||
# Apply filters from request
|
||||
feedback_type_filter = request.GET.get('feedback_type')
|
||||
feedback_type_filter = request.GET.get("feedback_type")
|
||||
if feedback_type_filter:
|
||||
queryset = queryset.filter(feedback_type=feedback_type_filter)
|
||||
|
||||
status_filter = request.GET.get('status')
|
||||
status_filter = request.GET.get("status")
|
||||
if status_filter:
|
||||
queryset = queryset.filter(status=status_filter)
|
||||
|
||||
category_filter = request.GET.get('category')
|
||||
category_filter = request.GET.get("category")
|
||||
if category_filter:
|
||||
queryset = queryset.filter(category=category_filter)
|
||||
|
||||
sentiment_filter = request.GET.get('sentiment')
|
||||
sentiment_filter = request.GET.get("sentiment")
|
||||
if sentiment_filter:
|
||||
queryset = queryset.filter(sentiment=sentiment_filter)
|
||||
|
||||
priority_filter = request.GET.get('priority')
|
||||
priority_filter = request.GET.get("priority")
|
||||
if priority_filter:
|
||||
queryset = queryset.filter(priority=priority_filter)
|
||||
|
||||
hospital_filter = request.GET.get('hospital')
|
||||
hospital_filter = request.GET.get("hospital")
|
||||
if hospital_filter:
|
||||
queryset = queryset.filter(hospital_id=hospital_filter)
|
||||
|
||||
department_filter = request.GET.get('department')
|
||||
department_filter = request.GET.get("department")
|
||||
if department_filter:
|
||||
queryset = queryset.filter(department_id=department_filter)
|
||||
|
||||
staff_filter = request.GET.get('staff')
|
||||
staff_filter = request.GET.get("staff")
|
||||
if staff_filter:
|
||||
queryset = queryset.filter(staff_id=staff_filter)
|
||||
|
||||
assigned_to_filter = request.GET.get('assigned_to')
|
||||
assigned_to_filter = request.GET.get("assigned_to")
|
||||
if assigned_to_filter:
|
||||
queryset = queryset.filter(assigned_to_id=assigned_to_filter)
|
||||
|
||||
rating_min = request.GET.get('rating_min')
|
||||
rating_min = request.GET.get("rating_min")
|
||||
if rating_min:
|
||||
queryset = queryset.filter(rating__gte=rating_min)
|
||||
|
||||
rating_max = request.GET.get('rating_max')
|
||||
rating_max = request.GET.get("rating_max")
|
||||
if rating_max:
|
||||
queryset = queryset.filter(rating__lte=rating_max)
|
||||
|
||||
is_featured = request.GET.get('is_featured')
|
||||
if is_featured == 'true':
|
||||
is_featured = request.GET.get("is_featured")
|
||||
if is_featured == "true":
|
||||
queryset = queryset.filter(is_featured=True)
|
||||
|
||||
requires_follow_up = request.GET.get('requires_follow_up')
|
||||
if requires_follow_up == 'true':
|
||||
requires_follow_up = request.GET.get("requires_follow_up")
|
||||
if requires_follow_up == "true":
|
||||
queryset = queryset.filter(requires_follow_up=True)
|
||||
|
||||
# Search
|
||||
search_query = request.GET.get('search')
|
||||
search_query = request.GET.get("search")
|
||||
if search_query:
|
||||
queryset = queryset.filter(
|
||||
Q(title__icontains=search_query) |
|
||||
Q(message__icontains=search_query) |
|
||||
Q(patient__mrn__icontains=search_query) |
|
||||
Q(patient__first_name__icontains=search_query) |
|
||||
Q(patient__last_name__icontains=search_query) |
|
||||
Q(contact_name__icontains=search_query)
|
||||
Q(title__icontains=search_query)
|
||||
| Q(message__icontains=search_query)
|
||||
| Q(patient__mrn__icontains=search_query)
|
||||
| Q(patient__first_name__icontains=search_query)
|
||||
| Q(patient__last_name__icontains=search_query)
|
||||
| Q(contact_name__icontains=search_query)
|
||||
)
|
||||
|
||||
# Date range filters
|
||||
date_from = request.GET.get('date_from')
|
||||
date_from = request.GET.get("date_from")
|
||||
if date_from:
|
||||
queryset = queryset.filter(created_at__gte=date_from)
|
||||
|
||||
date_to = request.GET.get('date_to')
|
||||
date_to = request.GET.get("date_to")
|
||||
if date_to:
|
||||
queryset = queryset.filter(created_at__lte=date_to)
|
||||
|
||||
# Ordering
|
||||
order_by = request.GET.get('order_by', '-created_at')
|
||||
order_by = request.GET.get("order_by", "-created_at")
|
||||
queryset = queryset.order_by(order_by)
|
||||
|
||||
# Pagination
|
||||
page_size = int(request.GET.get('page_size', 25))
|
||||
page_size = int(request.GET.get("page_size", 25))
|
||||
paginator = Paginator(queryset, page_size)
|
||||
page_number = request.GET.get('page', 1)
|
||||
page_number = request.GET.get("page", 1)
|
||||
page_obj = paginator.get_page(page_number)
|
||||
|
||||
# Get filter options
|
||||
hospitals = Hospital.objects.filter(status='active')
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
hospitals = hospitals.filter(id=user.hospital.id)
|
||||
|
||||
departments = Department.objects.filter(status='active')
|
||||
departments = Department.objects.filter(status="active")
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
departments = departments.filter(hospital=user.hospital)
|
||||
|
||||
@ -164,31 +159,30 @@ def feedback_list(request):
|
||||
|
||||
# Statistics
|
||||
stats = {
|
||||
'total': queryset.count(),
|
||||
'submitted': queryset.filter(status=FeedbackStatus.SUBMITTED).count(),
|
||||
'reviewed': queryset.filter(status=FeedbackStatus.REVIEWED).count(),
|
||||
'acknowledged': queryset.filter(status=FeedbackStatus.ACKNOWLEDGED).count(),
|
||||
'compliments': queryset.filter(feedback_type=FeedbackType.COMPLIMENT).count(),
|
||||
'suggestions': queryset.filter(feedback_type=FeedbackType.SUGGESTION).count(),
|
||||
'avg_rating': queryset.aggregate(Avg('rating'))['rating__avg'] or 0,
|
||||
'positive': queryset.filter(sentiment='positive').count(),
|
||||
'negative': queryset.filter(sentiment='negative').count(),
|
||||
"total": queryset.count(),
|
||||
"submitted": queryset.filter(status=FeedbackStatus.SUBMITTED).count(),
|
||||
"reviewed": queryset.filter(status=FeedbackStatus.REVIEWED).count(),
|
||||
"acknowledged": queryset.filter(status=FeedbackStatus.ACKNOWLEDGED).count(),
|
||||
"compliments": queryset.filter(feedback_type=FeedbackType.COMPLIMENT).count(),
|
||||
"suggestions": queryset.filter(feedback_type=FeedbackType.SUGGESTION).count(),
|
||||
"avg_rating": queryset.aggregate(Avg("rating"))["rating__avg"] or 0,
|
||||
"positive": queryset.filter(sentiment="positive").count(),
|
||||
"negative": queryset.filter(sentiment="negative").count(),
|
||||
}
|
||||
|
||||
context = {
|
||||
'page_obj': page_obj,
|
||||
'feedbacks': page_obj.object_list,
|
||||
'stats': stats,
|
||||
'hospitals': hospitals,
|
||||
'departments': departments,
|
||||
'assignable_users': assignable_users,
|
||||
'status_choices': FeedbackStatus.choices,
|
||||
'type_choices': FeedbackType.choices,
|
||||
'category_choices': FeedbackCategory.choices,
|
||||
'filters': request.GET,
|
||||
"page_obj": page_obj,
|
||||
"feedbacks": page_obj.object_list,
|
||||
"stats": stats,
|
||||
"departments": departments,
|
||||
"assignable_users": assignable_users,
|
||||
"status_choices": FeedbackStatus.choices,
|
||||
"type_choices": FeedbackType.choices,
|
||||
"category_choices": FeedbackCategory.choices,
|
||||
"filters": request.GET,
|
||||
}
|
||||
|
||||
return render(request, 'feedback/feedback_list.html', context)
|
||||
return render(request, "feedback/feedback_list.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
@ -204,14 +198,10 @@ def feedback_detail(request, pk):
|
||||
"""
|
||||
feedback = get_object_or_404(
|
||||
Feedback.objects.select_related(
|
||||
'patient', 'hospital', 'department', 'staff',
|
||||
'assigned_to', 'reviewed_by', 'acknowledged_by', 'closed_by'
|
||||
).prefetch_related(
|
||||
'attachments',
|
||||
'responses__created_by'
|
||||
),
|
||||
"patient", "hospital", "department", "staff", "assigned_to", "reviewed_by", "acknowledged_by", "closed_by"
|
||||
).prefetch_related("attachments", "responses__created_by"),
|
||||
pk=pk,
|
||||
is_deleted=False
|
||||
is_deleted=False,
|
||||
)
|
||||
|
||||
# Check access
|
||||
@ -219,19 +209,19 @@ def feedback_detail(request, pk):
|
||||
if not user.is_px_admin():
|
||||
if user.is_hospital_admin() and feedback.hospital != user.hospital:
|
||||
messages.error(request, "You don't have permission to view this feedback.")
|
||||
return redirect('feedback:feedback_list')
|
||||
return redirect("feedback:feedback_list")
|
||||
elif user.is_department_manager() and feedback.department != user.department:
|
||||
messages.error(request, "You don't have permission to view this feedback.")
|
||||
return redirect('feedback:feedback_list')
|
||||
return redirect("feedback:feedback_list")
|
||||
elif user.hospital and feedback.hospital != user.hospital:
|
||||
messages.error(request, "You don't have permission to view this feedback.")
|
||||
return redirect('feedback:feedback_list')
|
||||
return redirect("feedback:feedback_list")
|
||||
|
||||
# Get timeline (responses)
|
||||
timeline = feedback.responses.all().order_by('-created_at')
|
||||
timeline = feedback.responses.all().order_by("-created_at")
|
||||
|
||||
# Get attachments
|
||||
attachments = feedback.attachments.all().order_by('-created_at')
|
||||
attachments = feedback.attachments.all().order_by("-created_at")
|
||||
|
||||
# Get assignable users
|
||||
assignable_users = User.objects.filter(is_active=True)
|
||||
@ -239,77 +229,77 @@ def feedback_detail(request, pk):
|
||||
assignable_users = assignable_users.filter(hospital=feedback.hospital)
|
||||
|
||||
context = {
|
||||
'feedback': feedback,
|
||||
'timeline': timeline,
|
||||
'attachments': attachments,
|
||||
'assignable_users': assignable_users,
|
||||
'status_choices': FeedbackStatus.choices,
|
||||
'can_edit': user.is_px_admin() or user.is_hospital_admin(),
|
||||
"feedback": feedback,
|
||||
"timeline": timeline,
|
||||
"attachments": attachments,
|
||||
"assignable_users": assignable_users,
|
||||
"status_choices": FeedbackStatus.choices,
|
||||
"can_edit": user.is_px_admin() or user.is_hospital_admin(),
|
||||
}
|
||||
|
||||
return render(request, 'feedback/feedback_detail.html', context)
|
||||
return render(request, "feedback/feedback_detail.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def feedback_create(request):
|
||||
"""Create new feedback"""
|
||||
if request.method == 'POST':
|
||||
form = FeedbackForm(request.POST, user=request.user)
|
||||
if request.method == "POST":
|
||||
form = FeedbackForm(request.POST, request=request)
|
||||
if form.is_valid():
|
||||
try:
|
||||
feedback = form.save(commit=False)
|
||||
|
||||
# Set default sentiment if not set
|
||||
if not feedback.sentiment:
|
||||
feedback.sentiment = 'neutral'
|
||||
feedback.sentiment = "neutral"
|
||||
|
||||
feedback.save()
|
||||
|
||||
# Create initial response
|
||||
FeedbackResponse.objects.create(
|
||||
feedback=feedback,
|
||||
response_type='note',
|
||||
response_type="note",
|
||||
message=f"Feedback submitted by {request.user.get_full_name()}",
|
||||
created_by=request.user,
|
||||
is_internal=True
|
||||
is_internal=True,
|
||||
)
|
||||
|
||||
# Log audit
|
||||
AuditService.log_event(
|
||||
event_type='feedback_created',
|
||||
event_type="feedback_created",
|
||||
description=f"Feedback created: {feedback.title}",
|
||||
user=request.user,
|
||||
content_object=feedback,
|
||||
metadata={
|
||||
'feedback_type': feedback.feedback_type,
|
||||
'category': feedback.category,
|
||||
'rating': feedback.rating
|
||||
}
|
||||
"feedback_type": feedback.feedback_type,
|
||||
"category": feedback.category,
|
||||
"rating": feedback.rating,
|
||||
},
|
||||
)
|
||||
|
||||
messages.success(request, f"Feedback #{feedback.id} created successfully.")
|
||||
return redirect('feedback:feedback_detail', pk=feedback.id)
|
||||
return redirect("feedback:feedback_detail", pk=feedback.id)
|
||||
|
||||
except Exception as e:
|
||||
messages.error(request, f"Error creating feedback: {str(e)}")
|
||||
else:
|
||||
messages.error(request, "Please correct the errors below.")
|
||||
else:
|
||||
form = FeedbackForm(user=request.user)
|
||||
form = FeedbackForm(request=request)
|
||||
|
||||
# Get patients for selection
|
||||
patients = Patient.objects.filter(status='active')
|
||||
patients = Patient.objects.filter(status="active")
|
||||
if request.user.hospital:
|
||||
patients = patients.filter(primary_hospital=request.user.hospital)
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
'patients': patients,
|
||||
'is_create': True,
|
||||
"form": form,
|
||||
"patients": patients,
|
||||
"is_create": True,
|
||||
}
|
||||
|
||||
return render(request, 'feedback/feedback_form.html', context)
|
||||
return render(request, "feedback/feedback_form.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
@ -322,10 +312,10 @@ def feedback_update(request, pk):
|
||||
user = request.user
|
||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||
messages.error(request, "You don't have permission to edit feedback.")
|
||||
return redirect('feedback:feedback_detail', pk=pk)
|
||||
return redirect("feedback:feedback_detail", pk=pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = FeedbackForm(request.POST, instance=feedback, user=request.user)
|
||||
if request.method == "POST":
|
||||
form = FeedbackForm(request.POST, instance=feedback, request=request)
|
||||
if form.is_valid():
|
||||
try:
|
||||
feedback = form.save()
|
||||
@ -333,43 +323,43 @@ def feedback_update(request, pk):
|
||||
# Create update response
|
||||
FeedbackResponse.objects.create(
|
||||
feedback=feedback,
|
||||
response_type='note',
|
||||
response_type="note",
|
||||
message=f"Feedback updated by {request.user.get_full_name()}",
|
||||
created_by=request.user,
|
||||
is_internal=True
|
||||
is_internal=True,
|
||||
)
|
||||
|
||||
# Log audit
|
||||
AuditService.log_event(
|
||||
event_type='feedback_updated',
|
||||
event_type="feedback_updated",
|
||||
description=f"Feedback updated: {feedback.title}",
|
||||
user=request.user,
|
||||
content_object=feedback
|
||||
content_object=feedback,
|
||||
)
|
||||
|
||||
messages.success(request, "Feedback updated successfully.")
|
||||
return redirect('feedback:feedback_detail', pk=feedback.id)
|
||||
return redirect("feedback:feedback_detail", pk=feedback.id)
|
||||
|
||||
except Exception as e:
|
||||
messages.error(request, f"Error updating feedback: {str(e)}")
|
||||
else:
|
||||
messages.error(request, "Please correct the errors below.")
|
||||
else:
|
||||
form = FeedbackForm(instance=feedback, user=request.user)
|
||||
form = FeedbackForm(instance=feedback, request=request)
|
||||
|
||||
# Get patients for selection
|
||||
patients = Patient.objects.filter(status='active')
|
||||
patients = Patient.objects.filter(status="active")
|
||||
if request.user.hospital:
|
||||
patients = patients.filter(primary_hospital=request.user.hospital)
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
'feedback': feedback,
|
||||
'patients': patients,
|
||||
'is_create': False,
|
||||
"form": form,
|
||||
"feedback": feedback,
|
||||
"patients": patients,
|
||||
"is_create": False,
|
||||
}
|
||||
|
||||
return render(request, 'feedback/feedback_form.html', context)
|
||||
return render(request, "feedback/feedback_form.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
@ -382,32 +372,32 @@ def feedback_delete(request, pk):
|
||||
user = request.user
|
||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||
messages.error(request, "You don't have permission to delete feedback.")
|
||||
return redirect('feedback:feedback_detail', pk=pk)
|
||||
return redirect("feedback:feedback_detail", pk=pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
if request.method == "POST":
|
||||
try:
|
||||
feedback.soft_delete(user=request.user)
|
||||
|
||||
# Log audit
|
||||
AuditService.log_event(
|
||||
event_type='feedback_deleted',
|
||||
event_type="feedback_deleted",
|
||||
description=f"Feedback deleted: {feedback.title}",
|
||||
user=request.user,
|
||||
content_object=feedback
|
||||
content_object=feedback,
|
||||
)
|
||||
|
||||
messages.success(request, "Feedback deleted successfully.")
|
||||
return redirect('feedback:feedback_list')
|
||||
return redirect("feedback:feedback_list")
|
||||
|
||||
except Exception as e:
|
||||
messages.error(request, f"Error deleting feedback: {str(e)}")
|
||||
return redirect('feedback:feedback_detail', pk=pk)
|
||||
return redirect("feedback:feedback_detail", pk=pk)
|
||||
|
||||
context = {
|
||||
'feedback': feedback,
|
||||
"feedback": feedback,
|
||||
}
|
||||
|
||||
return render(request, 'feedback/feedback_delete_confirm.html', context)
|
||||
return render(request, "feedback/feedback_delete_confirm.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
@ -420,20 +410,20 @@ def feedback_assign(request, pk):
|
||||
user = request.user
|
||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||
messages.error(request, "You don't have permission to assign feedback.")
|
||||
return redirect('feedback:feedback_detail', pk=pk)
|
||||
return redirect("feedback:feedback_detail", pk=pk)
|
||||
|
||||
user_id = request.POST.get('user_id')
|
||||
note = request.POST.get('note', '')
|
||||
user_id = request.POST.get("user_id")
|
||||
note = request.POST.get("note", "")
|
||||
|
||||
if not user_id:
|
||||
messages.error(request, "Please select a user to assign.")
|
||||
return redirect('feedback:feedback_detail', pk=pk)
|
||||
return redirect("feedback:feedback_detail", pk=pk)
|
||||
|
||||
try:
|
||||
assignee = User.objects.get(id=user_id)
|
||||
feedback.assigned_to = assignee
|
||||
feedback.assigned_at = timezone.now()
|
||||
feedback.save(update_fields=['assigned_to', 'assigned_at'])
|
||||
feedback.save(update_fields=["assigned_to", "assigned_at"])
|
||||
|
||||
# Create response
|
||||
message = f"Assigned to {assignee.get_full_name()}"
|
||||
@ -441,19 +431,15 @@ def feedback_assign(request, pk):
|
||||
message += f"\nNote: {note}"
|
||||
|
||||
FeedbackResponse.objects.create(
|
||||
feedback=feedback,
|
||||
response_type='assignment',
|
||||
message=message,
|
||||
created_by=request.user,
|
||||
is_internal=True
|
||||
feedback=feedback, response_type="assignment", message=message, created_by=request.user, is_internal=True
|
||||
)
|
||||
|
||||
# Log audit
|
||||
AuditService.log_event(
|
||||
event_type='assignment',
|
||||
event_type="assignment",
|
||||
description=f"Feedback assigned to {assignee.get_full_name()}",
|
||||
user=request.user,
|
||||
content_object=feedback
|
||||
content_object=feedback,
|
||||
)
|
||||
|
||||
messages.success(request, f"Feedback assigned to {assignee.get_full_name()}.")
|
||||
@ -461,7 +447,7 @@ def feedback_assign(request, pk):
|
||||
except User.DoesNotExist:
|
||||
messages.error(request, "User not found.")
|
||||
|
||||
return redirect('feedback:feedback_detail', pk=pk)
|
||||
return redirect("feedback:feedback_detail", pk=pk)
|
||||
|
||||
|
||||
@login_required
|
||||
@ -474,14 +460,14 @@ def feedback_change_status(request, pk):
|
||||
user = request.user
|
||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||
messages.error(request, "You don't have permission to change feedback status.")
|
||||
return redirect('feedback:feedback_detail', pk=pk)
|
||||
return redirect("feedback:feedback_detail", pk=pk)
|
||||
|
||||
new_status = request.POST.get('status')
|
||||
note = request.POST.get('note', '')
|
||||
new_status = request.POST.get("status")
|
||||
note = request.POST.get("note", "")
|
||||
|
||||
if not new_status:
|
||||
messages.error(request, "Please select a status.")
|
||||
return redirect('feedback:feedback_detail', pk=pk)
|
||||
return redirect("feedback:feedback_detail", pk=pk)
|
||||
|
||||
old_status = feedback.status
|
||||
feedback.status = new_status
|
||||
@ -504,25 +490,25 @@ def feedback_change_status(request, pk):
|
||||
|
||||
FeedbackResponse.objects.create(
|
||||
feedback=feedback,
|
||||
response_type='status_change',
|
||||
response_type="status_change",
|
||||
message=message,
|
||||
created_by=request.user,
|
||||
old_status=old_status,
|
||||
new_status=new_status,
|
||||
is_internal=True
|
||||
is_internal=True,
|
||||
)
|
||||
|
||||
# Log audit
|
||||
AuditService.log_event(
|
||||
event_type='status_change',
|
||||
event_type="status_change",
|
||||
description=f"Feedback status changed from {old_status} to {new_status}",
|
||||
user=request.user,
|
||||
content_object=feedback,
|
||||
metadata={'old_status': old_status, 'new_status': new_status}
|
||||
metadata={"old_status": old_status, "new_status": new_status},
|
||||
)
|
||||
|
||||
messages.success(request, f"Feedback status changed to {new_status}.")
|
||||
return redirect('feedback:feedback_detail', pk=pk)
|
||||
return redirect("feedback:feedback_detail", pk=pk)
|
||||
|
||||
|
||||
@login_required
|
||||
@ -531,13 +517,13 @@ def feedback_add_response(request, pk):
|
||||
"""Add response to feedback"""
|
||||
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
|
||||
|
||||
response_type = request.POST.get('response_type', 'response')
|
||||
message = request.POST.get('message')
|
||||
is_internal = request.POST.get('is_internal') == 'on'
|
||||
response_type = request.POST.get("response_type", "response")
|
||||
message = request.POST.get("message")
|
||||
is_internal = request.POST.get("is_internal") == "on"
|
||||
|
||||
if not message:
|
||||
messages.error(request, "Please enter a response message.")
|
||||
return redirect('feedback:feedback_detail', pk=pk)
|
||||
return redirect("feedback:feedback_detail", pk=pk)
|
||||
|
||||
# Create response
|
||||
FeedbackResponse.objects.create(
|
||||
@ -545,11 +531,11 @@ def feedback_add_response(request, pk):
|
||||
response_type=response_type,
|
||||
message=message,
|
||||
created_by=request.user,
|
||||
is_internal=is_internal
|
||||
is_internal=is_internal,
|
||||
)
|
||||
|
||||
messages.success(request, "Response added successfully.")
|
||||
return redirect('feedback:feedback_detail', pk=pk)
|
||||
return redirect("feedback:feedback_detail", pk=pk)
|
||||
|
||||
|
||||
@login_required
|
||||
@ -562,15 +548,15 @@ def feedback_toggle_featured(request, pk):
|
||||
user = request.user
|
||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||
messages.error(request, "You don't have permission to feature feedback.")
|
||||
return redirect('feedback:feedback_detail', pk=pk)
|
||||
return redirect("feedback:feedback_detail", pk=pk)
|
||||
|
||||
feedback.is_featured = not feedback.is_featured
|
||||
feedback.save(update_fields=['is_featured'])
|
||||
feedback.save(update_fields=["is_featured"])
|
||||
|
||||
status = "featured" if feedback.is_featured else "unfeatured"
|
||||
messages.success(request, f"Feedback {status} successfully.")
|
||||
|
||||
return redirect('feedback:feedback_detail', pk=pk)
|
||||
return redirect("feedback:feedback_detail", pk=pk)
|
||||
|
||||
|
||||
@login_required
|
||||
@ -583,12 +569,12 @@ def feedback_toggle_follow_up(request, pk):
|
||||
user = request.user
|
||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||
messages.error(request, "You don't have permission to modify feedback.")
|
||||
return redirect('feedback:feedback_detail', pk=pk)
|
||||
return redirect("feedback:feedback_detail", pk=pk)
|
||||
|
||||
feedback.requires_follow_up = not feedback.requires_follow_up
|
||||
feedback.save(update_fields=['requires_follow_up'])
|
||||
feedback.save(update_fields=["requires_follow_up"])
|
||||
|
||||
status = "marked for follow-up" if feedback.requires_follow_up else "unmarked for follow-up"
|
||||
messages.success(request, f"Feedback {status} successfully.")
|
||||
|
||||
return redirect('feedback:feedback_detail', pk=pk)
|
||||
return redirect("feedback:feedback_detail", pk=pk)
|
||||
|
||||
@ -257,7 +257,13 @@ class SurveyTemplateMapping(UUIDModel, TimeStampedModel):
|
||||
db_index=True,
|
||||
help_text="Whether this mapping is active"
|
||||
)
|
||||
|
||||
|
||||
# Delay configuration
|
||||
send_delay_hours = models.IntegerField(
|
||||
default=1,
|
||||
help_text="Hours after discharge to send survey"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['hospital', 'patient_type']
|
||||
indexes = [
|
||||
|
||||
@ -7,11 +7,13 @@ internal format for sending surveys based on PatientType.
|
||||
Simplified Flow:
|
||||
1. Parse HIS patient data
|
||||
2. Determine survey type from PatientType
|
||||
3. Create survey instance
|
||||
4. Send survey via SMS
|
||||
3. Create survey instance with PENDING status
|
||||
4. Queue delayed send task
|
||||
5. Survey sent after delay (e.g., 1 hour for OPD)
|
||||
"""
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Optional, Tuple
|
||||
import logging
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
@ -19,6 +21,8 @@ from apps.organizations.models import Hospital, Patient
|
||||
from apps.surveys.models import SurveyTemplate, SurveyInstance, SurveyStatus
|
||||
from apps.integrations.models import InboundEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HISAdapter:
|
||||
"""
|
||||
@ -172,25 +176,73 @@ class HISAdapter:
|
||||
def get_survey_template(patient_type: str, hospital: Hospital) -> Optional[SurveyTemplate]:
|
||||
"""
|
||||
Get appropriate survey template based on PatientType using explicit mapping.
|
||||
|
||||
|
||||
Uses SurveyTemplateMapping to determine which template to send.
|
||||
|
||||
|
||||
Args:
|
||||
patient_type: HIS PatientType code (1, 2, 3, 4, O, E, APPOINTMENT)
|
||||
hospital: Hospital instance
|
||||
|
||||
|
||||
Returns:
|
||||
SurveyTemplate or None if not found
|
||||
"""
|
||||
from apps.integrations.models import SurveyTemplateMapping
|
||||
|
||||
|
||||
# Use explicit mapping to get template
|
||||
survey_template = SurveyTemplateMapping.get_template_for_patient_type(
|
||||
patient_type, hospital
|
||||
)
|
||||
|
||||
|
||||
return survey_template
|
||||
|
||||
|
||||
@staticmethod
|
||||
def get_delay_for_patient_type(patient_type: str, hospital) -> int:
|
||||
"""
|
||||
Get delay hours from SurveyTemplateMapping.
|
||||
|
||||
Falls back to default delays if no mapping found.
|
||||
|
||||
Args:
|
||||
patient_type: HIS PatientType code (1, 2, 3, 4, O, E)
|
||||
hospital: Hospital instance
|
||||
|
||||
Returns:
|
||||
Delay in hours
|
||||
"""
|
||||
from apps.integrations.models import SurveyTemplateMapping
|
||||
|
||||
# Try to get mapping with delay (hospital-specific)
|
||||
mapping = SurveyTemplateMapping.objects.filter(
|
||||
patient_type=patient_type,
|
||||
hospital=hospital,
|
||||
is_active=True
|
||||
).first()
|
||||
|
||||
if mapping and mapping.send_delay_hours:
|
||||
return mapping.send_delay_hours
|
||||
|
||||
# Fallback to global mapping
|
||||
mapping = SurveyTemplateMapping.objects.filter(
|
||||
patient_type=patient_type,
|
||||
hospital__isnull=True,
|
||||
is_active=True
|
||||
).first()
|
||||
|
||||
if mapping and mapping.send_delay_hours:
|
||||
return mapping.send_delay_hours
|
||||
|
||||
# Default delays by patient type
|
||||
default_delays = {
|
||||
'1': 24, # Inpatient - 24 hours
|
||||
'2': 1, # OPD - 1 hour
|
||||
'3': 2, # EMS - 2 hours
|
||||
'O': 1, # OPD - 1 hour
|
||||
'E': 2, # EMS - 2 hours
|
||||
'4': 4, # Daycase - 4 hours
|
||||
}
|
||||
|
||||
return default_delays.get(patient_type, 1) # Default 1 hour
|
||||
|
||||
@staticmethod
|
||||
def create_and_send_survey(
|
||||
patient: Patient,
|
||||
@ -199,20 +251,24 @@ class HISAdapter:
|
||||
survey_template: SurveyTemplate
|
||||
) -> Optional[SurveyInstance]:
|
||||
"""
|
||||
Create survey instance and send via SMS.
|
||||
Create survey instance and queue for delayed sending.
|
||||
|
||||
NEW: Survey is created with PENDING status and sent after delay.
|
||||
|
||||
Args:
|
||||
patient: Patient instance
|
||||
hospital: Hospital instance
|
||||
patient_data: HIS patient data
|
||||
survey_template: SurveyTemplate instance
|
||||
|
||||
|
||||
Returns:
|
||||
SurveyInstance or None if failed
|
||||
"""
|
||||
from apps.surveys.tasks import send_scheduled_survey
|
||||
|
||||
admission_id = patient_data.get("AdmissionID")
|
||||
discharge_date_str = patient_data.get("DischargeDate")
|
||||
discharge_date = HISAdapter.parse_date(discharge_date_str) if discharge_date_str else None
|
||||
patient_type = patient_data.get("PatientType")
|
||||
|
||||
# Check if survey already sent for this admission
|
||||
existing_survey = SurveyInstance.objects.filter(
|
||||
@ -222,58 +278,65 @@ class HISAdapter:
|
||||
).first()
|
||||
|
||||
if existing_survey:
|
||||
logger.info(f"Survey already exists for admission {admission_id}")
|
||||
return existing_survey
|
||||
|
||||
# Create survey instance
|
||||
# Get delay from SurveyTemplateMapping
|
||||
delay_hours = HISAdapter.get_delay_for_patient_type(patient_type, hospital)
|
||||
|
||||
# Calculate scheduled send time
|
||||
scheduled_send_at = timezone.now() + timedelta(hours=delay_hours)
|
||||
|
||||
# Create survey with PENDING status (NOT SENT)
|
||||
survey = SurveyInstance.objects.create(
|
||||
survey_template=survey_template,
|
||||
patient=patient,
|
||||
hospital=hospital,
|
||||
status=SurveyStatus.SENT, # Set to SENT as it will be sent immediately
|
||||
delivery_channel="SMS", # Send via SMS
|
||||
status=SurveyStatus.PENDING, # Changed from SENT
|
||||
delivery_channel="SMS",
|
||||
recipient_phone=patient.phone,
|
||||
recipient_email=patient.email,
|
||||
scheduled_send_at=scheduled_send_at,
|
||||
metadata={
|
||||
'admission_id': admission_id,
|
||||
'patient_type': patient_data.get("PatientType"),
|
||||
'patient_type': patient_type,
|
||||
'hospital_id': patient_data.get("HospitalID"),
|
||||
'insurance_company': patient_data.get("InsuranceCompanyName"),
|
||||
'is_vip': patient_data.get("IsVIP") == "1"
|
||||
'is_vip': patient_data.get("IsVIP") == "1",
|
||||
'discharge_date': discharge_date_str,
|
||||
'scheduled_send_at': scheduled_send_at.isoformat(),
|
||||
'delay_hours': delay_hours,
|
||||
}
|
||||
)
|
||||
|
||||
# Send survey via SMS
|
||||
try:
|
||||
from apps.surveys.services import SurveyDeliveryService
|
||||
delivery_success = SurveyDeliveryService.deliver_survey(survey)
|
||||
|
||||
if delivery_success:
|
||||
return survey
|
||||
else:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f"Survey created but SMS delivery failed for survey {survey.id}")
|
||||
return survey
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error sending survey SMS: {str(e)}", exc_info=True)
|
||||
return survey
|
||||
# Queue delayed send task
|
||||
send_scheduled_survey.apply_async(
|
||||
args=[str(survey.id)],
|
||||
countdown=delay_hours * 3600 # Convert to seconds
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Survey {survey.id} created for {patient_type}, "
|
||||
f"will send in {delay_hours}h at {scheduled_send_at}"
|
||||
)
|
||||
|
||||
return survey
|
||||
|
||||
@staticmethod
|
||||
def process_his_data(his_data: Dict) -> Dict:
|
||||
"""
|
||||
Main method to process HIS patient data and send surveys.
|
||||
|
||||
|
||||
Simplified Flow:
|
||||
1. Extract patient data
|
||||
2. Get or create patient and hospital
|
||||
3. Determine survey type from PatientType
|
||||
4. Create and send survey via SMS
|
||||
|
||||
4. Create survey with PENDING status
|
||||
5. Queue delayed send task
|
||||
|
||||
Args:
|
||||
his_data: HIS data in real format
|
||||
|
||||
|
||||
Returns:
|
||||
Dict with processing results
|
||||
"""
|
||||
@ -282,77 +345,76 @@ class HISAdapter:
|
||||
'message': '',
|
||||
'patient': None,
|
||||
'survey': None,
|
||||
'survey_sent': False
|
||||
'survey_queued': False
|
||||
}
|
||||
|
||||
|
||||
try:
|
||||
# Extract patient data
|
||||
patient_list = his_data.get("FetchPatientDataTimeStampList", [])
|
||||
|
||||
|
||||
if not patient_list:
|
||||
result['message'] = "No patient data found"
|
||||
return result
|
||||
|
||||
|
||||
patient_data = patient_list[0]
|
||||
|
||||
|
||||
# Validate status
|
||||
if his_data.get("Code") != 200 or his_data.get("Status") != "Success":
|
||||
result['message'] = f"HIS Error: {his_data.get('Message', 'Unknown error')}"
|
||||
return result
|
||||
|
||||
|
||||
# Check if patient is discharged (required for ALL patient types)
|
||||
patient_type = patient_data.get("PatientType")
|
||||
discharge_date_str = patient_data.get("DischargeDate")
|
||||
|
||||
|
||||
# All patient types require discharge date
|
||||
if not discharge_date_str:
|
||||
result['message'] = f'Patient type {patient_type} not discharged - no survey sent'
|
||||
result['success'] = True # Not an error, just no action needed
|
||||
return result
|
||||
|
||||
|
||||
# Get or create hospital
|
||||
hospital = HISAdapter.get_or_create_hospital(patient_data)
|
||||
if not hospital:
|
||||
result['message'] = "Could not determine hospital"
|
||||
return result
|
||||
|
||||
|
||||
# Get or create patient
|
||||
patient = HISAdapter.get_or_create_patient(patient_data, hospital)
|
||||
|
||||
|
||||
# Get survey template based on PatientType
|
||||
patient_type = patient_data.get("PatientType")
|
||||
survey_template = HISAdapter.get_survey_template(patient_type, hospital)
|
||||
|
||||
|
||||
if not survey_template:
|
||||
result['message'] = f"No survey template found for patient type '{patient_type}'"
|
||||
return result
|
||||
|
||||
# Create and send survey
|
||||
|
||||
# Create and queue survey (delayed sending)
|
||||
survey = HISAdapter.create_and_send_survey(
|
||||
patient, hospital, patient_data, survey_template
|
||||
)
|
||||
|
||||
|
||||
if survey:
|
||||
from apps.surveys.models import SurveyStatus
|
||||
survey_sent = survey.status == SurveyStatus.SENT
|
||||
# Survey is queued with PENDING status
|
||||
survey_queued = survey.status == SurveyStatus.PENDING
|
||||
else:
|
||||
survey_sent = False
|
||||
|
||||
survey_queued = False
|
||||
|
||||
result.update({
|
||||
'success': True,
|
||||
'message': 'Patient data processed successfully',
|
||||
'patient': patient,
|
||||
'patient_type': patient_type,
|
||||
'survey': survey,
|
||||
'survey_sent': survey_sent,
|
||||
'survey_queued': survey_queued,
|
||||
'scheduled_send_at': survey.scheduled_send_at.isoformat() if survey and survey.scheduled_send_at else None,
|
||||
'survey_url': survey.get_survey_url() if survey else None
|
||||
})
|
||||
|
||||
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error processing HIS data: {str(e)}", exc_info=True)
|
||||
result['message'] = f"Error processing HIS data: {str(e)}"
|
||||
result['success'] = False
|
||||
|
||||
|
||||
return result
|
||||
@ -68,7 +68,7 @@ class HISClient:
|
||||
"""Get API URL from configuration or environment."""
|
||||
if not self.config:
|
||||
# Fallback to environment variable
|
||||
return os.getenv("HIS_API_URL", "")
|
||||
return os.getenv("HIS_API_URL", "https://his.alhammadi.med.sa/SSRCE/API/FetchPatientVisitTimeStamps")
|
||||
return self.config.api_url
|
||||
|
||||
def _get_auth(self) -> Optional[tuple]:
|
||||
|
||||
@ -208,6 +208,127 @@ def process_pending_events():
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@shared_task
|
||||
def test_fetch_his_surveys_from_json():
|
||||
"""
|
||||
TEST TASK - Fetch surveys from local JSON file instead of HIS API.
|
||||
|
||||
This is a clone of fetch_his_surveys for testing purposes.
|
||||
Reads from /home/ismail/projects/HH/data.json
|
||||
|
||||
TODO: Remove this task after testing is complete.
|
||||
|
||||
Returns:
|
||||
dict: Summary of fetched and processed surveys
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from apps.integrations.services.his_adapter import HISAdapter
|
||||
|
||||
logger.info("Starting TEST HIS survey fetch from JSON file")
|
||||
|
||||
result = {
|
||||
"success": False,
|
||||
"patients_fetched": 0,
|
||||
"surveys_created": 0,
|
||||
"surveys_queued": 0,
|
||||
"errors": [],
|
||||
"details": [],
|
||||
}
|
||||
|
||||
try:
|
||||
# Read JSON file
|
||||
json_path = Path("/home/ismail/projects/HH/data.json")
|
||||
if not json_path.exists():
|
||||
error_msg = f"JSON file not found: {json_path}"
|
||||
logger.error(error_msg)
|
||||
result["errors"].append(error_msg)
|
||||
return result
|
||||
|
||||
with open(json_path, 'r') as f:
|
||||
his_data = json.load(f)
|
||||
|
||||
# Extract patient list
|
||||
patient_list = his_data.get("FetchPatientDataTimeStampList", [])
|
||||
|
||||
if not patient_list:
|
||||
logger.warning("No patient data found in JSON file")
|
||||
result["errors"].append("No patient data found")
|
||||
return result
|
||||
|
||||
logger.info(f"Found {len(patient_list)} patients in JSON file")
|
||||
result["patients_fetched"] = len(patient_list)
|
||||
|
||||
# Process each patient
|
||||
for patient_data in patient_list:
|
||||
try:
|
||||
# Wrap in proper format for HISAdapter
|
||||
patient_payload = {
|
||||
"FetchPatientDataTimeStampList": [patient_data],
|
||||
"FetchPatientDataTimeStampVisitDataList": [],
|
||||
"Code": 200,
|
||||
"Status": "Success",
|
||||
}
|
||||
|
||||
# Process using HISAdapter
|
||||
process_result = HISAdapter.process_his_data(patient_payload)
|
||||
|
||||
if process_result["success"]:
|
||||
result["surveys_created"] += 1
|
||||
|
||||
if process_result.get("survey_queued"):
|
||||
result["surveys_queued"] += 1
|
||||
|
||||
# Log survey details
|
||||
survey = process_result.get("survey")
|
||||
if survey:
|
||||
logger.info(
|
||||
f"Survey queued for {patient_data.get('PatientName')}: "
|
||||
f"Type={patient_data.get('PatientType')}, "
|
||||
f"Scheduled={survey.scheduled_send_at}, "
|
||||
f"Delay={process_result.get('metadata', {}).get('delay_hours', 'N/A')}h"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Survey created but not queued for {patient_data.get('PatientName')}"
|
||||
)
|
||||
else:
|
||||
# Not an error - patient may not be discharged
|
||||
if "not discharged" in process_result.get("message", ""):
|
||||
logger.debug(
|
||||
f"Skipping {patient_data.get('PatientName')}: Not discharged"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Failed to process {patient_data.get('PatientName')}: "
|
||||
f"{process_result.get('message', 'Unknown error')}"
|
||||
)
|
||||
result["errors"].append(
|
||||
f"{patient_data.get('PatientName')}: {process_result.get('message')}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error processing patient {patient_data.get('PatientName', 'Unknown')}: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
result["errors"].append(error_msg)
|
||||
|
||||
result["success"] = True
|
||||
|
||||
logger.info(
|
||||
f"TEST HIS survey fetch completed: "
|
||||
f"{result['patients_fetched']} patients, "
|
||||
f"{result['surveys_created']} surveys created, "
|
||||
f"{result['surveys_queued']} surveys queued"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Fatal error in test_fetch_his_surveys_from_json: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
result["errors"].append(error_msg)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@shared_task
|
||||
def fetch_his_surveys():
|
||||
"""
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"""
|
||||
Integrations UI Views
|
||||
"""
|
||||
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
@ -15,92 +16,93 @@ from .serializers import SurveyTemplateMappingSerializer
|
||||
class SurveyTemplateMappingViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for managing survey template mappings.
|
||||
|
||||
|
||||
Provides CRUD operations for mapping patient types to survey templates.
|
||||
"""
|
||||
queryset = SurveyTemplateMapping.objects.select_related(
|
||||
'hospital', 'survey_template'
|
||||
).all()
|
||||
|
||||
queryset = SurveyTemplateMapping.objects.select_related("hospital", "survey_template").all()
|
||||
serializer_class = SurveyTemplateMappingSerializer
|
||||
filterset_fields = ['hospital', 'patient_type', 'is_active']
|
||||
search_fields = ['hospital__name', 'patient_type', 'survey_template__name']
|
||||
ordering_fields = ['hospital', 'patient_type', 'is_active']
|
||||
ordering = ['hospital', 'patient_type', 'is_active']
|
||||
|
||||
filterset_fields = ["hospital", "patient_type", "is_active"]
|
||||
search_fields = ["hospital__name", "patient_type", "survey_template__name"]
|
||||
ordering_fields = ["hospital", "patient_type", "is_active"]
|
||||
ordering = ["hospital", "patient_type", "is_active"]
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Filter mappings by user's accessible hospitals.
|
||||
"""
|
||||
queryset = super().get_queryset()
|
||||
user = self.request.user
|
||||
|
||||
# If user is not superuser, filter by their hospital
|
||||
if not user.is_superuser and user.hospital:
|
||||
queryset = queryset.filter(hospital=user.hospital)
|
||||
elif not user.is_superuser and not user.hospital:
|
||||
# User without hospital assignment - no access
|
||||
queryset = queryset.none()
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
# Superusers see all mappings
|
||||
if user.is_superuser:
|
||||
return queryset
|
||||
|
||||
# PX Admins filter by session hospital
|
||||
if user.is_px_admin():
|
||||
tenant_hospital = getattr(self.request, "tenant_hospital", None)
|
||||
if tenant_hospital:
|
||||
return queryset.filter(hospital=tenant_hospital)
|
||||
# If no session hospital, show all (for management)
|
||||
return queryset
|
||||
|
||||
# Hospital users filter by their assigned hospital
|
||||
if hasattr(user, "hospital") and user.hospital:
|
||||
return queryset.filter(hospital=user.hospital)
|
||||
|
||||
# User without hospital assignment - no access
|
||||
return queryset.none()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Add created_by information"""
|
||||
serializer.save()
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def by_hospital(self, request):
|
||||
"""
|
||||
Get all mappings for a specific hospital.
|
||||
|
||||
|
||||
Query param: hospital_id
|
||||
"""
|
||||
hospital_id = request.query_params.get('hospital_id')
|
||||
|
||||
hospital_id = request.query_params.get("hospital_id")
|
||||
|
||||
if not hospital_id:
|
||||
return Response(
|
||||
{'error': 'hospital_id parameter is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
return Response({"error": "hospital_id parameter is required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
mappings = self.get_queryset().filter(hospital_id=hospital_id)
|
||||
serializer = self.get_serializer(mappings, many=True)
|
||||
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def by_patient_type(self, request):
|
||||
"""
|
||||
Get mapping for a specific patient type and hospital.
|
||||
|
||||
|
||||
Query params: hospital_id, patient_type_code
|
||||
"""
|
||||
hospital_id = request.query_params.get('hospital_id')
|
||||
patient_type_code = request.query_params.get('patient_type_code')
|
||||
|
||||
hospital_id = request.query_params.get("hospital_id")
|
||||
patient_type_code = request.query_params.get("patient_type_code")
|
||||
|
||||
if not hospital_id or not patient_type_code:
|
||||
return Response(
|
||||
{'error': 'hospital_id and patient_type_code parameters are required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
{"error": "hospital_id and patient_type_code parameters are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
mapping = SurveyTemplateMapping.get_template_for_patient_type(
|
||||
patient_type_code, hospital_id
|
||||
)
|
||||
|
||||
|
||||
mapping = SurveyTemplateMapping.get_template_for_patient_type(patient_type_code, hospital_id)
|
||||
|
||||
if not mapping:
|
||||
return Response(
|
||||
{'error': 'No mapping found'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
return Response({"error": "No mapping found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
serializer = self.get_serializer(mapping)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
|
||||
@action(detail=False, methods=["post"])
|
||||
def bulk_create(self, request):
|
||||
"""
|
||||
Create multiple mappings at once.
|
||||
|
||||
|
||||
Expects: {
|
||||
"mappings": [
|
||||
{
|
||||
@ -114,72 +116,92 @@ class SurveyTemplateMappingViewSet(viewsets.ModelViewSet):
|
||||
]
|
||||
}
|
||||
"""
|
||||
mappings_data = request.data.get('mappings', [])
|
||||
|
||||
mappings_data = request.data.get("mappings", [])
|
||||
|
||||
if not mappings_data:
|
||||
return Response(
|
||||
{'error': 'mappings array is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
return Response({"error": "mappings array is required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
created_mappings = []
|
||||
for mapping_data in mappings_data:
|
||||
serializer = self.get_serializer(data=mapping_data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
created_mappings.append(serializer.data)
|
||||
|
||||
|
||||
return Response(
|
||||
{'created': len(created_mappings), 'mappings': created_mappings},
|
||||
status=status.HTTP_201_CREATED
|
||||
{"created": len(created_mappings), "mappings": created_mappings}, status=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
|
||||
def survey_mapping_settings(request):
|
||||
"""
|
||||
Survey mapping settings page.
|
||||
|
||||
|
||||
Allows administrators to configure which survey templates
|
||||
are sent for each patient type at each hospital.
|
||||
"""
|
||||
from apps.organizations.models import Hospital
|
||||
|
||||
|
||||
user = request.user
|
||||
|
||||
|
||||
# Determine user's hospital context
|
||||
user_hospital = None
|
||||
if user.is_px_admin():
|
||||
user_hospital = getattr(request, "tenant_hospital", None)
|
||||
elif user.hospital:
|
||||
user_hospital = user.hospital
|
||||
|
||||
# Get user's accessible hospitals based on role
|
||||
if user.is_superuser:
|
||||
# Superusers can see all hospitals
|
||||
hospitals = Hospital.objects.all()
|
||||
hospitals = Hospital.objects.filter(status="active")
|
||||
elif user.is_px_admin():
|
||||
# PX Admins see all active hospitals for the dropdown
|
||||
# They use session-based hospital selection (request.tenant_hospital)
|
||||
hospitals = Hospital.objects.filter(status="active")
|
||||
elif user.hospital:
|
||||
# Regular users can only see their assigned hospital
|
||||
hospitals = Hospital.objects.filter(id=user.hospital.id)
|
||||
else:
|
||||
# User without hospital assignment - no access
|
||||
hospitals = []
|
||||
|
||||
# Get all mappings
|
||||
hospitals = Hospital.objects.none()
|
||||
|
||||
# Get all mappings based on user role
|
||||
if user.is_superuser:
|
||||
mappings = SurveyTemplateMapping.objects.select_related(
|
||||
'hospital', 'survey_template'
|
||||
).all()
|
||||
# Superusers see all mappings
|
||||
mappings = SurveyTemplateMapping.objects.select_related("hospital", "survey_template").all()
|
||||
elif user.is_px_admin():
|
||||
# PX Admins filter by session hospital
|
||||
if user_hospital:
|
||||
mappings = SurveyTemplateMapping.objects.filter(hospital=user_hospital).select_related(
|
||||
"hospital", "survey_template"
|
||||
)
|
||||
else:
|
||||
# No session hospital - show all (for management)
|
||||
mappings = SurveyTemplateMapping.objects.select_related("hospital", "survey_template").all()
|
||||
else:
|
||||
mappings = SurveyTemplateMapping.objects.filter(
|
||||
hospital__in=hospitals
|
||||
).select_related('hospital', 'survey_template')
|
||||
|
||||
# Regular users see only their hospital's mappings
|
||||
mappings = SurveyTemplateMapping.objects.filter(hospital__in=hospitals).select_related(
|
||||
"hospital", "survey_template"
|
||||
)
|
||||
|
||||
# Group mappings by hospital
|
||||
mappings_by_hospital = {}
|
||||
for mapping in mappings:
|
||||
# Skip mappings with missing hospital (orphaned records)
|
||||
if mapping.hospital is None:
|
||||
continue
|
||||
hospital_name = mapping.hospital.name
|
||||
if hospital_name not in mappings_by_hospital:
|
||||
mappings_by_hospital[hospital_name] = []
|
||||
mappings_by_hospital[hospital_name].append(mapping)
|
||||
|
||||
|
||||
context = {
|
||||
'hospitals': hospitals,
|
||||
'mappings': mappings,
|
||||
'mappings_by_hospital': mappings_by_hospital,
|
||||
'page_title': _('Survey Template Mappings'),
|
||||
"hospitals": hospitals,
|
||||
"mappings": mappings,
|
||||
"mappings_by_hospital": mappings_by_hospital,
|
||||
"page_title": _("Survey Template Mappings"),
|
||||
"user_hospital": user_hospital,
|
||||
}
|
||||
|
||||
return render(request, 'integrations/survey_mapping_settings.html', context)
|
||||
|
||||
return render(request, "integrations/survey_mapping_settings.html", context)
|
||||
|
||||
@ -105,10 +105,6 @@ def journey_instance_list(request):
|
||||
page_number = request.GET.get('page', 1)
|
||||
page_obj = paginator.get_page(page_number)
|
||||
|
||||
# Get filter options
|
||||
hospitals = Hospital.objects.filter(status='active')
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
hospitals = hospitals.filter(id=user.hospital.id)
|
||||
|
||||
departments = Department.objects.filter(status='active')
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
@ -125,7 +121,6 @@ def journey_instance_list(request):
|
||||
'page_obj': page_obj,
|
||||
'journeys': page_obj.object_list,
|
||||
'stats': stats,
|
||||
'hospitals': hospitals,
|
||||
'departments': departments,
|
||||
'filters': request.GET,
|
||||
}
|
||||
@ -221,15 +216,10 @@ def journey_template_list(request):
|
||||
page_number = request.GET.get('page', 1)
|
||||
page_obj = paginator.get_page(page_number)
|
||||
|
||||
# Get filter options
|
||||
hospitals = Hospital.objects.filter(status='active')
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
hospitals = hospitals.filter(id=user.hospital.id)
|
||||
|
||||
context = {
|
||||
'page_obj': page_obj,
|
||||
'templates': page_obj.object_list,
|
||||
'hospitals': hospitals,
|
||||
'filters': request.GET,
|
||||
}
|
||||
|
||||
|
||||
@ -1,171 +1,161 @@
|
||||
"""
|
||||
Organizations forms - Patient, Staff, Department management
|
||||
"""
|
||||
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.organizations.models import Patient, Staff, Department, Hospital
|
||||
from apps.core.form_mixins import HospitalFieldMixin, DepartmentFieldMixin
|
||||
|
||||
|
||||
class PatientForm(forms.ModelForm):
|
||||
"""Form for creating and editing patients"""
|
||||
|
||||
|
||||
class Meta:
|
||||
model = Patient
|
||||
fields = [
|
||||
'mrn', 'first_name', 'last_name', 'first_name_ar', 'last_name_ar',
|
||||
'national_id', 'date_of_birth', 'gender',
|
||||
'phone', 'email', 'address', 'city',
|
||||
'primary_hospital', 'status'
|
||||
"mrn",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"first_name_ar",
|
||||
"last_name_ar",
|
||||
"national_id",
|
||||
"date_of_birth",
|
||||
"gender",
|
||||
"phone",
|
||||
"email",
|
||||
"address",
|
||||
"city",
|
||||
"primary_hospital",
|
||||
"status",
|
||||
]
|
||||
widgets = {
|
||||
'mrn': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'e.g., PTN-20240101-123456'
|
||||
}),
|
||||
'first_name': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'First name in English'
|
||||
}),
|
||||
'last_name': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Last name in English'
|
||||
}),
|
||||
'first_name_ar': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'الاسم الأول',
|
||||
'dir': 'rtl'
|
||||
}),
|
||||
'last_name_ar': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'اسم العائلة',
|
||||
'dir': 'rtl'
|
||||
}),
|
||||
'national_id': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'National ID / Iqama number'
|
||||
}),
|
||||
'date_of_birth': forms.DateInput(attrs={
|
||||
'class': 'form-control',
|
||||
'type': 'date'
|
||||
}),
|
||||
'gender': forms.Select(attrs={
|
||||
'class': 'form-select'
|
||||
}),
|
||||
'phone': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': '+966501234567'
|
||||
}),
|
||||
'email': forms.EmailInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'patient@example.com'
|
||||
}),
|
||||
'address': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 2,
|
||||
'placeholder': 'Street address'
|
||||
}),
|
||||
'city': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'City'
|
||||
}),
|
||||
'primary_hospital': forms.Select(attrs={
|
||||
'class': 'form-select'
|
||||
}),
|
||||
'status': forms.Select(attrs={
|
||||
'class': 'form-select'
|
||||
}),
|
||||
"mrn": forms.TextInput(attrs={"class": "form-control", "placeholder": "e.g., PTN-20240101-123456"}),
|
||||
"first_name": forms.TextInput(attrs={"class": "form-control", "placeholder": "First name in English"}),
|
||||
"last_name": forms.TextInput(attrs={"class": "form-control", "placeholder": "Last name in English"}),
|
||||
"first_name_ar": forms.TextInput(
|
||||
attrs={"class": "form-control", "placeholder": "الاسم الأول", "dir": "rtl"}
|
||||
),
|
||||
"last_name_ar": forms.TextInput(
|
||||
attrs={"class": "form-control", "placeholder": "اسم العائلة", "dir": "rtl"}
|
||||
),
|
||||
"national_id": forms.TextInput(
|
||||
attrs={"class": "form-control", "placeholder": "National ID / Iqama number"}
|
||||
),
|
||||
"date_of_birth": forms.DateInput(attrs={"class": "form-control", "type": "date"}),
|
||||
"gender": forms.Select(attrs={"class": "form-select"}),
|
||||
"phone": forms.TextInput(attrs={"class": "form-control", "placeholder": "+966501234567"}),
|
||||
"email": forms.EmailInput(attrs={"class": "form-control", "placeholder": "patient@example.com"}),
|
||||
"address": forms.Textarea(attrs={"class": "form-control", "rows": 2, "placeholder": "Street address"}),
|
||||
"city": forms.TextInput(attrs={"class": "form-control", "placeholder": "City"}),
|
||||
"primary_hospital": forms.Select(attrs={"class": "form-select"}),
|
||||
"status": forms.Select(attrs={"class": "form-select"}),
|
||||
}
|
||||
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.user = user
|
||||
|
||||
|
||||
# Filter hospital choices based on user permissions
|
||||
if user.hospital and not user.is_px_admin():
|
||||
self.fields['primary_hospital'].queryset = Hospital.objects.filter(
|
||||
id=user.hospital.id,
|
||||
status='active'
|
||||
)
|
||||
self.fields['primary_hospital'].initial = user.hospital
|
||||
self.fields["primary_hospital"].queryset = Hospital.objects.filter(id=user.hospital.id, status="active")
|
||||
self.fields["primary_hospital"].initial = user.hospital
|
||||
else:
|
||||
self.fields['primary_hospital'].queryset = Hospital.objects.filter(status='active')
|
||||
|
||||
self.fields["primary_hospital"].queryset = Hospital.objects.filter(status="active")
|
||||
|
||||
# Make MRN optional for creation (will auto-generate if empty)
|
||||
if not self.instance.pk:
|
||||
self.fields['mrn'].required = False
|
||||
self.fields['mrn'].help_text = _('Leave blank to auto-generate')
|
||||
|
||||
self.fields["mrn"].required = False
|
||||
self.fields["mrn"].help_text = _("Leave blank to auto-generate")
|
||||
|
||||
def clean_mrn(self):
|
||||
"""Validate MRN is unique"""
|
||||
mrn = self.cleaned_data.get('mrn')
|
||||
mrn = self.cleaned_data.get("mrn")
|
||||
if not mrn:
|
||||
return mrn
|
||||
|
||||
|
||||
# Check uniqueness (excluding current instance)
|
||||
queryset = Patient.objects.filter(mrn=mrn)
|
||||
if self.instance.pk:
|
||||
queryset = queryset.exclude(pk=self.instance.pk)
|
||||
|
||||
|
||||
if queryset.exists():
|
||||
raise forms.ValidationError(_('A patient with this MRN already exists.'))
|
||||
|
||||
raise forms.ValidationError(_("A patient with this MRN already exists."))
|
||||
|
||||
return mrn
|
||||
|
||||
|
||||
def clean_phone(self):
|
||||
"""Normalize phone number"""
|
||||
phone = self.cleaned_data.get('phone', '')
|
||||
phone = self.cleaned_data.get("phone", "")
|
||||
if phone:
|
||||
# Remove spaces and dashes
|
||||
phone = phone.replace(' ', '').replace('-', '')
|
||||
phone = phone.replace(" ", "").replace("-", "")
|
||||
return phone
|
||||
|
||||
|
||||
def save(self, commit=True):
|
||||
"""Auto-generate MRN if not provided"""
|
||||
instance = super().save(commit=False)
|
||||
|
||||
|
||||
if not instance.mrn:
|
||||
instance.mrn = Patient.generate_mrn()
|
||||
|
||||
|
||||
if commit:
|
||||
instance.save()
|
||||
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
class StaffForm(forms.ModelForm):
|
||||
class StaffForm(HospitalFieldMixin, DepartmentFieldMixin, forms.ModelForm):
|
||||
"""Form for creating and editing staff"""
|
||||
|
||||
|
||||
class Meta:
|
||||
model = Staff
|
||||
fields = [
|
||||
'employee_id', 'first_name', 'last_name', 'first_name_ar', 'last_name_ar',
|
||||
'name', 'name_ar', 'staff_type', 'job_title', 'job_title_ar',
|
||||
'specialization', 'license_number', 'email', 'phone',
|
||||
'hospital', 'department', 'section_fk', 'subsection_fk',
|
||||
'report_to', 'is_head', 'gender', 'status'
|
||||
"employee_id",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"first_name_ar",
|
||||
"last_name_ar",
|
||||
"name",
|
||||
"name_ar",
|
||||
"staff_type",
|
||||
"job_title",
|
||||
"job_title_ar",
|
||||
"specialization",
|
||||
"license_number",
|
||||
"email",
|
||||
"phone",
|
||||
"hospital",
|
||||
"department",
|
||||
"section_fk",
|
||||
"subsection_fk",
|
||||
"report_to",
|
||||
"is_head",
|
||||
"gender",
|
||||
"status",
|
||||
]
|
||||
widgets = {
|
||||
'employee_id': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'first_name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'last_name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'first_name_ar': forms.TextInput(attrs={'class': 'form-control', 'dir': 'rtl'}),
|
||||
'last_name_ar': forms.TextInput(attrs={'class': 'form-control', 'dir': 'rtl'}),
|
||||
'name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'name_ar': forms.TextInput(attrs={'class': 'form-control', 'dir': 'rtl'}),
|
||||
'staff_type': forms.Select(attrs={'class': 'form-select'}),
|
||||
'job_title': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'job_title_ar': forms.TextInput(attrs={'class': 'form-control', 'dir': 'rtl'}),
|
||||
'specialization': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'license_number': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'email': forms.EmailInput(attrs={'class': 'form-control'}),
|
||||
'phone': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'hospital': forms.Select(attrs={'class': 'form-select'}),
|
||||
'department': forms.Select(attrs={'class': 'form-select'}),
|
||||
'section_fk': forms.Select(attrs={'class': 'form-select'}),
|
||||
'subsection_fk': forms.Select(attrs={'class': 'form-select'}),
|
||||
'report_to': forms.Select(attrs={'class': 'form-select'}),
|
||||
'is_head': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'gender': forms.Select(attrs={'class': 'form-select'}),
|
||||
'status': forms.Select(attrs={'class': 'form-select'}),
|
||||
"employee_id": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"first_name": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"last_name": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"first_name_ar": forms.TextInput(attrs={"class": "form-control", "dir": "rtl"}),
|
||||
"last_name_ar": forms.TextInput(attrs={"class": "form-control", "dir": "rtl"}),
|
||||
"name": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"name_ar": forms.TextInput(attrs={"class": "form-control", "dir": "rtl"}),
|
||||
"staff_type": forms.Select(attrs={"class": "form-select"}),
|
||||
"job_title": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"job_title_ar": forms.TextInput(attrs={"class": "form-control", "dir": "rtl"}),
|
||||
"specialization": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"license_number": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"email": forms.EmailInput(attrs={"class": "form-control"}),
|
||||
"phone": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"hospital": forms.Select(attrs={"class": "form-select"}),
|
||||
"department": forms.Select(attrs={"class": "form-select"}),
|
||||
"section_fk": forms.Select(attrs={"class": "form-select"}),
|
||||
"subsection_fk": forms.Select(attrs={"class": "form-select"}),
|
||||
"report_to": forms.Select(attrs={"class": "form-select"}),
|
||||
"is_head": forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||
"gender": forms.Select(attrs={"class": "form-select"}),
|
||||
"status": forms.Select(attrs={"class": "form-select"}),
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -15,6 +15,7 @@ from .views import (
|
||||
api_staff_hierarchy,
|
||||
api_staff_hierarchy_children,
|
||||
api_subsection_list,
|
||||
ajax_departments,
|
||||
ajax_main_sections,
|
||||
ajax_subsections,
|
||||
)
|
||||
@ -89,7 +90,8 @@ urlpatterns = [
|
||||
# AJAX Routes for cascading dropdowns in complaint form
|
||||
path('ajax/main-sections/', ajax_main_sections, name='ajax_main_sections'),
|
||||
path('ajax/subsections/', ajax_subsections, name='ajax_subsections'),
|
||||
|
||||
path('ajax/departments/', ajax_departments, name='ajax_departments'),
|
||||
|
||||
# Staff Hierarchy API (for D3 visualization)
|
||||
path('api/staff/hierarchy/', api_staff_hierarchy, name='api_staff_hierarchy'),
|
||||
path('api/staff/hierarchy/<uuid:staff_id>/children/', api_staff_hierarchy_children, name='api_staff_hierarchy_children'),
|
||||
|
||||
@ -781,6 +781,27 @@ def ajax_subsections(request):
|
||||
return Response({'subsections': serializer.data})
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([])
|
||||
def ajax_departments(request):
|
||||
"""
|
||||
AJAX endpoint for departments filtered by hospital.
|
||||
Used in complaint form for cascading dropdown.
|
||||
"""
|
||||
hospital_id = request.GET.get('hospital_id')
|
||||
|
||||
if hospital_id:
|
||||
departments = Department.objects.filter(
|
||||
hospital_id=hospital_id,
|
||||
status='active'
|
||||
).order_by('name_en')
|
||||
else:
|
||||
departments = Department.objects.none()
|
||||
|
||||
serializer = DepartmentSerializer(departments, many=True)
|
||||
return Response({'departments': serializer.data})
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def api_staff_hierarchy(request):
|
||||
|
||||
@ -4,6 +4,7 @@ Physician Rating Import Views
|
||||
UI views for manual CSV upload of doctor ratings.
|
||||
Similar to HIS Patient Import flow.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
@ -31,108 +32,108 @@ logger = logging.getLogger(__name__)
|
||||
def doctor_rating_import(request):
|
||||
"""
|
||||
Import doctor ratings from CSV (Doctor Rating Report format).
|
||||
|
||||
|
||||
CSV Format (Doctor Rating Report):
|
||||
- Header rows (rows 1-6 contain metadata)
|
||||
- Column headers in row 7: UHID, Patient Name, Gender, Full Age, Nationality,
|
||||
Mobile No, Patient Type, Admit Date, Discharge Date, Doctor Name, Rating,
|
||||
- Column headers in row 7: UHID, Patient Name, Gender, Full Age, Nationality,
|
||||
Mobile No, Patient Type, Admit Date, Discharge Date, Doctor Name, Rating,
|
||||
Feed Back, Rating Date
|
||||
- Department headers appear as rows with only first column filled
|
||||
- Data rows follow
|
||||
"""
|
||||
user = request.user
|
||||
|
||||
|
||||
# Check permission
|
||||
if not user.is_px_admin() and not user.is_hospital_admin():
|
||||
messages.error(request, "You don't have permission to import doctor ratings.")
|
||||
return redirect('physicians:physician_list')
|
||||
|
||||
return redirect("physicians:physician_list")
|
||||
|
||||
# Session storage for imported ratings
|
||||
session_key = f'doctor_rating_import_{user.id}'
|
||||
|
||||
if request.method == 'POST':
|
||||
form = DoctorRatingImportForm(request.POST, request.FILES, user=user)
|
||||
|
||||
session_key = f"doctor_rating_import_{user.id}"
|
||||
|
||||
if request.method == "POST":
|
||||
form = DoctorRatingImportForm(request.POST, request.FILES, request=request)
|
||||
|
||||
if form.is_valid():
|
||||
try:
|
||||
hospital = form.cleaned_data['hospital']
|
||||
csv_file = form.cleaned_data['csv_file']
|
||||
skip_rows = form.cleaned_data['skip_header_rows']
|
||||
|
||||
hospital = form.cleaned_data["hospital"]
|
||||
csv_file = form.cleaned_data["csv_file"]
|
||||
skip_rows = form.cleaned_data["skip_header_rows"]
|
||||
|
||||
# Parse CSV
|
||||
decoded_file = csv_file.read().decode('utf-8-sig')
|
||||
decoded_file = csv_file.read().decode("utf-8-sig")
|
||||
io_string = io.StringIO(decoded_file)
|
||||
reader = csv.reader(io_string)
|
||||
|
||||
|
||||
# Skip header/metadata rows
|
||||
for _ in range(skip_rows):
|
||||
next(reader, None)
|
||||
|
||||
|
||||
# Read header row
|
||||
header = next(reader, None)
|
||||
if not header:
|
||||
messages.error(request, "CSV file is empty or has no data rows.")
|
||||
return render(request, 'physicians/doctor_rating_import.html', {'form': form})
|
||||
|
||||
return render(request, "physicians/doctor_rating_import.html", {"form": form})
|
||||
|
||||
# Find column indices (handle different possible column names)
|
||||
header = [h.strip().lower() for h in header]
|
||||
col_map = {
|
||||
'uhid': _find_column(header, ['uhid', 'file number', 'file_number', 'mrn', 'patient id']),
|
||||
'patient_name': _find_column(header, ['patient name', 'patient_name', 'name']),
|
||||
'gender': _find_column(header, ['gender', 'sex']),
|
||||
'age': _find_column(header, ['full age', 'age', 'years']),
|
||||
'nationality': _find_column(header, ['nationality', 'country']),
|
||||
'mobile_no': _find_column(header, ['mobile no', 'mobile_no', 'mobile', 'phone', 'contact']),
|
||||
'patient_type': _find_column(header, ['patient type', 'patient_type', 'type', 'visit type']),
|
||||
'admit_date': _find_column(header, ['admit date', 'admit_date', 'admission date', 'visit date']),
|
||||
'discharge_date': _find_column(header, ['discharge date', 'discharge_date']),
|
||||
'doctor_name': _find_column(header, ['doctor name', 'doctor_name', 'physician name', 'physician']),
|
||||
'rating': _find_column(header, ['rating', 'score', 'rate']),
|
||||
'feedback': _find_column(header, ['feed back', 'feedback', 'comments', 'comment']),
|
||||
'rating_date': _find_column(header, ['rating date', 'rating_date', 'date']),
|
||||
'department': _find_column(header, ['department', 'dept', 'specialty']),
|
||||
"uhid": _find_column(header, ["uhid", "file number", "file_number", "mrn", "patient id"]),
|
||||
"patient_name": _find_column(header, ["patient name", "patient_name", "name"]),
|
||||
"gender": _find_column(header, ["gender", "sex"]),
|
||||
"age": _find_column(header, ["full age", "age", "years"]),
|
||||
"nationality": _find_column(header, ["nationality", "country"]),
|
||||
"mobile_no": _find_column(header, ["mobile no", "mobile_no", "mobile", "phone", "contact"]),
|
||||
"patient_type": _find_column(header, ["patient type", "patient_type", "type", "visit type"]),
|
||||
"admit_date": _find_column(header, ["admit date", "admit_date", "admission date", "visit date"]),
|
||||
"discharge_date": _find_column(header, ["discharge date", "discharge_date"]),
|
||||
"doctor_name": _find_column(header, ["doctor name", "doctor_name", "physician name", "physician"]),
|
||||
"rating": _find_column(header, ["rating", "score", "rate"]),
|
||||
"feedback": _find_column(header, ["feed back", "feedback", "comments", "comment"]),
|
||||
"rating_date": _find_column(header, ["rating date", "rating_date", "date"]),
|
||||
"department": _find_column(header, ["department", "dept", "specialty"]),
|
||||
}
|
||||
|
||||
|
||||
# Check required columns
|
||||
if col_map['uhid'] is None:
|
||||
if col_map["uhid"] is None:
|
||||
messages.error(request, "Could not find 'UHID' column in CSV.")
|
||||
return render(request, 'physicians/doctor_rating_import.html', {'form': form})
|
||||
|
||||
if col_map['doctor_name'] is None:
|
||||
return render(request, "physicians/doctor_rating_import.html", {"form": form})
|
||||
|
||||
if col_map["doctor_name"] is None:
|
||||
messages.error(request, "Could not find 'Doctor Name' column in CSV.")
|
||||
return render(request, 'physicians/doctor_rating_import.html', {'form': form})
|
||||
|
||||
if col_map['rating'] is None:
|
||||
return render(request, "physicians/doctor_rating_import.html", {"form": form})
|
||||
|
||||
if col_map["rating"] is None:
|
||||
messages.error(request, "Could not find 'Rating' column in CSV.")
|
||||
return render(request, 'physicians/doctor_rating_import.html', {'form': form})
|
||||
|
||||
return render(request, "physicians/doctor_rating_import.html", {"form": form})
|
||||
|
||||
# Process data rows
|
||||
imported_ratings = []
|
||||
errors = []
|
||||
row_num = skip_rows + 1
|
||||
current_department = ""
|
||||
|
||||
|
||||
for row in reader:
|
||||
row_num += 1
|
||||
if not row or not any(row): # Skip empty rows
|
||||
continue
|
||||
|
||||
|
||||
try:
|
||||
# Check if this is a department header row (only first column has value)
|
||||
if _is_department_header(row, col_map):
|
||||
current_department = row[0].strip()
|
||||
continue
|
||||
|
||||
|
||||
# Extract data
|
||||
uhid = _get_cell(row, col_map['uhid'], '').strip()
|
||||
patient_name = _get_cell(row, col_map['patient_name'], '').strip()
|
||||
doctor_name_raw = _get_cell(row, col_map['doctor_name'], '').strip()
|
||||
rating_str = _get_cell(row, col_map['rating'], '').strip()
|
||||
|
||||
uhid = _get_cell(row, col_map["uhid"], "").strip()
|
||||
patient_name = _get_cell(row, col_map["patient_name"], "").strip()
|
||||
doctor_name_raw = _get_cell(row, col_map["doctor_name"], "").strip()
|
||||
rating_str = _get_cell(row, col_map["rating"], "").strip()
|
||||
|
||||
# Skip if missing required fields
|
||||
if not uhid or not doctor_name_raw:
|
||||
continue
|
||||
|
||||
|
||||
# Validate rating
|
||||
try:
|
||||
rating = int(float(rating_str))
|
||||
@ -142,85 +143,87 @@ def doctor_rating_import(request):
|
||||
except (ValueError, TypeError):
|
||||
errors.append(f"Row {row_num}: Invalid rating format '{rating_str}'")
|
||||
continue
|
||||
|
||||
|
||||
# Extract optional fields
|
||||
gender = _get_cell(row, col_map['gender'], '').strip()
|
||||
age = _get_cell(row, col_map['age'], '').strip()
|
||||
nationality = _get_cell(row, col_map['nationality'], '').strip()
|
||||
mobile_no = _get_cell(row, col_map['mobile_no'], '').strip()
|
||||
patient_type = _get_cell(row, col_map['patient_type'], '').strip()
|
||||
admit_date = _get_cell(row, col_map['admit_date'], '').strip()
|
||||
discharge_date = _get_cell(row, col_map['discharge_date'], '').strip()
|
||||
feedback = _get_cell(row, col_map['feedback'], '').strip()
|
||||
rating_date = _get_cell(row, col_map['rating_date'], '').strip()
|
||||
department = _get_cell(row, col_map['department'], '').strip() or current_department
|
||||
|
||||
gender = _get_cell(row, col_map["gender"], "").strip()
|
||||
age = _get_cell(row, col_map["age"], "").strip()
|
||||
nationality = _get_cell(row, col_map["nationality"], "").strip()
|
||||
mobile_no = _get_cell(row, col_map["mobile_no"], "").strip()
|
||||
patient_type = _get_cell(row, col_map["patient_type"], "").strip()
|
||||
admit_date = _get_cell(row, col_map["admit_date"], "").strip()
|
||||
discharge_date = _get_cell(row, col_map["discharge_date"], "").strip()
|
||||
feedback = _get_cell(row, col_map["feedback"], "").strip()
|
||||
rating_date = _get_cell(row, col_map["rating_date"], "").strip()
|
||||
department = _get_cell(row, col_map["department"], "").strip() or current_department
|
||||
|
||||
# Parse doctor name to extract ID
|
||||
doctor_id, doctor_name_clean = DoctorRatingAdapter.parse_doctor_name(doctor_name_raw)
|
||||
|
||||
imported_ratings.append({
|
||||
'row_num': row_num,
|
||||
'uhid': uhid,
|
||||
'patient_name': patient_name,
|
||||
'doctor_name_raw': doctor_name_raw,
|
||||
'doctor_id': doctor_id,
|
||||
'doctor_name': doctor_name_clean,
|
||||
'rating': rating,
|
||||
'gender': gender,
|
||||
'age': age,
|
||||
'nationality': nationality,
|
||||
'mobile_no': mobile_no,
|
||||
'patient_type': patient_type,
|
||||
'admit_date': admit_date,
|
||||
'discharge_date': discharge_date,
|
||||
'feedback': feedback,
|
||||
'rating_date': rating_date,
|
||||
'department': department,
|
||||
})
|
||||
|
||||
|
||||
imported_ratings.append(
|
||||
{
|
||||
"row_num": row_num,
|
||||
"uhid": uhid,
|
||||
"patient_name": patient_name,
|
||||
"doctor_name_raw": doctor_name_raw,
|
||||
"doctor_id": doctor_id,
|
||||
"doctor_name": doctor_name_clean,
|
||||
"rating": rating,
|
||||
"gender": gender,
|
||||
"age": age,
|
||||
"nationality": nationality,
|
||||
"mobile_no": mobile_no,
|
||||
"patient_type": patient_type,
|
||||
"admit_date": admit_date,
|
||||
"discharge_date": discharge_date,
|
||||
"feedback": feedback,
|
||||
"rating_date": rating_date,
|
||||
"department": department,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"Row {row_num}: {str(e)}")
|
||||
logger.error(f"Error processing row {row_num}: {e}")
|
||||
|
||||
|
||||
# Store in session for review step
|
||||
request.session[session_key] = {
|
||||
'hospital_id': str(hospital.id),
|
||||
'ratings': imported_ratings,
|
||||
'errors': errors,
|
||||
'total_count': len(imported_ratings)
|
||||
"hospital_id": str(hospital.id),
|
||||
"ratings": imported_ratings,
|
||||
"errors": errors,
|
||||
"total_count": len(imported_ratings),
|
||||
}
|
||||
|
||||
|
||||
# Log audit
|
||||
AuditService.log_event(
|
||||
event_type='doctor_rating_csv_import',
|
||||
event_type="doctor_rating_csv_import",
|
||||
description=f"Parsed {len(imported_ratings)} doctor ratings from CSV by {user.get_full_name()}",
|
||||
user=user,
|
||||
metadata={
|
||||
'hospital': hospital.name,
|
||||
'total_count': len(imported_ratings),
|
||||
'error_count': len(errors)
|
||||
}
|
||||
"hospital": hospital.name,
|
||||
"total_count": len(imported_ratings),
|
||||
"error_count": len(errors),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
if imported_ratings:
|
||||
messages.success(
|
||||
request,
|
||||
f"Successfully parsed {len(imported_ratings)} doctor rating records. Please review before importing."
|
||||
f"Successfully parsed {len(imported_ratings)} doctor rating records. Please review before importing.",
|
||||
)
|
||||
return redirect('physicians:doctor_rating_review')
|
||||
return redirect("physicians:doctor_rating_review")
|
||||
else:
|
||||
messages.error(request, "No valid doctor rating records found in CSV.")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing Doctor Rating CSV: {str(e)}", exc_info=True)
|
||||
messages.error(request, f"Error processing CSV: {str(e)}")
|
||||
else:
|
||||
form = DoctorRatingImportForm(user=user)
|
||||
|
||||
form = DoctorRatingImportForm(request=request)
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
"form": form,
|
||||
}
|
||||
return render(request, 'physicians/doctor_rating_import.html', context)
|
||||
return render(request, "physicians/doctor_rating_import.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
@ -229,29 +232,27 @@ def doctor_rating_review(request):
|
||||
Review imported doctor ratings before creating records.
|
||||
"""
|
||||
user = request.user
|
||||
session_key = f'doctor_rating_import_{user.id}'
|
||||
session_key = f"doctor_rating_import_{user.id}"
|
||||
import_data = request.session.get(session_key)
|
||||
|
||||
|
||||
if not import_data:
|
||||
messages.error(request, "No import data found. Please upload CSV first.")
|
||||
return redirect('physicians:doctor_rating_import')
|
||||
|
||||
hospital = get_object_or_404(Hospital, id=import_data['hospital_id'])
|
||||
ratings = import_data['ratings']
|
||||
errors = import_data.get('errors', [])
|
||||
|
||||
return redirect("physicians:doctor_rating_import")
|
||||
|
||||
hospital = get_object_or_404(Hospital, id=import_data["hospital_id"])
|
||||
ratings = import_data["ratings"]
|
||||
errors = import_data.get("errors", [])
|
||||
|
||||
# Check for staff matches
|
||||
for r in ratings:
|
||||
staff = DoctorRatingAdapter.find_staff_by_doctor_id(
|
||||
r['doctor_id'], hospital, r['doctor_name']
|
||||
)
|
||||
r['staff_matched'] = staff is not None
|
||||
r['staff_name'] = staff.get_full_name() if staff else None
|
||||
|
||||
if request.method == 'POST':
|
||||
action = request.POST.get('action')
|
||||
|
||||
if action == 'import':
|
||||
staff = DoctorRatingAdapter.find_staff_by_doctor_id(r["doctor_id"], hospital, r["doctor_name"])
|
||||
r["staff_matched"] = staff is not None
|
||||
r["staff_name"] = staff.get_full_name() if staff else None
|
||||
|
||||
if request.method == "POST":
|
||||
action = request.POST.get("action")
|
||||
|
||||
if action == "import":
|
||||
# Queue bulk import job
|
||||
job = DoctorRatingImportJob.objects.create(
|
||||
name=f"CSV Import - {hospital.name} - {len(ratings)} ratings",
|
||||
@ -260,53 +261,46 @@ def doctor_rating_review(request):
|
||||
created_by=user,
|
||||
hospital=hospital,
|
||||
total_records=len(ratings),
|
||||
raw_data=ratings
|
||||
raw_data=ratings,
|
||||
)
|
||||
|
||||
|
||||
# Queue the background task
|
||||
process_doctor_rating_job.delay(str(job.id))
|
||||
|
||||
|
||||
# Log audit
|
||||
AuditService.log_event(
|
||||
event_type='doctor_rating_import_queued',
|
||||
event_type="doctor_rating_import_queued",
|
||||
description=f"Queued {len(ratings)} doctor ratings for import",
|
||||
user=user,
|
||||
metadata={
|
||||
'job_id': str(job.id),
|
||||
'hospital': hospital.name,
|
||||
'total_records': len(ratings)
|
||||
}
|
||||
metadata={"job_id": str(job.id), "hospital": hospital.name, "total_records": len(ratings)},
|
||||
)
|
||||
|
||||
|
||||
# Clear session
|
||||
del request.session[session_key]
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
f"Import job queued for {len(ratings)} ratings. You can check the status below."
|
||||
)
|
||||
return redirect('physicians:doctor_rating_job_status', job_id=job.id)
|
||||
|
||||
elif action == 'cancel':
|
||||
|
||||
messages.success(request, f"Import job queued for {len(ratings)} ratings. You can check the status below.")
|
||||
return redirect("physicians:doctor_rating_job_status", job_id=job.id)
|
||||
|
||||
elif action == "cancel":
|
||||
del request.session[session_key]
|
||||
messages.info(request, "Import cancelled.")
|
||||
return redirect('physicians:doctor_rating_import')
|
||||
|
||||
return redirect("physicians:doctor_rating_import")
|
||||
|
||||
# Pagination
|
||||
paginator = Paginator(ratings, 50)
|
||||
page_number = request.GET.get('page')
|
||||
page_number = request.GET.get("page")
|
||||
page_obj = paginator.get_page(page_number)
|
||||
|
||||
|
||||
context = {
|
||||
'hospital': hospital,
|
||||
'ratings': ratings,
|
||||
'page_obj': page_obj,
|
||||
'errors': errors,
|
||||
'total_count': len(ratings),
|
||||
'matched_count': sum(1 for r in ratings if r['staff_matched']),
|
||||
'unmatched_count': sum(1 for r in ratings if not r['staff_matched']),
|
||||
"hospital": hospital,
|
||||
"ratings": ratings,
|
||||
"page_obj": page_obj,
|
||||
"errors": errors,
|
||||
"total_count": len(ratings),
|
||||
"matched_count": sum(1 for r in ratings if r["staff_matched"]),
|
||||
"unmatched_count": sum(1 for r in ratings if not r["staff_matched"]),
|
||||
}
|
||||
return render(request, 'physicians/doctor_rating_review.html', context)
|
||||
return render(request, "physicians/doctor_rating_review.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
@ -316,12 +310,12 @@ def doctor_rating_job_status(request, job_id):
|
||||
"""
|
||||
user = request.user
|
||||
job = get_object_or_404(DoctorRatingImportJob, id=job_id)
|
||||
|
||||
|
||||
# Check permission
|
||||
if not user.is_px_admin() and job.hospital != user.hospital:
|
||||
messages.error(request, "You don't have permission to view this job.")
|
||||
return redirect('physicians:physician_list')
|
||||
|
||||
return redirect("physicians:physician_list")
|
||||
|
||||
# Calculate progress circle stroke-dashoffset
|
||||
# Circle circumference is 326.73 (2 * pi * r, where r=52)
|
||||
# When progress is 0%, offset should be 326.73 (empty)
|
||||
@ -329,15 +323,15 @@ def doctor_rating_job_status(request, job_id):
|
||||
circumference = 2 * 3.14159 * 52 # ~326.73
|
||||
progress = job.progress_percentage
|
||||
stroke_dashoffset = circumference * (1 - progress / 100)
|
||||
|
||||
|
||||
context = {
|
||||
'job': job,
|
||||
'progress': progress,
|
||||
'stroke_dashoffset': stroke_dashoffset,
|
||||
'is_complete': job.is_complete,
|
||||
'results': job.results,
|
||||
"job": job,
|
||||
"progress": progress,
|
||||
"stroke_dashoffset": stroke_dashoffset,
|
||||
"is_complete": job.is_complete,
|
||||
"results": job.results,
|
||||
}
|
||||
return render(request, 'physicians/doctor_rating_job_status.html', context)
|
||||
return render(request, "physicians/doctor_rating_job_status.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
@ -346,7 +340,7 @@ def doctor_rating_job_list(request):
|
||||
List all doctor rating import jobs for the user.
|
||||
"""
|
||||
user = request.user
|
||||
|
||||
|
||||
# Filter jobs
|
||||
if user.is_px_admin():
|
||||
jobs = DoctorRatingImportJob.objects.all()
|
||||
@ -354,13 +348,13 @@ def doctor_rating_job_list(request):
|
||||
jobs = DoctorRatingImportJob.objects.filter(hospital=user.hospital)
|
||||
else:
|
||||
jobs = DoctorRatingImportJob.objects.filter(created_by=user)
|
||||
|
||||
jobs = jobs.order_by('-created_at')[:50] # Last 50 jobs
|
||||
|
||||
|
||||
jobs = jobs.order_by("-created_at")[:50] # Last 50 jobs
|
||||
|
||||
context = {
|
||||
'jobs': jobs,
|
||||
"jobs": jobs,
|
||||
}
|
||||
return render(request, 'physicians/doctor_rating_job_list.html', context)
|
||||
return render(request, "physicians/doctor_rating_job_list.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
@ -369,28 +363,26 @@ def individual_ratings_list(request):
|
||||
List individual doctor ratings with filtering.
|
||||
"""
|
||||
user = request.user
|
||||
|
||||
|
||||
# Base queryset
|
||||
queryset = PhysicianIndividualRating.objects.select_related(
|
||||
'hospital', 'staff', 'staff__department'
|
||||
)
|
||||
|
||||
queryset = PhysicianIndividualRating.objects.select_related("hospital", "staff", "staff__department")
|
||||
|
||||
# Apply RBAC
|
||||
if not user.is_px_admin():
|
||||
if user.hospital:
|
||||
queryset = queryset.filter(hospital=user.hospital)
|
||||
else:
|
||||
queryset = queryset.none()
|
||||
|
||||
|
||||
# Filters
|
||||
hospital_id = request.GET.get('hospital')
|
||||
doctor_id = request.GET.get('doctor_id')
|
||||
rating_min = request.GET.get('rating_min')
|
||||
rating_max = request.GET.get('rating_max')
|
||||
date_from = request.GET.get('date_from')
|
||||
date_to = request.GET.get('date_to')
|
||||
source = request.GET.get('source')
|
||||
|
||||
hospital_id = request.GET.get("hospital")
|
||||
doctor_id = request.GET.get("doctor_id")
|
||||
rating_min = request.GET.get("rating_min")
|
||||
rating_max = request.GET.get("rating_max")
|
||||
date_from = request.GET.get("date_from")
|
||||
date_to = request.GET.get("date_to")
|
||||
source = request.GET.get("source")
|
||||
|
||||
if hospital_id:
|
||||
queryset = queryset.filter(hospital_id=hospital_id)
|
||||
if doctor_id:
|
||||
@ -405,43 +397,45 @@ def individual_ratings_list(request):
|
||||
queryset = queryset.filter(rating_date__date__lte=date_to)
|
||||
if source:
|
||||
queryset = queryset.filter(source=source)
|
||||
|
||||
|
||||
# Ordering
|
||||
queryset = queryset.order_by('-rating_date')
|
||||
|
||||
queryset = queryset.order_by("-rating_date")
|
||||
|
||||
# Pagination
|
||||
paginator = Paginator(queryset, 25)
|
||||
page_number = request.GET.get('page')
|
||||
page_number = request.GET.get("page")
|
||||
page_obj = paginator.get_page(page_number)
|
||||
|
||||
|
||||
# Get hospitals for filter
|
||||
from apps.organizations.models import Hospital
|
||||
|
||||
if user.is_px_admin():
|
||||
hospitals = Hospital.objects.filter(status='active')
|
||||
hospitals = Hospital.objects.filter(status="active")
|
||||
else:
|
||||
hospitals = Hospital.objects.filter(id=user.hospital.id) if user.hospital else Hospital.objects.none()
|
||||
|
||||
|
||||
context = {
|
||||
'page_obj': page_obj,
|
||||
'hospitals': hospitals,
|
||||
'sources': PhysicianIndividualRating.RatingSource.choices,
|
||||
'filters': {
|
||||
'hospital': hospital_id,
|
||||
'doctor_id': doctor_id,
|
||||
'rating_min': rating_min,
|
||||
'rating_max': rating_max,
|
||||
'date_from': date_from,
|
||||
'date_to': date_to,
|
||||
'source': source,
|
||||
}
|
||||
"page_obj": page_obj,
|
||||
"hospitals": hospitals,
|
||||
"sources": PhysicianIndividualRating.RatingSource.choices,
|
||||
"filters": {
|
||||
"hospital": hospital_id,
|
||||
"doctor_id": doctor_id,
|
||||
"rating_min": rating_min,
|
||||
"rating_max": rating_max,
|
||||
"date_from": date_from,
|
||||
"date_to": date_to,
|
||||
"source": source,
|
||||
},
|
||||
}
|
||||
return render(request, 'physicians/individual_ratings_list.html', context)
|
||||
return render(request, "physicians/individual_ratings_list.html", context)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _find_column(header, possible_names):
|
||||
"""Find column index by possible names."""
|
||||
for name in possible_names:
|
||||
@ -451,7 +445,7 @@ def _find_column(header, possible_names):
|
||||
return None
|
||||
|
||||
|
||||
def _get_cell(row, index, default=''):
|
||||
def _get_cell(row, index, default=""):
|
||||
"""Safely get cell value."""
|
||||
if index is None or index >= len(row):
|
||||
return default
|
||||
@ -461,29 +455,29 @@ def _get_cell(row, index, default=''):
|
||||
def _is_department_header(row, col_map):
|
||||
"""
|
||||
Check if a row is a department header row.
|
||||
|
||||
|
||||
Department headers typically have:
|
||||
- First column has text (department name)
|
||||
- All other columns are empty
|
||||
"""
|
||||
if not row or not row[0]:
|
||||
return False
|
||||
|
||||
|
||||
# Check if first column has text
|
||||
first_col = row[0].strip()
|
||||
if not first_col:
|
||||
return False
|
||||
|
||||
|
||||
# Check if other important columns are empty
|
||||
# If UHID, Doctor Name, Rating are all empty, it's likely a header
|
||||
uhid = _get_cell(row, col_map.get('uhid'), '').strip()
|
||||
doctor_name = _get_cell(row, col_map.get('doctor_name'), '').strip()
|
||||
rating = _get_cell(row, col_map.get('rating'), '').strip()
|
||||
|
||||
uhid = _get_cell(row, col_map.get("uhid"), "").strip()
|
||||
doctor_name = _get_cell(row, col_map.get("doctor_name"), "").strip()
|
||||
rating = _get_cell(row, col_map.get("rating"), "").strip()
|
||||
|
||||
# If these key fields are empty but first column has text, it's a department header
|
||||
if not uhid and not doctor_name and not rating:
|
||||
return True
|
||||
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@ -491,70 +485,66 @@ def _is_department_header(row, col_map):
|
||||
# AJAX Endpoints
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@login_required
|
||||
def api_job_progress(request, job_id):
|
||||
"""AJAX endpoint to get job progress."""
|
||||
user = request.user
|
||||
job = get_object_or_404(DoctorRatingImportJob, id=job_id)
|
||||
|
||||
|
||||
# Check permission
|
||||
if not user.is_px_admin() and job.hospital != user.hospital:
|
||||
return JsonResponse({'error': 'Permission denied'}, status=403)
|
||||
|
||||
return JsonResponse({
|
||||
'job_id': str(job.id),
|
||||
'status': job.status,
|
||||
'progress_percentage': job.progress_percentage,
|
||||
'processed_count': job.processed_count,
|
||||
'total_records': job.total_records,
|
||||
'success_count': job.success_count,
|
||||
'failed_count': job.failed_count,
|
||||
'is_complete': job.is_complete,
|
||||
})
|
||||
return JsonResponse({"error": "Permission denied"}, status=403)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"job_id": str(job.id),
|
||||
"status": job.status,
|
||||
"progress_percentage": job.progress_percentage,
|
||||
"processed_count": job.processed_count,
|
||||
"total_records": job.total_records,
|
||||
"success_count": job.success_count,
|
||||
"failed_count": job.failed_count,
|
||||
"is_complete": job.is_complete,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def api_match_doctor(request):
|
||||
"""
|
||||
AJAX endpoint to manually match a doctor to a staff record.
|
||||
|
||||
|
||||
POST data:
|
||||
- doctor_id: The doctor ID from the rating
|
||||
- doctor_name: The doctor name
|
||||
- staff_id: The staff ID to match to
|
||||
"""
|
||||
if request.method != 'POST':
|
||||
return JsonResponse({'error': 'POST required'}, status=405)
|
||||
|
||||
if request.method != "POST":
|
||||
return JsonResponse({"error": "POST required"}, status=405)
|
||||
|
||||
user = request.user
|
||||
doctor_id = request.POST.get('doctor_id')
|
||||
doctor_name = request.POST.get('doctor_name')
|
||||
staff_id = request.POST.get('staff_id')
|
||||
|
||||
doctor_id = request.POST.get("doctor_id")
|
||||
doctor_name = request.POST.get("doctor_name")
|
||||
staff_id = request.POST.get("staff_id")
|
||||
|
||||
if not staff_id:
|
||||
return JsonResponse({'error': 'staff_id required'}, status=400)
|
||||
|
||||
return JsonResponse({"error": "staff_id required"}, status=400)
|
||||
|
||||
try:
|
||||
staff = Staff.objects.get(id=staff_id)
|
||||
|
||||
|
||||
# Check permission
|
||||
if not user.is_px_admin() and staff.hospital != user.hospital:
|
||||
return JsonResponse({'error': 'Permission denied'}, status=403)
|
||||
|
||||
return JsonResponse({"error": "Permission denied"}, status=403)
|
||||
|
||||
# Update all unaggregated ratings for this doctor
|
||||
count = PhysicianIndividualRating.objects.filter(
|
||||
doctor_id=doctor_id,
|
||||
is_aggregated=False
|
||||
).update(staff=staff)
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'matched_count': count,
|
||||
'staff_name': staff.get_full_name()
|
||||
})
|
||||
|
||||
count = PhysicianIndividualRating.objects.filter(doctor_id=doctor_id, is_aggregated=False).update(staff=staff)
|
||||
|
||||
return JsonResponse({"success": True, "matched_count": count, "staff_name": staff.get_full_name()})
|
||||
|
||||
except Staff.DoesNotExist:
|
||||
return JsonResponse({'error': 'Staff not found'}, status=404)
|
||||
return JsonResponse({"error": "Staff not found"}, status=404)
|
||||
except Exception as e:
|
||||
logger.error(f"Error matching doctor: {str(e)}", exc_info=True)
|
||||
return JsonResponse({'error': str(e)}, status=500)
|
||||
return JsonResponse({"error": str(e)}, status=500)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -3,7 +3,9 @@ QI Projects Forms
|
||||
|
||||
Forms for creating and managing Quality Improvement projects and tasks.
|
||||
"""
|
||||
|
||||
from django import forms
|
||||
from django.forms import inlineformset_factory
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.core.form_mixins import HospitalFieldMixin
|
||||
@ -16,220 +18,276 @@ from .models import QIProject, QIProjectTask
|
||||
class QIProjectForm(HospitalFieldMixin, forms.ModelForm):
|
||||
"""
|
||||
Form for creating and editing QI Projects.
|
||||
|
||||
|
||||
Hospital field visibility:
|
||||
- PX Admins: See dropdown with all hospitals
|
||||
- Others: Hidden field, auto-set to user's hospital
|
||||
"""
|
||||
|
||||
|
||||
class Meta:
|
||||
model = QIProject
|
||||
fields = [
|
||||
'name', 'name_ar', 'description', 'hospital', 'department',
|
||||
'project_lead', 'team_members', 'status',
|
||||
'start_date', 'target_completion_date', 'outcome_description'
|
||||
"name",
|
||||
"name_ar",
|
||||
"description",
|
||||
"hospital",
|
||||
"department",
|
||||
"project_lead",
|
||||
"team_members",
|
||||
"status",
|
||||
"start_date",
|
||||
"target_completion_date",
|
||||
"outcome_description",
|
||||
]
|
||||
widgets = {
|
||||
'name': forms.TextInput(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
|
||||
'placeholder': _('Project name')
|
||||
}),
|
||||
'name_ar': forms.TextInput(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
|
||||
'placeholder': _('اسم المشروع'),
|
||||
'dir': 'rtl'
|
||||
}),
|
||||
'description': forms.Textarea(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm resize-none',
|
||||
'rows': 4,
|
||||
'placeholder': _('Describe the project objectives and scope...')
|
||||
}),
|
||||
'hospital': forms.Select(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white'
|
||||
}),
|
||||
'department': forms.Select(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white'
|
||||
}),
|
||||
'project_lead': forms.Select(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white'
|
||||
}),
|
||||
'team_members': forms.SelectMultiple(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white h-40'
|
||||
}),
|
||||
'status': forms.Select(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white'
|
||||
}),
|
||||
'start_date': forms.DateInput(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
|
||||
'type': 'date'
|
||||
}),
|
||||
'target_completion_date': forms.DateInput(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
|
||||
'type': 'date'
|
||||
}),
|
||||
'outcome_description': forms.Textarea(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm resize-none',
|
||||
'rows': 3,
|
||||
'placeholder': _('Document project outcomes and results...')
|
||||
}),
|
||||
"name": forms.TextInput(
|
||||
attrs={
|
||||
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
|
||||
"placeholder": _("Project name"),
|
||||
}
|
||||
),
|
||||
"name_ar": forms.TextInput(
|
||||
attrs={
|
||||
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
|
||||
"placeholder": _("اسم المشروع"),
|
||||
"dir": "rtl",
|
||||
}
|
||||
),
|
||||
"description": forms.Textarea(
|
||||
attrs={
|
||||
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm resize-none",
|
||||
"rows": 4,
|
||||
"placeholder": _("Describe the project objectives and scope..."),
|
||||
}
|
||||
),
|
||||
"hospital": forms.Select(
|
||||
attrs={
|
||||
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
|
||||
}
|
||||
),
|
||||
"department": forms.Select(
|
||||
attrs={
|
||||
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
|
||||
}
|
||||
),
|
||||
"project_lead": forms.Select(
|
||||
attrs={
|
||||
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
|
||||
}
|
||||
),
|
||||
"team_members": forms.SelectMultiple(
|
||||
attrs={
|
||||
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white h-40"
|
||||
}
|
||||
),
|
||||
"status": forms.Select(
|
||||
attrs={
|
||||
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
|
||||
}
|
||||
),
|
||||
"start_date": forms.DateInput(
|
||||
attrs={
|
||||
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
|
||||
"type": "date",
|
||||
}
|
||||
),
|
||||
"target_completion_date": forms.DateInput(
|
||||
attrs={
|
||||
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
|
||||
"type": "date",
|
||||
}
|
||||
),
|
||||
"outcome_description": forms.Textarea(
|
||||
attrs={
|
||||
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm resize-none",
|
||||
"rows": 3,
|
||||
"placeholder": _("Document project outcomes and results..."),
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
# Filter department choices based on hospital
|
||||
hospital_id = None
|
||||
if self.data.get('hospital'):
|
||||
hospital_id = self.data.get('hospital')
|
||||
elif self.initial.get('hospital'):
|
||||
hospital_id = self.initial.get('hospital')
|
||||
if self.data.get("hospital"):
|
||||
hospital_id = self.data.get("hospital")
|
||||
elif self.initial.get("hospital"):
|
||||
hospital_id = self.initial.get("hospital")
|
||||
elif self.instance and self.instance.pk and self.instance.hospital:
|
||||
hospital_id = self.instance.hospital.id
|
||||
elif self.user and self.user.is_px_admin():
|
||||
tenant_hospital = getattr(self.request, "tenant_hospital", None)
|
||||
if tenant_hospital:
|
||||
hospital_id = tenant_hospital.id
|
||||
elif self.user and self.user.hospital:
|
||||
hospital_id = self.user.hospital.id
|
||||
|
||||
|
||||
if hospital_id:
|
||||
self.fields['department'].queryset = Department.objects.filter(
|
||||
hospital_id=hospital_id,
|
||||
status='active'
|
||||
).order_by('name')
|
||||
|
||||
self.fields["department"].queryset = Department.objects.filter(
|
||||
hospital_id=hospital_id, status="active"
|
||||
).order_by("name")
|
||||
|
||||
# Filter user choices based on hospital
|
||||
self.fields['project_lead'].queryset = User.objects.filter(
|
||||
hospital_id=hospital_id,
|
||||
is_active=True
|
||||
).order_by('first_name', 'last_name')
|
||||
|
||||
self.fields['team_members'].queryset = User.objects.filter(
|
||||
hospital_id=hospital_id,
|
||||
is_active=True
|
||||
).order_by('first_name', 'last_name')
|
||||
self.fields["project_lead"].queryset = User.objects.filter(
|
||||
hospital_id=hospital_id, is_active=True
|
||||
).order_by("first_name", "last_name")
|
||||
|
||||
self.fields["team_members"].queryset = User.objects.filter(
|
||||
hospital_id=hospital_id, is_active=True
|
||||
).order_by("first_name", "last_name")
|
||||
else:
|
||||
self.fields['department'].queryset = Department.objects.none()
|
||||
self.fields['project_lead'].queryset = User.objects.none()
|
||||
self.fields['team_members'].queryset = User.objects.none()
|
||||
self.fields["department"].queryset = Department.objects.none()
|
||||
self.fields["project_lead"].queryset = User.objects.none()
|
||||
self.fields["team_members"].queryset = User.objects.none()
|
||||
|
||||
|
||||
class QIProjectTaskForm(forms.ModelForm):
|
||||
"""
|
||||
Form for creating and editing QI Project tasks.
|
||||
"""
|
||||
|
||||
|
||||
class Meta:
|
||||
model = QIProjectTask
|
||||
fields = ['title', 'description', 'assigned_to', 'status', 'due_date', 'order']
|
||||
fields = ["title", "description", "assigned_to", "status", "due_date", "order"]
|
||||
widgets = {
|
||||
'title': forms.TextInput(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
|
||||
'placeholder': _('Task title')
|
||||
}),
|
||||
'description': forms.Textarea(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm resize-none',
|
||||
'rows': 3,
|
||||
'placeholder': _('Task description...')
|
||||
}),
|
||||
'assigned_to': forms.Select(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white'
|
||||
}),
|
||||
'status': forms.Select(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white'
|
||||
}),
|
||||
'due_date': forms.DateInput(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
|
||||
'type': 'date'
|
||||
}),
|
||||
'order': forms.NumberInput(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
|
||||
'min': 0
|
||||
}),
|
||||
"title": forms.TextInput(
|
||||
attrs={
|
||||
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
|
||||
"placeholder": _("Task title"),
|
||||
}
|
||||
),
|
||||
"description": forms.Textarea(
|
||||
attrs={
|
||||
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm resize-none",
|
||||
"rows": 3,
|
||||
"placeholder": _("Task description..."),
|
||||
}
|
||||
),
|
||||
"assigned_to": forms.Select(
|
||||
attrs={
|
||||
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
|
||||
}
|
||||
),
|
||||
"status": forms.Select(
|
||||
attrs={
|
||||
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
|
||||
}
|
||||
),
|
||||
"due_date": forms.DateInput(
|
||||
attrs={
|
||||
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
|
||||
"type": "date",
|
||||
}
|
||||
),
|
||||
"order": forms.NumberInput(
|
||||
attrs={
|
||||
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
|
||||
"min": 0,
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.project = kwargs.pop('project', None)
|
||||
self.project = kwargs.pop("project", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
# Make order field not required (has default value of 0)
|
||||
self.fields['order'].required = False
|
||||
|
||||
self.fields["order"].required = False
|
||||
|
||||
# Filter assigned_to choices based on project hospital
|
||||
if self.project and self.project.hospital:
|
||||
self.fields['assigned_to'].queryset = User.objects.filter(
|
||||
hospital=self.project.hospital,
|
||||
is_active=True
|
||||
).order_by('first_name', 'last_name')
|
||||
self.fields["assigned_to"].queryset = User.objects.filter(
|
||||
hospital=self.project.hospital, is_active=True
|
||||
).order_by("first_name", "last_name")
|
||||
else:
|
||||
self.fields['assigned_to'].queryset = User.objects.none()
|
||||
self.fields["assigned_to"].queryset = User.objects.none()
|
||||
|
||||
|
||||
class QIProjectTemplateForm(HospitalFieldMixin, forms.ModelForm):
|
||||
"""
|
||||
Form for creating and editing QI Project templates.
|
||||
|
||||
|
||||
Hospital field visibility:
|
||||
- PX Admins: See dropdown with all hospitals
|
||||
- Others: Hidden field, auto-set to user's hospital
|
||||
|
||||
|
||||
Templates can be:
|
||||
- Global (hospital=None) - available to all
|
||||
- Hospital-specific - available only to that hospital
|
||||
"""
|
||||
|
||||
|
||||
class Meta:
|
||||
model = QIProject
|
||||
fields = [
|
||||
'name', 'name_ar', 'description', 'hospital',
|
||||
'department', 'target_completion_date'
|
||||
]
|
||||
fields = ["name", "name_ar", "description", "hospital", "department", "target_completion_date"]
|
||||
widgets = {
|
||||
'name': forms.TextInput(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
|
||||
'placeholder': _('Template name')
|
||||
}),
|
||||
'name_ar': forms.TextInput(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
|
||||
'placeholder': _('اسم القالب'),
|
||||
'dir': 'rtl'
|
||||
}),
|
||||
'description': forms.Textarea(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm resize-none',
|
||||
'rows': 4,
|
||||
'placeholder': _('Describe the project template...')
|
||||
}),
|
||||
'hospital': forms.Select(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white'
|
||||
}),
|
||||
'department': forms.Select(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white'
|
||||
}),
|
||||
'target_completion_date': forms.DateInput(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
|
||||
'type': 'date'
|
||||
}),
|
||||
"name": forms.TextInput(
|
||||
attrs={
|
||||
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
|
||||
"placeholder": _("Template name"),
|
||||
}
|
||||
),
|
||||
"name_ar": forms.TextInput(
|
||||
attrs={
|
||||
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
|
||||
"placeholder": _("اسم القالب"),
|
||||
"dir": "rtl",
|
||||
}
|
||||
),
|
||||
"description": forms.Textarea(
|
||||
attrs={
|
||||
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm resize-none",
|
||||
"rows": 4,
|
||||
"placeholder": _("Describe the project template..."),
|
||||
}
|
||||
),
|
||||
"hospital": forms.Select(
|
||||
attrs={
|
||||
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
|
||||
}
|
||||
),
|
||||
"department": forms.Select(
|
||||
attrs={
|
||||
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
|
||||
}
|
||||
),
|
||||
"target_completion_date": forms.DateInput(
|
||||
attrs={
|
||||
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
|
||||
"type": "date",
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
# Make hospital optional for templates (global templates)
|
||||
self.fields['hospital'].required = False
|
||||
self.fields['hospital'].empty_label = _('Global (All Hospitals)')
|
||||
|
||||
self.fields["hospital"].required = False
|
||||
self.fields["hospital"].empty_label = _("Global (All Hospitals)")
|
||||
|
||||
# Filter department choices based on hospital
|
||||
hospital_id = None
|
||||
if self.data.get('hospital'):
|
||||
hospital_id = self.data.get('hospital')
|
||||
elif self.initial.get('hospital'):
|
||||
hospital_id = self.initial.get('hospital')
|
||||
if self.data.get("hospital"):
|
||||
hospital_id = self.data.get("hospital")
|
||||
elif self.initial.get("hospital"):
|
||||
hospital_id = self.initial.get("hospital")
|
||||
elif self.instance and self.instance.pk and self.instance.hospital:
|
||||
hospital_id = self.instance.hospital.id
|
||||
|
||||
elif self.user and self.user.is_px_admin():
|
||||
tenant_hospital = getattr(self.request, "tenant_hospital", None)
|
||||
if tenant_hospital:
|
||||
hospital_id = tenant_hospital.id
|
||||
elif self.user and self.user.hospital:
|
||||
hospital_id = self.user.hospital.id
|
||||
|
||||
if hospital_id:
|
||||
self.fields['department'].queryset = Department.objects.filter(
|
||||
hospital_id=hospital_id,
|
||||
status='active'
|
||||
).order_by('name')
|
||||
self.fields["department"].queryset = Department.objects.filter(
|
||||
hospital_id=hospital_id, status="active"
|
||||
).order_by("name")
|
||||
else:
|
||||
self.fields['department'].queryset = Department.objects.none()
|
||||
self.fields["department"].queryset = Department.objects.none()
|
||||
|
||||
|
||||
class ConvertToProjectForm(forms.Form):
|
||||
@ -237,62 +295,104 @@ class ConvertToProjectForm(forms.Form):
|
||||
Form for converting a PX Action to a QI Project.
|
||||
Allows selecting a template and customizing the project details.
|
||||
"""
|
||||
|
||||
|
||||
template = forms.ModelChoiceField(
|
||||
queryset=QIProject.objects.none(),
|
||||
required=False,
|
||||
empty_label=_('Blank Project'),
|
||||
label=_('Project Template'),
|
||||
widget=forms.Select(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white'
|
||||
})
|
||||
empty_label=_("Blank Project"),
|
||||
label=_("Project Template"),
|
||||
widget=forms.Select(
|
||||
attrs={
|
||||
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
project_name = forms.CharField(
|
||||
max_length=200,
|
||||
label=_('Project Name'),
|
||||
widget=forms.TextInput(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
|
||||
'placeholder': _('Enter project name')
|
||||
})
|
||||
label=_("Project Name"),
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
|
||||
"placeholder": _("Enter project name"),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
project_lead = forms.ModelChoiceField(
|
||||
queryset=User.objects.none(),
|
||||
required=True,
|
||||
label=_('Project Lead'),
|
||||
widget=forms.Select(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white'
|
||||
})
|
||||
label=_("Project Lead"),
|
||||
widget=forms.Select(
|
||||
attrs={
|
||||
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
target_completion_date = forms.DateField(
|
||||
required=False,
|
||||
label=_('Target Completion Date'),
|
||||
widget=forms.DateInput(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
|
||||
'type': 'date'
|
||||
})
|
||||
label=_("Target Completion Date"),
|
||||
widget=forms.DateInput(
|
||||
attrs={
|
||||
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
|
||||
"type": "date",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.user = kwargs.pop('user', None)
|
||||
self.action = kwargs.pop('action', None)
|
||||
self.request = kwargs.pop("request", None)
|
||||
self.user = self.request.user if self.request else None
|
||||
self.action = kwargs.pop("action", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
if self.user and self.user.hospital:
|
||||
# Filter templates by hospital (or global)
|
||||
from django.db.models import Q
|
||||
self.fields['template'].queryset = QIProject.objects.filter(
|
||||
|
||||
self.fields["template"].queryset = QIProject.objects.filter(
|
||||
Q(hospital=self.user.hospital) | Q(hospital__isnull=True),
|
||||
status='template' # We'll add this status or use metadata
|
||||
).order_by('name')
|
||||
|
||||
status="template", # We'll add this status or use metadata
|
||||
).order_by("name")
|
||||
|
||||
# Filter project lead by hospital
|
||||
self.fields['project_lead'].queryset = User.objects.filter(
|
||||
hospital=self.user.hospital,
|
||||
is_active=True
|
||||
).order_by('first_name', 'last_name')
|
||||
self.fields["project_lead"].queryset = User.objects.filter(
|
||||
hospital=self.user.hospital, is_active=True
|
||||
).order_by("first_name", "last_name")
|
||||
else:
|
||||
self.fields['template'].queryset = QIProject.objects.none()
|
||||
self.fields['project_lead'].queryset = User.objects.none()
|
||||
self.fields["template"].queryset = QIProject.objects.none()
|
||||
self.fields["project_lead"].queryset = User.objects.none()
|
||||
|
||||
|
||||
# Inline formset for task templates (used with QIProject templates)
|
||||
class TaskTemplateForm(forms.ModelForm):
|
||||
"""Simplified form for task templates (no project field needed)"""
|
||||
|
||||
class Meta:
|
||||
model = QIProjectTask
|
||||
fields = ["title", "description"]
|
||||
widgets = {
|
||||
"title": forms.TextInput(
|
||||
attrs={
|
||||
"class": "form-control",
|
||||
"placeholder": _("Task title"),
|
||||
}
|
||||
),
|
||||
"description": forms.TextInput(
|
||||
attrs={
|
||||
"class": "form-control",
|
||||
"placeholder": _("Description"),
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
TaskTemplateFormSet = inlineformset_factory(
|
||||
QIProject,
|
||||
QIProjectTask,
|
||||
form=TaskTemplateForm,
|
||||
fields=["title", "description"],
|
||||
extra=1,
|
||||
can_delete=True,
|
||||
)
|
||||
|
||||
@ -4,6 +4,7 @@ QI Projects Console UI views
|
||||
Provides full CRUD functionality for Quality Improvement projects,
|
||||
task management, and template handling.
|
||||
"""
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.paginator import Paginator
|
||||
@ -15,7 +16,7 @@ from apps.core.decorators import block_source_user
|
||||
from apps.organizations.models import Hospital
|
||||
from apps.px_action_center.models import PXAction
|
||||
|
||||
from .forms import ConvertToProjectForm, QIProjectForm, QIProjectTaskForm, QIProjectTemplateForm
|
||||
from .forms import ConvertToProjectForm, QIProjectForm, QIProjectTaskForm, QIProjectTemplateForm, TaskTemplateFormSet
|
||||
from .models import QIProject, QIProjectTask
|
||||
|
||||
|
||||
@ -24,71 +25,72 @@ from .models import QIProject, QIProjectTask
|
||||
def project_list(request):
|
||||
"""QI Projects list view with filtering and pagination"""
|
||||
# Exclude templates from the list
|
||||
queryset = QIProject.objects.filter(is_template=False).select_related(
|
||||
'hospital', 'department', 'project_lead'
|
||||
).prefetch_related('team_members', 'related_actions')
|
||||
|
||||
queryset = (
|
||||
QIProject.objects.filter(is_template=False)
|
||||
.select_related("hospital", "department", "project_lead")
|
||||
.prefetch_related("team_members", "related_actions")
|
||||
)
|
||||
|
||||
# Apply RBAC filters
|
||||
user = request.user
|
||||
# Get selected hospital for PX Admins (from middleware)
|
||||
selected_hospital = getattr(request, 'tenant_hospital', None)
|
||||
|
||||
selected_hospital = getattr(request, "tenant_hospital", None)
|
||||
|
||||
if user.is_px_admin():
|
||||
# PX Admins see all, but filter by selected hospital if set
|
||||
if selected_hospital:
|
||||
queryset = queryset.filter(hospital=selected_hospital)
|
||||
elif user.hospital:
|
||||
queryset = queryset.filter(hospital=user.hospital)
|
||||
|
||||
|
||||
# Apply filters
|
||||
status_filter = request.GET.get('status')
|
||||
status_filter = request.GET.get("status")
|
||||
if status_filter:
|
||||
queryset = queryset.filter(status=status_filter)
|
||||
|
||||
hospital_filter = request.GET.get('hospital')
|
||||
|
||||
hospital_filter = request.GET.get("hospital")
|
||||
if hospital_filter:
|
||||
queryset = queryset.filter(hospital_id=hospital_filter)
|
||||
|
||||
|
||||
# Search
|
||||
search_query = request.GET.get('search')
|
||||
search_query = request.GET.get("search")
|
||||
if search_query:
|
||||
queryset = queryset.filter(
|
||||
Q(name__icontains=search_query) |
|
||||
Q(description__icontains=search_query) |
|
||||
Q(name_ar__icontains=search_query)
|
||||
Q(name__icontains=search_query)
|
||||
| Q(description__icontains=search_query)
|
||||
| Q(name_ar__icontains=search_query)
|
||||
)
|
||||
|
||||
|
||||
# Ordering
|
||||
queryset = queryset.order_by('-created_at')
|
||||
|
||||
queryset = queryset.order_by("-created_at")
|
||||
|
||||
# Pagination
|
||||
page_size = int(request.GET.get('page_size', 25))
|
||||
page_size = int(request.GET.get("page_size", 25))
|
||||
paginator = Paginator(queryset, page_size)
|
||||
page_number = request.GET.get('page', 1)
|
||||
page_number = request.GET.get("page", 1)
|
||||
page_obj = paginator.get_page(page_number)
|
||||
|
||||
|
||||
# Get hospitals for filter
|
||||
hospitals = Hospital.objects.filter(status='active')
|
||||
hospitals = Hospital.objects.filter(status="active")
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
hospitals = hospitals.filter(id=user.hospital.id)
|
||||
|
||||
|
||||
# Statistics
|
||||
stats = {
|
||||
'total': queryset.count(),
|
||||
'active': queryset.filter(status='active').count(),
|
||||
'completed': queryset.filter(status='completed').count(),
|
||||
'pending': queryset.filter(status='pending').count(),
|
||||
"total": queryset.count(),
|
||||
"active": queryset.filter(status="active").count(),
|
||||
"completed": queryset.filter(status="completed").count(),
|
||||
"pending": queryset.filter(status="pending").count(),
|
||||
}
|
||||
|
||||
|
||||
context = {
|
||||
'page_obj': page_obj,
|
||||
'projects': page_obj.object_list,
|
||||
'hospitals': hospitals,
|
||||
'stats': stats,
|
||||
'filters': request.GET,
|
||||
"page_obj": page_obj,
|
||||
"projects": page_obj.object_list,
|
||||
"stats": stats,
|
||||
"filters": request.GET,
|
||||
}
|
||||
|
||||
return render(request, 'projects/project_list.html', context)
|
||||
|
||||
return render(request, "projects/project_list.html", context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@ -96,34 +98,32 @@ def project_list(request):
|
||||
def project_detail(request, pk):
|
||||
"""QI Project detail view with task management"""
|
||||
project = get_object_or_404(
|
||||
QIProject.objects.filter(is_template=False).select_related(
|
||||
'hospital', 'department', 'project_lead'
|
||||
).prefetch_related(
|
||||
'team_members', 'related_actions', 'tasks'
|
||||
),
|
||||
pk=pk
|
||||
QIProject.objects.filter(is_template=False)
|
||||
.select_related("hospital", "department", "project_lead")
|
||||
.prefetch_related("team_members", "related_actions", "tasks"),
|
||||
pk=pk,
|
||||
)
|
||||
|
||||
|
||||
# Check permission
|
||||
user = request.user
|
||||
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
|
||||
messages.error(request, _("You don't have permission to view this project."))
|
||||
return redirect('projects:project_list')
|
||||
|
||||
return redirect("projects:project_list")
|
||||
|
||||
# Get tasks
|
||||
tasks = project.tasks.all().order_by('order', 'created_at')
|
||||
|
||||
tasks = project.tasks.all().order_by("order", "created_at")
|
||||
|
||||
# Get related actions
|
||||
related_actions = project.related_actions.all()
|
||||
|
||||
|
||||
context = {
|
||||
'project': project,
|
||||
'tasks': tasks,
|
||||
'related_actions': related_actions,
|
||||
'can_edit': user.is_px_admin() or user.is_hospital_admin or user.is_department_manager,
|
||||
"project": project,
|
||||
"tasks": tasks,
|
||||
"related_actions": related_actions,
|
||||
"can_edit": user.is_px_admin() or user.is_hospital_admin or user.is_department_manager,
|
||||
}
|
||||
|
||||
return render(request, 'projects/project_detail.html', context)
|
||||
|
||||
return render(request, "projects/project_detail.html", context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@ -131,63 +131,63 @@ def project_detail(request, pk):
|
||||
def project_create(request, template_pk=None):
|
||||
"""Create a new QI Project"""
|
||||
user = request.user
|
||||
|
||||
|
||||
# Check permission (PX Admin, Hospital Admin, or Department Manager)
|
||||
if not (user.is_px_admin() or user.is_hospital_admin or user.is_department_manager):
|
||||
messages.error(request, _("You don't have permission to create projects."))
|
||||
return redirect('projects:project_list')
|
||||
|
||||
return redirect("projects:project_list")
|
||||
|
||||
# Check for template parameter (from URL or GET)
|
||||
template_id = template_pk or request.GET.get('template')
|
||||
template_id = template_pk or request.GET.get("template")
|
||||
initial_data = {}
|
||||
template = None
|
||||
|
||||
|
||||
if template_id:
|
||||
try:
|
||||
template = QIProject.objects.get(pk=template_id, is_template=True)
|
||||
initial_data = {
|
||||
'name': template.name,
|
||||
'name_ar': template.name_ar,
|
||||
'description': template.description,
|
||||
'department': template.department,
|
||||
'target_completion_date': template.target_completion_date,
|
||||
"name": template.name,
|
||||
"name_ar": template.name_ar,
|
||||
"description": template.description,
|
||||
"department": template.department,
|
||||
"target_completion_date": template.target_completion_date,
|
||||
}
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
initial_data['hospital'] = user.hospital
|
||||
initial_data["hospital"] = user.hospital
|
||||
except QIProject.DoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
# Check for PX Action parameter (convert action to project)
|
||||
action_id = request.GET.get('action')
|
||||
action_id = request.GET.get("action")
|
||||
if action_id:
|
||||
try:
|
||||
action = PXAction.objects.get(pk=action_id)
|
||||
initial_data['name'] = f"QI Project: {action.title}"
|
||||
initial_data['description'] = action.description
|
||||
initial_data["name"] = f"QI Project: {action.title}"
|
||||
initial_data["description"] = action.description
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
initial_data['hospital'] = user.hospital
|
||||
initial_data["hospital"] = user.hospital
|
||||
else:
|
||||
initial_data['hospital'] = action.hospital
|
||||
initial_data["hospital"] = action.hospital
|
||||
except PXAction.DoesNotExist:
|
||||
pass
|
||||
|
||||
if request.method == 'POST':
|
||||
form = QIProjectForm(request.POST, user=user)
|
||||
|
||||
|
||||
if request.method == "POST":
|
||||
form = QIProjectForm(request.POST, request=request)
|
||||
|
||||
# Check for template in POST data (hidden field)
|
||||
template_id_post = request.POST.get('template_id')
|
||||
template_id_post = request.POST.get("template_id")
|
||||
if template_id_post:
|
||||
try:
|
||||
template = QIProject.objects.get(pk=template_id_post, is_template=True)
|
||||
except QIProject.DoesNotExist:
|
||||
template = None
|
||||
|
||||
|
||||
if form.is_valid():
|
||||
project = form.save(commit=False)
|
||||
project.created_by = user
|
||||
project.save()
|
||||
form.save_m2m() # Save many-to-many relationships
|
||||
|
||||
|
||||
# If created from template, copy tasks
|
||||
task_count = 0
|
||||
if template:
|
||||
@ -197,10 +197,10 @@ def project_create(request, template_pk=None):
|
||||
title=template_task.title,
|
||||
description=template_task.description,
|
||||
order=template_task.order,
|
||||
status='pending'
|
||||
status="pending",
|
||||
)
|
||||
task_count += 1
|
||||
|
||||
|
||||
# If created from action, link it
|
||||
if action_id:
|
||||
try:
|
||||
@ -208,25 +208,25 @@ def project_create(request, template_pk=None):
|
||||
project.related_actions.add(action)
|
||||
except PXAction.DoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
if template and task_count > 0:
|
||||
messages.success(
|
||||
request,
|
||||
_('QI Project created successfully with %(count)d task(s) from template.') % {'count': task_count}
|
||||
request,
|
||||
_("QI Project created successfully with %(count)d task(s) from template.") % {"count": task_count},
|
||||
)
|
||||
else:
|
||||
messages.success(request, _("QI Project created successfully."))
|
||||
return redirect('projects:project_detail', pk=project.pk)
|
||||
return redirect("projects:project_detail", pk=project.pk)
|
||||
else:
|
||||
form = QIProjectForm(user=user, initial=initial_data)
|
||||
|
||||
form = QIProjectForm(request=request, initial=initial_data)
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
'is_create': True,
|
||||
'template': template,
|
||||
"form": form,
|
||||
"is_create": True,
|
||||
"template": template,
|
||||
}
|
||||
|
||||
return render(request, 'projects/project_form.html', context)
|
||||
|
||||
return render(request, "projects/project_form.html", context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@ -235,33 +235,33 @@ def project_edit(request, pk):
|
||||
"""Edit an existing QI Project"""
|
||||
user = request.user
|
||||
project = get_object_or_404(QIProject, pk=pk, is_template=False)
|
||||
|
||||
|
||||
# Check permission
|
||||
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
|
||||
messages.error(request, _("You don't have permission to edit this project."))
|
||||
return redirect('projects:project_list')
|
||||
|
||||
return redirect("projects:project_list")
|
||||
|
||||
# Check edit permission (PX Admin, Hospital Admin, or Department Manager)
|
||||
if not (user.is_px_admin() or user.is_hospital_admin or user.is_department_manager):
|
||||
messages.error(request, _("You don't have permission to edit projects."))
|
||||
return redirect('projects:project_detail', pk=project.pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = QIProjectForm(request.POST, instance=project, user=user)
|
||||
return redirect("projects:project_detail", pk=project.pk)
|
||||
|
||||
if request.method == "POST":
|
||||
form = QIProjectForm(request.POST, instance=project, request=request)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("QI Project updated successfully."))
|
||||
return redirect('projects:project_detail', pk=project.pk)
|
||||
return redirect("projects:project_detail", pk=project.pk)
|
||||
else:
|
||||
form = QIProjectForm(instance=project, user=user)
|
||||
|
||||
form = QIProjectForm(instance=project, request=request)
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
'project': project,
|
||||
'is_create': False,
|
||||
"form": form,
|
||||
"project": project,
|
||||
"is_create": False,
|
||||
}
|
||||
|
||||
return render(request, 'projects/project_form.html', context)
|
||||
|
||||
return render(request, "projects/project_form.html", context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@ -270,27 +270,27 @@ def project_delete(request, pk):
|
||||
"""Delete a QI Project"""
|
||||
user = request.user
|
||||
project = get_object_or_404(QIProject, pk=pk, is_template=False)
|
||||
|
||||
|
||||
# Check permission (only PX Admin or Hospital Admin can delete)
|
||||
if not (user.is_px_admin() or user.is_hospital_admin):
|
||||
messages.error(request, _("You don't have permission to delete projects."))
|
||||
return redirect('projects:project_detail', pk=project.pk)
|
||||
|
||||
return redirect("projects:project_detail", pk=project.pk)
|
||||
|
||||
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
|
||||
messages.error(request, _("You don't have permission to delete this project."))
|
||||
return redirect('projects:project_list')
|
||||
|
||||
if request.method == 'POST':
|
||||
return redirect("projects:project_list")
|
||||
|
||||
if request.method == "POST":
|
||||
project_name = project.name
|
||||
project.delete()
|
||||
messages.success(request, _('Project "%(name)s" deleted successfully.') % {'name': project_name})
|
||||
return redirect('projects:project_list')
|
||||
|
||||
messages.success(request, _('Project "%(name)s" deleted successfully.') % {"name": project_name})
|
||||
return redirect("projects:project_list")
|
||||
|
||||
context = {
|
||||
'project': project,
|
||||
"project": project,
|
||||
}
|
||||
|
||||
return render(request, 'projects/project_delete_confirm.html', context)
|
||||
|
||||
return render(request, "projects/project_delete_confirm.html", context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@ -299,26 +299,26 @@ def project_save_as_template(request, pk):
|
||||
"""Save an existing project and its tasks as a template"""
|
||||
user = request.user
|
||||
project = get_object_or_404(QIProject, pk=pk, is_template=False)
|
||||
|
||||
|
||||
# Check permission (only PX Admin or Hospital Admin can create templates)
|
||||
if not (user.is_px_admin() or user.is_hospital_admin):
|
||||
messages.error(request, _("You don't have permission to create templates."))
|
||||
return redirect('projects:project_detail', pk=project.pk)
|
||||
|
||||
return redirect("projects:project_detail", pk=project.pk)
|
||||
|
||||
# Check hospital access
|
||||
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
|
||||
messages.error(request, _("You don't have permission to create templates from this project."))
|
||||
return redirect('projects:project_list')
|
||||
|
||||
if request.method == 'POST':
|
||||
template_name = request.POST.get('template_name', '').strip()
|
||||
template_description = request.POST.get('template_description', '').strip()
|
||||
make_global = request.POST.get('make_global') == 'on'
|
||||
|
||||
return redirect("projects:project_list")
|
||||
|
||||
if request.method == "POST":
|
||||
template_name = request.POST.get("template_name", "").strip()
|
||||
template_description = request.POST.get("template_description", "").strip()
|
||||
make_global = request.POST.get("make_global") == "on"
|
||||
|
||||
if not template_name:
|
||||
messages.error(request, _('Please provide a template name.'))
|
||||
return redirect('projects:project_save_as_template', pk=project.pk)
|
||||
|
||||
messages.error(request, _("Please provide a template name."))
|
||||
return redirect("projects:project_save_as_template", pk=project.pk)
|
||||
|
||||
# Create template from project
|
||||
template = QIProject.objects.create(
|
||||
name=template_name,
|
||||
@ -328,10 +328,10 @@ def project_save_as_template(request, pk):
|
||||
# If global, hospital is None; otherwise use project's hospital
|
||||
hospital=None if make_global else project.hospital,
|
||||
department=project.department,
|
||||
status='pending', # Default status for templates
|
||||
created_by=user
|
||||
status="pending", # Default status for templates
|
||||
created_by=user,
|
||||
)
|
||||
|
||||
|
||||
# Copy tasks from project to template
|
||||
for task in project.tasks.all():
|
||||
QIProjectTask.objects.create(
|
||||
@ -339,60 +339,59 @@ def project_save_as_template(request, pk):
|
||||
title=task.title,
|
||||
description=task.description,
|
||||
order=task.order,
|
||||
status='pending' # Reset status for template
|
||||
status="pending", # Reset status for template
|
||||
)
|
||||
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
_('Template "%(name)s" created successfully with %(count)d task(s).') % {
|
||||
'name': template_name,
|
||||
'count': project.tasks.count()
|
||||
}
|
||||
request,
|
||||
_('Template "%(name)s" created successfully with %(count)d task(s).')
|
||||
% {"name": template_name, "count": project.tasks.count()},
|
||||
)
|
||||
return redirect('projects:template_list')
|
||||
|
||||
return redirect("projects:template_list")
|
||||
|
||||
context = {
|
||||
'project': project,
|
||||
'suggested_name': f"Template: {project.name}",
|
||||
"project": project,
|
||||
"suggested_name": f"Template: {project.name}",
|
||||
}
|
||||
|
||||
return render(request, 'projects/project_save_as_template.html', context)
|
||||
|
||||
return render(request, "projects/project_save_as_template.html", context)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Task Management Views
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def task_create(request, project_pk):
|
||||
"""Add a new task to a project"""
|
||||
user = request.user
|
||||
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
|
||||
|
||||
|
||||
# Check permission
|
||||
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
|
||||
messages.error(request, _("You don't have permission to add tasks to this project."))
|
||||
return redirect('projects:project_detail', pk=project.pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
return redirect("projects:project_detail", pk=project.pk)
|
||||
|
||||
if request.method == "POST":
|
||||
form = QIProjectTaskForm(request.POST, project=project)
|
||||
if form.is_valid():
|
||||
task = form.save(commit=False)
|
||||
task.project = project
|
||||
task.save()
|
||||
messages.success(request, _("Task added successfully."))
|
||||
return redirect('projects:project_detail', pk=project.pk)
|
||||
return redirect("projects:project_detail", pk=project.pk)
|
||||
else:
|
||||
form = QIProjectTaskForm(project=project)
|
||||
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
'project': project,
|
||||
'is_create': True,
|
||||
"form": form,
|
||||
"project": project,
|
||||
"is_create": True,
|
||||
}
|
||||
|
||||
return render(request, 'projects/task_form.html', context)
|
||||
|
||||
return render(request, "projects/task_form.html", context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@ -402,34 +401,35 @@ def task_edit(request, project_pk, task_pk):
|
||||
user = request.user
|
||||
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
|
||||
task = get_object_or_404(QIProjectTask, pk=task_pk, project=project)
|
||||
|
||||
|
||||
# Check permission
|
||||
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
|
||||
messages.error(request, _("You don't have permission to edit tasks in this project."))
|
||||
return redirect('projects:project_detail', pk=project.pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
return redirect("projects:project_detail", pk=project.pk)
|
||||
|
||||
if request.method == "POST":
|
||||
form = QIProjectTaskForm(request.POST, instance=task, project=project)
|
||||
if form.is_valid():
|
||||
task = form.save()
|
||||
# If status changed to completed, set completed_date
|
||||
if task.status == 'completed' and not task.completed_date:
|
||||
if task.status == "completed" and not task.completed_date:
|
||||
from django.utils import timezone
|
||||
|
||||
task.completed_date = timezone.now().date()
|
||||
task.save()
|
||||
messages.success(request, _("Task updated successfully."))
|
||||
return redirect('projects:project_detail', pk=project.pk)
|
||||
return redirect("projects:project_detail", pk=project.pk)
|
||||
else:
|
||||
form = QIProjectTaskForm(instance=task, project=project)
|
||||
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
'project': project,
|
||||
'task': task,
|
||||
'is_create': False,
|
||||
"form": form,
|
||||
"project": project,
|
||||
"task": task,
|
||||
"is_create": False,
|
||||
}
|
||||
|
||||
return render(request, 'projects/task_form.html', context)
|
||||
|
||||
return render(request, "projects/task_form.html", context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@ -439,23 +439,23 @@ def task_delete(request, project_pk, task_pk):
|
||||
user = request.user
|
||||
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
|
||||
task = get_object_or_404(QIProjectTask, pk=task_pk, project=project)
|
||||
|
||||
|
||||
# Check permission
|
||||
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
|
||||
messages.error(request, _("You don't have permission to delete tasks in this project."))
|
||||
return redirect('projects:project_detail', pk=project.pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
return redirect("projects:project_detail", pk=project.pk)
|
||||
|
||||
if request.method == "POST":
|
||||
task.delete()
|
||||
messages.success(request, _("Task deleted successfully."))
|
||||
return redirect('projects:project_detail', pk=project.pk)
|
||||
|
||||
return redirect("projects:project_detail", pk=project.pk)
|
||||
|
||||
context = {
|
||||
'project': project,
|
||||
'task': task,
|
||||
"project": project,
|
||||
"task": task,
|
||||
}
|
||||
|
||||
return render(request, 'projects/task_delete_confirm.html', context)
|
||||
|
||||
return render(request, "projects/task_delete_confirm.html", context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@ -465,68 +465,68 @@ def task_toggle_status(request, project_pk, task_pk):
|
||||
user = request.user
|
||||
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
|
||||
task = get_object_or_404(QIProjectTask, pk=task_pk, project=project)
|
||||
|
||||
|
||||
# Check permission
|
||||
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
|
||||
messages.error(request, _("You don't have permission to update tasks in this project."))
|
||||
return redirect('projects:project_detail', pk=project.pk)
|
||||
|
||||
return redirect("projects:project_detail", pk=project.pk)
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
if task.status == 'completed':
|
||||
task.status = 'pending'
|
||||
|
||||
if task.status == "completed":
|
||||
task.status = "pending"
|
||||
task.completed_date = None
|
||||
else:
|
||||
task.status = 'completed'
|
||||
task.status = "completed"
|
||||
task.completed_date = timezone.now().date()
|
||||
|
||||
|
||||
task.save()
|
||||
messages.success(request, _("Task status updated."))
|
||||
return redirect('projects:project_detail', pk=project.pk)
|
||||
return redirect("projects:project_detail", pk=project.pk)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Template Management Views
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def template_list(request):
|
||||
"""List QI Project templates"""
|
||||
user = request.user
|
||||
|
||||
|
||||
# Only admins can manage templates
|
||||
if not (user.is_px_admin() or user.is_hospital_admin):
|
||||
messages.error(request, _("You don't have permission to view templates."))
|
||||
return redirect('projects:project_list')
|
||||
|
||||
queryset = QIProject.objects.filter(is_template=True).select_related('hospital', 'department')
|
||||
|
||||
return redirect("projects:project_list")
|
||||
|
||||
queryset = QIProject.objects.filter(is_template=True).select_related("hospital", "department")
|
||||
|
||||
# Apply RBAC filters
|
||||
if not user.is_px_admin():
|
||||
from django.db.models import Q
|
||||
queryset = queryset.filter(
|
||||
Q(hospital=user.hospital) | Q(hospital__isnull=True)
|
||||
)
|
||||
|
||||
|
||||
queryset = queryset.filter(Q(hospital=user.hospital) | Q(hospital__isnull=True))
|
||||
|
||||
# Search
|
||||
search_query = request.GET.get('search')
|
||||
search_query = request.GET.get("search")
|
||||
if search_query:
|
||||
queryset = queryset.filter(
|
||||
Q(name__icontains=search_query) |
|
||||
Q(description__icontains=search_query) |
|
||||
Q(name_ar__icontains=search_query)
|
||||
Q(name__icontains=search_query)
|
||||
| Q(description__icontains=search_query)
|
||||
| Q(name_ar__icontains=search_query)
|
||||
)
|
||||
|
||||
queryset = queryset.order_by('name')
|
||||
|
||||
|
||||
queryset = queryset.order_by("name")
|
||||
|
||||
context = {
|
||||
'templates': queryset,
|
||||
'can_create': user.is_px_admin() or user.is_hospital_admin,
|
||||
'can_edit': user.is_px_admin() or user.is_hospital_admin,
|
||||
"templates": queryset,
|
||||
"can_create": user.is_px_admin() or user.is_hospital_admin,
|
||||
"can_edit": user.is_px_admin() or user.is_hospital_admin,
|
||||
}
|
||||
|
||||
return render(request, 'projects/template_list.html', context)
|
||||
|
||||
return render(request, "projects/template_list.html", context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@ -534,33 +534,33 @@ def template_list(request):
|
||||
def template_detail(request, pk):
|
||||
"""View template details with tasks"""
|
||||
user = request.user
|
||||
|
||||
|
||||
# Only admins can view templates
|
||||
if not (user.is_px_admin() or user.is_hospital_admin):
|
||||
messages.error(request, _("You don't have permission to view templates."))
|
||||
return redirect('projects:project_list')
|
||||
|
||||
return redirect("projects:project_list")
|
||||
|
||||
template = get_object_or_404(
|
||||
QIProject.objects.filter(is_template=True).select_related('hospital', 'department'),
|
||||
pk=pk
|
||||
QIProject.objects.filter(is_template=True).select_related("hospital", "department"), pk=pk
|
||||
)
|
||||
|
||||
|
||||
# Check permission for hospital-specific templates
|
||||
if not user.is_px_admin():
|
||||
from django.db.models import Q
|
||||
|
||||
if template.hospital and template.hospital != user.hospital:
|
||||
messages.error(request, _("You don't have permission to view this template."))
|
||||
return redirect('projects:template_list')
|
||||
|
||||
return redirect("projects:template_list")
|
||||
|
||||
# Get tasks
|
||||
tasks = template.tasks.all().order_by('order', 'created_at')
|
||||
|
||||
tasks = template.tasks.all().order_by("order", "created_at")
|
||||
|
||||
context = {
|
||||
'template': template,
|
||||
'tasks': tasks,
|
||||
"template": template,
|
||||
"tasks": tasks,
|
||||
}
|
||||
|
||||
return render(request, 'projects/template_detail.html', context)
|
||||
|
||||
return render(request, "projects/template_detail.html", context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@ -568,30 +568,41 @@ def template_detail(request, pk):
|
||||
def template_create(request):
|
||||
"""Create a new project template"""
|
||||
user = request.user
|
||||
|
||||
|
||||
# Only admins can create templates
|
||||
if not (user.is_px_admin() or user.is_hospital_admin):
|
||||
messages.error(request, _("You don't have permission to create templates."))
|
||||
return redirect('projects:project_list')
|
||||
|
||||
if request.method == 'POST':
|
||||
form = QIProjectTemplateForm(request.POST, user=user)
|
||||
return redirect("projects:project_list")
|
||||
|
||||
if request.method == "POST":
|
||||
form = QIProjectTemplateForm(request.POST, request=request)
|
||||
if form.is_valid():
|
||||
template = form.save(commit=False)
|
||||
template.is_template = True
|
||||
template.created_by = user
|
||||
template.save()
|
||||
|
||||
# Save task templates formset
|
||||
formset = TaskTemplateFormSet(request.POST, instance=template, prefix='tasktemplate_set')
|
||||
if formset.is_valid():
|
||||
formset.save()
|
||||
|
||||
messages.success(request, _("Project template created successfully."))
|
||||
return redirect('projects:template_list')
|
||||
return redirect("projects:template_list")
|
||||
else:
|
||||
# Form is invalid, show formset with errors
|
||||
formset = TaskTemplateFormSet(request.POST, prefix='tasktemplate_set')
|
||||
else:
|
||||
form = QIProjectTemplateForm(user=user)
|
||||
|
||||
form = QIProjectTemplateForm(request=request)
|
||||
formset = TaskTemplateFormSet(prefix='tasktemplate_set')
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
'is_create': True,
|
||||
"form": form,
|
||||
"formset": formset,
|
||||
"is_create": True,
|
||||
}
|
||||
|
||||
return render(request, 'projects/template_form.html', context)
|
||||
|
||||
return render(request, "projects/template_form.html", context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@ -600,29 +611,33 @@ def template_edit(request, pk):
|
||||
"""Edit an existing project template"""
|
||||
user = request.user
|
||||
template = get_object_or_404(QIProject, pk=pk, is_template=True)
|
||||
|
||||
|
||||
# Check permission
|
||||
if not user.is_px_admin():
|
||||
if template.hospital and template.hospital != user.hospital:
|
||||
messages.error(request, _("You don't have permission to edit this template."))
|
||||
return redirect('projects:template_list')
|
||||
|
||||
if request.method == 'POST':
|
||||
form = QIProjectTemplateForm(request.POST, instance=template, user=user)
|
||||
if form.is_valid():
|
||||
return redirect("projects:template_list")
|
||||
|
||||
if request.method == "POST":
|
||||
form = QIProjectTemplateForm(request.POST, instance=template, request=request)
|
||||
formset = TaskTemplateFormSet(request.POST, instance=template, prefix='tasktemplate_set')
|
||||
if form.is_valid() and formset.is_valid():
|
||||
form.save()
|
||||
formset.save()
|
||||
messages.success(request, _("Project template updated successfully."))
|
||||
return redirect('projects:template_list')
|
||||
return redirect("projects:template_list")
|
||||
else:
|
||||
form = QIProjectTemplateForm(instance=template, user=user)
|
||||
|
||||
form = QIProjectTemplateForm(instance=template, request=request)
|
||||
formset = TaskTemplateFormSet(instance=template, prefix='tasktemplate_set')
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
'template': template,
|
||||
'is_create': False,
|
||||
"form": form,
|
||||
"formset": formset,
|
||||
"template": template,
|
||||
"is_create": False,
|
||||
}
|
||||
|
||||
return render(request, 'projects/template_form.html', context)
|
||||
|
||||
return render(request, "projects/template_form.html", context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@ -631,66 +646,67 @@ def template_delete(request, pk):
|
||||
"""Delete a project template"""
|
||||
user = request.user
|
||||
template = get_object_or_404(QIProject, pk=pk, is_template=True)
|
||||
|
||||
|
||||
# Check permission
|
||||
if not user.is_px_admin():
|
||||
if template.hospital and template.hospital != user.hospital:
|
||||
messages.error(request, _("You don't have permission to delete this template."))
|
||||
return redirect('projects:template_list')
|
||||
|
||||
if request.method == 'POST':
|
||||
return redirect("projects:template_list")
|
||||
|
||||
if request.method == "POST":
|
||||
template_name = template.name
|
||||
template.delete()
|
||||
messages.success(request, _('Template "%(name)s" deleted successfully.') % {'name': template_name})
|
||||
return redirect('projects:template_list')
|
||||
|
||||
messages.success(request, _('Template "%(name)s" deleted successfully.') % {"name": template_name})
|
||||
return redirect("projects:template_list")
|
||||
|
||||
context = {
|
||||
'template': template,
|
||||
"template": template,
|
||||
}
|
||||
|
||||
return render(request, 'projects/template_delete_confirm.html', context)
|
||||
|
||||
return render(request, "projects/template_delete_confirm.html", context)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PX Action Conversion View
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def convert_action_to_project(request, action_pk):
|
||||
"""Convert a PX Action to a QI Project"""
|
||||
user = request.user
|
||||
|
||||
|
||||
# Check permission
|
||||
if not (user.is_px_admin() or user.is_hospital_admin or user.is_department_manager):
|
||||
messages.error(request, _("You don't have permission to create projects."))
|
||||
return redirect('px_action_center:action_detail', pk=action_pk)
|
||||
|
||||
return redirect("px_action_center:action_detail", pk=action_pk)
|
||||
|
||||
action = get_object_or_404(PXAction, pk=action_pk)
|
||||
|
||||
|
||||
# Check hospital access
|
||||
if not user.is_px_admin() and user.hospital and action.hospital != user.hospital:
|
||||
messages.error(request, _("You don't have permission to convert this action."))
|
||||
return redirect('px_action_center:action_detail', pk=action_pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = ConvertToProjectForm(request.POST, user=user, action=action)
|
||||
return redirect("px_action_center:action_detail", pk=action_pk)
|
||||
|
||||
if request.method == "POST":
|
||||
form = ConvertToProjectForm(request.POST, request=request, action=action)
|
||||
if form.is_valid():
|
||||
# Create project from template or blank
|
||||
template = form.cleaned_data.get('template')
|
||||
|
||||
template = form.cleaned_data.get("template")
|
||||
|
||||
if template:
|
||||
# Copy from template
|
||||
project = QIProject.objects.create(
|
||||
name=form.cleaned_data['project_name'],
|
||||
name=form.cleaned_data["project_name"],
|
||||
name_ar=template.name_ar,
|
||||
description=template.description,
|
||||
hospital=action.hospital,
|
||||
department=template.department,
|
||||
project_lead=form.cleaned_data['project_lead'],
|
||||
target_completion_date=form.cleaned_data['target_completion_date'],
|
||||
status='pending',
|
||||
created_by=user
|
||||
project_lead=form.cleaned_data["project_lead"],
|
||||
target_completion_date=form.cleaned_data["target_completion_date"],
|
||||
status="pending",
|
||||
created_by=user,
|
||||
)
|
||||
# Copy tasks from template
|
||||
for template_task in template.tasks.all():
|
||||
@ -699,34 +715,34 @@ def convert_action_to_project(request, action_pk):
|
||||
title=template_task.title,
|
||||
description=template_task.description,
|
||||
order=template_task.order,
|
||||
status='pending'
|
||||
status="pending",
|
||||
)
|
||||
else:
|
||||
# Create blank project
|
||||
project = QIProject.objects.create(
|
||||
name=form.cleaned_data['project_name'],
|
||||
name=form.cleaned_data["project_name"],
|
||||
description=action.description,
|
||||
hospital=action.hospital,
|
||||
project_lead=form.cleaned_data['project_lead'],
|
||||
target_completion_date=form.cleaned_data['target_completion_date'],
|
||||
status='pending',
|
||||
created_by=user
|
||||
project_lead=form.cleaned_data["project_lead"],
|
||||
target_completion_date=form.cleaned_data["target_completion_date"],
|
||||
status="pending",
|
||||
created_by=user,
|
||||
)
|
||||
|
||||
|
||||
# Link to the action
|
||||
project.related_actions.add(action)
|
||||
|
||||
|
||||
messages.success(request, _("PX Action converted to QI Project successfully."))
|
||||
return redirect('projects:project_detail', pk=project.pk)
|
||||
return redirect("projects:project_detail", pk=project.pk)
|
||||
else:
|
||||
initial_data = {
|
||||
'project_name': f"QI Project: {action.title}",
|
||||
"project_name": f"QI Project: {action.title}",
|
||||
}
|
||||
form = ConvertToProjectForm(user=user, action=action, initial=initial_data)
|
||||
|
||||
form = ConvertToProjectForm(request=request, action=action, initial=initial_data)
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
'action': action,
|
||||
"form": form,
|
||||
"action": action,
|
||||
}
|
||||
|
||||
return render(request, 'projects/convert_action.html', context)
|
||||
|
||||
return render(request, "projects/convert_action.html", context)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"""
|
||||
PX Action Center forms - Manual action creation and management
|
||||
"""
|
||||
|
||||
from django import forms
|
||||
from django.db.models import Q
|
||||
|
||||
@ -12,127 +13,128 @@ from .models import PXAction, ActionSource, ActionStatus
|
||||
class ManualActionForm(forms.ModelForm):
|
||||
"""
|
||||
Form for manually creating PX Actions.
|
||||
|
||||
|
||||
Features:
|
||||
- Source type selection including meeting types
|
||||
- User permission-based filtering
|
||||
- Cascading dropdowns (hospital → department)
|
||||
- Assignment to users
|
||||
"""
|
||||
|
||||
|
||||
class Meta:
|
||||
model = PXAction
|
||||
fields = [
|
||||
'source_type', 'title', 'description',
|
||||
'hospital', 'department', 'category',
|
||||
'priority', 'severity', 'assigned_to',
|
||||
'due_at', 'requires_approval', 'action_plan'
|
||||
"source_type",
|
||||
"title",
|
||||
"description",
|
||||
"hospital",
|
||||
"department",
|
||||
"category",
|
||||
"priority",
|
||||
"severity",
|
||||
"assigned_to",
|
||||
"due_at",
|
||||
"requires_approval",
|
||||
"action_plan",
|
||||
]
|
||||
widgets = {
|
||||
'title': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Brief title for the action plan'
|
||||
}),
|
||||
'description': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 4,
|
||||
'placeholder': 'Detailed description of the issue or improvement needed'
|
||||
}),
|
||||
'due_at': forms.DateTimeInput(attrs={
|
||||
'type': 'datetime-local',
|
||||
'class': 'form-control'
|
||||
}),
|
||||
'action_plan': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 4,
|
||||
'placeholder': 'Proposed action steps to address the issue'
|
||||
}),
|
||||
"title": forms.TextInput(attrs={"class": "form-control", "placeholder": "Brief title for the action plan"}),
|
||||
"description": forms.Textarea(
|
||||
attrs={
|
||||
"class": "form-control",
|
||||
"rows": 4,
|
||||
"placeholder": "Detailed description of the issue or improvement needed",
|
||||
}
|
||||
),
|
||||
"due_at": forms.DateTimeInput(attrs={"type": "datetime-local", "class": "form-control"}),
|
||||
"action_plan": forms.Textarea(
|
||||
attrs={"class": "form-control", "rows": 4, "placeholder": "Proposed action steps to address the issue"}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
user = kwargs.pop('user', None)
|
||||
request = kwargs.pop("request", None)
|
||||
user = request.user if request else None
|
||||
tenant_hospital = getattr(request, "tenant_hospital", None) if request else None
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Make hospital field required
|
||||
self.fields['hospital'].required = True
|
||||
self.fields['hospital'].widget.attrs['class'] = 'form-select'
|
||||
|
||||
# Make source type required
|
||||
self.fields['source_type'].required = True
|
||||
self.fields['source_type'].widget.attrs['class'] = 'form-select'
|
||||
|
||||
# Make category required
|
||||
self.fields['category'].required = True
|
||||
self.fields['category'].widget.attrs['class'] = 'form-select'
|
||||
|
||||
# Make priority and severity required
|
||||
self.fields['priority'].required = True
|
||||
self.fields['priority'].widget.attrs['class'] = 'form-select'
|
||||
self.fields['severity'].required = True
|
||||
self.fields['severity'].widget.attrs['class'] = 'form-select'
|
||||
|
||||
# Filter hospitals based on user permissions
|
||||
if user and not user.is_px_admin():
|
||||
if user.hospital:
|
||||
self.fields['hospital'].queryset = self.fields['hospital'].queryset.filter(
|
||||
id=user.hospital.id
|
||||
)
|
||||
self.fields['hospital'].initial = user.hospital.id
|
||||
self.fields['hospital'].widget.attrs['disabled'] = True
|
||||
|
||||
# Make hospital field hidden and auto-set based on user context
|
||||
self.fields["hospital"].widget = forms.HiddenInput()
|
||||
self.fields["hospital"].required = False
|
||||
|
||||
# Set hospital value based on user context
|
||||
if user:
|
||||
if user.is_px_admin() and tenant_hospital:
|
||||
self.fields["hospital"].initial = tenant_hospital
|
||||
self.fields["hospital"].queryset = self.fields["hospital"].queryset.filter(id=tenant_hospital.id)
|
||||
hospital = tenant_hospital
|
||||
elif user.hospital:
|
||||
self.fields["hospital"].initial = user.hospital
|
||||
self.fields["hospital"].queryset = self.fields["hospital"].queryset.filter(id=user.hospital.id)
|
||||
hospital = user.hospital
|
||||
else:
|
||||
# User doesn't have a hospital, can't create actions
|
||||
self.fields['hospital'].queryset = self.fields['hospital'].queryset.none()
|
||||
|
||||
# Filter departments based on selected hospital
|
||||
if user and user.hospital:
|
||||
self.fields['department'].queryset = self.fields['department'].queryset.filter(
|
||||
hospital=user.hospital
|
||||
)
|
||||
self.fields['department'].widget.attrs['class'] = 'form-select'
|
||||
|
||||
hospital = None
|
||||
self.fields["hospital"].queryset = self.fields["hospital"].queryset.none()
|
||||
else:
|
||||
hospital = None
|
||||
|
||||
# Make source type required
|
||||
self.fields["source_type"].required = True
|
||||
self.fields["source_type"].widget.attrs["class"] = "form-select"
|
||||
|
||||
# Make category required
|
||||
self.fields["category"].required = True
|
||||
self.fields["category"].widget.attrs["class"] = "form-select"
|
||||
|
||||
# Make priority and severity required
|
||||
self.fields["priority"].required = True
|
||||
self.fields["priority"].widget.attrs["class"] = "form-select"
|
||||
self.fields["severity"].required = True
|
||||
self.fields["severity"].widget.attrs["class"] = "form-select"
|
||||
|
||||
# Filter departments based on hospital
|
||||
if hospital:
|
||||
self.fields["department"].queryset = self.fields["department"].queryset.filter(hospital=hospital)
|
||||
self.fields["department"].widget.attrs["class"] = "form-select"
|
||||
|
||||
# Filter assignable users
|
||||
from apps.accounts.models import User
|
||||
if user and user.hospital:
|
||||
self.fields['assigned_to'].queryset = User.objects.filter(
|
||||
is_active=True,
|
||||
hospital=user.hospital
|
||||
).order_by('first_name', 'last_name')
|
||||
self.fields['assigned_to'].widget.attrs['class'] = 'form-select'
|
||||
elif user and user.is_px_admin():
|
||||
# PX admins can assign to any active user
|
||||
self.fields['assigned_to'].queryset = User.objects.filter(
|
||||
is_active=True
|
||||
).order_by('first_name', 'last_name')
|
||||
self.fields['assigned_to'].widget.attrs['class'] = 'form-select'
|
||||
|
||||
if hospital:
|
||||
self.fields["assigned_to"].queryset = User.objects.filter(is_active=True, hospital=hospital).order_by(
|
||||
"first_name", "last_name"
|
||||
)
|
||||
self.fields["assigned_to"].widget.attrs["class"] = "form-select"
|
||||
else:
|
||||
self.fields['assigned_to'].queryset = User.objects.none()
|
||||
|
||||
self.fields["assigned_to"].queryset = User.objects.none()
|
||||
|
||||
# Set default for requires_approval
|
||||
self.fields['requires_approval'].initial = True
|
||||
self.fields['requires_approval'].widget.attrs['class'] = 'form-check-input'
|
||||
|
||||
self.fields["requires_approval"].initial = True
|
||||
self.fields["requires_approval"].widget.attrs["class"] = "form-check-input"
|
||||
|
||||
def clean_hospital(self):
|
||||
"""Validate hospital field"""
|
||||
hospital = self.cleaned_data.get('hospital')
|
||||
hospital = self.cleaned_data.get("hospital")
|
||||
if not hospital:
|
||||
raise forms.ValidationError("Hospital is required.")
|
||||
return hospital
|
||||
|
||||
|
||||
def clean_due_at(self):
|
||||
"""Validate due date is in the future"""
|
||||
due_at = self.cleaned_data.get('due_at')
|
||||
due_at = self.cleaned_data.get("due_at")
|
||||
if due_at:
|
||||
from django.utils import timezone
|
||||
|
||||
if due_at <= timezone.now():
|
||||
raise forms.ValidationError("Due date must be in the future.")
|
||||
return due_at
|
||||
|
||||
|
||||
def save(self, commit=True):
|
||||
"""Save action with manual source type"""
|
||||
action = super().save(commit=False)
|
||||
action.source_type = ActionSource.MANUAL
|
||||
|
||||
|
||||
if commit:
|
||||
action.save()
|
||||
|
||||
return action
|
||||
|
||||
return action
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"""
|
||||
PX Action Center UI views - Server-rendered templates for action center console
|
||||
"""
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
@ -29,7 +30,7 @@ from .models import (
|
||||
def action_list(request):
|
||||
"""
|
||||
PX Actions list view with advanced filters and views.
|
||||
|
||||
|
||||
Features:
|
||||
- Multiple views (All, My Actions, Overdue, Escalated, By Source)
|
||||
- Server-side pagination
|
||||
@ -39,15 +40,14 @@ def action_list(request):
|
||||
"""
|
||||
# Base queryset with optimizations
|
||||
queryset = PXAction.objects.select_related(
|
||||
'hospital', 'department', 'assigned_to',
|
||||
'approved_by', 'closed_by', 'content_type'
|
||||
"hospital", "department", "assigned_to", "approved_by", "closed_by", "content_type"
|
||||
)
|
||||
|
||||
|
||||
# Apply RBAC filters
|
||||
user = request.user
|
||||
# Get selected hospital for PX Admins (from middleware)
|
||||
selected_hospital = getattr(request, 'tenant_hospital', None)
|
||||
|
||||
selected_hospital = getattr(request, "tenant_hospital", None)
|
||||
|
||||
if user.is_px_admin():
|
||||
# PX Admins see all, but filter by selected hospital if set
|
||||
if selected_hospital:
|
||||
@ -60,129 +60,126 @@ def action_list(request):
|
||||
queryset = queryset.filter(hospital=user.hospital)
|
||||
else:
|
||||
queryset = queryset.none()
|
||||
|
||||
|
||||
# View filter (My Actions, Overdue, etc.)
|
||||
view_filter = request.GET.get('view', 'all')
|
||||
if view_filter == 'my_actions':
|
||||
view_filter = request.GET.get("view", "all")
|
||||
if view_filter == "my_actions":
|
||||
queryset = queryset.filter(assigned_to=user)
|
||||
elif view_filter == 'overdue':
|
||||
elif view_filter == "overdue":
|
||||
queryset = queryset.filter(is_overdue=True, status__in=[ActionStatus.OPEN, ActionStatus.IN_PROGRESS])
|
||||
elif view_filter == 'escalated':
|
||||
elif view_filter == "escalated":
|
||||
queryset = queryset.filter(escalation_level__gt=0)
|
||||
elif view_filter == 'pending_approval':
|
||||
elif view_filter == "pending_approval":
|
||||
queryset = queryset.filter(status=ActionStatus.PENDING_APPROVAL)
|
||||
elif view_filter == 'from_surveys':
|
||||
elif view_filter == "from_surveys":
|
||||
queryset = queryset.filter(source_type=ActionSource.SURVEY)
|
||||
elif view_filter == 'from_complaints':
|
||||
elif view_filter == "from_complaints":
|
||||
queryset = queryset.filter(source_type__in=[ActionSource.COMPLAINT, ActionSource.COMPLAINT_RESOLUTION])
|
||||
elif view_filter == 'from_social':
|
||||
elif view_filter == "from_social":
|
||||
queryset = queryset.filter(source_type=ActionSource.SOCIAL_MEDIA)
|
||||
|
||||
|
||||
# Apply filters from request
|
||||
status_filter = request.GET.get('status')
|
||||
status_filter = request.GET.get("status")
|
||||
if status_filter:
|
||||
queryset = queryset.filter(status=status_filter)
|
||||
|
||||
severity_filter = request.GET.get('severity')
|
||||
|
||||
severity_filter = request.GET.get("severity")
|
||||
if severity_filter:
|
||||
queryset = queryset.filter(severity=severity_filter)
|
||||
|
||||
priority_filter = request.GET.get('priority')
|
||||
|
||||
priority_filter = request.GET.get("priority")
|
||||
if priority_filter:
|
||||
queryset = queryset.filter(priority=priority_filter)
|
||||
|
||||
category_filter = request.GET.get('category')
|
||||
|
||||
category_filter = request.GET.get("category")
|
||||
if category_filter:
|
||||
queryset = queryset.filter(category=category_filter)
|
||||
|
||||
source_type_filter = request.GET.get('source_type')
|
||||
|
||||
source_type_filter = request.GET.get("source_type")
|
||||
if source_type_filter:
|
||||
queryset = queryset.filter(source_type=source_type_filter)
|
||||
|
||||
hospital_filter = request.GET.get('hospital')
|
||||
|
||||
hospital_filter = request.GET.get("hospital")
|
||||
if hospital_filter:
|
||||
queryset = queryset.filter(hospital_id=hospital_filter)
|
||||
|
||||
department_filter = request.GET.get('department')
|
||||
|
||||
department_filter = request.GET.get("department")
|
||||
if department_filter:
|
||||
queryset = queryset.filter(department_id=department_filter)
|
||||
|
||||
assigned_to_filter = request.GET.get('assigned_to')
|
||||
|
||||
assigned_to_filter = request.GET.get("assigned_to")
|
||||
if assigned_to_filter:
|
||||
queryset = queryset.filter(assigned_to_id=assigned_to_filter)
|
||||
|
||||
|
||||
# Search
|
||||
search_query = request.GET.get('search')
|
||||
search_query = request.GET.get("search")
|
||||
if search_query:
|
||||
queryset = queryset.filter(
|
||||
Q(title__icontains=search_query) |
|
||||
Q(description__icontains=search_query)
|
||||
)
|
||||
|
||||
queryset = queryset.filter(Q(title__icontains=search_query) | Q(description__icontains=search_query))
|
||||
|
||||
# Date range filters
|
||||
date_from = request.GET.get('date_from')
|
||||
date_from = request.GET.get("date_from")
|
||||
if date_from:
|
||||
queryset = queryset.filter(created_at__gte=date_from)
|
||||
|
||||
date_to = request.GET.get('date_to')
|
||||
|
||||
date_to = request.GET.get("date_to")
|
||||
if date_to:
|
||||
queryset = queryset.filter(created_at__lte=date_to)
|
||||
|
||||
|
||||
# Ordering
|
||||
order_by = request.GET.get('order_by', '-created_at')
|
||||
order_by = request.GET.get("order_by", "-created_at")
|
||||
queryset = queryset.order_by(order_by)
|
||||
|
||||
|
||||
# Pagination
|
||||
page_size = int(request.GET.get('page_size', 25))
|
||||
page_size = int(request.GET.get("page_size", 25))
|
||||
paginator = Paginator(queryset, page_size)
|
||||
page_number = request.GET.get('page', 1)
|
||||
page_number = request.GET.get("page", 1)
|
||||
page_obj = paginator.get_page(page_number)
|
||||
|
||||
|
||||
# Get filter options
|
||||
hospitals = Hospital.objects.filter(status='active')
|
||||
hospitals = Hospital.objects.filter(status="active")
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
hospitals = hospitals.filter(id=user.hospital.id)
|
||||
|
||||
departments = Department.objects.filter(status='active')
|
||||
|
||||
departments = Department.objects.filter(status="active")
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
departments = departments.filter(hospital=user.hospital)
|
||||
|
||||
|
||||
# Get assignable users
|
||||
assignable_users = User.objects.filter(is_active=True)
|
||||
if user.hospital:
|
||||
assignable_users = assignable_users.filter(hospital=user.hospital)
|
||||
|
||||
|
||||
# Statistics
|
||||
stats = {
|
||||
'total': queryset.count(),
|
||||
'open': queryset.filter(status=ActionStatus.OPEN).count(),
|
||||
'in_progress': queryset.filter(status=ActionStatus.IN_PROGRESS).count(),
|
||||
'overdue': queryset.filter(is_overdue=True).count(),
|
||||
'pending_approval': queryset.filter(status=ActionStatus.PENDING_APPROVAL).count(),
|
||||
'my_actions': queryset.filter(assigned_to=user).count(),
|
||||
"total": queryset.count(),
|
||||
"open": queryset.filter(status=ActionStatus.OPEN).count(),
|
||||
"in_progress": queryset.filter(status=ActionStatus.IN_PROGRESS).count(),
|
||||
"overdue": queryset.filter(is_overdue=True).count(),
|
||||
"pending_approval": queryset.filter(status=ActionStatus.PENDING_APPROVAL).count(),
|
||||
"my_actions": queryset.filter(assigned_to=user).count(),
|
||||
}
|
||||
|
||||
|
||||
context = {
|
||||
'page_obj': page_obj,
|
||||
'actions': page_obj.object_list,
|
||||
'stats': stats,
|
||||
'hospitals': hospitals,
|
||||
'departments': departments,
|
||||
'assignable_users': assignable_users,
|
||||
'status_choices': ActionStatus.choices,
|
||||
'source_choices': ActionSource.choices,
|
||||
'filters': request.GET,
|
||||
'current_view': view_filter,
|
||||
"page_obj": page_obj,
|
||||
"actions": page_obj.object_list,
|
||||
"stats": stats,
|
||||
"hospitals": hospitals,
|
||||
"departments": departments,
|
||||
"assignable_users": assignable_users,
|
||||
"status_choices": ActionStatus.choices,
|
||||
"source_choices": ActionSource.choices,
|
||||
"filters": request.GET,
|
||||
"current_view": view_filter,
|
||||
}
|
||||
|
||||
return render(request, 'actions/action_list.html', context)
|
||||
|
||||
return render(request, "actions/action_list.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
def action_detail(request, pk):
|
||||
"""
|
||||
PX Action detail view with logs, attachments, and workflow actions.
|
||||
|
||||
|
||||
Features:
|
||||
- Full action details
|
||||
- Activity log timeline
|
||||
@ -193,43 +190,39 @@ def action_detail(request, pk):
|
||||
"""
|
||||
action = get_object_or_404(
|
||||
PXAction.objects.select_related(
|
||||
'hospital', 'department', 'assigned_to',
|
||||
'approved_by', 'closed_by', 'content_type'
|
||||
).prefetch_related(
|
||||
'logs__created_by',
|
||||
'attachments__uploaded_by'
|
||||
),
|
||||
pk=pk
|
||||
"hospital", "department", "assigned_to", "approved_by", "closed_by", "content_type"
|
||||
).prefetch_related("logs__created_by", "attachments__uploaded_by"),
|
||||
pk=pk,
|
||||
)
|
||||
|
||||
|
||||
# Check access
|
||||
user = request.user
|
||||
if not user.is_px_admin():
|
||||
if user.is_hospital_admin() and action.hospital != user.hospital:
|
||||
messages.error(request, "You don't have permission to view this action.")
|
||||
return redirect('actions:action_list')
|
||||
return redirect("actions:action_list")
|
||||
elif user.is_department_manager() and action.department != user.department:
|
||||
messages.error(request, "You don't have permission to view this action.")
|
||||
return redirect('actions:action_list')
|
||||
return redirect("actions:action_list")
|
||||
elif user.hospital and action.hospital != user.hospital:
|
||||
messages.error(request, "You don't have permission to view this action.")
|
||||
return redirect('actions:action_list')
|
||||
|
||||
return redirect("actions:action_list")
|
||||
|
||||
# Get logs (timeline)
|
||||
logs = action.logs.all().order_by('-created_at')
|
||||
|
||||
logs = action.logs.all().order_by("-created_at")
|
||||
|
||||
# Get attachments
|
||||
attachments = action.attachments.all().order_by('-created_at')
|
||||
attachments = action.attachments.all().order_by("-created_at")
|
||||
evidence_attachments = attachments.filter(is_evidence=True)
|
||||
|
||||
|
||||
# Get assignable users
|
||||
assignable_users = User.objects.filter(is_active=True)
|
||||
if action.hospital:
|
||||
assignable_users = assignable_users.filter(hospital=action.hospital)
|
||||
|
||||
|
||||
# Check if overdue
|
||||
action.check_overdue()
|
||||
|
||||
|
||||
# Calculate SLA progress percentage
|
||||
if action.created_at and action.due_at:
|
||||
total_duration = (action.due_at - action.created_at).total_seconds()
|
||||
@ -237,20 +230,20 @@ def action_detail(request, pk):
|
||||
sla_progress = min(100, int((elapsed_duration / total_duration) * 100)) if total_duration > 0 else 0
|
||||
else:
|
||||
sla_progress = 0
|
||||
|
||||
|
||||
context = {
|
||||
'action': action,
|
||||
'logs': logs,
|
||||
'attachments': attachments,
|
||||
'evidence_attachments': evidence_attachments,
|
||||
'assignable_users': assignable_users,
|
||||
'status_choices': ActionStatus.choices,
|
||||
'sla_progress': sla_progress,
|
||||
'can_edit': user.is_px_admin() or user.is_hospital_admin() or action.assigned_to == user,
|
||||
'can_approve': user.is_px_admin(),
|
||||
"action": action,
|
||||
"logs": logs,
|
||||
"attachments": attachments,
|
||||
"evidence_attachments": evidence_attachments,
|
||||
"assignable_users": assignable_users,
|
||||
"status_choices": ActionStatus.choices,
|
||||
"sla_progress": sla_progress,
|
||||
"can_edit": user.is_px_admin() or user.is_hospital_admin() or action.assigned_to == user,
|
||||
"can_approve": user.is_px_admin(),
|
||||
}
|
||||
|
||||
return render(request, 'actions/action_detail.html', context)
|
||||
|
||||
return render(request, "actions/action_detail.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
@ -258,46 +251,46 @@ def action_detail(request, pk):
|
||||
def action_assign(request, pk):
|
||||
"""Assign action to user"""
|
||||
action = get_object_or_404(PXAction, pk=pk)
|
||||
|
||||
|
||||
# Check permission
|
||||
user = request.user
|
||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||
messages.error(request, "You don't have permission to assign actions.")
|
||||
return redirect('actions:action_detail', pk=pk)
|
||||
|
||||
user_id = request.POST.get('user_id')
|
||||
return redirect("actions:action_detail", pk=pk)
|
||||
|
||||
user_id = request.POST.get("user_id")
|
||||
if not user_id:
|
||||
messages.error(request, "Please select a user to assign.")
|
||||
return redirect('actions:action_detail', pk=pk)
|
||||
|
||||
return redirect("actions:action_detail", pk=pk)
|
||||
|
||||
try:
|
||||
assignee = User.objects.get(id=user_id)
|
||||
action.assigned_to = assignee
|
||||
action.assigned_at = timezone.now()
|
||||
action.save(update_fields=['assigned_to', 'assigned_at'])
|
||||
|
||||
action.save(update_fields=["assigned_to", "assigned_at"])
|
||||
|
||||
# Create log
|
||||
PXActionLog.objects.create(
|
||||
action=action,
|
||||
log_type='assignment',
|
||||
log_type="assignment",
|
||||
message=f"Assigned to {assignee.get_full_name()}",
|
||||
created_by=request.user
|
||||
created_by=request.user,
|
||||
)
|
||||
|
||||
|
||||
# Audit log
|
||||
AuditService.log_event(
|
||||
event_type='assignment',
|
||||
event_type="assignment",
|
||||
description=f"Action assigned to {assignee.get_full_name()}",
|
||||
user=request.user,
|
||||
content_object=action
|
||||
content_object=action,
|
||||
)
|
||||
|
||||
|
||||
messages.success(request, f"Action assigned to {assignee.get_full_name()}.")
|
||||
|
||||
|
||||
except User.DoesNotExist:
|
||||
messages.error(request, "User not found.")
|
||||
|
||||
return redirect('actions:action_detail', pk=pk)
|
||||
|
||||
return redirect("actions:action_detail", pk=pk)
|
||||
|
||||
|
||||
@login_required
|
||||
@ -305,64 +298,64 @@ def action_assign(request, pk):
|
||||
def action_change_status(request, pk):
|
||||
"""Change action status"""
|
||||
action = get_object_or_404(PXAction, pk=pk)
|
||||
|
||||
|
||||
# Check permission
|
||||
user = request.user
|
||||
if not (user.is_px_admin() or user.is_hospital_admin() or action.assigned_to == user):
|
||||
messages.error(request, "You don't have permission to change action status.")
|
||||
return redirect('actions:action_detail', pk=pk)
|
||||
|
||||
new_status = request.POST.get('status')
|
||||
note = request.POST.get('note', '')
|
||||
|
||||
return redirect("actions:action_detail", pk=pk)
|
||||
|
||||
new_status = request.POST.get("status")
|
||||
note = request.POST.get("note", "")
|
||||
|
||||
if not new_status:
|
||||
messages.error(request, "Please select a status.")
|
||||
return redirect('actions:action_detail', pk=pk)
|
||||
|
||||
return redirect("actions:action_detail", pk=pk)
|
||||
|
||||
# Validate status transitions
|
||||
if new_status == ActionStatus.PENDING_APPROVAL:
|
||||
if action.requires_approval:
|
||||
evidence_count = action.attachments.filter(is_evidence=True).count()
|
||||
if evidence_count == 0:
|
||||
messages.error(request, "Evidence is required before requesting approval.")
|
||||
return redirect('actions:action_detail', pk=pk)
|
||||
|
||||
return redirect("actions:action_detail", pk=pk)
|
||||
|
||||
elif new_status == ActionStatus.APPROVED:
|
||||
if not user.is_px_admin():
|
||||
messages.error(request, "Only PX Admins can approve actions.")
|
||||
return redirect('actions:action_detail', pk=pk)
|
||||
return redirect("actions:action_detail", pk=pk)
|
||||
action.approved_by = user
|
||||
action.approved_at = timezone.now()
|
||||
|
||||
|
||||
elif new_status == ActionStatus.CLOSED:
|
||||
action.closed_at = timezone.now()
|
||||
action.closed_by = user
|
||||
|
||||
|
||||
old_status = action.status
|
||||
action.status = new_status
|
||||
action.save()
|
||||
|
||||
|
||||
# Create log
|
||||
PXActionLog.objects.create(
|
||||
action=action,
|
||||
log_type='status_change',
|
||||
log_type="status_change",
|
||||
message=note or f"Status changed from {old_status} to {new_status}",
|
||||
created_by=user,
|
||||
old_status=old_status,
|
||||
new_status=new_status
|
||||
new_status=new_status,
|
||||
)
|
||||
|
||||
|
||||
# Audit log
|
||||
AuditService.log_event(
|
||||
event_type='status_change',
|
||||
event_type="status_change",
|
||||
description=f"Action status changed from {old_status} to {new_status}",
|
||||
user=user,
|
||||
content_object=action,
|
||||
metadata={'old_status': old_status, 'new_status': new_status}
|
||||
metadata={"old_status": old_status, "new_status": new_status},
|
||||
)
|
||||
|
||||
|
||||
messages.success(request, f"Action status changed to {new_status}.")
|
||||
return redirect('actions:action_detail', pk=pk)
|
||||
return redirect("actions:action_detail", pk=pk)
|
||||
|
||||
|
||||
@login_required
|
||||
@ -370,22 +363,17 @@ def action_change_status(request, pk):
|
||||
def action_add_note(request, pk):
|
||||
"""Add note to action"""
|
||||
action = get_object_or_404(PXAction, pk=pk)
|
||||
|
||||
note = request.POST.get('note')
|
||||
|
||||
note = request.POST.get("note")
|
||||
if not note:
|
||||
messages.error(request, "Please enter a note.")
|
||||
return redirect('actions:action_detail', pk=pk)
|
||||
|
||||
return redirect("actions:action_detail", pk=pk)
|
||||
|
||||
# Create log
|
||||
PXActionLog.objects.create(
|
||||
action=action,
|
||||
log_type='note',
|
||||
message=note,
|
||||
created_by=request.user
|
||||
)
|
||||
|
||||
PXActionLog.objects.create(action=action, log_type="note", message=note, created_by=request.user)
|
||||
|
||||
messages.success(request, "Note added successfully.")
|
||||
return redirect('actions:action_detail', pk=pk)
|
||||
return redirect("actions:action_detail", pk=pk)
|
||||
|
||||
|
||||
@login_required
|
||||
@ -393,39 +381,39 @@ def action_add_note(request, pk):
|
||||
def action_escalate(request, pk):
|
||||
"""Escalate action"""
|
||||
action = get_object_or_404(PXAction, pk=pk)
|
||||
|
||||
|
||||
# Check permission
|
||||
user = request.user
|
||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||
messages.error(request, "You don't have permission to escalate actions.")
|
||||
return redirect('actions:action_detail', pk=pk)
|
||||
|
||||
reason = request.POST.get('reason', '')
|
||||
|
||||
return redirect("actions:action_detail", pk=pk)
|
||||
|
||||
reason = request.POST.get("reason", "")
|
||||
|
||||
# Increment escalation level
|
||||
action.escalation_level += 1
|
||||
action.escalated_at = timezone.now()
|
||||
action.save(update_fields=['escalation_level', 'escalated_at'])
|
||||
|
||||
action.save(update_fields=["escalation_level", "escalated_at"])
|
||||
|
||||
# Create log
|
||||
PXActionLog.objects.create(
|
||||
action=action,
|
||||
log_type='escalation',
|
||||
log_type="escalation",
|
||||
message=f"Action escalated (Level {action.escalation_level}). Reason: {reason}",
|
||||
created_by=user
|
||||
created_by=user,
|
||||
)
|
||||
|
||||
|
||||
# Audit log
|
||||
AuditService.log_event(
|
||||
event_type='escalation',
|
||||
event_type="escalation",
|
||||
description=f"Action escalated to level {action.escalation_level}",
|
||||
user=user,
|
||||
content_object=action,
|
||||
metadata={'reason': reason, 'escalation_level': action.escalation_level}
|
||||
metadata={"reason": reason, "escalation_level": action.escalation_level},
|
||||
)
|
||||
|
||||
|
||||
messages.success(request, f"Action escalated to level {action.escalation_level}.")
|
||||
return redirect('actions:action_detail', pk=pk)
|
||||
return redirect("actions:action_detail", pk=pk)
|
||||
|
||||
|
||||
@login_required
|
||||
@ -433,49 +421,46 @@ def action_escalate(request, pk):
|
||||
def action_approve(request, pk):
|
||||
"""Approve action (PX Admin only)"""
|
||||
action = get_object_or_404(PXAction, pk=pk)
|
||||
|
||||
|
||||
# Check permission
|
||||
if not request.user.is_px_admin():
|
||||
messages.error(request, "Only PX Admins can approve actions.")
|
||||
return redirect('actions:action_detail', pk=pk)
|
||||
|
||||
return redirect("actions:action_detail", pk=pk)
|
||||
|
||||
if action.status != ActionStatus.PENDING_APPROVAL:
|
||||
messages.error(request, "Action is not pending approval.")
|
||||
return redirect('actions:action_detail', pk=pk)
|
||||
|
||||
return redirect("actions:action_detail", pk=pk)
|
||||
|
||||
# Approve
|
||||
action.status = ActionStatus.APPROVED
|
||||
action.approved_by = request.user
|
||||
action.approved_at = timezone.now()
|
||||
action.save()
|
||||
|
||||
|
||||
# Create log
|
||||
PXActionLog.objects.create(
|
||||
action=action,
|
||||
log_type='approval',
|
||||
log_type="approval",
|
||||
message=f"Action approved by {request.user.get_full_name()}",
|
||||
created_by=request.user,
|
||||
old_status=ActionStatus.PENDING_APPROVAL,
|
||||
new_status=ActionStatus.APPROVED
|
||||
new_status=ActionStatus.APPROVED,
|
||||
)
|
||||
|
||||
|
||||
# Audit log
|
||||
AuditService.log_event(
|
||||
event_type='approval',
|
||||
description="Action approved",
|
||||
user=request.user,
|
||||
content_object=action
|
||||
event_type="approval", description="Action approved", user=request.user, content_object=action
|
||||
)
|
||||
|
||||
|
||||
messages.success(request, "Action approved successfully.")
|
||||
return redirect('actions:action_detail', pk=pk)
|
||||
return redirect("actions:action_detail", pk=pk)
|
||||
|
||||
|
||||
@login_required
|
||||
def action_create(request):
|
||||
"""
|
||||
Create a new PX Action manually.
|
||||
|
||||
|
||||
Features:
|
||||
- Source type selection (including meeting types)
|
||||
- Hospital/department selection (filtered by permissions)
|
||||
@ -488,67 +473,68 @@ def action_create(request):
|
||||
user = request.user
|
||||
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager()):
|
||||
messages.error(request, "You don't have permission to create actions.")
|
||||
return redirect('actions:action_list')
|
||||
|
||||
if request.method == 'POST':
|
||||
form = ManualActionForm(request.POST, user=user)
|
||||
return redirect("actions:action_list")
|
||||
|
||||
if request.method == "POST":
|
||||
form = ManualActionForm(request.POST, request=request)
|
||||
if form.is_valid():
|
||||
action = form.save(commit=False)
|
||||
action.created_by = user
|
||||
|
||||
|
||||
# Set status to OPEN
|
||||
action.status = ActionStatus.OPEN
|
||||
|
||||
|
||||
# If assigned, set assignment timestamp
|
||||
if action.assigned_to:
|
||||
action.assigned_at = timezone.now()
|
||||
|
||||
|
||||
action.save()
|
||||
|
||||
|
||||
# Create log
|
||||
PXActionLog.objects.create(
|
||||
action=action,
|
||||
log_type='status_change',
|
||||
log_type="status_change",
|
||||
message=f"Action created manually from {action.get_source_type_display()}",
|
||||
created_by=user
|
||||
created_by=user,
|
||||
)
|
||||
|
||||
|
||||
# Audit log
|
||||
AuditService.log_event(
|
||||
event_type='action_created',
|
||||
event_type="action_created",
|
||||
description=f"Manual action created from {action.get_source_type_display()}",
|
||||
user=user,
|
||||
content_object=action
|
||||
content_object=action,
|
||||
)
|
||||
|
||||
|
||||
# Notify assigned user
|
||||
if action.assigned_to:
|
||||
from apps.notifications.services import NotificationService
|
||||
|
||||
NotificationService.send_notification(
|
||||
recipient=action.assigned_to,
|
||||
title=f"New Action Assigned: {action.title}",
|
||||
message=f"You have been assigned a new action. Due: {action.due_at.strftime('%Y-%m-%d %H:%M')}",
|
||||
notification_type='action_assigned',
|
||||
metadata={'link': f"/actions/{action.id}/"}
|
||||
notification_type="action_assigned",
|
||||
metadata={"link": f"/actions/{action.id}/"},
|
||||
)
|
||||
|
||||
|
||||
messages.success(request, "Action created successfully.")
|
||||
return redirect('actions:action_detail', pk=action.id)
|
||||
return redirect("actions:action_detail", pk=action.id)
|
||||
else:
|
||||
# Pre-fill hospital if user has one
|
||||
initial = {}
|
||||
if user.hospital:
|
||||
initial['hospital'] = user.hospital.id
|
||||
|
||||
form = ManualActionForm(user=user, initial=initial)
|
||||
|
||||
initial["hospital"] = user.hospital.id
|
||||
|
||||
form = ManualActionForm(request=request)
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
'source_choices': ActionSource.choices,
|
||||
'status_choices': ActionStatus.choices,
|
||||
"form": form,
|
||||
"source_choices": ActionSource.choices,
|
||||
"status_choices": ActionStatus.choices,
|
||||
}
|
||||
|
||||
return render(request, 'actions/action_create.html', context)
|
||||
|
||||
return render(request, "actions/action_create.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
@ -556,45 +542,35 @@ def action_create(request):
|
||||
def action_create_from_ai(request, complaint_id):
|
||||
"""
|
||||
Create a PX Action automatically from AI suggestion.
|
||||
|
||||
|
||||
Creates action in background and returns JSON response.
|
||||
Links action to the complaint automatically.
|
||||
"""
|
||||
from apps.complaints.models import Complaint
|
||||
import json
|
||||
|
||||
|
||||
complaint = get_object_or_404(Complaint, id=complaint_id)
|
||||
|
||||
|
||||
# Check permission
|
||||
user = request.user
|
||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'You do not have permission to create actions.'
|
||||
}, status=403)
|
||||
|
||||
return JsonResponse({"success": False, "error": "You do not have permission to create actions."}, status=403)
|
||||
|
||||
# Get action data from POST
|
||||
action_text = request.POST.get('action', '')
|
||||
priority = request.POST.get('priority', 'medium')
|
||||
category = request.POST.get('category', 'process_improvement')
|
||||
|
||||
action_text = request.POST.get("action", "")
|
||||
priority = request.POST.get("priority", "medium")
|
||||
category = request.POST.get("category", "process_improvement")
|
||||
|
||||
if not action_text:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'Action description is required.'
|
||||
}, status=400)
|
||||
|
||||
return JsonResponse({"success": False, "error": "Action description is required."}, status=400)
|
||||
|
||||
try:
|
||||
# Map priority/severity
|
||||
priority_map = {
|
||||
'high': 'high',
|
||||
'medium': 'medium',
|
||||
'low': 'low'
|
||||
}
|
||||
|
||||
priority_map = {"high": "high", "medium": "medium", "low": "low"}
|
||||
|
||||
# Create action
|
||||
action = PXAction.objects.create(
|
||||
source_type='complaint',
|
||||
source_type="complaint",
|
||||
content_type=ContentType.objects.get_for_model(Complaint),
|
||||
object_id=complaint.id,
|
||||
title=f"AI Suggested Action - {complaint.reference_number}",
|
||||
@ -602,63 +578,60 @@ def action_create_from_ai(request, complaint_id):
|
||||
hospital=complaint.hospital,
|
||||
department=complaint.department,
|
||||
category=category,
|
||||
priority=priority_map.get(priority, 'medium'),
|
||||
severity=priority_map.get(priority, 'medium'),
|
||||
priority=priority_map.get(priority, "medium"),
|
||||
severity=priority_map.get(priority, "medium"),
|
||||
status=ActionStatus.OPEN,
|
||||
assigned_to=complaint.assigned_to, # Assign to same person as complaint
|
||||
assigned_at=timezone.now() if complaint.assigned_to else None,
|
||||
)
|
||||
|
||||
|
||||
# Set due date based on priority (SLA)
|
||||
from datetime import timedelta
|
||||
due_days = {
|
||||
'high': 3,
|
||||
'medium': 7,
|
||||
'low': 14
|
||||
}
|
||||
|
||||
due_days = {"high": 3, "medium": 7, "low": 14}
|
||||
action.due_at = timezone.now() + timedelta(days=due_days.get(priority, 7))
|
||||
action.save()
|
||||
|
||||
|
||||
# Create log
|
||||
PXActionLog.objects.create(
|
||||
action=action,
|
||||
log_type='status_change',
|
||||
log_type="status_change",
|
||||
message=f"Action created automatically from AI suggestion for complaint {complaint.reference_number}",
|
||||
)
|
||||
|
||||
|
||||
# Audit log
|
||||
AuditService.log_event(
|
||||
event_type='action_created',
|
||||
event_type="action_created",
|
||||
description=f"Action created automatically from AI suggestion",
|
||||
user=user,
|
||||
content_object=action,
|
||||
metadata={
|
||||
'complaint_id': str(complaint.id),
|
||||
'complaint_reference': complaint.reference_number,
|
||||
'source': 'ai_suggestion'
|
||||
}
|
||||
"complaint_id": str(complaint.id),
|
||||
"complaint_reference": complaint.reference_number,
|
||||
"source": "ai_suggestion",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Notify assigned user
|
||||
if action.assigned_to:
|
||||
from apps.notifications.services import NotificationService
|
||||
|
||||
NotificationService.send_notification(
|
||||
recipient=action.assigned_to,
|
||||
title=f"New Action from AI: {action.title}",
|
||||
message=f"AI suggested a new action for complaint {complaint.reference_number}. Due: {action.due_at.strftime('%Y-%m-%d')}",
|
||||
notification_type='action_assigned',
|
||||
metadata={'link': f"/actions/{action.id}/"}
|
||||
notification_type="action_assigned",
|
||||
metadata={"link": f"/actions/{action.id}/"},
|
||||
)
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'action_id': str(action.id),
|
||||
'action_url': f"/actions/{action.id}/",
|
||||
'message': 'PX Action created successfully from AI suggestion.'
|
||||
})
|
||||
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"success": True,
|
||||
"action_id": str(action.id),
|
||||
"action_url": f"/actions/{action.id}/",
|
||||
"message": "PX Action created successfully from AI suggestion.",
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
return JsonResponse({"success": False, "error": str(e)}, status=500)
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
"""
|
||||
RCA (Root Cause Analysis) forms
|
||||
"""
|
||||
|
||||
from django import forms
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.core.models import PriorityChoices
|
||||
|
||||
from apps.core.form_mixins import HospitalFieldMixin
|
||||
from .models import (
|
||||
RCAActionStatus,
|
||||
RCAActionType,
|
||||
@ -19,79 +19,52 @@ from .models import (
|
||||
)
|
||||
|
||||
|
||||
class RootCauseAnalysisForm(forms.ModelForm):
|
||||
class RootCauseAnalysisForm(HospitalFieldMixin, forms.ModelForm):
|
||||
"""Form for creating and editing RootCauseAnalysis"""
|
||||
|
||||
class Meta:
|
||||
model = RootCauseAnalysis
|
||||
fields = [
|
||||
'title',
|
||||
'description',
|
||||
'background',
|
||||
'hospital',
|
||||
'department',
|
||||
'status',
|
||||
'severity',
|
||||
'priority',
|
||||
'assigned_to',
|
||||
'target_completion_date',
|
||||
'root_cause_summary',
|
||||
"title",
|
||||
"description",
|
||||
"background",
|
||||
"hospital",
|
||||
"department",
|
||||
"status",
|
||||
"severity",
|
||||
"priority",
|
||||
"assigned_to",
|
||||
"target_completion_date",
|
||||
"root_cause_summary",
|
||||
]
|
||||
widgets = {
|
||||
'title': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Enter RCA title'
|
||||
}),
|
||||
'description': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 4,
|
||||
'placeholder': 'Describe the incident or issue'
|
||||
}),
|
||||
'background': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 3,
|
||||
'placeholder': 'Provide background information and context'
|
||||
}),
|
||||
'hospital': forms.Select(attrs={'class': 'form-select'}),
|
||||
'department': forms.Select(attrs={'class': 'form-select'}),
|
||||
'status': forms.Select(attrs={'class': 'form-select'}),
|
||||
'severity': forms.Select(attrs={'class': 'form-select'}),
|
||||
'priority': forms.Select(attrs={'class': 'form-select'}),
|
||||
'assigned_to': forms.Select(attrs={'class': 'form-select'}),
|
||||
'target_completion_date': forms.DateInput(attrs={
|
||||
'class': 'form-control',
|
||||
'type': 'date'
|
||||
}),
|
||||
'root_cause_summary': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 4,
|
||||
'placeholder': 'Summary of root cause analysis findings'
|
||||
}),
|
||||
"title": forms.TextInput(attrs={"class": "form-control", "placeholder": "Enter RCA title"}),
|
||||
"description": forms.Textarea(
|
||||
attrs={"class": "form-control", "rows": 4, "placeholder": "Describe the incident or issue"}
|
||||
),
|
||||
"background": forms.Textarea(
|
||||
attrs={"class": "form-control", "rows": 3, "placeholder": "Provide background information and context"}
|
||||
),
|
||||
"hospital": forms.Select(attrs={"class": "form-select"}),
|
||||
"department": forms.Select(attrs={"class": "form-select"}),
|
||||
"status": forms.Select(attrs={"class": "form-select"}),
|
||||
"severity": forms.Select(attrs={"class": "form-select"}),
|
||||
"priority": forms.Select(attrs={"class": "form-select"}),
|
||||
"assigned_to": forms.Select(attrs={"class": "form-select"}),
|
||||
"target_completion_date": forms.DateInput(attrs={"class": "form-control", "type": "date"}),
|
||||
"root_cause_summary": forms.Textarea(
|
||||
attrs={"class": "form-control", "rows": 4, "placeholder": "Summary of root cause analysis findings"}
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
user = kwargs.pop('user', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Filter assigned_to to show only active users
|
||||
if 'assigned_to' in self.fields:
|
||||
if "assigned_to" in self.fields:
|
||||
from apps.accounts.models import User
|
||||
self.fields['assigned_to'].queryset = User.objects.filter(
|
||||
is_active=True
|
||||
).order_by('email')
|
||||
|
||||
# Set initial hospital if user has one
|
||||
if user and not self.instance.pk:
|
||||
from apps.organizations.models import Hospital
|
||||
try:
|
||||
user_hospital = Hospital.objects.filter(
|
||||
staff__user=user
|
||||
).first()
|
||||
if user_hospital:
|
||||
self.fields['hospital'].initial = user_hospital
|
||||
self.fields['hospital'].widget.attrs['readonly'] = True
|
||||
except:
|
||||
pass
|
||||
self.fields["assigned_to"].queryset = User.objects.filter(is_active=True).order_by("email")
|
||||
|
||||
|
||||
class RCARootCauseForm(forms.ModelForm):
|
||||
@ -100,54 +73,38 @@ class RCARootCauseForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = RCARootCause
|
||||
fields = [
|
||||
'description',
|
||||
'category',
|
||||
'contributing_factors',
|
||||
'likelihood',
|
||||
'impact',
|
||||
'evidence',
|
||||
"description",
|
||||
"category",
|
||||
"contributing_factors",
|
||||
"likelihood",
|
||||
"impact",
|
||||
"evidence",
|
||||
]
|
||||
widgets = {
|
||||
'description': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 3,
|
||||
'placeholder': 'Describe the root cause'
|
||||
}),
|
||||
'category': forms.Select(attrs={'class': 'form-select'}),
|
||||
'contributing_factors': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 2,
|
||||
'placeholder': 'Factors that contributed to this root cause'
|
||||
}),
|
||||
'likelihood': forms.NumberInput(attrs={
|
||||
'class': 'form-control',
|
||||
'min': 1,
|
||||
'max': 5,
|
||||
'placeholder': '1-5'
|
||||
}),
|
||||
'impact': forms.NumberInput(attrs={
|
||||
'class': 'form-control',
|
||||
'min': 1,
|
||||
'max': 5,
|
||||
'placeholder': '1-5'
|
||||
}),
|
||||
'evidence': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 2,
|
||||
'placeholder': 'Evidence supporting this root cause'
|
||||
}),
|
||||
"description": forms.Textarea(
|
||||
attrs={"class": "form-control", "rows": 3, "placeholder": "Describe the root cause"}
|
||||
),
|
||||
"category": forms.Select(attrs={"class": "form-select"}),
|
||||
"contributing_factors": forms.Textarea(
|
||||
attrs={"class": "form-control", "rows": 2, "placeholder": "Factors that contributed to this root cause"}
|
||||
),
|
||||
"likelihood": forms.NumberInput(attrs={"class": "form-control", "min": 1, "max": 5, "placeholder": "1-5"}),
|
||||
"impact": forms.NumberInput(attrs={"class": "form-control", "min": 1, "max": 5, "placeholder": "1-5"}),
|
||||
"evidence": forms.Textarea(
|
||||
attrs={"class": "form-control", "rows": 2, "placeholder": "Evidence supporting this root cause"}
|
||||
),
|
||||
}
|
||||
|
||||
def clean_likelihood(self):
|
||||
likelihood = self.cleaned_data.get('likelihood')
|
||||
likelihood = self.cleaned_data.get("likelihood")
|
||||
if likelihood and (likelihood < 1 or likelihood > 5):
|
||||
raise forms.ValidationError('Likelihood must be between 1 and 5')
|
||||
raise forms.ValidationError("Likelihood must be between 1 and 5")
|
||||
return likelihood
|
||||
|
||||
def clean_impact(self):
|
||||
impact = self.cleaned_data.get('impact')
|
||||
impact = self.cleaned_data.get("impact")
|
||||
if impact and (impact < 1 or impact > 5):
|
||||
raise forms.ValidationError('Impact must be between 1 and 5')
|
||||
raise forms.ValidationError("Impact must be between 1 and 5")
|
||||
return impact
|
||||
|
||||
|
||||
@ -157,209 +114,159 @@ class RCACorrectiveActionForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = RCACorrectiveAction
|
||||
fields = [
|
||||
'description',
|
||||
'action_type',
|
||||
'root_cause',
|
||||
'responsible_person',
|
||||
'target_date',
|
||||
'completion_date',
|
||||
'status',
|
||||
'effectiveness_measure',
|
||||
'effectiveness_assessment',
|
||||
'effectiveness_score',
|
||||
'obstacles',
|
||||
"description",
|
||||
"action_type",
|
||||
"root_cause",
|
||||
"responsible_person",
|
||||
"target_date",
|
||||
"completion_date",
|
||||
"status",
|
||||
"effectiveness_measure",
|
||||
"effectiveness_assessment",
|
||||
"effectiveness_score",
|
||||
"obstacles",
|
||||
]
|
||||
widgets = {
|
||||
'description': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 3,
|
||||
'placeholder': 'Describe the corrective action'
|
||||
}),
|
||||
'action_type': forms.Select(attrs={'class': 'form-select'}),
|
||||
'root_cause': forms.Select(attrs={'class': 'form-select'}),
|
||||
'responsible_person': forms.Select(attrs={'class': 'form-select'}),
|
||||
'target_date': forms.DateInput(attrs={
|
||||
'class': 'form-control',
|
||||
'type': 'date'
|
||||
}),
|
||||
'completion_date': forms.DateInput(attrs={
|
||||
'class': 'form-control',
|
||||
'type': 'date'
|
||||
}),
|
||||
'status': forms.Select(attrs={'class': 'form-select'}),
|
||||
'effectiveness_measure': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 2,
|
||||
'placeholder': 'How will effectiveness be measured?'
|
||||
}),
|
||||
'effectiveness_assessment': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 2,
|
||||
'placeholder': 'Assessment of action effectiveness'
|
||||
}),
|
||||
'effectiveness_score': forms.NumberInput(attrs={
|
||||
'class': 'form-control',
|
||||
'min': 1,
|
||||
'max': 5,
|
||||
'placeholder': '1-5'
|
||||
}),
|
||||
'obstacles': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 2,
|
||||
'placeholder': 'Obstacles encountered during implementation'
|
||||
}),
|
||||
"description": forms.Textarea(
|
||||
attrs={"class": "form-control", "rows": 3, "placeholder": "Describe the corrective action"}
|
||||
),
|
||||
"action_type": forms.Select(attrs={"class": "form-select"}),
|
||||
"root_cause": forms.Select(attrs={"class": "form-select"}),
|
||||
"responsible_person": forms.Select(attrs={"class": "form-select"}),
|
||||
"target_date": forms.DateInput(attrs={"class": "form-control", "type": "date"}),
|
||||
"completion_date": forms.DateInput(attrs={"class": "form-control", "type": "date"}),
|
||||
"status": forms.Select(attrs={"class": "form-select"}),
|
||||
"effectiveness_measure": forms.Textarea(
|
||||
attrs={"class": "form-control", "rows": 2, "placeholder": "How will effectiveness be measured?"}
|
||||
),
|
||||
"effectiveness_assessment": forms.Textarea(
|
||||
attrs={"class": "form-control", "rows": 2, "placeholder": "Assessment of action effectiveness"}
|
||||
),
|
||||
"effectiveness_score": forms.NumberInput(
|
||||
attrs={"class": "form-control", "min": 1, "max": 5, "placeholder": "1-5"}
|
||||
),
|
||||
"obstacles": forms.Textarea(
|
||||
attrs={"class": "form-control", "rows": 2, "placeholder": "Obstacles encountered during implementation"}
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
rca = kwargs.pop('rca', None)
|
||||
rca = kwargs.pop("rca", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Filter root_cause to show only for this RCA
|
||||
if 'root_cause' in self.fields and rca:
|
||||
self.fields['root_cause'].queryset = rca.root_causes.all()
|
||||
if "root_cause" in self.fields and rca:
|
||||
self.fields["root_cause"].queryset = rca.root_causes.all()
|
||||
|
||||
def clean_effectiveness_score(self):
|
||||
score = self.cleaned_data.get('effectiveness_score')
|
||||
score = self.cleaned_data.get("effectiveness_score")
|
||||
if score and (score < 1 or score > 5):
|
||||
raise forms.ValidationError('Effectiveness score must be between 1 and 5')
|
||||
raise forms.ValidationError("Effectiveness score must be between 1 and 5")
|
||||
return score
|
||||
|
||||
|
||||
class RCAFilterForm(forms.Form):
|
||||
"""Form for filtering RCA list"""
|
||||
|
||||
status = forms.ChoiceField(
|
||||
required=False,
|
||||
choices=[('', 'All Statuses')] + list(RCAStatus.choices),
|
||||
widget=forms.Select(attrs={'class': 'form-select'})
|
||||
choices=[("", "All Statuses")] + list(RCAStatus.choices),
|
||||
widget=forms.Select(attrs={"class": "form-select"}),
|
||||
)
|
||||
severity = forms.ChoiceField(
|
||||
required=False,
|
||||
choices=[('', 'All Severities')] + list(RCASeverity.choices),
|
||||
widget=forms.Select(attrs={'class': 'form-select'})
|
||||
choices=[("", "All Severities")] + list(RCASeverity.choices),
|
||||
widget=forms.Select(attrs={"class": "form-select"}),
|
||||
)
|
||||
priority = forms.ChoiceField(
|
||||
required=False,
|
||||
choices=[('', 'All Priorities')] + list(PriorityChoices.choices),
|
||||
widget=forms.Select(attrs={'class': 'form-select'})
|
||||
choices=[("", "All Priorities")] + list(PriorityChoices.choices),
|
||||
widget=forms.Select(attrs={"class": "form-select"}),
|
||||
)
|
||||
hospital = forms.ChoiceField(
|
||||
required=False,
|
||||
choices=[('', 'All Hospitals')],
|
||||
widget=forms.Select(attrs={'class': 'form-select'})
|
||||
required=False, choices=[("", "All Hospitals")], widget=forms.Select(attrs={"class": "form-select"})
|
||||
)
|
||||
department = forms.ChoiceField(
|
||||
required=False,
|
||||
choices=[('', 'All Departments')],
|
||||
widget=forms.Select(attrs={'class': 'form-select'})
|
||||
required=False, choices=[("", "All Departments")], widget=forms.Select(attrs={"class": "form-select"})
|
||||
)
|
||||
search = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Search RCAs...'
|
||||
})
|
||||
)
|
||||
date_from = forms.DateField(
|
||||
required=False,
|
||||
widget=forms.DateInput(attrs={'class': 'form-control', 'type': 'date'})
|
||||
)
|
||||
date_to = forms.DateField(
|
||||
required=False,
|
||||
widget=forms.DateInput(attrs={'class': 'form-control', 'type': 'date'})
|
||||
required=False, widget=forms.TextInput(attrs={"class": "form-control", "placeholder": "Search RCAs..."})
|
||||
)
|
||||
date_from = forms.DateField(required=False, widget=forms.DateInput(attrs={"class": "form-control", "type": "date"}))
|
||||
date_to = forms.DateField(required=False, widget=forms.DateInput(attrs={"class": "form-control", "type": "date"}))
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Populate hospital choices
|
||||
from apps.organizations.models import Hospital
|
||||
hospital_choices = [('', 'All Hospitals')]
|
||||
hospital_choices.extend([
|
||||
(h.id, h.name)
|
||||
for h in Hospital.objects.all()
|
||||
])
|
||||
self.fields['hospital'].choices = hospital_choices
|
||||
|
||||
hospital_choices = [("", "All Hospitals")]
|
||||
hospital_choices.extend([(h.id, h.name) for h in Hospital.objects.all()])
|
||||
self.fields["hospital"].choices = hospital_choices
|
||||
|
||||
|
||||
class RCAStatusChangeForm(forms.Form):
|
||||
"""Form for changing RCA status"""
|
||||
new_status = forms.ChoiceField(
|
||||
choices=RCAStatus.choices,
|
||||
widget=forms.Select(attrs={'class': 'form-select'})
|
||||
)
|
||||
|
||||
new_status = forms.ChoiceField(choices=RCAStatus.choices, widget=forms.Select(attrs={"class": "form-select"}))
|
||||
notes = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 3,
|
||||
'placeholder': 'Add notes about this status change'
|
||||
})
|
||||
widget=forms.Textarea(
|
||||
attrs={"class": "form-control", "rows": 3, "placeholder": "Add notes about this status change"}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class RCAApprovalForm(forms.Form):
|
||||
"""Form for approving RCA"""
|
||||
|
||||
approval_notes = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 3,
|
||||
'placeholder': 'Add approval notes'
|
||||
})
|
||||
widget=forms.Textarea(attrs={"class": "form-control", "rows": 3, "placeholder": "Add approval notes"}),
|
||||
)
|
||||
|
||||
|
||||
class RCAClosureForm(forms.Form):
|
||||
"""Form for closing RCA"""
|
||||
|
||||
closure_notes = forms.CharField(
|
||||
required=True,
|
||||
widget=forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 3,
|
||||
'placeholder': 'Provide closure notes and summary'
|
||||
})
|
||||
widget=forms.Textarea(
|
||||
attrs={"class": "form-control", "rows": 3, "placeholder": "Provide closure notes and summary"}
|
||||
),
|
||||
)
|
||||
actual_completion_date = forms.DateField(
|
||||
required=True,
|
||||
widget=forms.DateInput(attrs={
|
||||
'class': 'form-control',
|
||||
'type': 'date'
|
||||
})
|
||||
required=True, widget=forms.DateInput(attrs={"class": "form-control", "type": "date"})
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Set default to today
|
||||
self.fields['actual_completion_date'].initial = timezone.now().date()
|
||||
self.fields["actual_completion_date"].initial = timezone.now().date()
|
||||
|
||||
|
||||
class RCAAttachmentForm(forms.ModelForm):
|
||||
"""Form for uploading RCA attachments"""
|
||||
|
||||
class Meta:
|
||||
model = RCAAttachment
|
||||
fields = ['file', 'description']
|
||||
fields = ["file", "description"]
|
||||
widgets = {
|
||||
'file': forms.FileInput(attrs={'class': 'form-control'}),
|
||||
'description': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 2,
|
||||
'placeholder': 'Describe this attachment'
|
||||
}),
|
||||
"file": forms.FileInput(attrs={"class": "form-control"}),
|
||||
"description": forms.Textarea(
|
||||
attrs={"class": "form-control", "rows": 2, "placeholder": "Describe this attachment"}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class RCANoteForm(forms.ModelForm):
|
||||
"""Form for adding RCA notes"""
|
||||
|
||||
class Meta:
|
||||
model = RCANote
|
||||
fields = ['note', 'is_internal']
|
||||
fields = ["note", "is_internal"]
|
||||
widgets = {
|
||||
'note': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 3,
|
||||
'placeholder': 'Add a note'
|
||||
}),
|
||||
'is_internal': forms.CheckboxInput(attrs={
|
||||
'class': 'form-check-input'
|
||||
}),
|
||||
}
|
||||
"note": forms.Textarea(attrs={"class": "form-control", "rows": 3, "placeholder": "Add a note"}),
|
||||
"is_internal": forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"""
|
||||
Survey forms for CRUD operations
|
||||
"""
|
||||
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@ -12,344 +13,319 @@ from .models import SurveyInstance, SurveyTemplate, SurveyQuestion
|
||||
class SurveyTemplateForm(HospitalFieldMixin, forms.ModelForm):
|
||||
"""
|
||||
Form for creating/editing survey templates.
|
||||
|
||||
|
||||
Hospital field visibility:
|
||||
- PX Admins: See dropdown with all hospitals
|
||||
- Others: Hidden field, auto-set to user's hospital
|
||||
"""
|
||||
|
||||
|
||||
class Meta:
|
||||
model = SurveyTemplate
|
||||
fields = [
|
||||
'name', 'name_ar', 'hospital', 'survey_type',
|
||||
'scoring_method', 'negative_threshold', 'is_active'
|
||||
]
|
||||
fields = ["name", "name_ar", "hospital", "survey_type", "scoring_method", "negative_threshold", "is_active"]
|
||||
widgets = {
|
||||
'name': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'e.g., MD Consultation Feedback'
|
||||
}),
|
||||
'name_ar': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'الاسم بالعربية'
|
||||
}),
|
||||
'hospital': forms.Select(attrs={'class': 'form-select'}),
|
||||
'survey_type': forms.Select(attrs={'class': 'form-select'}),
|
||||
'scoring_method': forms.Select(attrs={'class': 'form-select'}),
|
||||
'negative_threshold': forms.NumberInput(attrs={
|
||||
'class': 'form-control',
|
||||
'step': '0.1',
|
||||
'min': '1',
|
||||
'max': '5'
|
||||
}),
|
||||
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
"name": forms.TextInput(attrs={"class": "form-control", "placeholder": "e.g., MD Consultation Feedback"}),
|
||||
"name_ar": forms.TextInput(attrs={"class": "form-control", "placeholder": "الاسم بالعربية"}),
|
||||
"hospital": forms.Select(attrs={"class": "form-select"}),
|
||||
"survey_type": forms.Select(attrs={"class": "form-select"}),
|
||||
"scoring_method": forms.Select(attrs={"class": "form-select"}),
|
||||
"negative_threshold": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.1", "min": "1", "max": "5"}
|
||||
),
|
||||
"is_active": forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||
}
|
||||
|
||||
|
||||
class SurveyQuestionForm(forms.ModelForm):
|
||||
"""Form for creating/editing survey questions"""
|
||||
|
||||
|
||||
class Meta:
|
||||
model = SurveyQuestion
|
||||
fields = [
|
||||
'text', 'text_ar', 'question_type', 'order',
|
||||
'is_required', 'choices_json'
|
||||
]
|
||||
fields = ["text", "text_ar", "question_type", "order", "is_required", "choices_json"]
|
||||
widgets = {
|
||||
'text': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 2,
|
||||
'placeholder': 'Enter question in English'
|
||||
}),
|
||||
'text_ar': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 2,
|
||||
'placeholder': 'أدخل السؤال بالعربية'
|
||||
}),
|
||||
'question_type': forms.Select(attrs={'class': 'form-select'}),
|
||||
'order': forms.NumberInput(attrs={
|
||||
'class': 'form-control',
|
||||
'min': '0'
|
||||
}),
|
||||
'is_required': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'choices_json': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 5,
|
||||
'placeholder': '[{"value": "1", "label": "Option 1", "label_ar": "خيار 1"}]'
|
||||
}),
|
||||
"text": forms.Textarea(
|
||||
attrs={"class": "form-control", "rows": 2, "placeholder": "Enter question in English"}
|
||||
),
|
||||
"text_ar": forms.Textarea(
|
||||
attrs={"class": "form-control", "rows": 2, "placeholder": "أدخل السؤال بالعربية"}
|
||||
),
|
||||
"question_type": forms.Select(attrs={"class": "form-select"}),
|
||||
"order": forms.NumberInput(attrs={"class": "form-control", "min": "0"}),
|
||||
"is_required": forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||
"choices_json": forms.Textarea(
|
||||
attrs={
|
||||
"class": "form-control",
|
||||
"rows": 5,
|
||||
"placeholder": '[{"value": "1", "label": "Option 1", "label_ar": "خيار 1"}]',
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['choices_json'].required = False
|
||||
self.fields['choices_json'].help_text = _(
|
||||
'JSON array of choices for multiple choice questions. '
|
||||
self.fields["choices_json"].required = False
|
||||
self.fields["choices_json"].help_text = _(
|
||||
"JSON array of choices for multiple choice questions. "
|
||||
'Format: [{"value": "1", "label": "Option 1", "label_ar": "خيار 1"}]'
|
||||
)
|
||||
|
||||
|
||||
SurveyQuestionFormSet = forms.inlineformset_factory(
|
||||
SurveyTemplate,
|
||||
SurveyQuestion,
|
||||
form=SurveyQuestionForm,
|
||||
extra=1,
|
||||
can_delete=True,
|
||||
min_num=1,
|
||||
validate_min=True
|
||||
SurveyTemplate, SurveyQuestion, form=SurveyQuestionForm, extra=1, can_delete=True, min_num=1, validate_min=True
|
||||
)
|
||||
|
||||
|
||||
class ManualSurveySendForm(forms.Form):
|
||||
"""Form for manually sending surveys to patients or staff"""
|
||||
|
||||
|
||||
RECIPIENT_TYPE_CHOICES = [
|
||||
('patient', _('Patient')),
|
||||
('staff', _('Staff')),
|
||||
("patient", _("Patient")),
|
||||
("staff", _("Staff")),
|
||||
]
|
||||
|
||||
|
||||
DELIVERY_CHANNEL_CHOICES = [
|
||||
('email', _('Email')),
|
||||
('sms', _('SMS')),
|
||||
("email", _("Email")),
|
||||
("sms", _("SMS")),
|
||||
]
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
|
||||
def __init__(self, request=None, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.user = user
|
||||
# Filter survey templates by user's hospital
|
||||
if user.hospital:
|
||||
self.fields['survey_template'].queryset = SurveyTemplate.objects.filter(
|
||||
hospital=user.hospital,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
self.request = request
|
||||
self.user = request.user if request else None
|
||||
|
||||
# Determine hospital context
|
||||
hospital = None
|
||||
if self.user and self.user.is_px_admin():
|
||||
hospital = getattr(request, "tenant_hospital", None)
|
||||
elif self.user and self.user.hospital:
|
||||
hospital = self.user.hospital
|
||||
|
||||
# Filter survey templates by hospital
|
||||
if hospital:
|
||||
self.fields["survey_template"].queryset = SurveyTemplate.objects.filter(hospital=hospital, is_active=True)
|
||||
|
||||
survey_template = forms.ModelChoiceField(
|
||||
queryset=SurveyTemplate.objects.filter(is_active=True),
|
||||
label=_('Survey Template'),
|
||||
widget=forms.Select(attrs={
|
||||
'class': 'form-select',
|
||||
'data-placeholder': _('Select a survey template')
|
||||
})
|
||||
label=_("Survey Template"),
|
||||
widget=forms.Select(attrs={"class": "form-select", "data-placeholder": _("Select a survey template")}),
|
||||
)
|
||||
|
||||
|
||||
recipient_type = forms.ChoiceField(
|
||||
choices=RECIPIENT_TYPE_CHOICES,
|
||||
label=_('Recipient Type'),
|
||||
widget=forms.RadioSelect(attrs={
|
||||
'class': 'form-check-input'
|
||||
})
|
||||
label=_("Recipient Type"),
|
||||
widget=forms.RadioSelect(attrs={"class": "form-check-input"}),
|
||||
)
|
||||
|
||||
|
||||
recipient = forms.CharField(
|
||||
label=_('Recipient'),
|
||||
widget=forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': _('Search by name or ID...'),
|
||||
'data-search-url': '/api/recipients/search/'
|
||||
}),
|
||||
help_text=_('Start typing to search for patient or staff')
|
||||
label=_("Recipient"),
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"class": "form-control",
|
||||
"placeholder": _("Search by name or ID..."),
|
||||
"data-search-url": "/api/recipients/search/",
|
||||
}
|
||||
),
|
||||
help_text=_("Start typing to search for patient or staff"),
|
||||
)
|
||||
|
||||
|
||||
delivery_channel = forms.ChoiceField(
|
||||
choices=DELIVERY_CHANNEL_CHOICES,
|
||||
label=_('Delivery Channel'),
|
||||
widget=forms.Select(attrs={
|
||||
'class': 'form-select'
|
||||
})
|
||||
label=_("Delivery Channel"),
|
||||
widget=forms.Select(attrs={"class": "form-select"}),
|
||||
)
|
||||
|
||||
|
||||
custom_message = forms.CharField(
|
||||
label=_('Custom Message (Optional)'),
|
||||
label=_("Custom Message (Optional)"),
|
||||
required=False,
|
||||
widget=forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 3,
|
||||
'placeholder': _('Add a custom message to the survey invitation...')
|
||||
})
|
||||
widget=forms.Textarea(
|
||||
attrs={
|
||||
"class": "form-control",
|
||||
"rows": 3,
|
||||
"placeholder": _("Add a custom message to the survey invitation..."),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ManualPhoneSurveySendForm(forms.Form):
|
||||
"""Form for sending surveys to a manually entered phone number"""
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
|
||||
def __init__(self, request=None, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.user = user
|
||||
# Filter survey templates by user's hospital
|
||||
if user.hospital:
|
||||
self.fields['survey_template'].queryset = SurveyTemplate.objects.filter(
|
||||
hospital=user.hospital,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
self.request = request
|
||||
self.user = request.user if request else None
|
||||
|
||||
# Determine hospital context
|
||||
hospital = None
|
||||
if self.user and self.user.is_px_admin():
|
||||
hospital = getattr(request, "tenant_hospital", None)
|
||||
elif self.user and self.user.hospital:
|
||||
hospital = self.user.hospital
|
||||
|
||||
# Filter survey templates by hospital
|
||||
if hospital:
|
||||
self.fields["survey_template"].queryset = SurveyTemplate.objects.filter(hospital=hospital, is_active=True)
|
||||
|
||||
survey_template = forms.ModelChoiceField(
|
||||
queryset=SurveyTemplate.objects.filter(is_active=True),
|
||||
label=_('Survey Template'),
|
||||
widget=forms.Select(attrs={
|
||||
'class': 'form-select'
|
||||
})
|
||||
label=_("Survey Template"),
|
||||
widget=forms.Select(attrs={"class": "form-select"}),
|
||||
)
|
||||
|
||||
|
||||
phone_number = forms.CharField(
|
||||
label=_('Phone Number'),
|
||||
widget=forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': _('+966501234567')
|
||||
}),
|
||||
help_text=_('Enter phone number with country code (e.g., +966...)')
|
||||
label=_("Phone Number"),
|
||||
widget=forms.TextInput(attrs={"class": "form-control", "placeholder": _("+966501234567")}),
|
||||
help_text=_("Enter phone number with country code (e.g., +966...)"),
|
||||
)
|
||||
|
||||
|
||||
recipient_name = forms.CharField(
|
||||
label=_('Recipient Name (Optional)'),
|
||||
label=_("Recipient Name (Optional)"),
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': _('Patient Name')
|
||||
})
|
||||
widget=forms.TextInput(attrs={"class": "form-control", "placeholder": _("Patient Name")}),
|
||||
)
|
||||
|
||||
|
||||
custom_message = forms.CharField(
|
||||
label=_('Custom Message (Optional)'),
|
||||
label=_("Custom Message (Optional)"),
|
||||
required=False,
|
||||
widget=forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 3,
|
||||
'placeholder': _('Add a custom message to the survey invitation...')
|
||||
})
|
||||
widget=forms.Textarea(
|
||||
attrs={
|
||||
"class": "form-control",
|
||||
"rows": 3,
|
||||
"placeholder": _("Add a custom message to the survey invitation..."),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def clean_phone_number(self):
|
||||
phone = self.cleaned_data['phone_number'].strip()
|
||||
phone = self.cleaned_data["phone_number"].strip()
|
||||
# Remove spaces, dashes, parentheses
|
||||
phone = phone.replace(' ', '').replace('-', '').replace('(', '').replace(')', '')
|
||||
if not phone.startswith('+'):
|
||||
raise forms.ValidationError(_('Phone number must start with country code (e.g., +966)'))
|
||||
phone = phone.replace(" ", "").replace("-", "").replace("(", "").replace(")", "")
|
||||
if not phone.startswith("+"):
|
||||
raise forms.ValidationError(_("Phone number must start with country code (e.g., +966)"))
|
||||
return phone
|
||||
|
||||
|
||||
class BulkCSVSurveySendForm(forms.Form):
|
||||
"""Form for bulk sending surveys via CSV upload"""
|
||||
|
||||
|
||||
survey_template = forms.ModelChoiceField(
|
||||
queryset=SurveyTemplate.objects.filter(is_active=True),
|
||||
label=_('Survey Template'),
|
||||
widget=forms.Select(attrs={
|
||||
'class': 'form-select'
|
||||
})
|
||||
label=_("Survey Template"),
|
||||
widget=forms.Select(attrs={"class": "form-select"}),
|
||||
)
|
||||
|
||||
|
||||
csv_file = forms.FileField(
|
||||
label=_('CSV File'),
|
||||
widget=forms.FileInput(attrs={
|
||||
'class': 'form-control',
|
||||
'accept': '.csv'
|
||||
}),
|
||||
help_text=_('Upload CSV with phone numbers. Format: phone_number,name(optional)')
|
||||
label=_("CSV File"),
|
||||
widget=forms.FileInput(attrs={"class": "form-control", "accept": ".csv"}),
|
||||
help_text=_("Upload CSV with phone numbers. Format: phone_number,name(optional)"),
|
||||
)
|
||||
|
||||
|
||||
custom_message = forms.CharField(
|
||||
label=_('Custom Message (Optional)'),
|
||||
label=_("Custom Message (Optional)"),
|
||||
required=False,
|
||||
widget=forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 3,
|
||||
'placeholder': _('Add a custom message to the survey invitation...')
|
||||
})
|
||||
widget=forms.Textarea(
|
||||
attrs={
|
||||
"class": "form-control",
|
||||
"rows": 3,
|
||||
"placeholder": _("Add a custom message to the survey invitation..."),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
|
||||
def __init__(self, request=None, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.user = user
|
||||
# Filter survey templates by user's hospital
|
||||
if user.hospital:
|
||||
self.fields['survey_template'].queryset = SurveyTemplate.objects.filter(
|
||||
hospital=user.hospital,
|
||||
is_active=True
|
||||
)
|
||||
self.request = request
|
||||
self.user = request.user if request else None
|
||||
|
||||
# Determine hospital context
|
||||
hospital = None
|
||||
if self.user and self.user.is_px_admin():
|
||||
hospital = getattr(request, "tenant_hospital", None)
|
||||
elif self.user and self.user.hospital:
|
||||
hospital = self.user.hospital
|
||||
|
||||
# Filter survey templates by hospital
|
||||
if hospital:
|
||||
self.fields["survey_template"].queryset = SurveyTemplate.objects.filter(hospital=hospital, is_active=True)
|
||||
|
||||
|
||||
class HISPatientImportForm(HospitalFieldMixin, forms.Form):
|
||||
"""
|
||||
Form for importing patient data from HIS/MOH Statistics CSV.
|
||||
|
||||
|
||||
Hospital field visibility:
|
||||
- PX Admins: See dropdown with all hospitals
|
||||
- Others: Hidden field, auto-set to user's hospital
|
||||
"""
|
||||
|
||||
|
||||
hospital = forms.ModelChoiceField(
|
||||
queryset=Hospital.objects.filter(status='active'),
|
||||
label=_('Hospital'),
|
||||
widget=forms.Select(attrs={
|
||||
'class': 'form-select'
|
||||
}),
|
||||
help_text=_('Select the hospital for these patient records')
|
||||
queryset=Hospital.objects.filter(status="active"),
|
||||
label=_("Hospital"),
|
||||
widget=forms.Select(attrs={"class": "form-select"}),
|
||||
help_text=_("Select the hospital for these patient records"),
|
||||
)
|
||||
|
||||
|
||||
csv_file = forms.FileField(
|
||||
label=_('HIS Statistics CSV File'),
|
||||
widget=forms.FileInput(attrs={
|
||||
'class': 'form-control',
|
||||
'accept': '.csv'
|
||||
}),
|
||||
help_text=_('Upload MOH Statistics CSV with patient visit data')
|
||||
label=_("HIS Statistics CSV File"),
|
||||
widget=forms.FileInput(attrs={"class": "form-control", "accept": ".csv"}),
|
||||
help_text=_("Upload MOH Statistics CSV with patient visit data"),
|
||||
)
|
||||
|
||||
|
||||
skip_header_rows = forms.IntegerField(
|
||||
label=_('Skip Header Rows'),
|
||||
label=_("Skip Header Rows"),
|
||||
initial=5,
|
||||
min_value=0,
|
||||
max_value=10,
|
||||
widget=forms.NumberInput(attrs={
|
||||
'class': 'form-control'
|
||||
}),
|
||||
help_text=_('Number of metadata/header rows to skip before data rows')
|
||||
widget=forms.NumberInput(attrs={"class": "form-control"}),
|
||||
help_text=_("Number of metadata/header rows to skip before data rows"),
|
||||
)
|
||||
|
||||
|
||||
class HISSurveySendForm(forms.Form):
|
||||
"""Form for sending surveys to imported HIS patients"""
|
||||
|
||||
|
||||
survey_template = forms.ModelChoiceField(
|
||||
queryset=SurveyTemplate.objects.filter(is_active=True),
|
||||
label=_('Survey Template'),
|
||||
widget=forms.Select(attrs={
|
||||
'class': 'form-select'
|
||||
})
|
||||
label=_("Survey Template"),
|
||||
widget=forms.Select(attrs={"class": "form-select"}),
|
||||
)
|
||||
|
||||
|
||||
delivery_channel = forms.ChoiceField(
|
||||
choices=[
|
||||
('sms', _('SMS')),
|
||||
('email', _('Email')),
|
||||
('both', _('Both SMS and Email')),
|
||||
("sms", _("SMS")),
|
||||
("email", _("Email")),
|
||||
("both", _("Both SMS and Email")),
|
||||
],
|
||||
label=_('Delivery Channel'),
|
||||
initial='sms',
|
||||
widget=forms.Select(attrs={
|
||||
'class': 'form-select'
|
||||
})
|
||||
label=_("Delivery Channel"),
|
||||
initial="sms",
|
||||
widget=forms.Select(attrs={"class": "form-select"}),
|
||||
)
|
||||
|
||||
|
||||
custom_message = forms.CharField(
|
||||
label=_('Custom Message (Optional)'),
|
||||
label=_("Custom Message (Optional)"),
|
||||
required=False,
|
||||
widget=forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 3,
|
||||
'placeholder': _('Add a custom message to the survey invitation...')
|
||||
})
|
||||
widget=forms.Textarea(
|
||||
attrs={
|
||||
"class": "form-control",
|
||||
"rows": 3,
|
||||
"placeholder": _("Add a custom message to the survey invitation..."),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
patient_ids = forms.CharField(
|
||||
widget=forms.HiddenInput(),
|
||||
required=True
|
||||
)
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
|
||||
patient_ids = forms.CharField(widget=forms.HiddenInput(), required=True)
|
||||
|
||||
def __init__(self, request=None, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.user = user
|
||||
if user.hospital:
|
||||
self.fields['survey_template'].queryset = SurveyTemplate.objects.filter(
|
||||
hospital=user.hospital,
|
||||
is_active=True
|
||||
)
|
||||
self.request = request
|
||||
self.user = request.user if request else None
|
||||
|
||||
# Determine hospital context
|
||||
hospital = None
|
||||
if self.user and self.user.is_px_admin():
|
||||
hospital = getattr(request, "tenant_hospital", None)
|
||||
elif self.user and self.user.hospital:
|
||||
hospital = self.user.hospital
|
||||
|
||||
# Filter survey templates by hospital
|
||||
if hospital:
|
||||
self.fields["survey_template"].queryset = SurveyTemplate.objects.filter(hospital=hospital, is_active=True)
|
||||
|
||||
@ -4,6 +4,7 @@ HIS Patient Import Views
|
||||
Handles importing patient data from HIS/MOH Statistics CSV
|
||||
and sending surveys to imported patients.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
@ -33,263 +34,295 @@ logger = logging.getLogger(__name__)
|
||||
def his_patient_import(request):
|
||||
"""
|
||||
Import patients from HIS/MOH Statistics CSV.
|
||||
|
||||
|
||||
CSV Format (MOH Statistics):
|
||||
SNo, Facility Name, Visit Type, Admit Date, Discharge Date,
|
||||
Patient Name, File Number, SSN, Mobile No, Payment Type,
|
||||
SNo, Facility Name, Visit Type, Admit Date, Discharge Date,
|
||||
Patient Name, File Number, SSN, Mobile No, Payment Type,
|
||||
Gender, Full Age, Nationality, Date of Birth, Diagnosis
|
||||
"""
|
||||
user = request.user
|
||||
|
||||
|
||||
# Check permission
|
||||
if not user.is_px_admin() and not user.is_hospital_admin():
|
||||
messages.error(request, "You don't have permission to import patient data.")
|
||||
return redirect('surveys:instance_list')
|
||||
|
||||
return redirect("surveys:instance_list")
|
||||
|
||||
# Session storage for imported patients
|
||||
session_key = f'his_import_{user.id}'
|
||||
|
||||
if request.method == 'POST':
|
||||
form = HISPatientImportForm(request.POST, request.FILES, user=user)
|
||||
|
||||
session_key = f"his_import_{user.id}"
|
||||
|
||||
if request.method == "POST":
|
||||
form = HISPatientImportForm(request.POST, request.FILES, request=request)
|
||||
|
||||
if form.is_valid():
|
||||
try:
|
||||
hospital = form.cleaned_data['hospital']
|
||||
csv_file = form.cleaned_data['csv_file']
|
||||
skip_rows = form.cleaned_data['skip_header_rows']
|
||||
|
||||
hospital = form.cleaned_data["hospital"]
|
||||
csv_file = form.cleaned_data["csv_file"]
|
||||
skip_rows = form.cleaned_data["skip_header_rows"]
|
||||
|
||||
# Parse CSV
|
||||
decoded_file = csv_file.read().decode('utf-8-sig')
|
||||
decoded_file = csv_file.read().decode("utf-8-sig")
|
||||
io_string = io.StringIO(decoded_file)
|
||||
reader = csv.reader(io_string)
|
||||
|
||||
|
||||
# Skip header/metadata rows
|
||||
for _ in range(skip_rows):
|
||||
next(reader, None)
|
||||
|
||||
|
||||
# Read header row
|
||||
header = next(reader, None)
|
||||
if not header:
|
||||
messages.error(request, "CSV file is empty or has no data rows.")
|
||||
return render(request, 'surveys/his_patient_import.html', {'form': form})
|
||||
|
||||
return render(request, "surveys/his_patient_import.html", {"form": form})
|
||||
|
||||
# Find column indices
|
||||
header = [h.strip().lower() for h in header]
|
||||
col_map = {
|
||||
'file_number': _find_column(header, ['file number', 'file_number', 'mrn', 'file no']),
|
||||
'patient_name': _find_column(header, ['patient name', 'patient_name', 'name']),
|
||||
'mobile_no': _find_column(header, ['mobile no', 'mobile_no', 'mobile', 'phone']),
|
||||
'ssn': _find_column(header, ['ssn', 'national id', 'national_id', 'id number']),
|
||||
'gender': _find_column(header, ['gender', 'sex']),
|
||||
'visit_type': _find_column(header, ['visit type', 'visit_type', 'type']),
|
||||
'admit_date': _find_column(header, ['admit date', 'admit_date', 'admission date']),
|
||||
'discharge_date': _find_column(header, ['discharge date', 'discharge_date']),
|
||||
'facility': _find_column(header, ['facility name', 'facility', 'hospital']),
|
||||
'nationality': _find_column(header, ['nationality', 'country']),
|
||||
'dob': _find_column(header, ['date of birth', 'dob', 'birth date']),
|
||||
"file_number": _find_column(header, ["file number", "file_number", "mrn", "file no"]),
|
||||
"patient_name": _find_column(header, ["patient name", "patient_name", "name"]),
|
||||
"mobile_no": _find_column(header, ["mobile no", "mobile_no", "mobile", "phone"]),
|
||||
"ssn": _find_column(header, ["ssn", "national id", "national_id", "id number"]),
|
||||
"gender": _find_column(header, ["gender", "sex"]),
|
||||
"visit_type": _find_column(header, ["visit type", "visit_type", "type"]),
|
||||
"admit_date": _find_column(header, ["admit date", "admit_date", "admission date"]),
|
||||
"discharge_date": _find_column(header, ["discharge date", "discharge_date"]),
|
||||
"facility": _find_column(header, ["facility name", "facility", "hospital"]),
|
||||
"nationality": _find_column(header, ["nationality", "country"]),
|
||||
"dob": _find_column(header, ["date of birth", "dob", "birth date"]),
|
||||
}
|
||||
|
||||
|
||||
# Check required columns
|
||||
if col_map['file_number'] is None:
|
||||
if col_map["file_number"] is None:
|
||||
messages.error(request, "Could not find 'File Number' column in CSV.")
|
||||
return render(request, 'surveys/his_patient_import.html', {'form': form})
|
||||
|
||||
if col_map['patient_name'] is None:
|
||||
return render(request, "surveys/his_patient_import.html", {"form": form})
|
||||
|
||||
if col_map["patient_name"] is None:
|
||||
messages.error(request, "Could not find 'Patient Name' column in CSV.")
|
||||
return render(request, 'surveys/his_patient_import.html', {'form': form})
|
||||
|
||||
return render(request, "surveys/his_patient_import.html", {"form": form})
|
||||
|
||||
# Process data rows
|
||||
imported_patients = []
|
||||
errors = []
|
||||
row_num = skip_rows + 1
|
||||
|
||||
|
||||
for row in reader:
|
||||
row_num += 1
|
||||
if not row or not any(row): # Skip empty rows
|
||||
continue
|
||||
|
||||
|
||||
try:
|
||||
# Extract data
|
||||
file_number = _get_cell(row, col_map['file_number'], '').strip()
|
||||
patient_name = _get_cell(row, col_map['patient_name'], '').strip()
|
||||
mobile_no = _get_cell(row, col_map['mobile_no'], '').strip()
|
||||
ssn = _get_cell(row, col_map['ssn'], '').strip()
|
||||
gender = _get_cell(row, col_map['gender'], '').strip().lower()
|
||||
visit_type = _get_cell(row, col_map['visit_type'], '').strip()
|
||||
admit_date = _get_cell(row, col_map['admit_date'], '').strip()
|
||||
discharge_date = _get_cell(row, col_map['discharge_date'], '').strip()
|
||||
facility = _get_cell(row, col_map['facility'], '').strip()
|
||||
nationality = _get_cell(row, col_map['nationality'], '').strip()
|
||||
dob = _get_cell(row, col_map['dob'], '').strip()
|
||||
|
||||
file_number = _get_cell(row, col_map["file_number"], "").strip()
|
||||
patient_name = _get_cell(row, col_map["patient_name"], "").strip()
|
||||
mobile_no = _get_cell(row, col_map["mobile_no"], "").strip()
|
||||
ssn = _get_cell(row, col_map["ssn"], "").strip()
|
||||
gender = _get_cell(row, col_map["gender"], "").strip().lower()
|
||||
visit_type = _get_cell(row, col_map["visit_type"], "").strip()
|
||||
admit_date = _get_cell(row, col_map["admit_date"], "").strip()
|
||||
discharge_date = _get_cell(row, col_map["discharge_date"], "").strip()
|
||||
facility = _get_cell(row, col_map["facility"], "").strip()
|
||||
nationality = _get_cell(row, col_map["nationality"], "").strip()
|
||||
dob = _get_cell(row, col_map["dob"], "").strip()
|
||||
|
||||
# Skip if missing required fields
|
||||
if not file_number or not patient_name:
|
||||
continue
|
||||
|
||||
|
||||
# Clean phone number
|
||||
if mobile_no:
|
||||
mobile_no = mobile_no.replace(' ', '').replace('-', '')
|
||||
if not mobile_no.startswith('+'):
|
||||
mobile_no = mobile_no.replace(" ", "").replace("-", "")
|
||||
if not mobile_no.startswith("+"):
|
||||
# Assume Saudi number if starts with 0
|
||||
if mobile_no.startswith('05'):
|
||||
mobile_no = '+966' + mobile_no[1:]
|
||||
elif mobile_no.startswith('5'):
|
||||
mobile_no = '+966' + mobile_no
|
||||
|
||||
if mobile_no.startswith("05"):
|
||||
mobile_no = "+966" + mobile_no[1:]
|
||||
elif mobile_no.startswith("5"):
|
||||
mobile_no = "+966" + mobile_no
|
||||
|
||||
# Parse name (First Middle Last format)
|
||||
name_parts = patient_name.split()
|
||||
first_name = name_parts[0] if name_parts else ''
|
||||
last_name = name_parts[-1] if len(name_parts) > 1 else ''
|
||||
|
||||
first_name = name_parts[0] if name_parts else ""
|
||||
last_name = name_parts[-1] if len(name_parts) > 1 else ""
|
||||
|
||||
# Parse dates
|
||||
parsed_admit = _parse_date(admit_date)
|
||||
parsed_discharge = _parse_date(discharge_date)
|
||||
parsed_dob = _parse_date(dob)
|
||||
|
||||
|
||||
# Normalize gender
|
||||
if gender in ['male', 'm']:
|
||||
gender = 'male'
|
||||
elif gender in ['female', 'f']:
|
||||
gender = 'female'
|
||||
if gender in ["male", "m"]:
|
||||
gender = "male"
|
||||
elif gender in ["female", "f"]:
|
||||
gender = "female"
|
||||
else:
|
||||
gender = ''
|
||||
|
||||
imported_patients.append({
|
||||
'row_num': row_num,
|
||||
'file_number': file_number,
|
||||
'patient_name': patient_name,
|
||||
'first_name': first_name,
|
||||
'last_name': last_name,
|
||||
'mobile_no': mobile_no,
|
||||
'ssn': ssn,
|
||||
'gender': gender,
|
||||
'visit_type': visit_type,
|
||||
'admit_date': parsed_admit.isoformat() if parsed_admit else None,
|
||||
'discharge_date': parsed_discharge.isoformat() if parsed_discharge else None,
|
||||
'facility': facility,
|
||||
'nationality': nationality,
|
||||
'dob': parsed_dob.isoformat() if parsed_dob else None,
|
||||
})
|
||||
|
||||
gender = ""
|
||||
|
||||
imported_patients.append(
|
||||
{
|
||||
"row_num": row_num,
|
||||
"file_number": file_number,
|
||||
"patient_name": patient_name,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
"mobile_no": mobile_no,
|
||||
"ssn": ssn,
|
||||
"gender": gender,
|
||||
"visit_type": visit_type,
|
||||
"admit_date": parsed_admit.isoformat() if parsed_admit else None,
|
||||
"discharge_date": parsed_discharge.isoformat() if parsed_discharge else None,
|
||||
"facility": facility,
|
||||
"nationality": nationality,
|
||||
"dob": parsed_dob.isoformat() if parsed_dob else None,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"Row {row_num}: {str(e)}")
|
||||
logger.error(f"Error processing row {row_num}: {e}")
|
||||
|
||||
|
||||
# Store in session for review step
|
||||
request.session[session_key] = {
|
||||
'hospital_id': str(hospital.id),
|
||||
'patients': imported_patients,
|
||||
'errors': errors,
|
||||
'total_count': len(imported_patients)
|
||||
"hospital_id": str(hospital.id),
|
||||
"patients": imported_patients,
|
||||
"errors": errors,
|
||||
"total_count": len(imported_patients),
|
||||
}
|
||||
|
||||
|
||||
# Log audit
|
||||
AuditService.log_event(
|
||||
event_type='his_patient_import',
|
||||
event_type="his_patient_import",
|
||||
description=f"Imported {len(imported_patients)} patients from HIS CSV by {user.get_full_name()}",
|
||||
user=user,
|
||||
metadata={
|
||||
'hospital': hospital.name,
|
||||
'total_count': len(imported_patients),
|
||||
'error_count': len(errors)
|
||||
}
|
||||
"hospital": hospital.name,
|
||||
"total_count": len(imported_patients),
|
||||
"error_count": len(errors),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
if imported_patients:
|
||||
messages.success(
|
||||
request,
|
||||
f"Successfully parsed {len(imported_patients)} patient records. Please review before creating."
|
||||
f"Successfully parsed {len(imported_patients)} patient records. Please review before creating.",
|
||||
)
|
||||
return redirect('surveys:his_patient_review')
|
||||
return redirect("surveys:his_patient_review")
|
||||
else:
|
||||
messages.error(request, "No valid patient records found in CSV.")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing HIS CSV: {str(e)}", exc_info=True)
|
||||
messages.error(request, f"Error processing CSV: {str(e)}")
|
||||
else:
|
||||
form = HISPatientImportForm(user=user)
|
||||
|
||||
form = HISPatientImportForm(request=request)
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
"form": form,
|
||||
}
|
||||
return render(request, 'surveys/his_patient_import.html', context)
|
||||
return render(request, "surveys/his_patient_import.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
def his_patient_review(request):
|
||||
"""
|
||||
Review imported patients before creating records and sending surveys.
|
||||
Shows summary statistics instead of full patient list for performance.
|
||||
"""
|
||||
user = request.user
|
||||
session_key = f'his_import_{user.id}'
|
||||
session_key = f"his_import_{user.id}"
|
||||
import_data = request.session.get(session_key)
|
||||
|
||||
|
||||
if not import_data:
|
||||
messages.error(request, "No import data found. Please upload CSV first.")
|
||||
return redirect('surveys:his_patient_import')
|
||||
|
||||
hospital = get_object_or_404(Hospital, id=import_data['hospital_id'])
|
||||
patients = import_data['patients']
|
||||
errors = import_data.get('errors', [])
|
||||
|
||||
# Check for existing patients
|
||||
return redirect("surveys:his_patient_import")
|
||||
|
||||
hospital = get_object_or_404(Hospital, id=import_data["hospital_id"])
|
||||
patients = import_data["patients"]
|
||||
errors = import_data.get("errors", [])
|
||||
|
||||
# Calculate summary statistics
|
||||
total_count = len(patients)
|
||||
new_count = 0
|
||||
existing_count = 0
|
||||
visit_types = {}
|
||||
has_mobile = 0
|
||||
missing_mobile = 0
|
||||
|
||||
# Check for existing patients and calculate stats
|
||||
for p in patients:
|
||||
existing = Patient.objects.filter(mrn=p['file_number']).first()
|
||||
p['exists'] = existing is not None
|
||||
|
||||
if request.method == 'POST':
|
||||
action = request.POST.get('action')
|
||||
selected_ids = request.POST.getlist('selected_patients')
|
||||
existing = Patient.objects.filter(mrn=p["file_number"]).first()
|
||||
p["exists"] = existing is not None
|
||||
|
||||
if action == 'create':
|
||||
# Create/update patient records
|
||||
if p["exists"]:
|
||||
existing_count += 1
|
||||
else:
|
||||
new_count += 1
|
||||
|
||||
# Count visit types
|
||||
visit_type = p.get("visit_type", "Unknown") or "Unknown"
|
||||
visit_types[visit_type] = visit_types.get(visit_type, 0) + 1
|
||||
|
||||
# Count mobile availability
|
||||
if p.get("mobile_no"):
|
||||
has_mobile += 1
|
||||
else:
|
||||
missing_mobile += 1
|
||||
|
||||
if request.method == "POST":
|
||||
action = request.POST.get("action")
|
||||
|
||||
if action == "create":
|
||||
# Create/update all patient records (no individual selection needed)
|
||||
created_count = 0
|
||||
updated_count = 0
|
||||
|
||||
|
||||
with transaction.atomic():
|
||||
for p in patients:
|
||||
if p['file_number'] in selected_ids:
|
||||
patient, created = Patient.objects.update_or_create(
|
||||
mrn=p['file_number'],
|
||||
defaults={
|
||||
'first_name': p['first_name'],
|
||||
'last_name': p['last_name'],
|
||||
'phone': p['mobile_no'],
|
||||
'national_id': p['ssn'],
|
||||
'gender': p['gender'] if p['gender'] else '',
|
||||
'primary_hospital': hospital,
|
||||
}
|
||||
)
|
||||
if created:
|
||||
created_count += 1
|
||||
else:
|
||||
updated_count += 1
|
||||
# Store only the patient ID to avoid serialization issues
|
||||
p['patient_id'] = str(patient.id)
|
||||
|
||||
patient, created = Patient.objects.update_or_create(
|
||||
mrn=p["file_number"],
|
||||
defaults={
|
||||
"first_name": p["first_name"],
|
||||
"last_name": p["last_name"],
|
||||
"phone": p["mobile_no"],
|
||||
"national_id": p["ssn"],
|
||||
"gender": p["gender"] if p["gender"] else "",
|
||||
"primary_hospital": hospital,
|
||||
},
|
||||
)
|
||||
if created:
|
||||
created_count += 1
|
||||
else:
|
||||
updated_count += 1
|
||||
# Store only the patient ID to avoid serialization issues
|
||||
p["patient_id"] = str(patient.id)
|
||||
|
||||
# Update session with created patients
|
||||
request.session[session_key]['patients'] = patients
|
||||
request.session[session_key]["patients"] = patients
|
||||
request.session.modified = True
|
||||
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
f"Created {created_count} new patients, updated {updated_count} existing patients."
|
||||
request, f"Created {created_count} new patients, updated {updated_count} existing patients."
|
||||
)
|
||||
return redirect('surveys:his_patient_survey_send')
|
||||
|
||||
elif action == 'cancel':
|
||||
return redirect("surveys:his_patient_survey_send")
|
||||
|
||||
elif action == "cancel":
|
||||
del request.session[session_key]
|
||||
messages.info(request, "Import cancelled.")
|
||||
return redirect('surveys:his_patient_import')
|
||||
|
||||
return redirect("surveys:his_patient_import")
|
||||
|
||||
# Get a small sample for preview (first 5 records)
|
||||
sample_patients = patients[:5]
|
||||
|
||||
context = {
|
||||
'hospital': hospital,
|
||||
'patients': patients,
|
||||
'errors': errors,
|
||||
'total_count': len(patients),
|
||||
"hospital": hospital,
|
||||
"errors": errors,
|
||||
"total_count": total_count,
|
||||
"new_count": new_count,
|
||||
"existing_count": existing_count,
|
||||
"visit_types": sorted(visit_types.items(), key=lambda x: x[1], reverse=True),
|
||||
"has_mobile": has_mobile,
|
||||
"missing_mobile": missing_mobile,
|
||||
"sample_patients": sample_patients,
|
||||
"sample_count": len(sample_patients),
|
||||
}
|
||||
return render(request, 'surveys/his_patient_review.html', context)
|
||||
return render(request, "surveys/his_patient_review.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
@ -298,47 +331,67 @@ def his_patient_survey_send(request):
|
||||
Send surveys to imported patients - Queues a background task.
|
||||
"""
|
||||
user = request.user
|
||||
session_key = f'his_import_{user.id}'
|
||||
session_key = f"his_import_{user.id}"
|
||||
import_data = request.session.get(session_key)
|
||||
|
||||
|
||||
if not import_data:
|
||||
messages.error(request, "No import data found. Please upload CSV first.")
|
||||
return redirect('surveys:his_patient_import')
|
||||
|
||||
hospital = get_object_or_404(Hospital, id=import_data['hospital_id'])
|
||||
all_patients = import_data['patients']
|
||||
|
||||
return redirect("surveys:his_patient_import")
|
||||
|
||||
hospital = get_object_or_404(Hospital, id=import_data["hospital_id"])
|
||||
all_patients = import_data["patients"]
|
||||
|
||||
# Filter only patients with records in database
|
||||
# Get patient IDs from session data
|
||||
patient_ids = [p["patient_id"] for p in all_patients if "patient_id" in p]
|
||||
|
||||
# Fetch all patients in one query for efficiency
|
||||
patient_objs = {str(p.id): p for p in Patient.objects.filter(id__in=patient_ids)}
|
||||
|
||||
# Build render data with patient objects (don't modify session data!)
|
||||
patients = []
|
||||
for p in all_patients:
|
||||
if 'patient_id' in p:
|
||||
patients.append(p)
|
||||
|
||||
if "patient_id" in p and p["patient_id"] in patient_objs:
|
||||
# Create a copy and add the patient object for template rendering
|
||||
patient_copy = dict(p)
|
||||
patient_copy["patient_obj"] = patient_objs[p["patient_id"]]
|
||||
patients.append(patient_copy)
|
||||
|
||||
if not patients:
|
||||
messages.error(request, "No patients have been created yet. Please create patients first.")
|
||||
return redirect('surveys:his_patient_review')
|
||||
|
||||
if request.method == 'POST':
|
||||
form = HISSurveySendForm(request.POST, user=user)
|
||||
|
||||
return redirect("surveys:his_patient_review")
|
||||
|
||||
if request.method == "POST":
|
||||
form = HISSurveySendForm(data=request.POST, request=request)
|
||||
|
||||
if form.is_valid():
|
||||
survey_template = form.cleaned_data['survey_template']
|
||||
delivery_channel = form.cleaned_data['delivery_channel']
|
||||
custom_message = form.cleaned_data.get('custom_message', '')
|
||||
selected_ids = request.POST.getlist('selected_patients')
|
||||
|
||||
# Filter selected patients
|
||||
selected_patients = [p for p in patients if p['file_number'] in selected_ids]
|
||||
|
||||
if not selected_patients:
|
||||
survey_template = form.cleaned_data["survey_template"]
|
||||
delivery_channel = form.cleaned_data["delivery_channel"]
|
||||
custom_message = form.cleaned_data.get("custom_message", "")
|
||||
selected_ids = request.POST.getlist("selected_patients")
|
||||
|
||||
# Filter selected patients from render data
|
||||
selected_patients_render = [p for p in patients if p["file_number"] in selected_ids]
|
||||
|
||||
if not selected_patients_render:
|
||||
messages.error(request, "No patients selected.")
|
||||
return render(request, 'surveys/his_patient_survey_send.html', {
|
||||
'form': form,
|
||||
'hospital': hospital,
|
||||
'patients': patients,
|
||||
'total_count': len(patients),
|
||||
})
|
||||
|
||||
return render(
|
||||
request,
|
||||
"surveys/his_patient_survey_send.html",
|
||||
{
|
||||
"form": form,
|
||||
"hospital": hospital,
|
||||
"patients": patients,
|
||||
"total_count": len(patients),
|
||||
},
|
||||
)
|
||||
|
||||
# Prepare patient data for job (exclude patient_obj which is not serializable)
|
||||
selected_patients = [
|
||||
{k: v for k, v in p.items() if k != "patient_obj"}
|
||||
for p in selected_patients_render
|
||||
]
|
||||
|
||||
# Create bulk job
|
||||
job = BulkSurveyJob.objects.create(
|
||||
name=f"HIS Import - {hospital.name} - {timezone.now().strftime('%Y-%m-%d %H:%M')}",
|
||||
@ -350,48 +403,49 @@ def his_patient_survey_send(request):
|
||||
total_patients=len(selected_patients),
|
||||
delivery_channel=delivery_channel,
|
||||
custom_message=custom_message,
|
||||
patient_data=selected_patients
|
||||
patient_data=selected_patients,
|
||||
)
|
||||
|
||||
|
||||
# Queue the background task
|
||||
send_bulk_surveys.delay(str(job.id))
|
||||
|
||||
|
||||
# Log audit
|
||||
AuditService.log_event(
|
||||
event_type='his_survey_queued',
|
||||
event_type="his_survey_queued",
|
||||
description=f"Queued bulk survey job for {len(selected_patients)} patients",
|
||||
user=user,
|
||||
metadata={
|
||||
'job_id': str(job.id),
|
||||
'hospital': hospital.name,
|
||||
'survey_template': survey_template.name,
|
||||
'patient_count': len(selected_patients)
|
||||
}
|
||||
"job_id": str(job.id),
|
||||
"hospital": hospital.name,
|
||||
"survey_template": survey_template.name,
|
||||
"patient_count": len(selected_patients),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Clear session
|
||||
del request.session[session_key]
|
||||
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
f"Survey sending job queued for {len(selected_patients)} patients. "
|
||||
f"You can check the status in Survey Jobs."
|
||||
f"You can check the status in Survey Jobs.",
|
||||
)
|
||||
return redirect('surveys:bulk_job_status', job_id=job.id)
|
||||
return redirect("surveys:bulk_job_status", job_id=job.id)
|
||||
else:
|
||||
form = HISSurveySendForm(user=user)
|
||||
|
||||
form = HISSurveySendForm(request=request)
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
'hospital': hospital,
|
||||
'patients': patients,
|
||||
'total_count': len(patients),
|
||||
"form": form,
|
||||
"hospital": hospital,
|
||||
"patients": patients,
|
||||
"total_count": len(patients),
|
||||
}
|
||||
return render(request, 'surveys/his_patient_survey_send.html', context)
|
||||
return render(request, "surveys/his_patient_survey_send.html", context)
|
||||
|
||||
|
||||
# Helper functions
|
||||
|
||||
|
||||
def _find_column(header, possible_names):
|
||||
"""Find column index by possible names"""
|
||||
for name in possible_names:
|
||||
@ -401,7 +455,7 @@ def _find_column(header, possible_names):
|
||||
return None
|
||||
|
||||
|
||||
def _get_cell(row, index, default=''):
|
||||
def _get_cell(row, index, default=""):
|
||||
"""Safely get cell value"""
|
||||
if index is None or index >= len(row):
|
||||
return default
|
||||
@ -412,24 +466,23 @@ def _parse_date(date_str):
|
||||
"""Parse date from various formats"""
|
||||
if not date_str:
|
||||
return None
|
||||
|
||||
|
||||
formats = [
|
||||
'%d-%b-%Y %H:%M:%S',
|
||||
'%d-%b-%Y',
|
||||
'%d/%m/%Y %H:%M:%S',
|
||||
'%d/%m/%Y',
|
||||
'%Y-%m-%d %H:%M:%S',
|
||||
'%Y-%m-%d',
|
||||
"%d-%b-%Y %H:%M:%S",
|
||||
"%d-%b-%Y",
|
||||
"%d/%m/%Y %H:%M:%S",
|
||||
"%d/%m/%Y",
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
"%Y-%m-%d",
|
||||
]
|
||||
|
||||
|
||||
for fmt in formats:
|
||||
try:
|
||||
return datetime.strptime(date_str.strip(), fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@login_required
|
||||
@ -439,19 +492,19 @@ def bulk_job_status(request, job_id):
|
||||
"""
|
||||
user = request.user
|
||||
job = get_object_or_404(BulkSurveyJob, id=job_id)
|
||||
|
||||
|
||||
# Check permission
|
||||
if not user.is_px_admin() and job.created_by != user and job.hospital != user.hospital:
|
||||
messages.error(request, "You don't have permission to view this job.")
|
||||
return redirect('surveys:instance_list')
|
||||
|
||||
return redirect("surveys:instance_list")
|
||||
|
||||
context = {
|
||||
'job': job,
|
||||
'progress': job.progress_percentage,
|
||||
'is_complete': job.is_complete,
|
||||
'results': job.results,
|
||||
"job": job,
|
||||
"progress": job.progress_percentage,
|
||||
"is_complete": job.is_complete,
|
||||
"results": job.results,
|
||||
}
|
||||
return render(request, 'surveys/bulk_job_status.html', context)
|
||||
return render(request, "surveys/bulk_job_status.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
@ -460,7 +513,7 @@ def bulk_job_list(request):
|
||||
List all bulk survey jobs for the user.
|
||||
"""
|
||||
user = request.user
|
||||
|
||||
|
||||
# Filter jobs
|
||||
if user.is_px_admin():
|
||||
jobs = BulkSurveyJob.objects.all()
|
||||
@ -468,10 +521,10 @@ def bulk_job_list(request):
|
||||
jobs = BulkSurveyJob.objects.filter(hospital=user.hospital)
|
||||
else:
|
||||
jobs = BulkSurveyJob.objects.filter(created_by=user)
|
||||
|
||||
jobs = jobs.order_by('-created_at')[:50] # Last 50 jobs
|
||||
|
||||
|
||||
jobs = jobs.order_by("-created_at")[:50] # Last 50 jobs
|
||||
|
||||
context = {
|
||||
'jobs': jobs,
|
||||
"jobs": jobs,
|
||||
}
|
||||
return render(request, 'surveys/bulk_job_list.html', context)
|
||||
return render(request, "surveys/bulk_job_list.html", context)
|
||||
|
||||
@ -237,6 +237,12 @@ class SurveyInstance(UUIDModel, TimeStampedModel, TenantModel):
|
||||
|
||||
# Timestamps
|
||||
sent_at = models.DateTimeField(null=True, blank=True, db_index=True)
|
||||
scheduled_send_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
db_index=True,
|
||||
help_text="When this survey should be sent (for delayed sending)"
|
||||
)
|
||||
opened_at = models.DateTimeField(null=True, blank=True)
|
||||
completed_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ This module contains tasks for:
|
||||
- Survey-related background operations
|
||||
- Bulk survey sending
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from celery import shared_task
|
||||
@ -34,47 +35,41 @@ def analyze_survey_comment(survey_instance_id):
|
||||
from apps.core.ai_service import AIService, AIServiceError
|
||||
|
||||
try:
|
||||
survey = SurveyInstance.objects.select_related('patient', 'hospital').get(id=survey_instance_id)
|
||||
survey = SurveyInstance.objects.select_related("patient", "hospital").get(id=survey_instance_id)
|
||||
|
||||
# Check if comment exists
|
||||
if not survey.comment or not survey.comment.strip():
|
||||
logger.info(f"No comment to analyze for survey {survey_instance_id}")
|
||||
return {
|
||||
'status': 'skipped',
|
||||
'reason': 'no_comment'
|
||||
}
|
||||
return {"status": "skipped", "reason": "no_comment"}
|
||||
|
||||
# Check if already analyzed
|
||||
if survey.comment_analyzed:
|
||||
logger.info(f"Comment already analyzed for survey {survey_instance_id}")
|
||||
return {
|
||||
'status': 'skipped',
|
||||
'reason': 'already_analyzed'
|
||||
}
|
||||
return {"status": "skipped", "reason": "already_analyzed"}
|
||||
|
||||
logger.info(f"Starting AI analysis for survey comment {survey_instance_id}")
|
||||
|
||||
# Analyze sentiment
|
||||
try:
|
||||
sentiment_analysis = AIService.classify_sentiment(survey.comment)
|
||||
sentiment = sentiment_analysis.get('sentiment', 'neutral')
|
||||
sentiment_score = sentiment_analysis.get('score', 0.0)
|
||||
sentiment_confidence = sentiment_analysis.get('confidence', 0.0)
|
||||
sentiment = sentiment_analysis.get("sentiment", "neutral")
|
||||
sentiment_score = sentiment_analysis.get("score", 0.0)
|
||||
sentiment_confidence = sentiment_analysis.get("confidence", 0.0)
|
||||
except AIServiceError as e:
|
||||
logger.error(f"Sentiment analysis failed for survey {survey_instance_id}: {str(e)}")
|
||||
sentiment = 'neutral'
|
||||
sentiment = "neutral"
|
||||
sentiment_score = 0.0
|
||||
sentiment_confidence = 0.0
|
||||
|
||||
# Analyze emotion
|
||||
try:
|
||||
emotion_analysis = AIService.analyze_emotion(survey.comment)
|
||||
emotion = emotion_analysis.get('emotion', 'neutral')
|
||||
emotion_intensity = emotion_analysis.get('intensity', 0.0)
|
||||
emotion_confidence = emotion_analysis.get('confidence', 0.0)
|
||||
emotion = emotion_analysis.get("emotion", "neutral")
|
||||
emotion_intensity = emotion_analysis.get("intensity", 0.0)
|
||||
emotion_confidence = emotion_analysis.get("confidence", 0.0)
|
||||
except AIServiceError as e:
|
||||
logger.error(f"Emotion analysis failed for survey {survey_instance_id}: {str(e)}")
|
||||
emotion = 'neutral'
|
||||
emotion = "neutral"
|
||||
emotion_intensity = 0.0
|
||||
emotion_confidence = 0.0
|
||||
|
||||
@ -101,69 +96,63 @@ def analyze_survey_comment(survey_instance_id):
|
||||
- topics_ar: List of topics in Arabic
|
||||
- feedback_type: "positive", "negative", or "neutral"
|
||||
"""
|
||||
|
||||
|
||||
summary_result = AIService.chat_completion(
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": "You are a helpful assistant analyzing patient survey comments. Always respond with valid JSON."
|
||||
"content": "You are a helpful assistant analyzing patient survey comments. Always respond with valid JSON.",
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": summary_prompt
|
||||
}
|
||||
{"role": "user", "content": summary_prompt},
|
||||
],
|
||||
response_format={"type": "json_object"}
|
||||
response_format={"type": "json_object"},
|
||||
)
|
||||
|
||||
# Parse the JSON response
|
||||
import json
|
||||
|
||||
summary_data = json.loads(summary_result)
|
||||
|
||||
summary_en = summary_data.get('summary_en', '')
|
||||
summary_ar = summary_data.get('summary_ar', '')
|
||||
topics_en = summary_data.get('topics_en', [])
|
||||
topics_ar = summary_data.get('topics_ar', [])
|
||||
feedback_type = summary_data.get('feedback_type', 'neutral')
|
||||
summary_en = summary_data.get("summary_en", "")
|
||||
summary_ar = summary_data.get("summary_ar", "")
|
||||
topics_en = summary_data.get("topics_en", [])
|
||||
topics_ar = summary_data.get("topics_ar", [])
|
||||
feedback_type = summary_data.get("feedback_type", "neutral")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Summary generation failed for survey {survey_instance_id}: {str(e)}")
|
||||
summary_en = survey.comment[:200] # Fallback to comment text
|
||||
summary_ar = ''
|
||||
summary_ar = ""
|
||||
topics_en = []
|
||||
topics_ar = []
|
||||
feedback_type = sentiment # Fallback to sentiment
|
||||
|
||||
# Update survey with analysis results
|
||||
survey.comment_analysis = {
|
||||
'sentiment': sentiment,
|
||||
'sentiment_score': sentiment_score,
|
||||
'sentiment_confidence': sentiment_confidence,
|
||||
'emotion': emotion,
|
||||
'emotion_intensity': emotion_intensity,
|
||||
'emotion_confidence': emotion_confidence,
|
||||
'summary_en': summary_en,
|
||||
'summary_ar': summary_ar,
|
||||
'topics_en': topics_en,
|
||||
'topics_ar': topics_ar,
|
||||
'feedback_type': feedback_type,
|
||||
'analyzed_at': timezone.now().isoformat()
|
||||
"sentiment": sentiment,
|
||||
"sentiment_score": sentiment_score,
|
||||
"sentiment_confidence": sentiment_confidence,
|
||||
"emotion": emotion,
|
||||
"emotion_intensity": emotion_intensity,
|
||||
"emotion_confidence": emotion_confidence,
|
||||
"summary_en": summary_en,
|
||||
"summary_ar": summary_ar,
|
||||
"topics_en": topics_en,
|
||||
"topics_ar": topics_ar,
|
||||
"feedback_type": feedback_type,
|
||||
"analyzed_at": timezone.now().isoformat(),
|
||||
}
|
||||
survey.comment_analyzed = True
|
||||
survey.save(update_fields=['comment_analysis', 'comment_analyzed'])
|
||||
survey.save(update_fields=["comment_analysis", "comment_analyzed"])
|
||||
|
||||
# Log audit
|
||||
from apps.core.services import create_audit_log
|
||||
|
||||
create_audit_log(
|
||||
event_type='survey_comment_analyzed',
|
||||
event_type="survey_comment_analyzed",
|
||||
description=f"Survey comment analyzed with AI: sentiment={sentiment}, emotion={emotion}",
|
||||
content_object=survey,
|
||||
metadata={
|
||||
'sentiment': sentiment,
|
||||
'emotion': emotion,
|
||||
'feedback_type': feedback_type,
|
||||
'topics': topics_en
|
||||
}
|
||||
metadata={"sentiment": sentiment, "emotion": emotion, "feedback_type": feedback_type, "topics": topics_en},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@ -174,25 +163,25 @@ def analyze_survey_comment(survey_instance_id):
|
||||
)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'survey_id': str(survey.id),
|
||||
'sentiment': sentiment,
|
||||
'sentiment_score': sentiment_score,
|
||||
'sentiment_confidence': sentiment_confidence,
|
||||
'emotion': emotion,
|
||||
'emotion_intensity': emotion_intensity,
|
||||
'emotion_confidence': emotion_confidence,
|
||||
'summary_en': summary_en,
|
||||
'summary_ar': summary_ar,
|
||||
'topics_en': topics_en,
|
||||
'topics_ar': topics_ar,
|
||||
'feedback_type': feedback_type
|
||||
"status": "success",
|
||||
"survey_id": str(survey.id),
|
||||
"sentiment": sentiment,
|
||||
"sentiment_score": sentiment_score,
|
||||
"sentiment_confidence": sentiment_confidence,
|
||||
"emotion": emotion,
|
||||
"emotion_intensity": emotion_intensity,
|
||||
"emotion_confidence": emotion_confidence,
|
||||
"summary_en": summary_en,
|
||||
"summary_ar": summary_ar,
|
||||
"topics_en": topics_en,
|
||||
"topics_ar": topics_ar,
|
||||
"feedback_type": feedback_type,
|
||||
}
|
||||
|
||||
except SurveyInstance.DoesNotExist:
|
||||
error_msg = f"SurveyInstance {survey_instance_id} not found"
|
||||
logger.error(error_msg)
|
||||
return {'status': 'error', 'reason': error_msg}
|
||||
return {"status": "error", "reason": error_msg}
|
||||
|
||||
|
||||
@shared_task
|
||||
@ -213,97 +202,91 @@ def send_satisfaction_feedback(survey_instance_id, user_id):
|
||||
from apps.surveys.models import SurveyInstance, SurveyTemplate
|
||||
|
||||
try:
|
||||
survey = SurveyInstance.objects.select_related(
|
||||
'patient', 'hospital', 'survey_template'
|
||||
).get(id=survey_instance_id)
|
||||
survey = SurveyInstance.objects.select_related("patient", "hospital", "survey_template").get(
|
||||
id=survey_instance_id
|
||||
)
|
||||
|
||||
# Get feedback survey template
|
||||
try:
|
||||
feedback_template = SurveyTemplate.objects.get(
|
||||
hospital=survey.hospital,
|
||||
survey_type='complaint_resolution',
|
||||
is_active=True
|
||||
hospital=survey.hospital, survey_type="complaint_resolution", is_active=True
|
||||
)
|
||||
except SurveyTemplate.DoesNotExist:
|
||||
logger.warning(
|
||||
f"No feedback survey template found for hospital {survey.hospital.name}"
|
||||
)
|
||||
return {'status': 'skipped', 'reason': 'no_template'}
|
||||
logger.warning(f"No feedback survey template found for hospital {survey.hospital.name}")
|
||||
return {"status": "skipped", "reason": "no_template"}
|
||||
|
||||
# Check if already sent
|
||||
if survey.satisfaction_feedback_sent:
|
||||
logger.info(f"Satisfaction feedback already sent for survey {survey_instance_id}")
|
||||
return {'status': 'skipped', 'reason': 'already_sent'}
|
||||
return {"status": "skipped", "reason": "already_sent"}
|
||||
|
||||
# Create feedback survey instance
|
||||
feedback_survey = SurveyInstance.objects.create(
|
||||
survey_template=feedback_template,
|
||||
patient=survey.patient,
|
||||
encounter_id=survey.encounter_id,
|
||||
delivery_channel='sms',
|
||||
delivery_channel="sms",
|
||||
recipient_phone=survey.patient.phone,
|
||||
recipient_email=survey.patient.email,
|
||||
metadata={
|
||||
'original_survey_id': str(survey.id),
|
||||
'original_survey_title': survey.survey_template.name,
|
||||
'original_score': float(survey.total_score) if survey.total_score else None,
|
||||
'feedback_type': 'satisfaction'
|
||||
}
|
||||
"original_survey_id": str(survey.id),
|
||||
"original_survey_title": survey.survey_template.name,
|
||||
"original_score": float(survey.total_score) if survey.total_score else None,
|
||||
"feedback_type": "satisfaction",
|
||||
},
|
||||
)
|
||||
|
||||
# Mark original survey as having feedback sent
|
||||
survey.satisfaction_feedback_sent = True
|
||||
survey.satisfaction_feedback_sent_at = timezone.now()
|
||||
survey.satisfaction_feedback = feedback_survey
|
||||
survey.save(update_fields=[
|
||||
'satisfaction_feedback_sent',
|
||||
'satisfaction_feedback_sent_at',
|
||||
'satisfaction_feedback'
|
||||
])
|
||||
survey.save(
|
||||
update_fields=["satisfaction_feedback_sent", "satisfaction_feedback_sent_at", "satisfaction_feedback"]
|
||||
)
|
||||
|
||||
# Send survey invitation
|
||||
from apps.notifications.services import NotificationService
|
||||
|
||||
notification_log = NotificationService.send_survey_invitation(
|
||||
survey_instance=feedback_survey,
|
||||
language='en' # TODO: Get from patient preference
|
||||
language="en", # TODO: Get from patient preference
|
||||
)
|
||||
|
||||
# Update feedback survey status
|
||||
feedback_survey.status = 'sent'
|
||||
feedback_survey.status = "sent"
|
||||
feedback_survey.sent_at = timezone.now()
|
||||
feedback_survey.save(update_fields=['status', 'sent_at'])
|
||||
feedback_survey.save(update_fields=["status", "sent_at"])
|
||||
|
||||
# Log audit
|
||||
from apps.core.services import create_audit_log
|
||||
|
||||
create_audit_log(
|
||||
event_type='satisfaction_feedback_sent',
|
||||
event_type="satisfaction_feedback_sent",
|
||||
description=f"Satisfaction feedback survey sent for survey: {survey.survey_template.name}",
|
||||
content_object=feedback_survey,
|
||||
metadata={
|
||||
'original_survey_id': str(survey.id),
|
||||
'feedback_template': feedback_template.name,
|
||||
'sent_by_user_id': user_id
|
||||
}
|
||||
"original_survey_id": str(survey.id),
|
||||
"feedback_template": feedback_template.name,
|
||||
"sent_by_user_id": user_id,
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Satisfaction feedback survey sent for survey {survey_instance_id}"
|
||||
)
|
||||
logger.info(f"Satisfaction feedback survey sent for survey {survey_instance_id}")
|
||||
|
||||
return {
|
||||
'status': 'sent',
|
||||
'feedback_survey_id': str(feedback_survey.id),
|
||||
'notification_log_id': str(notification_log.id)
|
||||
"status": "sent",
|
||||
"feedback_survey_id": str(feedback_survey.id),
|
||||
"notification_log_id": str(notification_log.id),
|
||||
}
|
||||
|
||||
except SurveyInstance.DoesNotExist:
|
||||
error_msg = f"SurveyInstance {survey_instance_id} not found"
|
||||
logger.error(error_msg)
|
||||
return {'status': 'error', 'reason': error_msg}
|
||||
return {"status": "error", "reason": error_msg}
|
||||
except Exception as e:
|
||||
error_msg = f"Error sending satisfaction feedback: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
return {'status': 'error', 'reason': error_msg}
|
||||
return {"status": "error", "reason": error_msg}
|
||||
|
||||
|
||||
@shared_task
|
||||
@ -326,21 +309,19 @@ def create_action_from_negative_survey(survey_instance_id):
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
try:
|
||||
survey = SurveyInstance.objects.select_related(
|
||||
'survey_template',
|
||||
'patient',
|
||||
'hospital'
|
||||
).get(id=survey_instance_id)
|
||||
survey = SurveyInstance.objects.select_related("survey_template", "patient", "hospital").get(
|
||||
id=survey_instance_id
|
||||
)
|
||||
|
||||
# Verify survey is negative
|
||||
if not survey.is_negative:
|
||||
logger.info(f"Survey {survey_instance_id} is not negative, skipping action creation")
|
||||
return {'status': 'skipped', 'reason': 'not_negative'}
|
||||
return {"status": "skipped", "reason": "not_negative"}
|
||||
|
||||
# Check if action already created
|
||||
if survey.metadata.get('px_action_created'):
|
||||
if survey.metadata.get("px_action_created"):
|
||||
logger.info(f"PX Action already created for survey {survey_instance_id}")
|
||||
return {'status': 'skipped', 'reason': 'already_created'}
|
||||
return {"status": "skipped", "reason": "already_created"}
|
||||
|
||||
# Calculate score for priority/severity determination
|
||||
score = float(survey.total_score) if survey.total_score else 0.0
|
||||
@ -360,19 +341,19 @@ def create_action_from_negative_survey(survey_instance_id):
|
||||
priority = PriorityChoices.LOW
|
||||
|
||||
# Determine category based on survey template or journey stage
|
||||
category = 'service_quality' # Default
|
||||
if survey.survey_template.survey_type == 'post_discharge':
|
||||
category = 'clinical_quality'
|
||||
elif survey.survey_template.survey_type == 'inpatient_satisfaction':
|
||||
category = 'service_quality'
|
||||
category = "service_quality" # Default
|
||||
if survey.survey_template.survey_type == "post_discharge":
|
||||
category = "clinical_quality"
|
||||
elif survey.survey_template.survey_type == "inpatient_satisfaction":
|
||||
category = "service_quality"
|
||||
elif survey.journey_instance and survey.journey_instance.stage:
|
||||
stage = survey.journey_instance.stage.lower()
|
||||
if 'admission' in stage or 'registration' in stage:
|
||||
category = 'process_improvement'
|
||||
elif 'treatment' in stage or 'procedure' in stage:
|
||||
category = 'clinical_quality'
|
||||
elif 'discharge' in stage or 'billing' in stage:
|
||||
category = 'process_improvement'
|
||||
if "admission" in stage or "registration" in stage:
|
||||
category = "process_improvement"
|
||||
elif "treatment" in stage or "procedure" in stage:
|
||||
category = "clinical_quality"
|
||||
elif "discharge" in stage or "billing" in stage:
|
||||
category = "process_improvement"
|
||||
|
||||
# Build description
|
||||
description_parts = [
|
||||
@ -384,9 +365,7 @@ def create_action_from_negative_survey(survey_instance_id):
|
||||
description_parts.append(f"Patient Comment: {survey.comment}")
|
||||
|
||||
if survey.journey_instance:
|
||||
description_parts.append(
|
||||
f"Journey Stage: {survey.journey_instance.stage}"
|
||||
)
|
||||
description_parts.append(f"Journey Stage: {survey.journey_instance.stage}")
|
||||
|
||||
if survey.encounter_id:
|
||||
description_parts.append(f"Encounter ID: {survey.encounter_id}")
|
||||
@ -397,7 +376,7 @@ def create_action_from_negative_survey(survey_instance_id):
|
||||
survey_ct = ContentType.objects.get_for_model(SurveyInstance)
|
||||
|
||||
action = PXAction.objects.create(
|
||||
source_type='survey',
|
||||
source_type="survey",
|
||||
content_type=survey_ct,
|
||||
object_id=survey.id,
|
||||
title=f"Negative Survey: {survey.survey_template.name} (Score: {score:.1f})",
|
||||
@ -407,54 +386,55 @@ def create_action_from_negative_survey(survey_instance_id):
|
||||
category=category,
|
||||
priority=priority,
|
||||
severity=severity,
|
||||
status='open',
|
||||
status="open",
|
||||
metadata={
|
||||
'source_survey_id': str(survey.id),
|
||||
'source_survey_template': survey.survey_template.name,
|
||||
'survey_score': score,
|
||||
'is_negative': True,
|
||||
'has_comment': bool(survey.comment),
|
||||
'encounter_id': survey.encounter_id,
|
||||
'auto_created': True
|
||||
}
|
||||
"source_survey_id": str(survey.id),
|
||||
"source_survey_template": survey.survey_template.name,
|
||||
"survey_score": score,
|
||||
"is_negative": True,
|
||||
"has_comment": bool(survey.comment),
|
||||
"encounter_id": survey.encounter_id,
|
||||
"auto_created": True,
|
||||
},
|
||||
)
|
||||
|
||||
# Create action log entry
|
||||
PXActionLog.objects.create(
|
||||
action=action,
|
||||
log_type='note',
|
||||
log_type="note",
|
||||
message=(
|
||||
f"Action automatically created from negative survey. "
|
||||
f"Score: {score:.1f}, Template: {survey.survey_template.name}"
|
||||
),
|
||||
metadata={
|
||||
'survey_id': str(survey.id),
|
||||
'survey_score': score,
|
||||
'auto_created': True,
|
||||
'severity': severity,
|
||||
'priority': priority
|
||||
}
|
||||
"survey_id": str(survey.id),
|
||||
"survey_score": score,
|
||||
"auto_created": True,
|
||||
"severity": severity,
|
||||
"priority": priority,
|
||||
},
|
||||
)
|
||||
|
||||
# Update survey metadata to track action creation
|
||||
if not survey.metadata:
|
||||
survey.metadata = {}
|
||||
survey.metadata['px_action_created'] = True
|
||||
survey.metadata['px_action_id'] = str(action.id)
|
||||
survey.save(update_fields=['metadata'])
|
||||
survey.metadata["px_action_created"] = True
|
||||
survey.metadata["px_action_id"] = str(action.id)
|
||||
survey.save(update_fields=["metadata"])
|
||||
|
||||
# Log audit
|
||||
from apps.core.services import create_audit_log
|
||||
|
||||
create_audit_log(
|
||||
event_type='px_action_created',
|
||||
event_type="px_action_created",
|
||||
description=f"PX Action created from negative survey: {survey.survey_template.name}",
|
||||
content_object=action,
|
||||
metadata={
|
||||
'survey_id': str(survey.id),
|
||||
'survey_template': survey.survey_template.name,
|
||||
'survey_score': score,
|
||||
'trigger': 'negative_survey'
|
||||
}
|
||||
"survey_id": str(survey.id),
|
||||
"survey_template": survey.survey_template.name,
|
||||
"survey_score": score,
|
||||
"trigger": "negative_survey",
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@ -463,36 +443,34 @@ def create_action_from_negative_survey(survey_instance_id):
|
||||
)
|
||||
|
||||
return {
|
||||
'status': 'action_created',
|
||||
'action_id': str(action.id),
|
||||
'survey_score': score,
|
||||
'severity': severity,
|
||||
'priority': priority
|
||||
"status": "action_created",
|
||||
"action_id": str(action.id),
|
||||
"survey_score": score,
|
||||
"severity": severity,
|
||||
"priority": priority,
|
||||
}
|
||||
|
||||
except SurveyInstance.DoesNotExist:
|
||||
error_msg = f"SurveyInstance {survey_instance_id} not found"
|
||||
logger.error(error_msg)
|
||||
return {'status': 'error', 'reason': error_msg}
|
||||
return {"status": "error", "reason": error_msg}
|
||||
except Exception as e:
|
||||
error_msg = f"Error creating action from negative survey: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
return {'status': 'error', 'reason': error_msg}
|
||||
|
||||
|
||||
return {"status": "error", "reason": error_msg}
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3)
|
||||
def send_bulk_surveys(self, job_id):
|
||||
"""
|
||||
Send surveys to multiple patients in the background.
|
||||
|
||||
|
||||
This task processes a BulkSurveyJob and sends surveys to all patients.
|
||||
It updates the job progress as it goes and handles errors gracefully.
|
||||
|
||||
|
||||
Args:
|
||||
job_id: UUID of the BulkSurveyJob
|
||||
|
||||
|
||||
Returns:
|
||||
dict: Result with counts and status
|
||||
"""
|
||||
@ -500,69 +478,68 @@ def send_bulk_surveys(self, job_id):
|
||||
from apps.organizations.models import Patient, Hospital
|
||||
from apps.surveys.services import SurveyDeliveryService
|
||||
from apps.core.services import AuditService
|
||||
|
||||
|
||||
try:
|
||||
# Get the job
|
||||
job = BulkSurveyJob.objects.get(id=job_id)
|
||||
|
||||
|
||||
# Update status to processing
|
||||
job.status = BulkSurveyJob.JobStatus.PROCESSING
|
||||
job.started_at = timezone.now()
|
||||
job.save(update_fields=['status', 'started_at'])
|
||||
|
||||
job.save(update_fields=["status", "started_at"])
|
||||
|
||||
logger.info(f"Starting bulk survey job {job_id} for {job.total_patients} patients")
|
||||
|
||||
|
||||
# Get settings
|
||||
survey_template = job.survey_template
|
||||
hospital = job.hospital
|
||||
delivery_channel = job.delivery_channel
|
||||
custom_message = job.custom_message
|
||||
patient_data_list = job.patient_data
|
||||
|
||||
|
||||
# Results tracking
|
||||
success_count = 0
|
||||
failed_count = 0
|
||||
failed_patients = []
|
||||
created_survey_ids = []
|
||||
|
||||
|
||||
# Process each patient
|
||||
for idx, patient_info in enumerate(patient_data_list):
|
||||
try:
|
||||
# Update progress periodically
|
||||
if idx % 5 == 0:
|
||||
job.processed_count = idx
|
||||
job.save(update_fields=['processed_count'])
|
||||
|
||||
job.save(update_fields=["processed_count"])
|
||||
|
||||
# Get patient
|
||||
patient_id = patient_info.get('patient_id')
|
||||
file_number = patient_info.get('file_number', 'unknown')
|
||||
|
||||
patient_id = patient_info.get("patient_id")
|
||||
file_number = patient_info.get("file_number", "unknown")
|
||||
|
||||
try:
|
||||
patient = Patient.objects.get(id=patient_id)
|
||||
except Patient.DoesNotExist:
|
||||
failed_count += 1
|
||||
failed_patients.append({
|
||||
'file_number': file_number,
|
||||
'reason': 'Patient not found'
|
||||
})
|
||||
failed_patients.append({"file_number": file_number, "reason": "Patient not found"})
|
||||
continue
|
||||
|
||||
|
||||
# Determine delivery channels
|
||||
channels = []
|
||||
if delivery_channel in ['sms', 'both'] and patient.phone:
|
||||
channels.append('sms')
|
||||
if delivery_channel in ['email', 'both'] and patient.email:
|
||||
channels.append('email')
|
||||
|
||||
if delivery_channel in ["sms", "both"] and patient.phone:
|
||||
channels.append("sms")
|
||||
if delivery_channel in ["email", "both"] and patient.email:
|
||||
channels.append("email")
|
||||
|
||||
if not channels:
|
||||
failed_count += 1
|
||||
failed_patients.append({
|
||||
'file_number': file_number,
|
||||
'patient_name': patient.get_full_name(),
|
||||
'reason': 'No contact information'
|
||||
})
|
||||
failed_patients.append(
|
||||
{
|
||||
"file_number": file_number,
|
||||
"patient_name": patient.get_full_name(),
|
||||
"reason": "No contact information",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
|
||||
# Create and send survey for each channel
|
||||
for channel in channels:
|
||||
survey_instance = SurveyInstance.objects.create(
|
||||
@ -571,52 +548,51 @@ def send_bulk_surveys(self, job_id):
|
||||
hospital=hospital,
|
||||
delivery_channel=channel,
|
||||
status=SurveyStatus.SENT,
|
||||
recipient_phone=patient.phone if channel == 'sms' else '',
|
||||
recipient_email=patient.email if channel == 'email' else '',
|
||||
recipient_phone=patient.phone if channel == "sms" else "",
|
||||
recipient_email=patient.email if channel == "email" else "",
|
||||
metadata={
|
||||
'sent_manually': True,
|
||||
'sent_by': str(job.created_by.id) if job.created_by else None,
|
||||
'custom_message': custom_message,
|
||||
'recipient_type': 'bulk_import',
|
||||
'his_file_number': file_number,
|
||||
'bulk_job_id': str(job.id),
|
||||
}
|
||||
"sent_manually": True,
|
||||
"sent_by": str(job.created_by.id) if job.created_by else None,
|
||||
"custom_message": custom_message,
|
||||
"recipient_type": "bulk_import",
|
||||
"his_file_number": file_number,
|
||||
"bulk_job_id": str(job.id),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Send survey
|
||||
success = SurveyDeliveryService.deliver_survey(survey_instance)
|
||||
|
||||
|
||||
if success:
|
||||
success_count += 1
|
||||
created_survey_ids.append(str(survey_instance.id))
|
||||
else:
|
||||
failed_count += 1
|
||||
failed_patients.append({
|
||||
'file_number': file_number,
|
||||
'patient_name': patient.get_full_name(),
|
||||
'reason': 'Delivery failed'
|
||||
})
|
||||
failed_patients.append(
|
||||
{
|
||||
"file_number": file_number,
|
||||
"patient_name": patient.get_full_name(),
|
||||
"reason": "Delivery failed",
|
||||
}
|
||||
)
|
||||
survey_instance.delete()
|
||||
|
||||
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
failed_patients.append({
|
||||
'file_number': patient_info.get('file_number', 'unknown'),
|
||||
'reason': str(e)
|
||||
})
|
||||
failed_patients.append({"file_number": patient_info.get("file_number", "unknown"), "reason": str(e)})
|
||||
logger.error(f"Error processing patient in bulk job {job_id}: {e}")
|
||||
|
||||
|
||||
# Update job with final results
|
||||
job.processed_count = len(patient_data_list)
|
||||
job.success_count = success_count
|
||||
job.failed_count = failed_count
|
||||
job.results = {
|
||||
'success_count': success_count,
|
||||
'failed_count': failed_count,
|
||||
'failed_patients': failed_patients[:50], # Limit stored failures
|
||||
'survey_ids': created_survey_ids[:100], # Limit stored IDs
|
||||
"success_count": success_count,
|
||||
"failed_count": failed_count,
|
||||
"failed_patients": failed_patients[:50], # Limit stored failures
|
||||
"survey_ids": created_survey_ids[:100], # Limit stored IDs
|
||||
}
|
||||
|
||||
|
||||
# Determine final status
|
||||
if failed_count == 0:
|
||||
job.status = BulkSurveyJob.JobStatus.COMPLETED
|
||||
@ -625,41 +601,41 @@ def send_bulk_surveys(self, job_id):
|
||||
job.error_message = "All surveys failed to send"
|
||||
else:
|
||||
job.status = BulkSurveyJob.JobStatus.PARTIAL
|
||||
|
||||
|
||||
job.completed_at = timezone.now()
|
||||
job.save()
|
||||
|
||||
|
||||
# Log audit
|
||||
AuditService.log_event(
|
||||
event_type='bulk_survey_completed',
|
||||
event_type="bulk_survey_completed",
|
||||
description=f"Bulk survey job completed: {success_count} sent, {failed_count} failed",
|
||||
user=job.created_by,
|
||||
metadata={
|
||||
'job_id': str(job.id),
|
||||
'hospital': hospital.name,
|
||||
'survey_template': survey_template.name,
|
||||
'success_count': success_count,
|
||||
'failed_count': failed_count
|
||||
}
|
||||
"job_id": str(job.id),
|
||||
"hospital": hospital.name,
|
||||
"survey_template": survey_template.name,
|
||||
"success_count": success_count,
|
||||
"failed_count": failed_count,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
logger.info(f"Bulk survey job {job_id} completed: {success_count} success, {failed_count} failed")
|
||||
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'job_id': str(job.id),
|
||||
'success_count': success_count,
|
||||
'failed_count': failed_count,
|
||||
'total': len(patient_data_list)
|
||||
"status": "success",
|
||||
"job_id": str(job.id),
|
||||
"success_count": success_count,
|
||||
"failed_count": failed_count,
|
||||
"total": len(patient_data_list),
|
||||
}
|
||||
|
||||
|
||||
except BulkSurveyJob.DoesNotExist:
|
||||
logger.error(f"BulkSurveyJob {job_id} not found")
|
||||
return {'status': 'error', 'error': 'Job not found'}
|
||||
|
||||
return {"status": "error", "error": "Job not found"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in bulk survey task {job_id}: {e}", exc_info=True)
|
||||
|
||||
|
||||
# Update job status to failed
|
||||
try:
|
||||
job = BulkSurveyJob.objects.get(id=job_id)
|
||||
@ -669,10 +645,93 @@ def send_bulk_surveys(self, job_id):
|
||||
job.save()
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
# Retry on failure
|
||||
if self.request.retries < self.max_retries:
|
||||
logger.info(f"Retrying bulk survey job {job_id} (attempt {self.request.retries + 1})")
|
||||
raise self.retry(countdown=60 * (self.request.retries + 1))
|
||||
|
||||
return {'status': 'error', 'error': str(e)}
|
||||
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_scheduled_survey(survey_instance_id):
|
||||
"""
|
||||
Send a scheduled survey.
|
||||
|
||||
This task is called after the delay period expires.
|
||||
It sends the survey via the configured delivery channel (SMS/Email).
|
||||
|
||||
Args:
|
||||
survey_instance_id: UUID of the SurveyInstance to send
|
||||
|
||||
Returns:
|
||||
dict: Result with status and details
|
||||
"""
|
||||
from apps.surveys.models import SurveyInstance, SurveyStatus
|
||||
from apps.surveys.services import SurveyDeliveryService
|
||||
|
||||
try:
|
||||
survey = SurveyInstance.objects.get(id=survey_instance_id)
|
||||
|
||||
# Check if already sent
|
||||
if survey.status != SurveyStatus.PENDING:
|
||||
logger.warning(f"Survey {survey.id} already sent/cancelled (status: {survey.status})")
|
||||
return {"status": "skipped", "reason": "already_sent", "survey_id": survey.id}
|
||||
|
||||
# Check if scheduled time has passed
|
||||
if survey.scheduled_send_at and survey.scheduled_send_at > timezone.now():
|
||||
logger.warning(f"Survey {survey.id} not due yet (scheduled: {survey.scheduled_send_at})")
|
||||
return {"status": "delayed", "scheduled_at": survey.scheduled_send_at.isoformat(), "survey_id": survey.id}
|
||||
|
||||
# Send survey
|
||||
success = SurveyDeliveryService.deliver_survey(survey)
|
||||
|
||||
if success:
|
||||
survey.status = SurveyStatus.SENT
|
||||
survey.sent_at = timezone.now()
|
||||
survey.save()
|
||||
logger.info(f"Scheduled survey {survey.id} sent successfully")
|
||||
return {"status": "sent", "survey_id": survey.id}
|
||||
else:
|
||||
survey.status = SurveyStatus.FAILED
|
||||
survey.save()
|
||||
logger.error(f"Scheduled survey {survey.id} delivery failed")
|
||||
return {"status": "failed", "survey_id": survey.id, "reason": "delivery_failed"}
|
||||
|
||||
except SurveyInstance.DoesNotExist:
|
||||
logger.error(f"Survey {survey_instance_id} not found")
|
||||
return {"status": "error", "reason": "not_found"}
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending scheduled survey: {e}", exc_info=True)
|
||||
return {"status": "error", "reason": str(e)}
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_pending_scheduled_surveys():
|
||||
"""
|
||||
Periodic task to send any overdue scheduled surveys.
|
||||
|
||||
Runs every 10 minutes as a safety net to catch any surveys
|
||||
that weren't sent due to task failures or delays.
|
||||
|
||||
Returns:
|
||||
dict: Result with count of queued surveys
|
||||
"""
|
||||
from apps.surveys.models import SurveyInstance
|
||||
|
||||
# Find surveys that should have been sent but weren't
|
||||
# Use sent_at__isnull=True since there's no PENDING status
|
||||
overdue_surveys = SurveyInstance.objects.filter(sent_at__isnull=True, scheduled_send_at__lte=timezone.now())[
|
||||
:50
|
||||
] # Max 50 at a time
|
||||
|
||||
sent_count = 0
|
||||
for survey in overdue_surveys:
|
||||
send_scheduled_survey.delay(str(survey.id))
|
||||
sent_count += 1
|
||||
|
||||
if sent_count > 0:
|
||||
logger.info(f"Queued {sent_count} overdue scheduled surveys")
|
||||
|
||||
return {"queued": sent_count}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -9,6 +9,11 @@ from celery.schedules import crontab
|
||||
# Set the default Django settings module for the 'celery' program.
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.dev')
|
||||
|
||||
# Apply zoneinfo compatibility patch for django-celery-beat with Python 3.12+
|
||||
# This must be done before Celery app is created
|
||||
from config.celery_scheduler import apply_tzcrontab_patch
|
||||
apply_tzcrontab_patch()
|
||||
|
||||
app = Celery('px360')
|
||||
|
||||
# Using a string here means the worker doesn't have to serialize
|
||||
@ -30,11 +35,21 @@ app.conf.beat_schedule = {
|
||||
# Fetch surveys from HIS every 5 minutes
|
||||
'fetch-his-surveys': {
|
||||
'task': 'apps.integrations.tasks.fetch_his_surveys',
|
||||
'schedule': crontab(minute='*/5'), # Every 5 minutes
|
||||
'schedule': crontab(minute='*/2'), # Every 5 minutes
|
||||
'options': {
|
||||
'expires': 240, # Task expires after 4 minutes if not picked up
|
||||
}
|
||||
},
|
||||
# TEST TASK - Fetch from JSON file (uncomment for testing, remove when done)
|
||||
# 'test-fetch-his-surveys-from-json': {
|
||||
# 'task': 'apps.integrations.tasks.test_fetch_his_surveys_from_json',
|
||||
# 'schedule': crontab(minute='*/5'), # Every 5 minutes
|
||||
# },
|
||||
# Send pending scheduled surveys every 10 minutes
|
||||
'send-pending-scheduled-surveys': {
|
||||
'task': 'apps.surveys.tasks.send_pending_scheduled_surveys',
|
||||
'schedule': crontab(minute='*/10'), # Every 10 minutes
|
||||
},
|
||||
# Check for overdue complaints every 15 minutes
|
||||
'check-overdue-complaints': {
|
||||
'task': 'apps.complaints.tasks.check_overdue_complaints',
|
||||
|
||||
@ -1,36 +1,46 @@
|
||||
"""
|
||||
Custom Celery Beat scheduler to fix Python 3.12 zoneinfo compatibility issue.
|
||||
This patches the _default_now method to work with zoneinfo.ZoneInfo instead of pytz.
|
||||
This patches the TzAwareCrontab class to work with zoneinfo.ZoneInfo instead of pytz.
|
||||
|
||||
The error occurs because django-celery-beat assumes pytz timezone objects
|
||||
which have a `normalize()` method that zoneinfo.ZoneInfo doesn't have.
|
||||
"""
|
||||
from django_celery_beat.schedulers import DatabaseScheduler, ModelEntry
|
||||
import functools
|
||||
|
||||
|
||||
class PatchedModelEntry(ModelEntry):
|
||||
"""
|
||||
Custom model entry that fixes the zoneinfo.ZoneInfo compatibility issue.
|
||||
def apply_tzcrontab_patch():
|
||||
"""
|
||||
Apply monkey-patch to django_celery_beat.tzcrontab.TzAwareCrontab
|
||||
to fix zoneinfo.ZoneInfo compatibility.
|
||||
|
||||
def _default_now(self):
|
||||
"""
|
||||
Return the current time in the configured timezone.
|
||||
This fixes the AttributeError: 'zoneinfo.ZoneInfo' object has no attribute 'localize'
|
||||
"""
|
||||
from django.utils import timezone
|
||||
return timezone.now()
|
||||
|
||||
|
||||
class PatchedDatabaseScheduler(DatabaseScheduler):
|
||||
"""
|
||||
Custom scheduler that fixes the zoneinfo.ZoneInfo compatibility issue
|
||||
in django-celery-beat 2.1.0 with Python 3.12.
|
||||
This should be called at Celery app initialization.
|
||||
"""
|
||||
from django_celery_beat import tzcrontab
|
||||
|
||||
Entry = PatchedModelEntry
|
||||
original_init = tzcrontab.TzAwareCrontab.__init__
|
||||
|
||||
def _default_now(self):
|
||||
"""
|
||||
Return the current time in the configured timezone.
|
||||
This fixes the AttributeError: 'zoneinfo.ZoneInfo' object has no attribute 'localize'
|
||||
"""
|
||||
from django.utils import timezone
|
||||
return timezone.now()
|
||||
@functools.wraps(original_init)
|
||||
def patched_init(self, *args, **kwargs):
|
||||
# Get the tz argument, default to None
|
||||
tz = kwargs.get('tz', None)
|
||||
|
||||
# Check if it's a zoneinfo.ZoneInfo (no 'normalize' attribute)
|
||||
if tz is not None and not hasattr(tz, 'normalize'):
|
||||
# Replace with a patched nowfun that works with zoneinfo
|
||||
def zoneinfo_aware_nowfunc():
|
||||
"""Get current time in the scheduler's timezone using Django's timezone utility."""
|
||||
from django.utils import timezone as django_timezone
|
||||
now = django_timezone.now()
|
||||
return now.astimezone(self.tz)
|
||||
|
||||
# Store the zoneinfo-compatible nowfun
|
||||
self._zoneinfo_nowfun = zoneinfo_aware_nowfunc
|
||||
|
||||
# Call original init
|
||||
original_init(self, *args, **kwargs)
|
||||
|
||||
# If we detected zoneinfo, override the nowfun
|
||||
if hasattr(self, '_zoneinfo_nowfun'):
|
||||
self.nowfun = self._zoneinfo_nowfun
|
||||
|
||||
tzcrontab.TzAwareCrontab.__init__ = patched_init
|
||||
245
docs/SETUP_COMPLETE.md
Normal file
245
docs/SETUP_COMPLETE.md
Normal 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
362
docs/SETUP_GUIDE.md
Normal 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
|
||||
@ -28,7 +28,7 @@ dependencies = [
|
||||
"openpyxl>=3.1.5",
|
||||
"litellm>=1.0.0",
|
||||
"watchdog>=6.0.0",
|
||||
"django-celery-beat>=2.1.0",
|
||||
"django-celery-beat>=2.7.0",
|
||||
"google-api-python-client>=2.187.0",
|
||||
"tweepy>=4.16.0",
|
||||
"google-auth-oauthlib>=1.2.3",
|
||||
|
||||
7429
rating_data.json
Normal file
7429
rating_data.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -3,44 +3,124 @@
|
||||
|
||||
{% block title %}{% if form.instance.pk %}{% trans "Edit Category" %}{% else %}{% trans "New Category" %}{% endif %} - PX360{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.page-header-gradient {
|
||||
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
|
||||
color: white;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
|
||||
}
|
||||
|
||||
.form-section {
|
||||
background: #fff;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.form-section:hover {
|
||||
border-color: #005696;
|
||||
box-shadow: 0 4px 12px rgba(0, 86, 150, 0.1);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-control, .form-select {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.form-control:focus, .form-select:focus {
|
||||
outline: none;
|
||||
border-color: #005696;
|
||||
box-shadow: 0 0 0 3px rgba(0, 86, 150, 0.1);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #005696;
|
||||
color: white;
|
||||
border-radius: 0.75rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: #007bbd;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: white;
|
||||
color: #64748b;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 0.75rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: #005696;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
|
||||
<div class="p-6 md:p-8 bg-gradient-to-br from-slate-50 to-blue-50 min-h-screen">
|
||||
<!-- Page Header -->
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-14 h-14 bg-gradient-to-br from-blue to-indigo-500 rounded-2xl shadow-lg shadow-blue-200">
|
||||
<i data-lucide="{% if form.instance.pk %}edit{% else %}plus{% endif %}" class="w-8 h-8 text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-navy">
|
||||
{% if form.instance.pk %}{% trans "Edit Category" %}{% else %}{% trans "New Category" %}{% endif %}
|
||||
</h1>
|
||||
<p class="text-slate text-sm">
|
||||
{% if form.instance.pk %}{{ form.instance.name_en }}{% else %}{% trans "Create a new acknowledgement category" %}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="page-header-gradient flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div>
|
||||
<nav aria-label="breadcrumb" class="mb-2">
|
||||
<ol class="flex items-center gap-2 text-sm text-white/80">
|
||||
<li><a href="{% url 'accounts:acknowledgements:ack_category_list' %}" class="hover:text-white transition">{% trans "Categories" %}</a></li>
|
||||
<li><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
|
||||
<li class="text-white">{% if form.instance.pk %}{% trans "Edit" %}{% else %}{% trans "Create" %}{% endif %}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1 class="text-2xl font-bold flex items-center gap-2">
|
||||
<i data-lucide="{% if form.instance.pk %}edit{% else %}plus{% endif %}" class="w-8 h-8"></i>
|
||||
{% if form.instance.pk %}{% trans "Edit Category" %}{% else %}{% trans "New Category" %}{% endif %}
|
||||
</h1>
|
||||
</div>
|
||||
<a href="{% url 'accounts:acknowledgements:ack_category_list' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-white border-2 border-slate-200 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition">
|
||||
<a href="{% url 'accounts:acknowledgements:ack_category_list' %}" class="inline-flex items-center gap-2 px-4 py-2 bg-white/20 text-white rounded-lg hover:bg-white/30 transition">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4"></i>
|
||||
{% trans "Back to Categories" %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Form Card -->
|
||||
<!-- Form Section -->
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
|
||||
<div class="px-6 py-5 border-b border-slate-100 bg-gradient-to-r from-blue-50 to-transparent">
|
||||
<h2 class="text-lg font-bold text-navy flex items-center gap-2">
|
||||
<i data-lucide="file-text" class="w-5 h-5 text-blue-500"></i>
|
||||
{% trans "Category Details" %}
|
||||
</h2>
|
||||
<div class="form-section">
|
||||
<div class="flex items-center gap-2 mb-4 pb-3 border-b border-slate-100">
|
||||
<i data-lucide="file-text" class="w-5 h-5 text-blue-600"></i>
|
||||
<h2 class="text-lg font-bold text-slate-800">{% trans "Category Details" %}</h2>
|
||||
</div>
|
||||
<form method="post" class="p-6 space-y-6">
|
||||
<form method="post" class="space-y-6">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.name_en.id_for_label }}">
|
||||
<label class="form-label" for="{{ form.name_en.id_for_label }}">
|
||||
{% trans "Name (English)" %}
|
||||
</label>
|
||||
{{ form.name_en }}
|
||||
@ -50,7 +130,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.name_ar.id_for_label }}">
|
||||
<label class="form-label" for="{{ form.name_ar.id_for_label }}">
|
||||
{% trans "Name (Arabic)" %}
|
||||
</label>
|
||||
{{ form.name_ar }}
|
||||
@ -62,7 +142,7 @@
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.code.id_for_label }}">
|
||||
<label class="form-label" for="{{ form.code.id_for_label }}">
|
||||
{% trans "Code" %}
|
||||
</label>
|
||||
{{ form.code }}
|
||||
@ -72,7 +152,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.order.id_for_label }}">
|
||||
<label class="form-label" for="{{ form.order.id_for_label }}">
|
||||
{% trans "Display Order" %}
|
||||
</label>
|
||||
{{ form.order }}
|
||||
@ -82,7 +162,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.icon.id_for_label }}">
|
||||
<label class="form-label" for="{{ form.icon.id_for_label }}">
|
||||
{% trans "Icon" %}
|
||||
</label>
|
||||
{{ form.icon }}
|
||||
@ -94,7 +174,7 @@
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.color.id_for_label }}">
|
||||
<label class="form-label" for="{{ form.color.id_for_label }}">
|
||||
{% trans "Color" %}
|
||||
</label>
|
||||
<div class="flex gap-3 items-center">
|
||||
@ -107,21 +187,21 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.is_active.id_for_label }}">
|
||||
<label class="form-label" for="{{ form.is_active.id_for_label }}">
|
||||
{% trans "Status" %}
|
||||
</label>
|
||||
<div class="flex items-center gap-3 mt-3">
|
||||
{{ form.is_active }}
|
||||
<span class="text-slate">{% trans "Active" %}</span>
|
||||
<span class="text-slate-600">{% trans "Active" %}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-6 border-t border-slate-100">
|
||||
<a href="{% url 'accounts:acknowledgements:ack_category_list' %}" class="px-6 py-2.5 bg-slate-100 text-slate-700 rounded-xl font-semibold hover:bg-slate-200 transition">
|
||||
<a href="{% url 'accounts:acknowledgements:ack_category_list' %}" class="btn-secondary">
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
<button type="submit" class="inline-flex items-center gap-2 px-6 py-2.5 bg-gradient-to-r from-blue to-navy text-white rounded-xl font-semibold hover:from-navy hover:to-blue transition shadow-lg shadow-blue-200">
|
||||
<button type="submit" class="btn-primary">
|
||||
<i data-lucide="save" class="w-4 h-4"></i>
|
||||
{% trans "Save Category" %}
|
||||
</button>
|
||||
@ -150,4 +230,4 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@ -3,42 +3,88 @@
|
||||
|
||||
{% block title %}{% trans "Acknowledgement Categories" %} - PX360{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.page-header-gradient {
|
||||
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
|
||||
color: white;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.section-card:hover {
|
||||
border-color: #005696;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
background: linear-gradient(to right, #f8fafc, #f1f5f9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
|
||||
<!-- Page Header -->
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-14 h-14 bg-gradient-to-br from-blue to-indigo-500 rounded-2xl shadow-lg shadow-blue-200">
|
||||
<i data-lucide="folder" class="w-8 h-8 text-white"></i>
|
||||
<div class="page-header-gradient">
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="section-icon bg-white/20">
|
||||
<i data-lucide="folder" class="w-6 h-6 text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">
|
||||
{% trans "Acknowledgement Categories" %}
|
||||
</h1>
|
||||
<p class="text-white/80 text-sm">
|
||||
{% trans "Manage categories for acknowledgement items" %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-navy">
|
||||
{% trans "Acknowledgement Categories" %}
|
||||
</h1>
|
||||
<p class="text-slate text-sm">
|
||||
{% trans "Manage categories for acknowledgement items" %}
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<a href="{% url 'accounts:acknowledgements:ack_dashboard' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-white/20 border border-white/30 text-white rounded-xl font-semibold hover:bg-white/30 transition">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4"></i>
|
||||
{% trans "Back" %}
|
||||
</a>
|
||||
<a href="{% url 'accounts:acknowledgements:ack_category_create' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-white text-blue-700 rounded-xl font-semibold hover:bg-blue-50 transition shadow-lg">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i>
|
||||
{% trans "New Category" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<a href="{% url 'accounts:acknowledgements:ack_dashboard' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-white border-2 border-slate-200 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4"></i>
|
||||
{% trans "Back" %}
|
||||
</a>
|
||||
<a href="{% url 'accounts:acknowledgements:ack_category_create' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-blue to-navy text-white rounded-xl font-semibold hover:from-navy hover:to-blue transition shadow-lg shadow-blue-200">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i>
|
||||
{% trans "New Category" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Categories List -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
|
||||
<div class="px-6 py-5 border-b border-slate-100 bg-gradient-to-r from-blue-50 to-transparent">
|
||||
<h2 class="text-lg font-bold text-navy flex items-center gap-2">
|
||||
<i data-lucide="list" class="w-5 h-5 text-blue-500"></i>
|
||||
{% trans "All Categories" %}
|
||||
</h2>
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-icon bg-blue-100">
|
||||
<i data-lucide="list" class="w-5 h-5 text-blue-600"></i>
|
||||
</div>
|
||||
<h2 class="text-lg font-bold text-navy">{% trans "All Categories" %}</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
{% if categories %}
|
||||
@ -130,4 +176,4 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@ -3,44 +3,124 @@
|
||||
|
||||
{% block title %}{% if form.instance.pk %}{% trans "Edit Checklist Item" %}{% else %}{% trans "New Checklist Item" %}{% endif %} - PX360{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.page-header-gradient {
|
||||
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
|
||||
color: white;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
|
||||
}
|
||||
|
||||
.form-section {
|
||||
background: #fff;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.form-section:hover {
|
||||
border-color: #005696;
|
||||
box-shadow: 0 4px 12px rgba(0, 86, 150, 0.1);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-control, .form-select {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.form-control:focus, .form-select:focus {
|
||||
outline: none;
|
||||
border-color: #005696;
|
||||
box-shadow: 0 0 0 3px rgba(0, 86, 150, 0.1);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #005696;
|
||||
color: white;
|
||||
border-radius: 0.75rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: #007bbd;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: white;
|
||||
color: #64748b;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 0.75rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: #005696;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
|
||||
<div class="p-6 md:p-8 bg-gradient-to-br from-slate-50 to-blue-50 min-h-screen">
|
||||
<!-- Page Header -->
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-14 h-14 bg-gradient-to-br from-purple-500 to-indigo-500 rounded-2xl shadow-lg shadow-purple-200">
|
||||
<i data-lucide="{% if form.instance.pk %}edit{% else %}plus{% endif %}" class="w-8 h-8 text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-navy">
|
||||
{% if form.instance.pk %}{% trans "Edit Checklist Item" %}{% else %}{% trans "New Checklist Item" %}{% endif %}
|
||||
</h1>
|
||||
<p class="text-slate text-sm">
|
||||
{% if form.instance.pk %}{{ form.instance.text_en }}{% else %}{% trans "Create a new acknowledgement checklist item" %}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="page-header-gradient flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div>
|
||||
<nav aria-label="breadcrumb" class="mb-2">
|
||||
<ol class="flex items-center gap-2 text-sm text-white/80">
|
||||
<li><a href="{% url 'accounts:acknowledgements:ack_checklist_list' %}" class="hover:text-white transition">{% trans "Checklist Items" %}</a></li>
|
||||
<li><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
|
||||
<li class="text-white">{% if form.instance.pk %}{% trans "Edit" %}{% else %}{% trans "Create" %}{% endif %}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1 class="text-2xl font-bold flex items-center gap-2">
|
||||
<i data-lucide="{% if form.instance.pk %}edit{% else %}plus{% endif %}" class="w-8 h-8"></i>
|
||||
{% if form.instance.pk %}{% trans "Edit Checklist Item" %}{% else %}{% trans "New Checklist Item" %}{% endif %}
|
||||
</h1>
|
||||
</div>
|
||||
<a href="{% url 'accounts:acknowledgements:ack_checklist_list' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-white border-2 border-slate-200 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition">
|
||||
<a href="{% url 'accounts:acknowledgements:ack_checklist_list' %}" class="inline-flex items-center gap-2 px-4 py-2 bg-white/20 text-white rounded-lg hover:bg-white/30 transition">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4"></i>
|
||||
{% trans "Back to List" %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Form Card -->
|
||||
<!-- Form Section -->
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
|
||||
<div class="px-6 py-5 border-b border-slate-100 bg-gradient-to-r from-purple-50 to-transparent">
|
||||
<h2 class="text-lg font-bold text-navy flex items-center gap-2">
|
||||
<i data-lucide="file-text" class="w-5 h-5 text-purple-500"></i>
|
||||
{% trans "Checklist Item Details" %}
|
||||
</h2>
|
||||
<div class="form-section">
|
||||
<div class="flex items-center gap-2 mb-4 pb-3 border-b border-slate-100">
|
||||
<i data-lucide="file-text" class="w-5 h-5 text-purple-600"></i>
|
||||
<h2 class="text-lg font-bold text-slate-800">{% trans "Checklist Item Details" %}</h2>
|
||||
</div>
|
||||
<form method="post" class="p-6 space-y-6">
|
||||
<form method="post" class="space-y-6">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.category.id_for_label }}">
|
||||
<label class="form-label" for="{{ form.category.id_for_label }}">
|
||||
{% trans "Category" %} *
|
||||
</label>
|
||||
{{ form.category }}
|
||||
@ -50,7 +130,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.code.id_for_label }}">
|
||||
<label class="form-label" for="{{ form.code.id_for_label }}">
|
||||
{% trans "Code" %}
|
||||
</label>
|
||||
{{ form.code }}
|
||||
@ -61,7 +141,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.text_en.id_for_label }}">
|
||||
<label class="form-label" for="{{ form.text_en.id_for_label }}">
|
||||
{% trans "Text (English)" %} *
|
||||
</label>
|
||||
{{ form.text_en }}
|
||||
@ -71,7 +151,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.text_ar.id_for_label }}">
|
||||
<label class="form-label" for="{{ form.text_ar.id_for_label }}">
|
||||
{% trans "Text (Arabic)" %}
|
||||
</label>
|
||||
{{ form.text_ar }}
|
||||
@ -81,7 +161,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.description_en.id_for_label }}">
|
||||
<label class="form-label" for="{{ form.description_en.id_for_label }}">
|
||||
{% trans "Description (English)" %}
|
||||
</label>
|
||||
{{ form.description_en }}
|
||||
@ -91,7 +171,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.description_ar.id_for_label }}">
|
||||
<label class="form-label" for="{{ form.description_ar.id_for_label }}">
|
||||
{% trans "Description (Arabic)" %}
|
||||
</label>
|
||||
{{ form.description_ar }}
|
||||
@ -102,7 +182,7 @@
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.order.id_for_label }}">
|
||||
<label class="form-label" for="{{ form.order.id_for_label }}">
|
||||
{% trans "Display Order" %}
|
||||
</label>
|
||||
{{ form.order }}
|
||||
@ -115,7 +195,7 @@
|
||||
<div class="mt-6">
|
||||
<div class="flex items-center gap-3">
|
||||
{{ form.requires_signature }}
|
||||
<label class="text-sm text-navy" for="{{ form.requires_signature.id_for_label }}">
|
||||
<label class="text-sm text-slate-700" for="{{ form.requires_signature.id_for_label }}">
|
||||
{% trans "Requires Signature" %}
|
||||
</label>
|
||||
</div>
|
||||
@ -126,7 +206,7 @@
|
||||
<div class="mt-6">
|
||||
<div class="flex items-center gap-3">
|
||||
{{ form.is_active }}
|
||||
<label class="text-sm text-navy" for="{{ form.is_active.id_for_label }}">
|
||||
<label class="text-sm text-slate-700" for="{{ form.is_active.id_for_label }}">
|
||||
{% trans "Active" %}
|
||||
</label>
|
||||
</div>
|
||||
@ -135,10 +215,10 @@
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-6 border-t border-slate-100">
|
||||
<a href="{% url 'accounts:acknowledgements:ack_checklist_list' %}" class="px-6 py-2.5 bg-slate-100 text-slate-700 rounded-xl font-semibold hover:bg-slate-200 transition">
|
||||
<a href="{% url 'accounts:acknowledgements:ack_checklist_list' %}" class="btn-secondary">
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
<button type="submit" class="inline-flex items-center gap-2 px-6 py-2.5 bg-gradient-to-r from-purple-500 to-indigo-500 text-white rounded-xl font-semibold hover:from-indigo-500 hover:to-purple-500 transition shadow-lg shadow-purple-200">
|
||||
<button type="submit" class="btn-primary">
|
||||
<i data-lucide="save" class="w-4 h-4"></i>
|
||||
{% trans "Save Item" %}
|
||||
</button>
|
||||
@ -153,4 +233,4 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@ -3,73 +3,121 @@
|
||||
|
||||
{% block title %}{% trans "Acknowledgement Checklist Items" %} - PX360{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.page-header-gradient {
|
||||
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
|
||||
color: white;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.section-card:hover {
|
||||
border-color: #005696;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
background: linear-gradient(to right, #f8fafc, #f1f5f9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
|
||||
<!-- Page Header -->
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-14 h-14 bg-gradient-to-br from-purple-500 to-indigo-500 rounded-2xl shadow-lg shadow-purple-200">
|
||||
<i data-lucide="check-square" class="w-8 h-8 text-white"></i>
|
||||
<div class="page-header-gradient">
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="section-icon bg-white/20">
|
||||
<i data-lucide="check-square" class="w-6 h-6 text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">
|
||||
{% trans "Checklist Items" %}
|
||||
</h1>
|
||||
<p class="text-white/80 text-sm">
|
||||
{% trans "Manage acknowledgement checklist items" %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-navy">
|
||||
{% trans "Checklist Items" %}
|
||||
</h1>
|
||||
<p class="text-slate text-sm">
|
||||
{% trans "Manage acknowledgement checklist items" %}
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<a href="{% url 'accounts:acknowledgements:ack_dashboard' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-white/20 border border-white/30 text-white rounded-xl font-semibold hover:bg-white/30 transition">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4"></i>
|
||||
{% trans "Back" %}
|
||||
</a>
|
||||
<a href="{% url 'accounts:acknowledgements:ack_checklist_create' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-white text-purple-700 rounded-xl font-semibold hover:bg-purple-50 transition shadow-lg">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i>
|
||||
{% trans "New Item" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<a href="{% url 'accounts:acknowledgements:ack_dashboard' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-white border-2 border-slate-200 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4"></i>
|
||||
{% trans "Back" %}
|
||||
</a>
|
||||
<a href="{% url 'accounts:acknowledgements:ack_checklist_create' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-purple-500 to-indigo-500 text-white rounded-xl font-semibold hover:from-indigo-500 hover:to-purple-500 transition shadow-lg shadow-purple-200">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i>
|
||||
{% trans "New Item" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Section -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-4 mb-6">
|
||||
<form method="get" class="flex flex-wrap gap-4 items-end">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate mb-1">{% trans "Category" %}</label>
|
||||
<select name="category" class="px-4 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-blue focus:border-blue">
|
||||
<option value="">{% trans "All Categories" %}</option>
|
||||
{% for cat in categories %}
|
||||
<option value="{{ cat.id }}" {% if request.GET.category == cat.id|stringformat:"s" %}selected{% endif %}>{{ cat.name_en }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate mb-1">{% trans "Status" %}</label>
|
||||
<select name="is_active" class="px-4 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-blue focus:border-blue">
|
||||
<option value="">{% trans "All" %}</option>
|
||||
<option value="true" {% if request.GET.is_active == "true" %}selected{% endif %}>{% trans "Active" %}</option>
|
||||
<option value="false" {% if request.GET.is_active == "false" %}selected{% endif %}>{% trans "Inactive" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-2 bg-blue text-white rounded-xl text-sm font-semibold hover:bg-navy transition">
|
||||
<i data-lucide="filter" class="w-4 h-4 inline mr-1"></i>
|
||||
{% trans "Filter" %}
|
||||
</button>
|
||||
<a href="{% url 'accounts:acknowledgements:ack_checklist_list' %}" class="px-4 py-2 text-slate hover:text-navy text-sm">
|
||||
{% trans "Clear" %}
|
||||
</a>
|
||||
</form>
|
||||
<div class="section-card mb-6">
|
||||
<div class="p-4">
|
||||
<form method="get" class="flex flex-wrap gap-4 items-end">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate mb-1">{% trans "Category" %}</label>
|
||||
<select name="category" class="px-4 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-blue focus:border-blue">
|
||||
<option value="">{% trans "All Categories" %}</option>
|
||||
{% for cat in categories %}
|
||||
<option value="{{ cat.id }}" {% if request.GET.category == cat.id|stringformat:"s" %}selected{% endif %}>{{ cat.name_en }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate mb-1">{% trans "Status" %}</label>
|
||||
<select name="is_active" class="px-4 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-blue focus:border-blue">
|
||||
<option value="">{% trans "All" %}</option>
|
||||
<option value="true" {% if request.GET.is_active == "true" %}selected{% endif %}>{% trans "Active" %}</option>
|
||||
<option value="false" {% if request.GET.is_active == "false" %}selected{% endif %}>{% trans "Inactive" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-2 bg-blue text-white rounded-xl text-sm font-semibold hover:bg-navy transition">
|
||||
<i data-lucide="filter" class="w-4 h-4 inline mr-1"></i>
|
||||
{% trans "Filter" %}
|
||||
</button>
|
||||
<a href="{% url 'accounts:acknowledgements:ack_checklist_list' %}" class="px-4 py-2 text-slate hover:text-navy text-sm">
|
||||
{% trans "Clear" %}
|
||||
</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Checklist Items List -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
|
||||
<div class="px-6 py-5 border-b border-slate-100 bg-gradient-to-r from-purple-50 to-transparent">
|
||||
<h2 class="text-lg font-bold text-navy flex items-center gap-2">
|
||||
<i data-lucide="list" class="w-5 h-5 text-purple-500"></i>
|
||||
{% trans "All Checklist Items" %}
|
||||
<span class="ml-2 px-2 py-0.5 bg-slate-100 text-slate text-sm rounded-full">{{ items.count }}</span>
|
||||
</h2>
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-icon bg-purple-100">
|
||||
<i data-lucide="list" class="w-5 h-5 text-purple-600"></i>
|
||||
</div>
|
||||
<h2 class="text-lg font-bold text-navy">{% trans "All Checklist Items" %}</h2>
|
||||
<span class="ml-2 px-2 py-0.5 bg-slate-100 text-slate text-sm rounded-full">{{ items.count }}</span>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
{% if items %}
|
||||
@ -157,4 +205,4 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@ -3,36 +3,82 @@
|
||||
|
||||
{% block title %}{% trans "Completed Acknowledgements" %} - PX360{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.page-header-gradient {
|
||||
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
|
||||
color: white;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.section-card:hover {
|
||||
border-color: #005696;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
background: linear-gradient(to right, #f8fafc, #f1f5f9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
|
||||
<!-- Page Header -->
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-14 h-14 bg-gradient-to-br from-emerald-500 to-green-500 rounded-2xl shadow-lg shadow-emerald-200">
|
||||
<i data-lucide="check-circle" class="w-8 h-8 text-white"></i>
|
||||
<div class="page-header-gradient">
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="section-icon bg-white/20">
|
||||
<i data-lucide="check-circle" class="w-6 h-6 text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">
|
||||
{% trans "Completed Acknowledgements" %}
|
||||
</h1>
|
||||
<p class="text-white/80 text-sm">
|
||||
{% trans "View your signed acknowledgements" %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-navy">
|
||||
{% trans "Completed Acknowledgements" %}
|
||||
</h1>
|
||||
<p class="text-slate text-sm">
|
||||
{% trans "View your signed acknowledgements" %}
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<a href="{% url 'accounts:acknowledgements:ack_dashboard' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-white/20 border border-white/30 text-white rounded-xl font-semibold hover:bg-white/30 transition">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4"></i>
|
||||
{% trans "Back to Dashboard" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<a href="{% url 'accounts:acknowledgements:ack_dashboard' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-white border-2 border-blue-200 text-blue-700 rounded-xl font-semibold hover:bg-blue-50 transition">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4"></i>
|
||||
{% trans "Back to Dashboard" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Card -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-emerald-100 p-6 mb-8">
|
||||
<div class="section-card p-6 mb-6 border-emerald-200">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 bg-gradient-to-br from-emerald-500 to-green-500 rounded-xl flex items-center justify-center">
|
||||
<i data-lucide="file-check" class="w-6 h-6 text-white"></i>
|
||||
<div class="section-icon bg-emerald-100">
|
||||
<i data-lucide="file-check" class="w-6 h-6 text-emerald-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate uppercase">{% trans "Total Signed" %}</p>
|
||||
@ -42,12 +88,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Acknowledgements List -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
|
||||
<div class="px-6 py-5 border-b border-slate-100 bg-gradient-to-r from-emerald-50 to-transparent">
|
||||
<h2 class="text-lg font-bold text-navy flex items-center gap-2">
|
||||
<i data-lucide="clipboard-check" class="w-5 h-5 text-emerald-500"></i>
|
||||
{% trans "Signed Documents" %}
|
||||
</h2>
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-icon bg-emerald-100">
|
||||
<i data-lucide="clipboard-check" class="w-5 h-5 text-emerald-600"></i>
|
||||
</div>
|
||||
<h2 class="text-lg font-bold text-navy">{% trans "Signed Documents" %}</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
{% if acknowledgements %}
|
||||
@ -130,4 +176,4 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@ -1,135 +1,73 @@
|
||||
{% extends 'emails/base_email_template.html' %}
|
||||
{% load i18n %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% trans "Password Reset - PX360" %}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background-color: #f6f9fc;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.header {
|
||||
background: linear-gradient(135deg, #0086d2 0%, #005d93 100%);
|
||||
color: white;
|
||||
padding: 30px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.header p {
|
||||
margin: 10px 0 0 0;
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.content {
|
||||
padding: 40px 30px;
|
||||
}
|
||||
.content h2 {
|
||||
color: #333;
|
||||
font-size: 20px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.content p {
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.button-container {
|
||||
text-align: center;
|
||||
margin: 30px 0;
|
||||
}
|
||||
.reset-button {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #0086d2 0%, #005d93 100%);
|
||||
color: white;
|
||||
padding: 15px 30px;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
.reset-button:hover {
|
||||
text-decoration: none;
|
||||
box-shadow: 0 4px 12px rgba(0, 134, 210, 0.3);
|
||||
}
|
||||
.link-text {
|
||||
word-break: break-all;
|
||||
color: #0086d2;
|
||||
font-size: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.footer {
|
||||
background-color: #f8f9fa;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
.footer p {
|
||||
margin: 5px 0;
|
||||
}
|
||||
.warning-box {
|
||||
background-color: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
font-size: 14px;
|
||||
color: #856404;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<h1>{% trans "Password Reset Request" %}</h1>
|
||||
<p>{% trans "Patient Experience Management System" %}</p>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="content">
|
||||
<h2>{% trans "Hello, {{ user.email }}" %}</h2>
|
||||
|
||||
<p>{% trans "We received a request to reset your password for your PX360 account. If you made this request, click the button below to reset your password:" %}</p>
|
||||
{% block title %}{% trans "Password Reset Request - PX360 Al Hammadi Hospital" %}{% endblock %}
|
||||
|
||||
<div class="button-container">
|
||||
<a href="{{ protocol }}://{{ domain }}{% url 'accounts:password_reset_confirm' uidb64=uid token=token %}" class="reset-button">
|
||||
{% trans "Reset My Password" %}
|
||||
</a>
|
||||
</div>
|
||||
{% block preheader %}{% trans "We received a request to reset your password. Click to reset it now." %}{% endblock %}
|
||||
|
||||
<p>{% trans "Or copy and paste this link into your browser:" %}</p>
|
||||
<p class="link-text">{{ protocol }}://{{ domain }}{% url 'accounts:password_reset_confirm' uidb64=uid token=token %}</p>
|
||||
{% block hero_title %}{% trans "Password Reset Request" %}{% endblock %}
|
||||
|
||||
<div class="warning-box">
|
||||
<strong>{% trans "Important:" %}</strong><br>
|
||||
{% block hero_subtitle %}{% trans "Patient Experience Management System" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Greeting -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding-bottom: 20px;">
|
||||
<p style="margin: 0; font-size: 16px; color: #1e293b; line-height: 1.6;">
|
||||
{% trans "Hello" %} <strong>{{ user.email }}</strong>,
|
||||
</p>
|
||||
<p style="margin: 15px 0 0 0; font-size: 16px; color: #64748b; line-height: 1.6;">
|
||||
{% trans "We received a request to reset your password for your PX360 account. If you made this request, click the button below to reset your password:" %}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Reset Link Text -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding-bottom: 15px;">
|
||||
<p style="margin: 0; font-size: 14px; color: #64748b;">
|
||||
{% trans "Or copy and paste this link into your browser:" %}
|
||||
</p>
|
||||
<p style="margin: 5px 0 0 0; font-size: 12px; color: #007bbd; word-break: break-all;">
|
||||
{{ protocol }}://{{ domain }}{% url 'accounts:password_reset_confirm' uidb64=uid token=token %}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Warning Box -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding: 15px; background-color: #fff3cd; border-left: 4px solid #ffc107; border-radius: 8px;">
|
||||
<p style="margin: 0 0 10px 0; font-size: 14px; font-weight: 600; color: #856404;">
|
||||
{% trans "Important:" %}
|
||||
</p>
|
||||
<p style="margin: 0; font-size: 14px; color: #856404; line-height: 1.6;">
|
||||
{% trans "This link will expire in 24 hours. If you didn't request this password reset, please ignore this email and your password will remain unchanged." %}
|
||||
</div>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p>{% trans "If you continue to have problems, please contact our support team." %}</p>
|
||||
</div>
|
||||
<!-- Support Message -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-top: 20px;">
|
||||
<tr>
|
||||
<td>
|
||||
<p style="margin: 0; font-size: 14px; color: #64748b; line-height: 1.6;">
|
||||
{% trans "If you continue to have problems, please contact our support team." %}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endblock %}
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
<p>{% trans "This is an automated email from PX360" %}</p>
|
||||
<p>© {% now "Y" %} Al Hammadi Hospital. {% trans "All rights reserved." %}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{% block cta_url %}{{ protocol }}://{{ domain }}{% url 'accounts:password_reset_confirm' uidb64=uid token=token %}{% endblock %}
|
||||
{% block cta_text %}{% trans "Reset My Password" %}{% endblock %}
|
||||
|
||||
{% block footer_address %}
|
||||
PX360 - Patient Experience Management<br>
|
||||
Al Hammadi Hospital
|
||||
{% endblock %}
|
||||
|
||||
@ -3,35 +3,83 @@
|
||||
|
||||
{% block title %}{% trans "Acknowledgement Checklist Items" %}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.page-header-gradient {
|
||||
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
|
||||
color: white;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.section-card:hover {
|
||||
border-color: #005696;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
background: linear-gradient(to right, #f8fafc, #f1f5f9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
|
||||
<!-- Page Header -->
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-14 h-14 bg-gradient-to-br from-blue to-navy rounded-2xl shadow-lg shadow-blue-200">
|
||||
<i data-lucide="list-checks" class="w-8 h-8 text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold text-navy mb-1">
|
||||
{% trans "Checklist Items" %}
|
||||
</h2>
|
||||
<p class="text-slate">{% trans "Manage acknowledgement checklist items" %}</p>
|
||||
<div class="page-header-gradient">
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="section-icon bg-white/20">
|
||||
<i data-lucide="list-checks" class="w-6 h-6 text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold">
|
||||
{% trans "Checklist Items" %}
|
||||
</h2>
|
||||
<p class="text-white/80">{% trans "Manage acknowledgement checklist items" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onclick="document.getElementById('createChecklistItemModal').classList.remove('hidden')"
|
||||
class="bg-white text-[#005696] px-6 py-3 rounded-xl font-bold hover:bg-white/90 transition shadow-lg flex items-center gap-2">
|
||||
<i data-lucide="plus" class="w-5 h-5"></i>
|
||||
{% trans "Add Checklist Item" %}
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" onclick="document.getElementById('createChecklistItemModal').classList.remove('hidden')"
|
||||
class="bg-gradient-to-r from-blue to-navy text-white px-6 py-3 rounded-xl font-bold hover:from-navy hover:to-blue transition shadow-lg shadow-blue-200 flex items-center gap-2">
|
||||
<i data-lucide="plus" class="w-5 h-5"></i>
|
||||
{% trans "Add Checklist Item" %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Checklist Items List -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-blue-100 overflow-hidden">
|
||||
<div class="px-6 py-5 border-b border-blue-100 bg-gradient-to-r from-blue-50 to-transparent flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<h3 class="font-bold text-navy flex items-center gap-2 text-lg">
|
||||
<i data-lucide="clipboard-list" class="w-5 h-5 text-blue"></i>
|
||||
{% trans "All Items" %}
|
||||
</h3>
|
||||
<div class="section-card">
|
||||
<div class="section-header flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="section-icon bg-gradient-to-br from-blue to-navy">
|
||||
<i data-lucide="clipboard-list" class="w-5 h-5 text-white"></i>
|
||||
</div>
|
||||
<h3 class="font-bold text-navy text-lg">{% trans "All Items" %}</h3>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 w-full md:w-auto">
|
||||
<div class="relative flex-1 md:w-64">
|
||||
<input type="text" id="searchInput" placeholder="{% trans 'Search items...' %}"
|
||||
|
||||
@ -1,163 +1,107 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>User Onboarding Completed</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.logo {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #0d6efd;
|
||||
}
|
||||
h1 {
|
||||
color: #198754;
|
||||
font-size: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.content {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.success-icon {
|
||||
font-size: 48px;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.user-info {
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.user-info table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.user-info td {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
.user-info td:first-child {
|
||||
font-weight: bold;
|
||||
color: #666;
|
||||
width: 40%;
|
||||
}
|
||||
.user-info tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
background-color: #0d6efd;
|
||||
color: #ffffff !important;
|
||||
text-decoration: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 6px;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.button:hover {
|
||||
background-color: #0b5ed7;
|
||||
}
|
||||
.button-container {
|
||||
text-align: center;
|
||||
margin: 30px 0;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 40px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #eee;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}
|
||||
.timestamp {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">PX360</div>
|
||||
<p style="color: #666; margin-top: 5px;">Patient Experience Platform</p>
|
||||
</div>
|
||||
|
||||
<div class="success-icon">✅</div>
|
||||
|
||||
<h1>User Onboarding Completed</h1>
|
||||
|
||||
<div class="content">
|
||||
<p>A new user has successfully completed the onboarding process and is now active in the PX360 system.</p>
|
||||
{% extends 'emails/base_email_template.html' %}
|
||||
|
||||
{% block title %}User Onboarding Completed - Al Hammadi Hospital{% endblock %}
|
||||
|
||||
{% block preheader %}A new user has completed onboarding and is now active.{% endblock %}
|
||||
|
||||
{% block hero_title %}✅ User Onboarding Completed{% endblock %}
|
||||
|
||||
{% block hero_subtitle %}A new team member has joined PX360{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Notification Message -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding-bottom: 20px;">
|
||||
<p style="margin: 0; font-size: 16px; color: #1e293b; line-height: 1.6;">
|
||||
A new user has successfully completed the onboarding process and is now active in the PX360 system.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- User Information Card -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 25px 0; background-color: #f8fafc; border-radius: 8px; border: 1px solid #e2e8f0;">
|
||||
<tr>
|
||||
<td style="padding: 20px;">
|
||||
<h3 style="margin: 0 0 15px 0; font-size: 18px; font-weight: 600; color: #005696; text-align: center;">
|
||||
User Information
|
||||
</h3>
|
||||
|
||||
<div class="user-info">
|
||||
<table>
|
||||
<tr>
|
||||
<td>Name:</td>
|
||||
<td>{{ user.get_full_name|default:"Not provided" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Email:</td>
|
||||
<td>{{ user.email }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Username:</td>
|
||||
<td>{{ user.username }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Employee ID:</td>
|
||||
<td>{{ user.employee_id|default:"Not provided" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Hospital:</td>
|
||||
<td>{{ user.hospital.name|default:"Not assigned" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Department:</td>
|
||||
<td>{{ user.department.name|default:"Not assigned" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Completed At:</td>
|
||||
<td>{{ user.acknowledgement_completed_at|date:"F j, Y, g:i a" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="button-container">
|
||||
<a href="{{ user_detail_url }}" class="button">View User Details</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="timestamp">
|
||||
This notification was sent on {{ "now"|date:"F j, Y, g:i a" }}
|
||||
</p>
|
||||
|
||||
<div class="footer">
|
||||
<p>This is an automated notification from PX360.</p>
|
||||
<p>© {{ "now"|date:"Y" }} PX360 - Patient Experience Platform</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
<!-- Detail Rows -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td width="120" style="padding: 10px 0; font-size: 14px; color: #64748b; font-weight: 500;">
|
||||
Name:
|
||||
</td>
|
||||
<td style="padding: 10px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
|
||||
{{ user.get_full_name|default:"Not provided" }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 10px 0; font-size: 14px; color: #64748b; font-weight: 500;">
|
||||
Email:
|
||||
</td>
|
||||
<td style="padding: 10px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
|
||||
{{ user.email }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 10px 0; font-size: 14px; color: #64748b; font-weight: 500;">
|
||||
Username:
|
||||
</td>
|
||||
<td style="padding: 10px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
|
||||
{{ user.username }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 10px 0; font-size: 14px; color: #64748b; font-weight: 500;">
|
||||
Employee ID:
|
||||
</td>
|
||||
<td style="padding: 10px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
|
||||
{{ user.employee_id|default:"Not provided" }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 10px 0; font-size: 14px; color: #64748b; font-weight: 500;">
|
||||
Hospital:
|
||||
</td>
|
||||
<td style="padding: 10px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
|
||||
{{ user.hospital.name|default:"Not assigned" }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 10px 0; font-size: 14px; color: #64748b; font-weight: 500;">
|
||||
Department:
|
||||
</td>
|
||||
<td style="padding: 10px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
|
||||
{{ user.department.name|default:"Not assigned" }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 10px 0; font-size: 14px; color: #64748b; font-weight: 500;">
|
||||
Completed At:
|
||||
</td>
|
||||
<td style="padding: 10px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
|
||||
{{ user.acknowledgement_completed_at|date:"F j, Y, g:i a" }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endblock %}
|
||||
|
||||
{% block cta_url %}{{ user_detail_url }}{% endblock %}
|
||||
{% block cta_text %}View User Details{% endblock %}
|
||||
|
||||
{% block info_title %}Notification Details{% endblock %}
|
||||
{% block info_content %}
|
||||
This notification was sent on {{ "now"|date:"F j, Y, g:i a" }}.<br>
|
||||
This is an automated notification from PX360.
|
||||
{% endblock %}
|
||||
|
||||
{% block footer_address %}
|
||||
PX360 - Patient Experience Platform<br>
|
||||
Al Hammadi Hospital
|
||||
{% endblock %}
|
||||
|
||||
@ -3,35 +3,85 @@
|
||||
|
||||
{% block title %}{% trans "Manage Onboarding Content" %}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.page-header-gradient {
|
||||
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
|
||||
color: white;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.section-card:hover {
|
||||
border-color: #005696;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
background: linear-gradient(to right, #f8fafc, #f1f5f9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-8">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-14 h-14 bg-gradient-to-br from-blue to-indigo-500 rounded-2xl shadow-lg shadow-blue-200">
|
||||
<i data-lucide="file-text" class="w-8 h-8 text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{% url 'accounts:acknowledgement-dashboard' %}" class="inline-flex items-center text-blue hover:text-navy mb-2 font-medium">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i>
|
||||
{% trans "Back to Dashboard" %}
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold text-navy">
|
||||
{% trans "Onboarding Content" %}
|
||||
</h1>
|
||||
<p class="text-slate">
|
||||
{% trans "Manage the content shown during staff onboarding" %}
|
||||
</p>
|
||||
<!-- Page Header -->
|
||||
<div class="page-header-gradient">
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="section-icon bg-white/20">
|
||||
<i data-lucide="file-text" class="w-6 h-6 text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{% url 'accounts:acknowledgement-dashboard' %}" class="inline-flex items-center text-white/80 hover:text-white mb-1 font-medium transition">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i>
|
||||
{% trans "Back to Dashboard" %}
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold">
|
||||
{% trans "Onboarding Content" %}
|
||||
</h1>
|
||||
<p class="text-white/80">{% trans "Manage the content shown during staff onboarding" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="{% url 'accounts:onboarding_content_create' %}" class="bg-white text-[#005696] px-6 py-3 rounded-xl font-bold hover:bg-white/90 transition shadow-lg flex items-center gap-2">
|
||||
<i data-lucide="plus" class="w-5 h-5"></i>
|
||||
{% trans "Add Content" %}
|
||||
</a>
|
||||
</div>
|
||||
<a href="{% url 'accounts:onboarding_content_create' %}" class="inline-flex items-center justify-center bg-gradient-to-r from-blue to-navy text-white px-6 py-3 rounded-xl font-bold hover:from-navy hover:to-blue transition shadow-lg shadow-blue-200">
|
||||
<i data-lucide="plus" class="w-5 h-5 mr-2"></i>
|
||||
{% trans "Add Content" %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Content List -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-blue-100 overflow-hidden">
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-icon bg-gradient-to-br from-blue to-indigo-500">
|
||||
<i data-lucide="folder-open" class="w-5 h-5 text-white"></i>
|
||||
</div>
|
||||
<h3 class="font-bold text-navy text-lg">{% trans "All Content Items" %}</h3>
|
||||
</div>
|
||||
{% if content_items %}
|
||||
<div class="divide-y divide-blue-50">
|
||||
{% for item in content_items %}
|
||||
|
||||
@ -1,138 +1,108 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Welcome to PX360</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.logo {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #0d6efd;
|
||||
}
|
||||
h1 {
|
||||
color: #0d6efd;
|
||||
font-size: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.greeting {
|
||||
font-size: 18px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.content {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
background-color: #0d6efd;
|
||||
color: #ffffff !important;
|
||||
text-decoration: none;
|
||||
padding: 14px 30px;
|
||||
border-radius: 6px;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.button:hover {
|
||||
background-color: #0b5ed7;
|
||||
}
|
||||
.button-container {
|
||||
text-align: center;
|
||||
margin: 30px 0;
|
||||
}
|
||||
.expiry-notice {
|
||||
background-color: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.expiry-notice strong {
|
||||
color: #856404;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 40px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #eee;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}
|
||||
.link-fallback {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
word-break: break-all;
|
||||
margin-top: 15px;
|
||||
}
|
||||
ul {
|
||||
padding-left: 20px;
|
||||
}
|
||||
li {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">PX360</div>
|
||||
<p style="color: #666; margin-top: 5px;">Patient Experience Platform</p>
|
||||
</div>
|
||||
|
||||
<h1>Welcome to PX360!</h1>
|
||||
|
||||
<p class="greeting">Hello {{ user.first_name|default:user.email }},</p>
|
||||
|
||||
<div class="content">
|
||||
<p>You have been invited to join PX360, our comprehensive Patient Experience management platform. To complete your account setup, please click the button below:</p>
|
||||
{% extends 'emails/base_email_template.html' %}
|
||||
|
||||
{% block title %}Welcome to PX360 - Al Hammadi Hospital{% endblock %}
|
||||
|
||||
{% block preheader %}You have been invited to join PX360. Complete your account setup.{% endblock %}
|
||||
|
||||
{% block hero_title %}Welcome to PX360!{% endblock %}
|
||||
|
||||
{% block hero_subtitle %}Your comprehensive Patient Experience management platform{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Greeting -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding-bottom: 20px;">
|
||||
<p style="margin: 0; font-size: 16px; color: #1e293b; line-height: 1.6;">
|
||||
Hello <strong>{{ user.first_name|default:user.email }}</strong>,
|
||||
</p>
|
||||
<p style="margin: 15px 0 0 0; font-size: 16px; color: #64748b; line-height: 1.6;">
|
||||
You have been invited to join PX360, our comprehensive Patient Experience management platform. To complete your account setup, please click the button below.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- What You'll Do -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-top: 25px;">
|
||||
<tr>
|
||||
<td>
|
||||
<h3 style="margin: 0 0 15px 0; font-size: 18px; font-weight: 600; color: #005696;">
|
||||
During the onboarding process, you will:
|
||||
</h3>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<!-- Item 1 -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 12px;">
|
||||
<tr>
|
||||
<td width="40" valign="top" style="padding-right: 10px;">
|
||||
<span style="display: inline-block; width: 32px; height: 32px; background-color: #eef6fb; border-radius: 50%; text-align: center; line-height: 32px; font-size: 18px;">✓</span>
|
||||
</td>
|
||||
<td>
|
||||
<p style="margin: 0; font-size: 15px; color: #1e293b; line-height: 1.5;">
|
||||
Learn about PX360 features and your role responsibilities
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="button-container">
|
||||
<a href="{{ activation_url }}" class="button">Complete Account Setup</a>
|
||||
</div>
|
||||
<!-- Item 2 -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 12px;">
|
||||
<tr>
|
||||
<td width="40" valign="top" style="padding-right: 10px;">
|
||||
<span style="display: inline-block; width: 32px; height: 32px; background-color: #eef6fb; border-radius: 50%; text-align: center; line-height: 32px; font-size: 18px;">📋</span>
|
||||
</td>
|
||||
<td>
|
||||
<p style="margin: 0; font-size: 15px; color: #1e293b; line-height: 1.5;">
|
||||
Review and acknowledge important policies and guidelines
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p>During the onboarding process, you will:</p>
|
||||
<ul>
|
||||
<li>Learn about PX360 features and your role responsibilities</li>
|
||||
<li>Review and acknowledge important policies and guidelines</li>
|
||||
<li>Set up your username and password</li>
|
||||
<li>Complete your profile information</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="expiry-notice">
|
||||
<strong>⏰ Important:</strong> This invitation link will expire on <strong>{{ expires_at|date:"F j, Y, g:i a" }}</strong>. Please complete your registration before this date.
|
||||
</div>
|
||||
|
||||
<p class="link-fallback">
|
||||
If the button above doesn't work, copy and paste this link into your browser:<br>
|
||||
<a href="{{ activation_url }}">{{ activation_url }}</a>
|
||||
</p>
|
||||
|
||||
<div class="footer">
|
||||
<p>This is an automated message from PX360. Please do not reply to this email.</p>
|
||||
<p>If you did not expect this invitation or have questions, please contact your system administrator.</p>
|
||||
<p>© {{ "now"|date:"Y" }} PX360 - Patient Experience Platform</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
<!-- Item 3 -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 12px;">
|
||||
<tr>
|
||||
<td width="40" valign="top" style="padding-right: 10px;">
|
||||
<span style="display: inline-block; width: 32px; height: 32px; background-color: #eef6fb; border-radius: 50%; text-align: center; line-height: 32px; font-size: 18px;">🔐</span>
|
||||
</td>
|
||||
<td>
|
||||
<p style="margin: 0; font-size: 15px; color: #1e293b; line-height: 1.5;">
|
||||
Set up your username and password
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Item 4 -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td width="40" valign="top" style="padding-right: 10px;">
|
||||
<span style="display: inline-block; width: 32px; height: 32px; background-color: #eef6fb; border-radius: 50%; text-align: center; line-height: 32px; font-size: 18px;">👤</span>
|
||||
</td>
|
||||
<td>
|
||||
<p style="margin: 0; font-size: 15px; color: #1e293b; line-height: 1.5;">
|
||||
Complete your profile information
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endblock %}
|
||||
|
||||
{% block cta_url %}{{ activation_url }}{% endblock %}
|
||||
{% block cta_text %}Complete Account Setup{% endblock %}
|
||||
|
||||
{% block info_title %}⏰ Important: Invitation Expiry{% endblock %}
|
||||
{% block info_content %}
|
||||
This invitation link will expire on <strong>{{ expires_at|date:"F j, Y, g:i a" }}</strong>. Please complete your registration before this date.
|
||||
{% endblock %}
|
||||
|
||||
{% block footer_address %}
|
||||
PX360 - Patient Experience Platform<br>
|
||||
Al Hammadi Hospital
|
||||
{% endblock %}
|
||||
|
||||
@ -3,31 +3,124 @@
|
||||
|
||||
{% block title %}{% trans "Provisional Accounts" %}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.page-header-gradient {
|
||||
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
|
||||
color: white;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.section-card:hover {
|
||||
border-color: #005696;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
background: linear-gradient(to right, #f8fafc, #f1f5f9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-8">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-14 h-14 bg-gradient-to-br from-blue to-purple-500 rounded-2xl shadow-lg shadow-blue-200">
|
||||
<i data-lucide="user-plus" class="w-8 h-8 text-white"></i>
|
||||
<!-- Page Header -->
|
||||
<div class="page-header-gradient">
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="section-icon bg-white/20">
|
||||
<i data-lucide="user-plus" class="w-6 h-6 text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{% url 'accounts:acknowledgement-dashboard' %}" class="inline-flex items-center text-white/80 hover:text-white mb-1 font-medium transition">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i>
|
||||
{% trans "Back to Dashboard" %}
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold">
|
||||
{% trans "Provisional Accounts" %}
|
||||
</h1>
|
||||
<p class="text-white/80">{% trans "View accounts pending activation" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{% url 'accounts:acknowledgement-dashboard' %}" class="inline-flex items-center text-blue hover:text-navy mb-2 font-medium">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i>
|
||||
{% trans "Back to Dashboard" %}
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold text-navy">
|
||||
{% trans "Provisional Accounts" %}
|
||||
</h1>
|
||||
<p class="text-slate">
|
||||
{% trans "View accounts pending activation" %}
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<button onclick="openCreateModal()" class="bg-white text-navy px-4 py-2 rounded-lg font-medium hover:bg-white/90 transition flex items-center gap-2">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i>
|
||||
{% trans "Create New User" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div class="bg-white rounded-xl p-6 border-2 border-blue-100 shadow-sm">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center">
|
||||
<i data-lucide="users" class="w-6 h-6 text-blue-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-navy">{{ total_count }}</div>
|
||||
<div class="text-sm text-slate">{% trans "Total Provisional" %}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-6 border-2 border-emerald-100 shadow-sm">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 bg-emerald-100 rounded-xl flex items-center justify-center">
|
||||
<i data-lucide="user-check" class="w-6 h-6 text-emerald-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-emerald-600">{{ completed_count }}</div>
|
||||
<div class="text-sm text-slate">{% trans "Completed" %}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-6 border-2 border-orange-100 shadow-sm">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 bg-orange-100 rounded-xl flex items-center justify-center">
|
||||
<i data-lucide="clock" class="w-6 h-6 text-orange-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-orange-600">{{ in_progress_count }}</div>
|
||||
<div class="text-sm text-slate">{% trans "In Progress" %}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Accounts List -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-blue-100 overflow-hidden">
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-icon bg-gradient-to-br from-blue to-purple-500">
|
||||
<i data-lucide="users" class="w-5 h-5 text-white"></i>
|
||||
</div>
|
||||
<h3 class="font-bold text-navy text-lg">{% trans "Pending Accounts" %}</h3>
|
||||
</div>
|
||||
{% if provisional_accounts %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
@ -80,7 +173,7 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="text-slate text-sm">{{ account.invited_at|date:"M d, Y" }}</span>
|
||||
<span class="text-slate text-sm">{{ account.created_at|date:"M d, Y" }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
{% if account.invitation_expires_at %}
|
||||
@ -99,7 +192,7 @@
|
||||
<i data-lucide="check" class="w-3 h-3"></i>
|
||||
{% trans "Active" %}
|
||||
</span>
|
||||
elif account.invitation_expires_at and account.invitation_expires_at < now %}
|
||||
{% elif account.invitation_expires_at and account.invitation_expires_at < now %}
|
||||
<span class="inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-bold bg-red-100 text-red-700">
|
||||
<i data-lucide="alert-circle" class="w-3 h-3"></i>
|
||||
{% trans "Expired" %}
|
||||
@ -112,12 +205,23 @@
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex gap-2">
|
||||
<button class="px-3 py-2 text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-lg transition font-medium text-sm" title="{% trans 'Resend Invite' %}">
|
||||
<i data-lucide="mail" class="w-4 h-4"></i>
|
||||
</button>
|
||||
<button class="px-3 py-2 text-red-600 bg-red-50 hover:bg-red-100 rounded-lg transition font-medium text-sm" title="{% trans 'Deactivate' %}">
|
||||
<i data-lucide="user-x" class="w-4 h-4"></i>
|
||||
</button>
|
||||
<a href="{% url 'accounts:provisional-user-progress' account.id %}" class="px-3 py-2 text-emerald-600 bg-emerald-50 hover:bg-emerald-100 rounded-lg transition font-medium text-sm" title="{% trans 'View Progress' %}">
|
||||
<i data-lucide="eye" class="w-4 h-4"></i>
|
||||
</a>
|
||||
<form method="post" action="{% url 'accounts:bulk-resend-invitations' %}" class="inline" onsubmit="return confirm('{% trans "Resend invitation to this user?" %}')">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="user_ids" value="{{ account.id }}">
|
||||
<button type="submit" class="px-3 py-2 text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-lg transition font-medium text-sm" title="{% trans 'Resend Invite' %}">
|
||||
<i data-lucide="mail" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="{% url 'accounts:bulk-deactivate-users' %}" class="inline" onsubmit="return confirm('{% trans "Deactivate this user?" %}')">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="user_ids" value="{{ account.id }}">
|
||||
<button type="submit" class="px-3 py-2 text-red-600 bg-red-50 hover:bg-red-100 rounded-lg transition font-medium text-sm" title="{% trans 'Deactivate' %}">
|
||||
<i data-lucide="user-x" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@ -137,7 +241,139 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create User Modal -->
|
||||
<div id="createUserModal" class="fixed inset-0 bg-black/50 z-50 hidden items-center justify-center">
|
||||
<div class="bg-white rounded-2xl w-full max-w-2xl m-4 shadow-2xl">
|
||||
<div class="bg-gradient-to-r from-navy to-blue p-6 rounded-t-2xl">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-xl font-bold text-white flex items-center gap-2">
|
||||
<i data-lucide="user-plus" class="w-6 h-6"></i>
|
||||
{% trans "Create New User" %}
|
||||
</h3>
|
||||
<button onclick="closeCreateModal()" class="text-white/80 hover:text-white transition">
|
||||
<i data-lucide="x" class="w-6 h-6"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" class="p-6 space-y-6">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
{% trans "Email" %} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="email" name="email" required
|
||||
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-navy focus:ring-2 focus:ring-navy/20 transition"
|
||||
placeholder="user@example.com">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
{% trans "Employee ID" %}
|
||||
</label>
|
||||
<input type="text" name="employee_id"
|
||||
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-navy focus:ring-2 focus:ring-navy/20 transition"
|
||||
placeholder="EMP001">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
{% trans "First Name" %} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" name="first_name" required
|
||||
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-navy focus:ring-2 focus:ring-navy/20 transition"
|
||||
placeholder="John">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
{% trans "Last Name" %} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" name="last_name" required
|
||||
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-navy focus:ring-2 focus:ring-navy/20 transition"
|
||||
placeholder="Doe">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
{% trans "Hospital" %}
|
||||
</label>
|
||||
<select name="hospital"
|
||||
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-navy focus:ring-2 focus:ring-navy/20 transition bg-white">
|
||||
<option value="">{% trans "Select Hospital" %}</option>
|
||||
{% for hospital in hospitals %}
|
||||
<option value="{{ hospital.id }}">{{ hospital.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
{% trans "Department" %}
|
||||
</label>
|
||||
<select name="department"
|
||||
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-navy focus:ring-2 focus:ring-navy/20 transition bg-white">
|
||||
<option value="">{% trans "Select Department" %}</option>
|
||||
{% for department in departments %}
|
||||
<option value="{{ department.id }}">{{ department.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
{% trans "Roles" %} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{% for role in roles %}
|
||||
<label class="flex items-center gap-2 p-3 border-2 border-gray-200 rounded-xl cursor-pointer hover:border-navy hover:bg-blue-50 transition">
|
||||
<input type="checkbox" name="roles" value="{{ role.name }}" class="w-4 h-4 text-navy rounded focus:ring-navy">
|
||||
<span class="text-sm">{{ role.display_name }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4 border-t">
|
||||
<button type="button" onclick="closeCreateModal()"
|
||||
class="flex-1 px-6 py-3 border-2 border-gray-300 text-gray-700 rounded-xl font-medium hover:bg-gray-50 transition">
|
||||
{% trans "Cancel" %}
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="flex-1 px-6 py-3 bg-gradient-to-r from-navy to-blue text-white rounded-xl font-medium hover:shadow-lg transition">
|
||||
{% trans "Create User" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function openCreateModal() {
|
||||
document.getElementById('createUserModal').classList.remove('hidden');
|
||||
document.getElementById('createUserModal').classList.add('flex');
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function closeCreateModal() {
|
||||
document.getElementById('createUserModal').classList.add('hidden');
|
||||
document.getElementById('createUserModal').classList.remove('flex');
|
||||
}
|
||||
|
||||
// Close modal when clicking outside
|
||||
document.getElementById('createUserModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeCreateModal();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
|
||||
@ -1,134 +1,97 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Reminder: Complete Your PX360 Account Setup</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.logo {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #0d6efd;
|
||||
}
|
||||
h1 {
|
||||
color: #fd7e14;
|
||||
font-size: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.greeting {
|
||||
font-size: 18px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.content {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
background-color: #0d6efd;
|
||||
color: #ffffff !important;
|
||||
text-decoration: none;
|
||||
padding: 14px 30px;
|
||||
border-radius: 6px;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.button:hover {
|
||||
background-color: #0b5ed7;
|
||||
}
|
||||
.button-container {
|
||||
text-align: center;
|
||||
margin: 30px 0;
|
||||
}
|
||||
.expiry-notice {
|
||||
background-color: #f8d7da;
|
||||
border: 1px solid #f5c6cb;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.expiry-notice strong {
|
||||
color: #721c24;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 40px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #eee;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}
|
||||
.link-fallback {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
word-break: break-all;
|
||||
margin-top: 15px;
|
||||
}
|
||||
.reminder-icon {
|
||||
font-size: 48px;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">PX360</div>
|
||||
<p style="color: #666; margin-top: 5px;">Patient Experience Platform</p>
|
||||
</div>
|
||||
|
||||
<div class="reminder-icon">⏰</div>
|
||||
|
||||
<h1>Reminder: Complete Your Account Setup</h1>
|
||||
|
||||
<p class="greeting">Hello {{ user.first_name|default:user.email }},</p>
|
||||
|
||||
<div class="content">
|
||||
<p>We noticed that you haven't completed your PX360 account setup yet. Your invitation is still active, and we'd love to have you on board!</p>
|
||||
{% extends 'emails/base_email_template.html' %}
|
||||
|
||||
{% block title %}Reminder: Complete Your PX360 Account Setup - Al Hammadi Hospital{% endblock %}
|
||||
|
||||
{% block preheader %}Your PX360 account setup is pending. Please complete before expiry.{% endblock %}
|
||||
|
||||
{% block hero_title %}⏰ Reminder: Complete Your Setup{% endblock %}
|
||||
|
||||
{% block hero_subtitle %}Your PX360 account invitation is still active{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Greeting -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding-bottom: 20px;">
|
||||
<p style="margin: 0; font-size: 16px; color: #1e293b; line-height: 1.6;">
|
||||
Hello <strong>{{ user.first_name|default:user.email }}</strong>,
|
||||
</p>
|
||||
<p style="margin: 15px 0 0 0; font-size: 16px; color: #64748b; line-height: 1.6;">
|
||||
We noticed that you haven't completed your PX360 account setup yet. Your invitation is still active, and we'd love to have you on board!
|
||||
</p>
|
||||
<p style="margin: 15px 0 0 0; font-size: 16px; color: #64748b; line-height: 1.6;">
|
||||
Click the button below to continue where you left off:
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Quick Benefits -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-top: 25px;">
|
||||
<tr>
|
||||
<td>
|
||||
<h3 style="margin: 0 0 15px 0; font-size: 18px; font-weight: 600; color: #005696;">
|
||||
Why Complete Your Setup?
|
||||
</h3>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<!-- Benefit 1 -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 12px;">
|
||||
<tr>
|
||||
<td width="40" valign="top" style="padding-right: 10px;">
|
||||
<span style="display: inline-block; width: 32px; height: 32px; background-color: #eef6fb; border-radius: 50%; text-align: center; line-height: 32px; font-size: 18px;">✓</span>
|
||||
</td>
|
||||
<td>
|
||||
<p style="margin: 0; font-size: 15px; color: #1e293b; line-height: 1.5;">
|
||||
<strong>Access Your Dashboard:</strong> Manage your tasks and responsibilities
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p>Click the button below to continue where you left off:</p>
|
||||
<!-- Benefit 2 -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 12px;">
|
||||
<tr>
|
||||
<td width="40" valign="top" style="padding-right: 10px;">
|
||||
<span style="display: inline-block; width: 32px; height: 32px; background-color: #eef6fb; border-radius: 50%; text-align: center; line-height: 32px; font-size: 18px;">📊</span>
|
||||
</td>
|
||||
<td>
|
||||
<p style="margin: 0; font-size: 15px; color: #1e293b; line-height: 1.5;">
|
||||
<strong>View Analytics:</strong> Track patient experience metrics
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="button-container">
|
||||
<a href="{{ activation_url }}" class="button">Complete Account Setup</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="expiry-notice">
|
||||
<strong>⚠️ Time Sensitive:</strong> Your invitation link will expire on <strong>{{ expires_at|date:"F j, Y, g:i a" }}</strong>. Please complete your registration before this date to avoid requesting a new invitation.
|
||||
</div>
|
||||
|
||||
<p class="link-fallback">
|
||||
If the button above doesn't work, copy and paste this link into your browser:<br>
|
||||
<a href="{{ activation_url }}">{{ activation_url }}</a>
|
||||
</p>
|
||||
|
||||
<div class="footer">
|
||||
<p>This is an automated reminder from PX360. Please do not reply to this email.</p>
|
||||
<p>If you have already completed your registration, please disregard this message.</p>
|
||||
<p>If you have questions, please contact your system administrator.</p>
|
||||
<p>© {{ "now"|date:"Y" }} PX360 - Patient Experience Platform</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
<!-- Benefit 3 -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td width="40" valign="top" style="padding-right: 10px;">
|
||||
<span style="display: inline-block; width: 32px; height: 32px; background-color: #eef6fb; border-radius: 50%; text-align: center; line-height: 32px; font-size: 18px;">🎯</span>
|
||||
</td>
|
||||
<td>
|
||||
<p style="margin: 0; font-size: 15px; color: #1e293b; line-height: 1.5;">
|
||||
<strong>Take Action:</strong> Respond to complaints and feedback
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endblock %}
|
||||
|
||||
{% block cta_url %}{{ activation_url }}{% endblock %}
|
||||
{% block cta_text %}Complete Account Setup{% endblock %}
|
||||
|
||||
{% block info_title %}⚠️ Time Sensitive: Invitation Expiry{% endblock %}
|
||||
{% block info_content %}
|
||||
Your invitation link will expire on <strong>{{ expires_at|date:"F j, Y, g:i a" }}</strong>. Please complete your registration before this date to avoid requesting a new invitation.
|
||||
{% endblock %}
|
||||
|
||||
{% block footer_address %}
|
||||
PX360 - Patient Experience Platform<br>
|
||||
Al Hammadi Hospital
|
||||
{% endblock %}
|
||||
|
||||
@ -45,6 +45,12 @@
|
||||
{% trans "Hospital Notifications" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if user.is_px_admin %}
|
||||
<button class="settings-tab px-6 py-3 rounded-xl font-medium text-sm transition flex items-center gap-2" data-tab="users">
|
||||
<i data-lucide="users" class="w-4 h-4"></i>
|
||||
{% trans "Users" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -715,6 +721,150 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Users Management Tab (PX Admin only) -->
|
||||
{% if user.is_px_admin %}
|
||||
<div class="settings-content hidden" id="users-content">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div class="lg:col-span-2">
|
||||
<h5 class="text-xl font-bold text-gray-800 mb-6 flex items-center gap-2">
|
||||
<i data-lucide="users" class="w-5 h-5 text-navy"></i>
|
||||
{% trans "User Management" %}
|
||||
</h5>
|
||||
|
||||
<!-- Quick Stats -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-gradient-to-br from-navy to-blue text-white p-4 rounded-xl text-center">
|
||||
<i data-lucide="users" class="w-8 h-8 mx-auto mb-2"></i>
|
||||
<h6 class="font-bold text-2xl mb-0">{{ total_users_count|default:0 }}</h6>
|
||||
<span class="text-xs text-white/80">{% trans "Total Users" %}</span>
|
||||
</div>
|
||||
<div class="bg-gradient-to-br from-emerald-500 to-emerald-600 text-white p-4 rounded-xl text-center">
|
||||
<i data-lucide="user-check" class="w-8 h-8 mx-auto mb-2"></i>
|
||||
<h6 class="font-bold text-2xl mb-0">{{ active_users_count|default:0 }}</h6>
|
||||
<span class="text-xs text-white/80">{% trans "Active Users" %}</span>
|
||||
</div>
|
||||
<div class="bg-gradient-to-br from-orange-500 to-orange-600 text-white p-4 rounded-xl text-center">
|
||||
<i data-lucide="user-plus" class="w-8 h-8 mx-auto mb-2"></i>
|
||||
<h6 class="font-bold text-2xl mb-0">{{ provisional_users_count|default:0 }}</h6>
|
||||
<span class="text-xs text-white/80">{% trans "Pending" %}</span>
|
||||
</div>
|
||||
<div class="bg-gradient-to-br from-red-500 to-red-600 text-white p-4 rounded-xl text-center">
|
||||
<i data-lucide="user-x" class="w-8 h-8 mx-auto mb-2"></i>
|
||||
<h6 class="font-bold text-2xl mb-0">{{ inactive_users_count|default:0 }}</h6>
|
||||
<span class="text-xs text-white/80">{% trans "Inactive" %}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<a href="{% url 'accounts:provisional-user-list' %}" class="block bg-white border-2 border-blue-200 rounded-xl p-4 hover:border-blue-400 hover:shadow-md transition group">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center group-hover:bg-blue-200 transition">
|
||||
<i data-lucide="list" class="w-6 h-6 text-blue-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h6 class="font-bold text-gray-800">{% trans "Provisional Users" %}</h6>
|
||||
<p class="text-xs text-gray-500">{% trans "Manage pending accounts" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600">{% trans "View and manage pending user accounts awaiting activation. Create new users and track their onboarding progress." %}</p>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'accounts:bulk-invite-users' %}" class="block bg-white border-2 border-emerald-200 rounded-xl p-4 hover:border-emerald-400 hover:shadow-md transition group">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="w-12 h-12 bg-emerald-100 rounded-xl flex items-center justify-center group-hover:bg-emerald-200 transition">
|
||||
<i data-lucide="upload" class="w-6 h-6 text-emerald-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h6 class="font-bold text-gray-800">{% trans "Bulk Invite" %}</h6>
|
||||
<p class="text-xs text-gray-500">{% trans "Import via CSV" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600">{% trans "Import multiple users at once using a CSV file. Great for onboarding entire departments." %}</p>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'accounts:export-provisional-users' %}" class="block bg-white border-2 border-amber-200 rounded-xl p-4 hover:border-amber-400 hover:shadow-md transition group">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="w-12 h-12 bg-amber-100 rounded-xl flex items-center justify-center group-hover:bg-amber-200 transition">
|
||||
<i data-lucide="download" class="w-6 h-6 text-amber-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h6 class="font-bold text-gray-800">{% trans "Export Users" %}</h6>
|
||||
<p class="text-xs text-gray-500">{% trans "Download CSV report" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600">{% trans "Download user data as CSV for reporting and compliance purposes." %}</p>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'accounts:provisional-user-list' %}" class="block bg-white border-2 border-purple-200 rounded-xl p-4 hover:border-purple-400 hover:shadow-md transition group">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center group-hover:bg-purple-200 transition">
|
||||
<i data-lucide="bar-chart-2" class="w-6 h-6 text-purple-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h6 class="font-bold text-gray-800">{% trans "Onboarding Dashboard" %}</h6>
|
||||
<p class="text-xs text-gray-500">{% trans "View analytics" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600">{% trans "Access the full onboarding dashboard with detailed statistics and user progress tracking." %}</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="bg-light rounded-2xl p-6">
|
||||
<h6 class="font-bold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<i data-lucide="lightbulb" class="w-5 h-5 text-navy"></i>
|
||||
{% trans "User Management Tips" %}
|
||||
</h6>
|
||||
|
||||
<div class="space-y-4 text-sm text-gray-600">
|
||||
<div class="flex gap-3">
|
||||
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<span class="font-bold text-blue-600">1</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-800">{% trans "Bulk Invite for Efficiency" %}</p>
|
||||
<p class="text-xs mt-1">{% trans "Use CSV upload when adding multiple users to save time and ensure consistency." %}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<div class="w-8 h-8 bg-emerald-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<span class="font-bold text-emerald-600">2</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-800">{% trans "Resend Expired Invitations" %}>
|
||||
<p class="text-xs mt-1">{% trans "Monitor pending users and resend invitations to those who haven't activated their accounts." %}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<div class="w-8 h-8 bg-orange-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<span class="font-bold text-orange-600">3</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-800">{% trans "Review Inactive Accounts" %}</p>
|
||||
<p class="text-xs mt-1">{% trans "Regularly audit and deactivate inactive accounts to maintain security and compliance." %}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<div class="w-8 h-8 bg-purple-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<span class="font-bold text-purple-600">4</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-800">{% trans "Export for Compliance" %}</p>
|
||||
<p class="text-xs mt-1">{% trans "Export user data regularly for audit trails and compliance reporting requirements." %}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -3,65 +3,158 @@
|
||||
|
||||
{% block title %}{% if action == 'create' %}{% trans "New Acknowledgement" %}{% else %}{% trans "Edit Acknowledgement" %}{% endif %} - PX360{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.page-header-gradient {
|
||||
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
|
||||
color: white;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
|
||||
}
|
||||
|
||||
.form-section {
|
||||
background: #fff;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.form-section:hover {
|
||||
border-color: #005696;
|
||||
box-shadow: 0 4px 12px rgba(0, 86, 150, 0.1);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-control, .form-select {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.form-control:focus, .form-select:focus {
|
||||
outline: none;
|
||||
border-color: #005696;
|
||||
box-shadow: 0 0 0 3px rgba(0, 86, 150, 0.1);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #005696;
|
||||
color: white;
|
||||
border-radius: 0.75rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: #007bbd;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: white;
|
||||
color: #64748b;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 0.75rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: #005696;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3 mb-8">
|
||||
<div class="flex items-center justify-center w-12 h-12 bg-gradient-to-br from-blue to-navy rounded-2xl shadow-lg shadow-blue-200">
|
||||
<i data-lucide="{% if action == 'create' %}plus{% else %}edit{% endif %}" class="w-6 h-6 text-white"></i>
|
||||
</div>
|
||||
<div class="p-6 md:p-8 bg-gradient-to-br from-slate-50 to-blue-50 min-h-screen">
|
||||
<!-- Page Header -->
|
||||
<div class="page-header-gradient flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-navy">
|
||||
<nav aria-label="breadcrumb" class="mb-2">
|
||||
<ol class="flex items-center gap-2 text-sm text-white/80">
|
||||
<li><a href="{% url 'accounts:simple_acknowledgements:admin_list' %}" class="hover:text-white transition">{% trans "Acknowledgements" %}</a></li>
|
||||
<li><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
|
||||
<li class="text-white">{% if action == 'create' %}{% trans "Create" %}{% else %}{% trans "Edit" %}{% endif %}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1 class="text-2xl font-bold flex items-center gap-2">
|
||||
<i data-lucide="{% if action == 'create' %}plus{% else %}edit{% endif %}" class="w-8 h-8"></i>
|
||||
{% if action == 'create' %}{% trans "New Acknowledgement" %}{% else %}{% trans "Edit Acknowledgement" %}{% endif %}
|
||||
</h1>
|
||||
<p class="text-slate text-sm">
|
||||
{% if action == 'create' %}{% trans "Create a new acknowledgement for employees" %}{% else %}{% trans "Update acknowledgement details" %}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<a href="{% url 'accounts:simple_acknowledgements:admin_list' %}" class="inline-flex items-center gap-2 px-4 py-2 bg-white/20 text-white rounded-lg hover:bg-white/30 transition">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4"></i>
|
||||
{% trans "Back to List" %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Form Section -->
|
||||
<div class="max-w-3xl">
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden">
|
||||
<form method="post" enctype="multipart/form-data" class="p-6 space-y-6">
|
||||
<div class="form-section">
|
||||
<div class="flex items-center gap-2 mb-4 pb-3 border-b border-slate-100">
|
||||
<i data-lucide="file-text" class="w-5 h-5 text-blue-600"></i>
|
||||
<h2 class="text-lg font-bold text-slate-800">{% trans "Acknowledgement Details" %}</h2>
|
||||
</div>
|
||||
<form method="post" enctype="multipart/form-data" class="space-y-6">
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-navy mb-2">
|
||||
<label class="form-label">
|
||||
{% trans "Title" %} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" name="title" required
|
||||
value="{{ acknowledgement.title|default:'' }}"
|
||||
class="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue focus:border-transparent transition"
|
||||
class="form-control"
|
||||
placeholder="{% trans 'Enter acknowledgement title' %}">
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-navy mb-2">
|
||||
<label class="form-label">
|
||||
{% trans "Description" %}
|
||||
</label>
|
||||
<textarea name="description" rows="5"
|
||||
class="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue focus:border-transparent transition"
|
||||
class="form-control"
|
||||
placeholder="{% trans 'Enter detailed description of the acknowledgement' %}">{{ acknowledgement.description|default:'' }}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- PDF Document -->
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-navy mb-2">
|
||||
<label class="form-label">
|
||||
{% trans "PDF Document" %}
|
||||
</label>
|
||||
<div class="border-2 border-dashed border-slate-300 rounded-xl p-6 text-center hover:border-blue transition">
|
||||
<div class="border-2 border-dashed border-slate-300 rounded-xl p-6 text-center hover:border-blue-500 transition">
|
||||
<input type="file" name="pdf_document" accept=".pdf"
|
||||
class="w-full"
|
||||
onchange="updateFileName(this)">
|
||||
{% if acknowledgement.pdf_document %}
|
||||
<p class="text-sm text-slate mt-2">
|
||||
{% trans "Current:" %} <a href="{{ acknowledgement.pdf_document.url }}" target="_blank" class="text-blue hover:underline">{{ acknowledgement.pdf_document.name }}</a>
|
||||
<p class="text-sm text-slate-600 mt-2">
|
||||
{% trans "Current:" %} <a href="{{ acknowledgement.pdf_document.url }}" target="_blank" class="text-blue-600 hover:underline">{{ acknowledgement.pdf_document.name }}</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="text-xs text-slate mt-2">{% trans "Upload a PDF document for employees to review (optional)" %}</p>
|
||||
<p class="text-xs text-slate-500 mt-2">{% trans "Upload a PDF document for employees to review (optional)" %}</p>
|
||||
</div>
|
||||
|
||||
<!-- Settings -->
|
||||
@ -70,10 +163,10 @@
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input type="checkbox" name="is_active"
|
||||
{% if acknowledgement.is_active|default:True %}checked{% endif %}
|
||||
class="w-5 h-5 text-blue rounded focus:ring-blue">
|
||||
class="w-5 h-5 text-blue-600 rounded focus:ring-blue-500">
|
||||
<div>
|
||||
<span class="font-semibold text-navy">{% trans "Active" %}</span>
|
||||
<p class="text-xs text-slate">{% trans "Show in employee checklist" %}</p>
|
||||
<span class="font-semibold text-slate-800">{% trans "Active" %}</span>
|
||||
<p class="text-xs text-slate-500">{% trans "Show in employee checklist" %}</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
@ -81,10 +174,10 @@
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input type="checkbox" name="is_required"
|
||||
{% if acknowledgement.is_required|default:True %}checked{% endif %}
|
||||
class="w-5 h-5 text-blue rounded focus:ring-blue">
|
||||
class="w-5 h-5 text-blue-600 rounded focus:ring-blue-500">
|
||||
<div>
|
||||
<span class="font-semibold text-navy">{% trans "Required" %}</span>
|
||||
<p class="text-xs text-slate">{% trans "Must be signed by all employees" %}</p>
|
||||
<span class="font-semibold text-slate-800">{% trans "Required" %}</span>
|
||||
<p class="text-xs text-slate-500">{% trans "Must be signed by all employees" %}</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
@ -92,24 +185,23 @@
|
||||
|
||||
<!-- Order -->
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-navy mb-2">
|
||||
<label class="form-label">
|
||||
{% trans "Display Order" %}
|
||||
</label>
|
||||
<input type="number" name="order"
|
||||
value="{{ acknowledgement.order|default:0 }}"
|
||||
class="w-32 px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue focus:border-transparent transition">
|
||||
<p class="text-xs text-slate mt-2">{% trans "Lower numbers appear first" %}</p>
|
||||
class="form-control w-32">
|
||||
<p class="text-xs text-slate-500 mt-2">{% trans "Lower numbers appear first" %}</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3 pt-4 border-t border-slate-100">
|
||||
<div class="flex justify-end gap-3 pt-6 border-t border-slate-100">
|
||||
<a href="{% url 'accounts:simple_acknowledgements:admin_list' %}"
|
||||
class="inline-flex items-center justify-center gap-2 px-6 py-3 bg-slate-100 text-slate-700 rounded-xl font-semibold hover:bg-slate-200 transition">
|
||||
class="btn-secondary">
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="inline-flex items-center justify-center gap-2 px-6 py-3 bg-gradient-to-r from-blue to-navy text-white rounded-xl font-semibold hover:shadow-lg transition">
|
||||
<i data-lucide="save" class="w-5 h-5"></i>
|
||||
<button type="submit" class="btn-primary">
|
||||
<i data-lucide="save" class="w-4 h-4"></i>
|
||||
{% if action == 'create' %}{% trans "Create Acknowledgement" %}{% else %}{% trans "Save Changes" %}{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -3,39 +3,85 @@
|
||||
|
||||
{% block title %}{% trans "Manage Acknowledgements" %} - PX360{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.page-header-gradient {
|
||||
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
|
||||
color: white;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.section-card:hover {
|
||||
border-color: #005696;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
background: linear-gradient(to right, #f8fafc, #f1f5f9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-12 h-12 bg-gradient-to-br from-blue to-navy rounded-2xl shadow-lg shadow-blue-200">
|
||||
<i data-lucide="clipboard-list" class="w-6 h-6 text-white"></i>
|
||||
<!-- Page Header -->
|
||||
<div class="page-header-gradient">
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="section-icon bg-white/20">
|
||||
<i data-lucide="clipboard-list" class="w-6 h-6 text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{% trans "Acknowledgements" %}</h1>
|
||||
<p class="text-white/80 text-sm">{% trans "Manage employee acknowledgement forms" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-navy">{% trans "Acknowledgements" %}</h1>
|
||||
<p class="text-slate text-sm">{% trans "Manage employee acknowledgement forms" %}</p>
|
||||
<div class="flex gap-3">
|
||||
<a href="{% url 'accounts:simple_acknowledgements:admin_signatures' %}"
|
||||
class="inline-flex items-center gap-2 px-5 py-2.5 bg-white/20 border border-white/30 text-white rounded-xl font-semibold hover:bg-white/30 transition">
|
||||
<i data-lucide="users" class="w-4 h-4"></i>
|
||||
{% trans "View Signatures" %}
|
||||
</a>
|
||||
<a href="{% url 'accounts:simple_acknowledgements:admin_create' %}"
|
||||
class="inline-flex items-center gap-2 px-5 py-2.5 bg-white text-emerald-700 rounded-xl font-semibold hover:bg-emerald-50 transition shadow-lg">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i>
|
||||
{% trans "New Acknowledgement" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<a href="{% url 'accounts:simple_acknowledgements:admin_signatures' %}"
|
||||
class="inline-flex items-center gap-2 px-5 py-2.5 bg-white border-2 border-slate-200 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition">
|
||||
<i data-lucide="users" class="w-4 h-4"></i>
|
||||
{% trans "View Signatures" %}
|
||||
</a>
|
||||
<a href="{% url 'accounts:simple_acknowledgements:admin_create' %}"
|
||||
class="inline-flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-emerald-500 to-emerald-600 text-white rounded-xl font-semibold hover:shadow-lg transition">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i>
|
||||
{% trans "New Acknowledgement" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-5">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="section-card p-5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-blue-100 rounded-xl flex items-center justify-center">
|
||||
<i data-lucide="clipboard" class="w-5 h-5 text-blue"></i>
|
||||
<div class="section-icon bg-blue-100">
|
||||
<i data-lucide="clipboard" class="w-5 h-5 text-blue-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-slate uppercase font-semibold">{% trans "Total" %}</p>
|
||||
@ -43,9 +89,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-emerald-100 p-5">
|
||||
<div class="section-card p-5 border-emerald-200">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-emerald-100 rounded-xl flex items-center justify-center">
|
||||
<div class="section-icon bg-emerald-100">
|
||||
<i data-lucide="check-circle" class="w-5 h-5 text-emerald-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
@ -57,9 +103,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Acknowledgements Table -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-slate-100 bg-slate-50">
|
||||
<h2 class="font-bold text-navy">{% trans "All Acknowledgements" %}</h2>
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-icon bg-blue-100">
|
||||
<i data-lucide="list" class="w-5 h-5 text-blue-600"></i>
|
||||
</div>
|
||||
<h2 class="text-lg font-bold text-navy">{% trans "All Acknowledgements" %}</h2>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
|
||||
@ -3,84 +3,134 @@
|
||||
|
||||
{% block title %}{% trans "My Acknowledgements" %} - PX360{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.page-header-gradient {
|
||||
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
|
||||
color: white;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.section-card:hover {
|
||||
border-color: #005696;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
background: linear-gradient(to right, #f8fafc, #f1f5f9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
|
||||
<!-- Page Header -->
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-14 h-14 bg-gradient-to-br from-blue to-indigo-500 rounded-2xl shadow-lg shadow-blue-200">
|
||||
<i data-lucide="clipboard-signature" class="w-8 h-8 text-white"></i>
|
||||
<div class="page-header-gradient">
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="section-icon bg-white/20">
|
||||
<i data-lucide="clipboard-signature" class="w-6 h-6 text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{% trans "My Acknowledgements" %}</h1>
|
||||
<p class="text-white/80 text-sm">{% trans "Review and sign required documents" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-navy">{% trans "My Acknowledgements" %}</h1>
|
||||
<p class="text-slate text-sm">{% trans "Review and sign required documents" %}</p>
|
||||
<div class="flex gap-3">
|
||||
{% if user.is_px_admin or user.is_staff %}
|
||||
<a href="{% url 'accounts:simple_acknowledgements:admin_create' %}"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-white text-emerald-700 rounded-xl font-semibold hover:bg-emerald-50 transition shadow-lg">
|
||||
<i data-lucide="plus-circle" class="w-4 h-4"></i>
|
||||
{% trans "Create New" %}
|
||||
</a>
|
||||
<a href="{% url 'accounts:simple_acknowledgements:admin_list' %}"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-white/20 border border-white/30 text-white rounded-xl font-semibold hover:bg-white/30 transition">
|
||||
<i data-lucide="settings" class="w-4 h-4"></i>
|
||||
{% trans "Manage" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="flex items-center gap-2 px-4 py-2 bg-emerald-500 text-white rounded-xl font-semibold">
|
||||
<i data-lucide="check-circle" class="w-4 h-4"></i>
|
||||
{{ signed }} {% trans "Signed" %}
|
||||
</div>
|
||||
{% if pending > 0 %}
|
||||
<div class="flex items-center gap-2 px-4 py-2 bg-orange-500 text-white rounded-xl font-semibold animate-pulse">
|
||||
<i data-lucide="alert-circle" class="w-4 h-4"></i>
|
||||
{{ pending }} {% trans "Pending" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
{% if user.is_px_admin or user.is_staff %}
|
||||
<a href="{% url 'accounts:simple_acknowledgements:admin_create' %}"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-emerald-500 to-emerald-600 text-white rounded-xl font-semibold hover:from-emerald-600 hover:to-emerald-700 transition shadow-lg shadow-emerald-200">
|
||||
<i data-lucide="plus-circle" class="w-4 h-4"></i>
|
||||
{% trans "Create New" %}
|
||||
</a>
|
||||
<a href="{% url 'accounts:simple_acknowledgements:admin_list' %}"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-white border-2 border-slate-200 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition">
|
||||
<i data-lucide="settings" class="w-4 h-4"></i>
|
||||
{% trans "Manage" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="flex items-center gap-2 px-4 py-2 bg-emerald-100 text-emerald-700 rounded-xl font-semibold">
|
||||
<i data-lucide="check-circle" class="w-4 h-4"></i>
|
||||
{{ signed }} {% trans "Signed" %}
|
||||
</div>
|
||||
{% if pending > 0 %}
|
||||
<div class="flex items-center gap-2 px-4 py-2 bg-orange-100 text-orange-700 rounded-xl font-semibold animate-pulse">
|
||||
<i data-lucide="alert-circle" class="w-4 h-4"></i>
|
||||
{{ pending }} {% trans "Pending" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if pending > 0 %}
|
||||
<!-- Alert Banner -->
|
||||
<div class="bg-gradient-to-r from-orange-500 to-orange-600 rounded-2xl shadow-lg shadow-orange-200 p-6 mb-8 text-white">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center">
|
||||
<i data-lucide="bell" class="w-6 h-6"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-lg font-bold">{% trans "Action Required" %}</h2>
|
||||
<p class="text-orange-100">
|
||||
{% blocktrans %}
|
||||
You have {{ pending }} acknowledgement(s) waiting for your signature. Please review and sign them below.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<div class="section-card mb-6 border-orange-300 bg-gradient-to-r from-orange-500 to-orange-600">
|
||||
<div class="p-6 text-white">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center">
|
||||
<i data-lucide="bell" class="w-6 h-6"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-lg font-bold">{% trans "Action Required" %}</h2>
|
||||
<p class="text-orange-100">
|
||||
{% blocktrans %}
|
||||
You have {{ pending }} acknowledgement(s) waiting for your signature. Please review and sign them below.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Progress Card -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-blue-100 p-6 mb-6">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<span class="font-semibold text-navy">{% trans "Completion Progress" %}</span>
|
||||
<span class="text-2xl font-bold text-blue">{{ progress }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-slate-200 rounded-full h-3 overflow-hidden">
|
||||
<div class="bg-gradient-to-r from-emerald-400 to-emerald-500 h-3 rounded-full transition-all duration-500" style="width: {{ progress }}%"></div>
|
||||
<div class="section-card mb-6">
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<span class="font-semibold text-navy">{% trans "Completion Progress" %}</span>
|
||||
<span class="text-2xl font-bold text-blue">{{ progress }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-slate-200 rounded-full h-3 overflow-hidden">
|
||||
<div class="bg-gradient-to-r from-emerald-400 to-emerald-500 h-3 rounded-full transition-all duration-500" style="width: {{ progress }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pending Acknowledgements -->
|
||||
{% if pending_acks %}
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-orange-200 overflow-hidden mb-6">
|
||||
<div class="px-6 py-4 border-b border-orange-100 bg-gradient-to-r from-orange-50 to-transparent">
|
||||
<h5 class="font-bold text-navy flex items-center gap-2">
|
||||
<i data-lucide="alert-circle" class="w-5 h-5 text-orange-500"></i>
|
||||
{% trans "Pending Signatures" %}
|
||||
<span class="bg-orange-100 text-orange-700 px-2 py-0.5 rounded-full text-sm">{{ pending_acks|length }}</span>
|
||||
</h5>
|
||||
<div class="section-card mb-6 border-orange-200">
|
||||
<div class="section-header border-orange-200 bg-gradient-to-r from-orange-50 to-transparent">
|
||||
<div class="section-icon bg-orange-100">
|
||||
<i data-lucide="alert-circle" class="w-5 h-5 text-orange-600"></i>
|
||||
</div>
|
||||
<h2 class="text-lg font-bold text-navy">{% trans "Pending Signatures" %}</h2>
|
||||
<span class="bg-orange-100 text-orange-700 px-2 py-0.5 rounded-full text-sm">{{ pending_acks|length }}</span>
|
||||
</div>
|
||||
<div class="divide-y divide-orange-50">
|
||||
{% for item in pending_acks %}
|
||||
@ -134,13 +184,13 @@
|
||||
|
||||
<!-- Completed Acknowledgements -->
|
||||
{% if signed_acks %}
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-emerald-200 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-emerald-100 bg-gradient-to-r from-emerald-50 to-transparent">
|
||||
<h5 class="font-bold text-navy flex items-center gap-2">
|
||||
<i data-lucide="check-circle" class="w-5 h-5 text-emerald-500"></i>
|
||||
{% trans "Completed" %}
|
||||
<span class="bg-emerald-100 text-emerald-700 px-2 py-0.5 rounded-full text-sm">{{ signed_acks|length }}</span>
|
||||
</h5>
|
||||
<div class="section-card border-emerald-200">
|
||||
<div class="section-header border-emerald-200 bg-gradient-to-r from-emerald-50 to-transparent">
|
||||
<div class="section-icon bg-emerald-100">
|
||||
<i data-lucide="check-circle" class="w-5 h-5 text-emerald-600"></i>
|
||||
</div>
|
||||
<h2 class="text-lg font-bold text-navy">{% trans "Completed" %}</h2>
|
||||
<span class="bg-emerald-100 text-emerald-700 px-2 py-0.5 rounded-full text-sm">{{ signed_acks|length }}</span>
|
||||
</div>
|
||||
<div class="divide-y divide-emerald-50">
|
||||
{% for item in signed_acks %}
|
||||
@ -181,12 +231,14 @@
|
||||
{% endif %}
|
||||
|
||||
{% if not pending_acks and not signed_acks %}
|
||||
<div class="text-center py-12">
|
||||
<div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i data-lucide="clipboard-check" class="w-8 h-8 text-slate-400"></i>
|
||||
<div class="section-card">
|
||||
<div class="text-center py-12">
|
||||
<div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i data-lucide="clipboard-check" class="w-8 h-8 text-slate-400"></i>
|
||||
</div>
|
||||
<p class="text-slate font-medium">{% trans "No acknowledgements available at this time." %}</p>
|
||||
<p class="text-sm text-slate mt-2">{% trans "You will be notified when new acknowledgements are assigned to you." %}</p>
|
||||
</div>
|
||||
<p class="text-slate font-medium">{% trans "No acknowledgements available at this time." %}</p>
|
||||
<p class="text-sm text-slate mt-2">{% trans "You will be notified when new acknowledgements are assigned to you." %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@ -3,157 +3,235 @@
|
||||
|
||||
{% block title %}{% trans "Action Plans" %} - PX360{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.page-header-gradient {
|
||||
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
|
||||
color: white;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.section-card:hover {
|
||||
border-color: #005696;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
background: linear-gradient(to right, #f8fafc, #f1f5f9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.section-icon-blue {
|
||||
background: linear-gradient(135deg, #005696, #007bbd);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.section-icon-orange {
|
||||
background: linear-gradient(135deg, #f97316, #fb923c);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.section-icon-gray {
|
||||
background: linear-gradient(135deg, #64748b, #94a3b8);
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<!-- Page Header -->
|
||||
<div class="page-header-gradient">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-navy mb-2 flex items-center gap-3">
|
||||
<i data-lucide="check-circle-2" class="w-8 h-8 text-orange-500"></i>
|
||||
<h1 class="text-3xl font-bold mb-2 flex items-center gap-3">
|
||||
<i data-lucide="check-circle-2" class="w-8 h-8"></i>
|
||||
{% trans "Action Plans" %}
|
||||
</h1>
|
||||
<p class="text-slate">{% trans "Manage improvement action plans" %}</p>
|
||||
<p class="opacity-90">{% trans "Manage improvement action plans" %}</p>
|
||||
</div>
|
||||
<a href="{% url 'actions:action_create' %}" class="bg-navy text-white px-6 py-3 rounded-2xl font-bold flex items-center gap-2 shadow-lg shadow-blue-200 hover:bg-blue transition">
|
||||
<a href="{% url 'actions:action_create' %}" class="bg-white text-[#005696] px-6 py-3 rounded-xl font-bold flex items-center gap-2 shadow-lg hover:bg-gray-50 transition">
|
||||
<i data-lucide="plus" class="w-5 h-5"></i> {% trans "Create Action Plan" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border-2 border-slate-200 mb-6">
|
||||
<div class="px-6 py-4 border-b-2 border-slate-200 flex justify-between items-center bg-gradient-to-r from-slate-50 to-slate-100">
|
||||
<h3 class="font-bold text-gray-800 flex items-center gap-2">
|
||||
<i data-lucide="filter" class="w-5 h-5 text-navy"></i>
|
||||
{% trans "Filters" %}
|
||||
</h3>
|
||||
<!-- Filters Section -->
|
||||
<div class="section-card mb-6">
|
||||
<div class="section-header">
|
||||
<div class="section-icon section-icon-blue">
|
||||
<i data-lucide="filter" class="w-5 h-5"></i>
|
||||
</div>
|
||||
<h3 class="font-bold text-gray-800">{% trans "Filters" %}</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form method="get" class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">{% trans "Search" %}</label>
|
||||
<input type="text" class="w-full px-4 py-3 border border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-navy focus:border-transparent transition" name="search" placeholder="{% trans 'Search action plans...' %}" value="{{ filters.search }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">{% trans "Status" %}</label>
|
||||
<select class="w-full px-4 py-3 border border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-navy focus:border-transparent transition" name="status">
|
||||
<option value="">{% trans "All Status" %}</option>
|
||||
<option value="pending" {% if filters.status == 'pending' %}selected{% endif %}>{% trans "Pending" %}</option>
|
||||
<option value="in_progress" {% if filters.status == 'in_progress' %}selected{% endif %}>{% trans "In Progress" %}</option>
|
||||
<option value="completed" {% if filters.status == 'completed' %}selected{% endif %}>{% trans "Completed" %}</option>
|
||||
<option value="cancelled" {% if filters.status == 'cancelled' %}selected{% endif %}>{% trans "Cancelled" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">{% trans "Priority" %}</label>
|
||||
<select class="w-full px-4 py-3 border border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-navy focus:border-transparent transition" name="priority">
|
||||
<option value="">{% trans "All Priorities" %}</option>
|
||||
<option value="high" {% if filters.priority == 'high' %}selected{% endif %}>{% trans "High" %}</option>
|
||||
<option value="medium" {% if filters.priority == 'medium' %}selected{% endif %}>{% trans "Medium" %}</option>
|
||||
<option value="low" {% if filters.priority == 'low' %}selected{% endif %}>{% trans "Low" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="md:col-span-2 flex items-end">
|
||||
<div class="flex gap-2 w-full">
|
||||
<button type="submit" class="flex-1 bg-navy text-white px-4 py-3 rounded-xl font-semibold hover:bg-blue transition flex items-center justify-center gap-2">
|
||||
<i data-lucide="search" class="w-4 h-4"></i> {% trans "Filter" %}
|
||||
</button>
|
||||
<a href="{% url 'actions:action_list' %}" class="bg-gray-100 text-gray-600 px-4 py-3 rounded-xl font-semibold hover:bg-gray-200 transition flex items-center justify-center gap-2">
|
||||
<i data-lucide="x-circle" class="w-4 h-4"></i>
|
||||
</a>
|
||||
<form method="get" class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">{% trans "Search" %}</label>
|
||||
<input type="text" class="w-full px-4 py-3 border border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-[#005696] focus:border-transparent transition" name="search" placeholder="{% trans 'Search action plans...' %}" value="{{ filters.search }}">
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">{% trans "Status" %}</label>
|
||||
<select class="w-full px-4 py-3 border border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-[#005696] focus:border-transparent transition" name="status">
|
||||
<option value="">{% trans "All Status" %}</option>
|
||||
<option value="pending" {% if filters.status == 'pending' %}selected{% endif %}>{% trans "Pending" %}</option>
|
||||
<option value="in_progress" {% if filters.status == 'in_progress' %}selected{% endif %}>{% trans "In Progress" %}</option>
|
||||
<option value="completed" {% if filters.status == 'completed' %}selected{% endif %}>{% trans "Completed" %}</option>
|
||||
<option value="cancelled" {% if filters.status == 'cancelled' %}selected{% endif %}>{% trans "Cancelled" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">{% trans "Priority" %}</label>
|
||||
<select class="w-full px-4 py-3 border border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-[#005696] focus:border-transparent transition" name="priority">
|
||||
<option value="">{% trans "All Priorities" %}</option>
|
||||
<option value="high" {% if filters.priority == 'high' %}selected{% endif %}>{% trans "High" %}</option>
|
||||
<option value="medium" {% if filters.priority == 'medium' %}selected{% endif %}>{% trans "Medium" %}</option>
|
||||
<option value="low" {% if filters.priority == 'low' %}selected{% endif %}>{% trans "Low" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="md:col-span-2 flex items-end">
|
||||
<div class="flex gap-2 w-full">
|
||||
<button type="submit" class="flex-1 bg-[#005696] text-white px-4 py-3 rounded-xl font-semibold hover:bg-[#0069a8] transition flex items-center justify-center gap-2">
|
||||
<i data-lucide="search" class="w-4 h-4"></i> {% trans "Filter" %}
|
||||
</button>
|
||||
<a href="{% url 'actions:action_list' %}" class="bg-gray-100 text-gray-600 px-4 py-3 rounded-xl font-semibold hover:bg-gray-200 transition flex items-center justify-center gap-2">
|
||||
<i data-lucide="x-circle" class="w-4 h-4"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Plans Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{% for action in actions %}
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-50 overflow-hidden hover:shadow-md transition">
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div class="flex gap-2">
|
||||
{% if action.priority == 'high' %}
|
||||
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-red-100 text-red-600">{% trans "High" %}</span>
|
||||
{% elif action.priority == 'medium' %}
|
||||
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-orange-100 text-orange-600">{% trans "Medium" %}</span>
|
||||
{% else %}
|
||||
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-green-100 text-green-600">{% trans "Low" %}</span>
|
||||
{% endif %}
|
||||
<!-- Action Plans Section -->
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-icon section-icon-orange">
|
||||
<i data-lucide="check-square" class="w-5 h-5"></i>
|
||||
</div>
|
||||
<h3 class="font-bold text-gray-800">{% trans "Action Plans" %}</h3>
|
||||
<span class="ml-auto bg-gray-100 text-gray-600 px-3 py-1 rounded-full text-sm font-semibold">
|
||||
{{ page_obj.paginator.count|default:actions|length }} {% trans "Total" %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<!-- Action Plans Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{% for action in actions %}
|
||||
<div class="bg-white rounded-2xl shadow-sm border-2 border-gray-100 overflow-hidden hover:shadow-md hover:border-[#005696]/30 transition-all duration-300">
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div class="flex gap-2">
|
||||
{% if action.priority == 'high' %}
|
||||
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-red-100 text-red-600">{% trans "High" %}</span>
|
||||
{% elif action.priority == 'medium' %}
|
||||
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-orange-100 text-orange-600">{% trans "Medium" %}</span>
|
||||
{% else %}
|
||||
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-green-100 text-green-600">{% trans "Low" %}</span>
|
||||
{% endif %}
|
||||
|
||||
{% if action.status == 'completed' %}
|
||||
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-green-100 text-green-600">{% trans "Completed" %}</span>
|
||||
{% elif action.status == 'in_progress' %}
|
||||
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-blue-100 text-blue-600">{% trans "In Progress" %}</span>
|
||||
{% elif action.status == 'cancelled' %}
|
||||
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-gray-100 text-gray-600">{% trans "Cancelled" %}</span>
|
||||
{% else %}
|
||||
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-yellow-100 text-yellow-600">{% trans "Pending" %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="text-xs text-gray-400">{{ action.created_at|date:"M d, Y" }}</span>
|
||||
</div>
|
||||
|
||||
{% if action.status == 'completed' %}
|
||||
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-green-100 text-green-600">{% trans "Completed" %}</span>
|
||||
{% elif action.status == 'in_progress' %}
|
||||
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-blue-100 text-blue-600">{% trans "In Progress" %}</span>
|
||||
{% elif action.status == 'cancelled' %}
|
||||
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-gray-100 text-gray-600">{% trans "Cancelled" %}</span>
|
||||
{% else %}
|
||||
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-yellow-100 text-yellow-600">{% trans "Pending" %}</span>
|
||||
{% endif %}
|
||||
<h3 class="font-bold text-gray-800 mb-2">{{ action.title }}</h3>
|
||||
<p class="text-gray-500 text-sm mb-4 line-clamp-2">{{ action.description|truncatewords:20 }}</p>
|
||||
|
||||
<div class="space-y-2 text-sm">
|
||||
{% if action.due_date %}
|
||||
<div class="flex items-center gap-2 text-gray-600">
|
||||
<i data-lucide="calendar" class="w-4 h-4 text-[#005696]"></i>
|
||||
<span>{% trans "Due:" %} {{ action.due_date|date:"M d, Y" }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if action.assigned_to %}
|
||||
<div class="flex items-center gap-2 text-gray-600">
|
||||
<i data-lucide="user" class="w-4 h-4 text-[#005696]"></i>
|
||||
<span>{{ action.assigned_to.get_full_name|default:action.assigned_to.username }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 bg-gray-50 border-t border-gray-100 flex justify-between items-center">
|
||||
<a href="{% url 'actions:action_detail' action.id %}" class="text-[#005696] text-sm font-bold hover:underline flex items-center gap-1">
|
||||
{% trans "View Details" %} <i data-lucide="arrow-right" class="w-4 h-4"></i>
|
||||
</a>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400">{{ action.created_at|date:"M d, Y" }}</span>
|
||||
</div>
|
||||
|
||||
<h3 class="font-bold text-gray-800 mb-2">{{ action.title }}</h3>
|
||||
<p class="text-gray-500 text-sm mb-4 line-clamp-2">{{ action.description|truncatewords:20 }}</p>
|
||||
|
||||
<div class="space-y-2 text-sm">
|
||||
{% if action.due_date %}
|
||||
<div class="flex items-center gap-2 text-gray-600">
|
||||
<i data-lucide="calendar" class="w-4 h-4"></i>
|
||||
<span>{% trans "Due:" %} {{ action.due_date|date:"M d, Y" }}</span>
|
||||
{% empty %}
|
||||
<div class="col-span-full">
|
||||
<div class="bg-gray-50 rounded-2xl border-2 border-dashed border-gray-200 p-12 text-center">
|
||||
<div class="section-icon section-icon-gray mx-auto mb-4" style="width: 64px; height: 64px;">
|
||||
<i data-lucide="check-square" class="w-8 h-8"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-800 mb-2">{% trans "No Action Plans Found" %}</h3>
|
||||
<p class="text-gray-500 mb-6">{% trans "Get started by creating your first action plan" %}</p>
|
||||
<a href="{% url 'actions:action_create' %}" class="inline-flex items-center gap-2 bg-[#005696] text-white px-6 py-3 rounded-xl font-bold shadow-lg hover:bg-[#0069a8] transition">
|
||||
<i data-lucide="plus" class="w-5 h-5"></i> {% trans "Create Action Plan" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if page_obj.has_other_pages %}
|
||||
<div class="mt-6 flex justify-center">
|
||||
<div class="flex gap-2">
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="?page={{ page_obj.previous_page_number }}&{{ filters|urlencode }}" class="px-4 py-2 border border-gray-200 rounded-xl text-sm font-semibold hover:bg-gray-50 transition">
|
||||
<i data-lucide="chevron-left" class="w-4 h-4 inline"></i> {% trans "Previous" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if action.assigned_to %}
|
||||
<div class="flex items-center gap-2 text-gray-600">
|
||||
<i data-lucide="user" class="w-4 h-4"></i>
|
||||
<span>{{ action.assigned_to.get_full_name|default:action.assigned_to.username }}</span>
|
||||
</div>
|
||||
<span class="px-4 py-2 bg-[#005696] text-white rounded-xl text-sm font-bold">
|
||||
{% blocktrans with current=page_obj.number total=page_obj.paginator.num_pages %}Page {{ current }} of {{ total }}{% endblocktrans %}
|
||||
</span>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?page={{ page_obj.next_page_number }}&{{ filters|urlencode }}" class="px-4 py-2 border border-gray-200 rounded-xl text-sm font-semibold hover:bg-gray-50 transition">
|
||||
{% trans "Next" %} <i data-lucide="chevron-right" class="w-4 h-4 inline"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 bg-gray-50 border-t border-gray-100 flex justify-between items-center">
|
||||
<a href="{% url 'actions:action_detail' action.id %}" class="text-navy text-sm font-bold hover:underline flex items-center gap-1">
|
||||
{% trans "View Details" %} <i data-lucide="arrow-right" class="w-4 h-4"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="col-span-full">
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-50 p-12 text-center">
|
||||
<i data-lucide="check-square" class="w-16 h-16 mx-auto mb-4 text-gray-300"></i>
|
||||
<h3 class="text-xl font-bold text-gray-800 mb-2">{% trans "No Action Plans Found" %}</h3>
|
||||
<p class="text-gray-500 mb-6">{% trans "Get started by creating your first action plan" %}</p>
|
||||
<a href="{% url 'actions:action_create' %}" class="inline-flex items-center gap-2 bg-navy text-white px-6 py-3 rounded-2xl font-bold shadow-lg shadow-blue-200 hover:bg-blue transition">
|
||||
<i data-lucide="plus" class="w-5 h-5"></i> {% trans "Create Action Plan" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if page_obj.has_other_pages %}
|
||||
<div class="mt-6 flex justify-center">
|
||||
<div class="flex gap-2">
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="?page={{ page_obj.previous_page_number }}&{{ filters|urlencode }}" class="px-4 py-2 border border-gray-200 rounded-xl text-sm font-semibold hover:bg-gray-50 transition">
|
||||
<i data-lucide="chevron-left" class="w-4 h-4"></i> {% trans "Previous" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<span class="px-4 py-2 bg-navy text-white rounded-xl text-sm font-bold">
|
||||
{% blocktrans with current=page_obj.number total=page_obj.paginator.num_pages %}Page {{ current }} of {{ total }}{% endblocktrans %}
|
||||
</span>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?page={{ page_obj.next_page_number }}&{{ filters|urlencode }}" class="px-4 py-2 border border-gray-200 rounded-xl text-sm font-semibold hover:bg-gray-50 transition">
|
||||
{% trans "Next" %} <i data-lucide="chevron-right" class="w-4 h-4"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@ -3,100 +3,174 @@
|
||||
|
||||
{% block title %}{% trans "Sentiment Analysis Results" %}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.page-header-gradient {
|
||||
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
|
||||
color: white;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.section-card:hover {
|
||||
border-color: #005696;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
background: linear-gradient(to right, #f8fafc, #f1f5f9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 20px;
|
||||
background: #e2e8f0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #005696, #007bbd);
|
||||
border-radius: 10px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
|
||||
<!-- Page Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0">{% trans "Sentiment Analysis Results" %}</h1>
|
||||
<p class="text-muted">{% trans "AI-powered sentiment analysis of text content" %}</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{% url 'ai_engine:analyze_text' %}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> {% trans "Analyze Text" %}
|
||||
</a>
|
||||
<a href="{% url 'ai_engine:sentiment_dashboard' %}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-graph-up"></i> {% trans "Dashboard" %}
|
||||
</a>
|
||||
<div class="page-header-gradient">
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="section-icon bg-white/20">
|
||||
<i data-lucide="brain" class="w-6 h-6 text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{% trans "Sentiment Analysis Results" %}</h1>
|
||||
<p class="text-white/80 text-sm">{% trans "AI-powered sentiment analysis of text content" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<a href="{% url 'ai_engine:analyze_text' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-white text-navy rounded-xl font-bold hover:bg-gray-100 transition">
|
||||
<i data-lucide="plus-circle" class="w-4 h-4"></i> {% trans "Analyze Text" %}
|
||||
</a>
|
||||
<a href="{% url 'ai_engine:sentiment_dashboard' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-white/20 text-white rounded-xl font-bold hover:bg-white/30 transition">
|
||||
<i data-lucide="bar-chart-3" class="w-4 h-4"></i> {% trans "Dashboard" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted mb-2">{% trans "Total Results" %}</h6>
|
||||
<h3 class="mb-0">{{ stats.total }}</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-icon bg-blue-100">
|
||||
<i data-lucide="bar-chart-2" class="w-5 h-5 text-blue-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate uppercase">{% trans "Total Results" %}</p>
|
||||
<p class="text-2xl font-bold text-navy">{{ stats.total }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-success">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted mb-2">{% trans "Positive" %}</h6>
|
||||
<h3 class="mb-0 text-success">
|
||||
{{ stats.positive }} <small>({{ stats.positive_pct }}%)</small>
|
||||
</h3>
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-icon bg-emerald-100">
|
||||
<i data-lucide="smile" class="w-5 h-5 text-emerald-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate uppercase">{% trans "Positive" %}</p>
|
||||
<p class="text-2xl font-bold text-emerald-600">{{ stats.positive }} <small class="text-sm text-slate">({{ stats.positive_pct }}%)</small></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-secondary">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted mb-2">{% trans "Neutral" %}</h6>
|
||||
<h3 class="mb-0 text-secondary">
|
||||
{{ stats.neutral }} <small>({{ stats.neutral_pct }}%)</small>
|
||||
</h3>
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-icon bg-slate-100">
|
||||
<i data-lucide="meh" class="w-5 h-5 text-slate-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate uppercase">{% trans "Neutral" %}</p>
|
||||
<p class="text-2xl font-bold text-slate-600">{{ stats.neutral }} <small class="text-sm text-slate">({{ stats.neutral_pct }}%)</small></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-danger">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted mb-2">{% trans "Negative" %}</h6>
|
||||
<h3 class="mb-0 text-danger">
|
||||
{{ stats.negative }} <small>({{ stats.negative_pct }}%)</small>
|
||||
</h3>
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-icon bg-red-100">
|
||||
<i data-lucide="frown" class="w-5 h-5 text-red-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate uppercase">{% trans "Negative" %}</p>
|
||||
<p class="text-2xl font-bold text-red-600">{{ stats.negative }} <small class="text-sm text-slate">({{ stats.negative_pct }}%)</small></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">{% trans "Filters" %}</h5>
|
||||
<div class="section-card mb-8">
|
||||
<div class="section-header">
|
||||
<div class="section-icon bg-navy/10">
|
||||
<i data-lucide="filter" class="w-5 h-5 text-navy"></i>
|
||||
</div>
|
||||
<h3 class="font-bold text-navy">{% trans "Filters" %}</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="get" class="row g-3">
|
||||
<div class="col-md-2">
|
||||
<div class="p-6">
|
||||
<form method="get" class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-navy mb-2">{% trans "Sentiment" %}</label>
|
||||
{{ filter_form.sentiment }}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-navy mb-2">{% trans "Language" %}</label>
|
||||
{{ filter_form.language }}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-navy mb-2">{% trans "AI Service" %}</label>
|
||||
{{ filter_form.ai_service }}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-navy mb-2">{% trans "Min Confidence" %}</label>
|
||||
{{ filter_form.min_confidence }}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="md:col-span-3">
|
||||
<label class="block text-sm font-bold text-navy mb-2">{% trans "Search" %}</label>
|
||||
{{ filter_form.search }}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
{{ filter_form.date_from }}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
{{ filter_form.date_to }}
|
||||
</div>
|
||||
<div class="col-md-8 text-end">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-funnel"></i> {% trans "Apply Filters" %}
|
||||
<div class="flex items-end gap-2">
|
||||
<button type="submit" class="inline-flex items-center gap-2 px-5 py-2.5 bg-navy text-white rounded-xl font-bold hover:bg-blue transition">
|
||||
<i data-lucide="filter" class="w-4 h-4"></i> {% trans "Apply Filters" %}
|
||||
</button>
|
||||
<a href="{% url 'ai_engine:sentiment_list' %}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-circle"></i> {% trans "Clear" %}
|
||||
<a href="{% url 'ai_engine:sentiment_list' %}" class="inline-flex items-center gap-2 px-5 py-2.5 border-2 border-slate-200 text-slate rounded-xl font-bold hover:bg-slate-50 transition">
|
||||
<i data-lucide="x-circle" class="w-4 h-4"></i> {% trans "Clear" %}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
@ -104,89 +178,97 @@
|
||||
</div>
|
||||
|
||||
<!-- Results Table -->
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">{% trans "Results" %} ({{ page_obj.paginator.count }})</h5>
|
||||
<div class="section-card">
|
||||
<div class="section-header flex justify-between items-center">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="section-icon bg-navy/10">
|
||||
<i data-lucide="list" class="w-5 h-5 text-navy"></i>
|
||||
</div>
|
||||
<h3 class="font-bold text-navy">{% trans "Results" %} ({{ page_obj.paginator.count }})</h3>
|
||||
</div>
|
||||
<div>
|
||||
<select class="form-select form-select-sm" onchange="window.location.href='?page_size=' + this.value + '{% for key, value in filters.items %}{% if key != 'page_size' %}&{{ key }}={{ value }}{% endif %}{% endfor %}'">
|
||||
<select class="px-4 py-2 border-2 border-slate-200 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition" onchange="window.location.href='?page_size=' + this.value + '{% for key, value in filters.items %}{% if key != 'page_size' %}&{{ key }}={{ value }}{% endif %}{% endfor %}'">
|
||||
<option value="25" {% if request.GET.page_size == '25' %}selected{% endif %}>25 per page</option>
|
||||
<option value="50" {% if request.GET.page_size == '50' %}selected{% endif %}>50 per page</option>
|
||||
<option value="100" {% if request.GET.page_size == '100' %}selected{% endif %}>100 per page</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-blue-50">
|
||||
<tr>
|
||||
<th>{% trans "Text" %}</th>
|
||||
<th>{% trans "Sentiment" %}</th>
|
||||
<th>{% trans "Score" %}</th>
|
||||
<th>{% trans "Confidence" %}</th>
|
||||
<th>{% trans "Language" %}</th>
|
||||
<th>{% trans "Related To" %}</th>
|
||||
<th>{% trans "Date" %}</th>
|
||||
<th>{% trans "Actions" %}</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">{% trans "Text" %}</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">{% trans "Sentiment" %}</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">{% trans "Score" %}</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">{% trans "Confidence" %}</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">{% trans "Language" %}</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">{% trans "Related To" %}</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">{% trans "Date" %}</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">{% trans "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody class="divide-y divide-blue-50">
|
||||
{% for result in results %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="text-truncate" style="max-width: 300px;" title="{{ result.text }}">
|
||||
<tr class="hover:bg-blue-50/50 transition">
|
||||
<td class="px-6 py-4">
|
||||
<div class="truncate max-w-xs" title="{{ result.text }}">
|
||||
{{ result.text }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<td class="px-6 py-4">
|
||||
{% if result.sentiment == 'positive' %}
|
||||
<span class="badge bg-success">😊 {% trans "Positive" %}</span>
|
||||
<span class="inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-bold bg-emerald-100 text-emerald-700">
|
||||
<i data-lucide="smile" class="w-3 h-3"></i> {% trans "Positive" %}
|
||||
</span>
|
||||
{% elif result.sentiment == 'negative' %}
|
||||
<span class="badge bg-danger">😞 {% trans "Negative" %}</span>
|
||||
<span class="inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-bold bg-red-100 text-red-700">
|
||||
<i data-lucide="frown" class="w-3 h-3"></i> {% trans "Negative" %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">😐 {% trans "Neutral" %}</span>
|
||||
<span class="inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-bold bg-slate-100 text-slate-700">
|
||||
<i data-lucide="meh" class="w-3 h-3"></i> {% trans "Neutral" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-light text-dark">{{ result.sentiment_score|floatformat:2 }}</span>
|
||||
<td class="px-6 py-4">
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold bg-blue-100 text-blue-700">{{ result.sentiment_score|floatformat:2 }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="progress" style="height: 20px;">
|
||||
<div class="progress-bar" role="progressbar"
|
||||
style="width: {{ result.confidence|floatformat:0 }}%"
|
||||
aria-valuenow="{{ result.confidence|floatformat:0 }}"
|
||||
aria-valuemin="0" aria-valuemax="100">
|
||||
{{ result.confidence|floatformat:0 }}%
|
||||
</div>
|
||||
<td class="px-6 py-4">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar-fill" style="width: {{ result.confidence|floatformat:0 }}%"></div>
|
||||
</div>
|
||||
<span class="text-xs text-slate mt-1">{{ result.confidence|floatformat:0 }}%</span>
|
||||
</td>
|
||||
<td>
|
||||
<td class="px-6 py-4">
|
||||
{% if result.language == 'ar' %}
|
||||
<span class="badge bg-info">العربية</span>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold bg-cyan-100 text-cyan-700">العربية</span>
|
||||
{% else %}
|
||||
<span class="badge bg-info">English</span>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold bg-blue-100 text-blue-700">English</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<td class="px-6 py-4">
|
||||
{% if result.content_type %}
|
||||
<small class="text-muted">{{ result.content_type.model }}</small>
|
||||
<small class="text-slate">{{ result.content_type.model }}</small>
|
||||
{% else %}
|
||||
<small class="text-muted">-</small>
|
||||
<small class="text-slate">-</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<small>{{ result.created_at|date:"Y-m-d H:i" }}</small>
|
||||
<td class="px-6 py-4">
|
||||
<small class="text-slate">{{ result.created_at|date:"Y-m-d H:i" }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<td class="px-6 py-4">
|
||||
<a href="{% url 'ai_engine:sentiment_detail' result.id %}"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i>
|
||||
class="inline-flex items-center gap-1 px-3 py-2 text-navy bg-blue-50 rounded-lg hover:bg-blue-100 transition font-medium text-sm">
|
||||
<i data-lucide="eye" class="w-4 h-4"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="8" class="text-center text-muted py-4">
|
||||
{% trans "No sentiment results found." %}
|
||||
<td colspan="8" class="text-center py-12">
|
||||
<i data-lucide="inbox" class="w-16 h-16 text-slate-300 mx-auto mb-4"></i>
|
||||
<p class="text-slate font-medium">{% trans "No sentiment results found." %}</p>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@ -196,30 +278,38 @@
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if page_obj.has_other_pages %}
|
||||
<div class="card-footer">
|
||||
<nav aria-label="Page navigation">
|
||||
<ul class="pagination justify-content-center mb-0">
|
||||
<div class="px-6 py-4 border-t border-slate-100">
|
||||
<nav class="flex justify-center">
|
||||
<ul class="flex gap-1">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page=1{% for key, value in filters.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">{% trans "First" %}</a>
|
||||
<li>
|
||||
<a class="px-3 py-2 text-slate hover:bg-slate-100 rounded-lg transition" href="?page=1{% for key, value in filters.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">
|
||||
<i data-lucide="chevrons-left" class="w-4 h-4"></i>
|
||||
</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% for key, value in filters.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">{% trans "Previous" %}</a>
|
||||
<li>
|
||||
<a class="px-3 py-2 text-slate hover:bg-slate-100 rounded-lg transition" href="?page={{ page_obj.previous_page_number }}{% for key, value in filters.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">
|
||||
<i data-lucide="chevron-left" class="w-4 h-4"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<li class="page-item active">
|
||||
<span class="page-link">
|
||||
<li>
|
||||
<span class="px-4 py-2 bg-navy text-white rounded-lg font-bold">
|
||||
{% trans "Page" %} {{ page_obj.number }} {% trans "of" %} {{ page_obj.paginator.num_pages }}
|
||||
</span>
|
||||
</li>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% for key, value in filters.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">{% trans "Next" %}</a>
|
||||
<li>
|
||||
<a class="px-3 py-2 text-slate hover:bg-slate-100 rounded-lg transition" href="?page={{ page_obj.next_page_number }}{% for key, value in filters.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">
|
||||
<i data-lucide="chevron-right" class="w-4 h-4"></i>
|
||||
</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% for key, value in filters.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">{% trans "Last" %}</a>
|
||||
<li>
|
||||
<a class="px-3 py-2 text-slate hover:bg-slate-100 rounded-lg transition" href="?page={{ page_obj.paginator.num_pages }}{% for key, value in filters.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">
|
||||
<i data-lucide="chevrons-right" class="w-4 h-4"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
@ -228,4 +318,10 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -111,20 +111,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hospital -->
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-gray-700 mb-2">{% trans "Hospital" %}</label>
|
||||
<select class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-navy focus:border-transparent transition"
|
||||
name="hospital" id="hospitalFilter" onchange="loadDepartments()">
|
||||
<option value="">{% trans "All Hospitals" %}</option>
|
||||
{% for hospital in hospitals %}
|
||||
<option value="{{ hospital.id }}" {% if filters.hospital == hospital.id|stringformat:"s" %}selected{% endif %}>
|
||||
{{ hospital.name_en|default:hospital.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Department -->
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-gray-700 mb-2">{% trans "Department" %}</label>
|
||||
@ -463,7 +449,7 @@ function handleDateRangeChange() {
|
||||
|
||||
function updateFilters() {
|
||||
currentFilters.date_range = document.getElementById('dateRange').value;
|
||||
currentFilters.hospital = document.getElementById('hospitalFilter').value;
|
||||
currentFilters.hospital = '{{ current_hospital.id|default:"" }}';
|
||||
currentFilters.department = document.getElementById('departmentFilter').value;
|
||||
currentFilters.kpi_category = document.getElementById('kpiCategoryFilter').value;
|
||||
currentFilters.custom_start = document.getElementById('customStart').value;
|
||||
@ -669,7 +655,6 @@ function refreshDashboard() {
|
||||
|
||||
function resetFilters() {
|
||||
document.getElementById('dateRange').value = '30d';
|
||||
document.getElementById('hospitalFilter').value = '';
|
||||
document.getElementById('departmentFilter').value = '';
|
||||
document.getElementById('kpiCategoryFilter').value = '';
|
||||
document.getElementById('customStart').value = '';
|
||||
|
||||
@ -240,14 +240,6 @@
|
||||
<p class="mt-1 opacity-90">{% trans "Comprehensive overview of patient experience metrics" %}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<select class="form-select-px360" onchange="window.location.href='?hospital='+this.value">
|
||||
<option value="">{% trans "All Hospitals" %}</option>
|
||||
{% for hospital in hospitals %}
|
||||
<option value="{{ hospital.id }}" {% if selected_hospital and selected_hospital.id == hospital.id %}selected{% endif %}>
|
||||
{{ hospital.name_en }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button onclick="refreshDashboard()" class="p-2.5 bg-white/20 hover:bg-white/30 rounded-xl transition" title="{% trans 'Refresh' %}">
|
||||
<i data-lucide="refresh-cw" class="w-5 h-5 text-white"></i>
|
||||
</button>
|
||||
|
||||
@ -4,20 +4,71 @@
|
||||
|
||||
{% block title %}KPI Definitions - PX360{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.page-header-gradient {
|
||||
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
|
||||
color: white;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
|
||||
}
|
||||
.section-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.section-card:hover {
|
||||
border-color: #005696;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
|
||||
}
|
||||
.section-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
background: linear-gradient(to right, #f8fafc, #f1f5f9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.section-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2 class="mb-1">
|
||||
<i class="bi bi-speedometer text-primary me-2"></i>
|
||||
KPI Definitions
|
||||
</h2>
|
||||
<p class="text-muted mb-0">Manage key performance indicators</p>
|
||||
<!-- Page Header -->
|
||||
<div class="page-header-gradient">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h2 class="mb-1">
|
||||
<i data-lucide="gauge" class="me-2" style="width: 28px; height: 28px; vertical-align: text-bottom;"></i>
|
||||
KPI Definitions
|
||||
</h2>
|
||||
<p class="mb-0 opacity-75">Manage key performance indicators</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<!-- Table Section -->
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-icon" style="background: linear-gradient(135deg, #005696, #007bbd);">
|
||||
<i data-lucide="list" style="width: 20px; height: 20px; color: white;"></i>
|
||||
</div>
|
||||
<h5 class="mb-0 fw-bold">{% trans "KPI List" %}</h5>
|
||||
</div>
|
||||
<div class="p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
@ -73,7 +124,7 @@
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center py-5">
|
||||
<i class="bi bi-speedometer" style="font-size: 3rem; color: #ccc;"></i>
|
||||
<i data-lucide="gauge" style="width: 48px; height: 48px; color: #ccc;"></i>
|
||||
<p class="text-muted mt-3">No KPIs defined</p>
|
||||
</td>
|
||||
</tr>
|
||||
@ -90,7 +141,7 @@
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
<i data-lucide="chevron-left" style="width: 16px; height: 16px;"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
@ -108,7 +159,7 @@
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}">
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
<i data-lucide="chevron-right" style="width: 16px; height: 16px;"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
@ -117,13 +168,3 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
</final_file_content>
|
||||
|
||||
IMPORTANT: For any future changes to this file, use the final_file_content shown above as your reference. This content reflects the current state of the file, including any auto-formatting (e.g., if you used single quotes but the formatter converted them to double quotes). Always base your SEARCH/REPLACE operations on this final version to ensure accuracy.
|
||||
|
||||
</function_calls>
|
||||
|
||||
<function_calls>
|
||||
<invoke name="read_file">
|
||||
<parameter name="path">apps/analytics/urls.py
|
||||
|
||||
@ -58,19 +58,7 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Hospital -->
|
||||
<div>
|
||||
<label for="hospital" class="block text-xs font-bold text-slate uppercase tracking-wider mb-2">
|
||||
{% trans "Hospital" %} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select name="hospital" id="hospital" required
|
||||
class="w-full px-4 py-2.5 bg-slate-50 border border-slate-200 rounded-xl text-sm focus:bg-white focus:border-navy focus:ring-2 focus:ring-navy/10 outline-none transition">
|
||||
<option value="">{% trans "Select Hospital" %}</option>
|
||||
{% for hospital in hospitals %}
|
||||
<option value="{{ hospital.id }}">{{ hospital.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<input type="hidden" name="hospital" value="{{ current_hospital.id }}">
|
||||
|
||||
<!-- Year and Month -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
|
||||
@ -5,6 +5,42 @@
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.page-header-gradient {
|
||||
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
|
||||
color: white;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
|
||||
}
|
||||
.section-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.section-card:hover {
|
||||
border-color: #005696;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
|
||||
}
|
||||
.section-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
background: linear-gradient(to right, #f8fafc, #f1f5f9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.section-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.status-completed { background-color: #dcfce7; color: #166534; }
|
||||
.status-pending { background-color: #fef9c3; color: #854d0e; }
|
||||
.status-generating { background-color: #e0f2fe; color: #075985; }
|
||||
@ -16,29 +52,29 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Header -->
|
||||
<header class="mb-6">
|
||||
<!-- Page Header -->
|
||||
<div class="page-header-gradient">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-navy flex items-center gap-3">
|
||||
<h1 class="text-2xl font-bold flex items-center gap-3">
|
||||
<i data-lucide="bar-chart-3" class="w-7 h-7"></i>
|
||||
{% trans "KPI Reports" %}
|
||||
</h1>
|
||||
<p class="text-sm text-slate mt-1">{% trans "Monthly automated reports for MOH and internal KPIs" %}</p>
|
||||
<p class="text-sm mt-1 opacity-75">{% trans "Monthly automated reports for MOH and internal KPIs" %}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative group">
|
||||
<i data-lucide="search" class="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate group-focus-within:text-navy"></i>
|
||||
<i data-lucide="search" class="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-white/70 group-focus-within:text-white"></i>
|
||||
<input type="text" id="searchInput" placeholder="{% trans 'Search KPI ID or indicator...' %}"
|
||||
class="pl-10 pr-4 py-2.5 bg-slate-100 border-transparent border focus:border-navy/30 focus:bg-white rounded-xl text-sm outline-none w-64 transition-all">
|
||||
class="pl-10 pr-4 py-2.5 bg-white/20 border-transparent border focus:border-white/50 focus:bg-white/30 rounded-xl text-sm outline-none w-64 transition-all text-white placeholder-white/70">
|
||||
</div>
|
||||
<a href="{% url 'analytics:kpi_report_generate' %}"
|
||||
class="bg-navy text-white px-5 py-2.5 rounded-xl text-sm font-bold shadow-lg shadow-navy/20 hover:bg-blue flex items-center gap-2 transition">
|
||||
class="bg-white text-[#005696] px-5 py-2.5 rounded-xl text-sm font-bold shadow-lg hover:bg-gray-100 flex items-center gap-2 transition">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "Generate Report" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="grid grid-cols-4 gap-6 mb-6">
|
||||
@ -80,236 +116,233 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Tabs -->
|
||||
<div class="bg-white px-6 py-4 rounded-t-2xl border-b flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="?" class="filter-btn px-4 py-1.5 rounded-full text-xs font-semibold transition {% if not filters.status %}active{% endif %}">
|
||||
{% trans "All Reports" %}
|
||||
</a>
|
||||
<a href="?status=completed" class="filter-btn px-4 py-1.5 rounded-full text-xs font-semibold transition {% if filters.status == 'completed' %}active{% endif %}">
|
||||
{% trans "Completed" %}
|
||||
</a>
|
||||
<a href="?status=pending" class="filter-btn px-4 py-1.5 rounded-full text-xs font-semibold transition {% if filters.status == 'pending' %}active{% endif %}">
|
||||
{% trans "Pending" %}
|
||||
</a>
|
||||
<a href="?status=failed" class="filter-btn px-4 py-1.5 rounded-full text-xs font-semibold transition {% if filters.status == 'failed' %}active{% endif %}">
|
||||
{% trans "Failed" %}
|
||||
</a>
|
||||
<div class="h-4 w-[1px] bg-slate-200 mx-2"></div>
|
||||
<button onclick="toggleFilters()" class="flex items-center gap-2 text-xs font-bold text-blue uppercase tracking-tight hover:underline">
|
||||
<i data-lucide="filter" class="w-3 h-3"></i> {% trans "Advanced Filters" %}
|
||||
<!-- Filter Section -->
|
||||
<div class="section-card mb-6">
|
||||
<div class="section-header">
|
||||
<div class="section-icon" style="background: linear-gradient(135deg, #005696, #007bbd);">
|
||||
<i data-lucide="filter" style="width: 20px; height: 20px; color: white;"></i>
|
||||
</div>
|
||||
<h5 class="mb-0 fw-bold">{% trans "Filters" %}</h5>
|
||||
<button onclick="toggleFilters()" class="ms-auto text-slate hover:text-navy transition p-2 rounded-lg hover:bg-white">
|
||||
<i data-lucide="chevron-up" id="filterToggleIcon" class="w-5 h-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-[10px] font-bold text-slate uppercase">
|
||||
{% trans "Showing:" %} <span class="text-navy">{{ page_obj.start_index|default:0 }}-{{ page_obj.end_index|default:0 }} {% trans "of" %} {{ page_obj.paginator.count|default:0 }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Filter Tabs -->
|
||||
<div class="px-6 py-4 border-b flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="?" class="filter-btn px-4 py-1.5 rounded-full text-xs font-semibold transition {% if not filters.status %}active{% endif %}">
|
||||
{% trans "All Reports" %}
|
||||
</a>
|
||||
<a href="?status=completed" class="filter-btn px-4 py-1.5 rounded-full text-xs font-semibold transition {% if filters.status == 'completed' %}active{% endif %}">
|
||||
{% trans "Completed" %}
|
||||
</a>
|
||||
<a href="?status=pending" class="filter-btn px-4 py-1.5 rounded-full text-xs font-semibold transition {% if filters.status == 'pending' %}active{% endif %}">
|
||||
{% trans "Pending" %}
|
||||
</a>
|
||||
<a href="?status=failed" class="filter-btn px-4 py-1.5 rounded-full text-xs font-semibold transition {% if filters.status == 'failed' %}active{% endif %}">
|
||||
{% trans "Failed" %}
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-[10px] font-bold text-slate uppercase">
|
||||
{% trans "Showing:" %} <span class="text-navy">{{ page_obj.start_index|default:0 }}-{{ page_obj.end_index|default:0 }} {% trans "of" %} {{ page_obj.paginator.count|default:0 }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Filters (Hidden by default) -->
|
||||
<div id="advancedFilters" class="hidden bg-slate-50 px-6 py-4 border-b">
|
||||
<form method="get" class="flex flex-wrap gap-4">
|
||||
{% if filters.status %}
|
||||
<input type="hidden" name="status" value="{{ filters.status }}">
|
||||
{% endif %}
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-xs font-bold text-slate uppercase">{% trans "Report Type" %}</label>
|
||||
<select name="report_type" class="px-3 py-1.5 bg-white border rounded-lg text-xs">
|
||||
<option value="">{% trans "All Types" %}</option>
|
||||
{% for type_value, type_label in report_types %}
|
||||
<option value="{{ type_value }}" {% if filters.report_type == type_value %}selected{% endif %}>
|
||||
{{ type_label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{% if request.user.is_px_admin %}
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-xs font-bold text-slate uppercase">{% trans "Hospital" %}</label>
|
||||
<select name="hospital" class="px-3 py-1.5 bg-white border rounded-lg text-xs">
|
||||
<option value="">{% trans "All Hospitals" %}</option>
|
||||
{% for hospital in hospitals %}
|
||||
<option value="{{ hospital.id }}" {% if filters.hospital == hospital.id|stringformat:'s' %}selected{% endif %}>
|
||||
{{ hospital.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-xs font-bold text-slate uppercase">{% trans "Year" %}</label>
|
||||
<select name="year" class="px-3 py-1.5 bg-white border rounded-lg text-xs">
|
||||
<option value="">{% trans "All Years" %}</option>
|
||||
{% for y in years %}
|
||||
<option value="{{ y }}" {% if filters.year == y|stringformat:'s' %}selected{% endif %}>
|
||||
{{ y }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-xs font-bold text-slate uppercase">{% trans "Month" %}</label>
|
||||
<select name="month" class="px-3 py-1.5 bg-white border rounded-lg text-xs">
|
||||
<option value="">{% trans "All Months" %}</option>
|
||||
{% for m, m_label in months %}
|
||||
<option value="{{ m }}" {% if filters.month == m|stringformat:'s' %}selected{% endif %}>
|
||||
{{ m_label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="px-4 py-1.5 bg-navy text-white rounded-lg text-xs font-bold">{% trans "Apply" %}</button>
|
||||
<a href="?" class="px-4 py-1.5 border rounded-lg text-xs font-semibold text-slate hover:bg-white">{% trans "Clear" %}</a>
|
||||
</form>
|
||||
<!-- Advanced Filters (Hidden by default) -->
|
||||
<div id="advancedFilters" class="hidden px-6 py-4 bg-slate-50 border-b">
|
||||
<form method="get" class="flex flex-wrap gap-4">
|
||||
{% if filters.status %}
|
||||
<input type="hidden" name="status" value="{{ filters.status }}">
|
||||
{% endif %}
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-xs font-bold text-slate uppercase">{% trans "Report Type" %}</label>
|
||||
<select name="report_type" class="px-3 py-1.5 bg-white border rounded-lg text-xs">
|
||||
<option value="">{% trans "All Types" %}</option>
|
||||
{% for type_value, type_label in report_types %}
|
||||
<option value="{{ type_value }}" {% if filters.report_type == type_value %}selected{% endif %}>
|
||||
{{ type_label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-xs font-bold text-slate uppercase">{% trans "Year" %}</label>
|
||||
<select name="year" class="px-3 py-1.5 bg-white border rounded-lg text-xs">
|
||||
<option value="">{% trans "All Years" %}</option>
|
||||
{% for y in years %}
|
||||
<option value="{{ y }}" {% if filters.year == y|stringformat:'s' %}selected{% endif %}>
|
||||
{{ y }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-xs font-bold text-slate uppercase">{% trans "Month" %}</label>
|
||||
<select name="month" class="px-3 py-1.5 bg-white border rounded-lg text-xs">
|
||||
<option value="">{% trans "All Months" %}</option>
|
||||
{% for m, m_label in months %}
|
||||
<option value="{{ m }}" {% if filters.month == m|stringformat:'s' %}selected{% endif %}>
|
||||
{{ m_label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="px-4 py-1.5 bg-navy text-white rounded-lg text-xs font-bold">{% trans "Apply" %}</button>
|
||||
<a href="?" class="px-4 py-1.5 border rounded-lg text-xs font-semibold text-slate hover:bg-white">{% trans "Clear" %}</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reports Grid -->
|
||||
{% if reports %}
|
||||
<div class="bg-white rounded-b-2xl shadow-sm border p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{% for report in reports %}
|
||||
<div class="card bg-white p-5 border border-slate-200 rounded-2xl hover:shadow-lg hover:-translate-y-1 transition-all duration-200 cursor-pointer group"
|
||||
onclick="window.location.href='{% url 'analytics:kpi_report_detail' report.id %}'">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-2 py-1 text-xs font-bold rounded bg-navy text-white">
|
||||
{{ report.kpi_id }}
|
||||
<div class="section-card">
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{% for report in reports %}
|
||||
<div class="card bg-white p-5 border border-slate-200 rounded-2xl hover:shadow-lg hover:-translate-y-1 transition-all duration-200 cursor-pointer group"
|
||||
onclick="window.location.href='{% url 'analytics:kpi_report_detail' report.id %}'">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-2 py-1 text-xs font-bold rounded bg-navy text-white">
|
||||
{{ report.kpi_id }}
|
||||
</span>
|
||||
<span class="text-xs text-slate">{{ report.report_period_display }}</span>
|
||||
</div>
|
||||
<span class="px-2 py-0.5 text-xs rounded-full font-semibold uppercase
|
||||
{% if report.status == 'completed' %}status-completed
|
||||
{% elif report.status == 'failed' %}status-failed
|
||||
{% elif report.status == 'generating' %}status-generating
|
||||
{% else %}status-pending{% endif %}">
|
||||
{{ report.get_status_display }}
|
||||
</span>
|
||||
<span class="text-xs text-slate">{{ report.report_period_display }}</span>
|
||||
</div>
|
||||
<span class="px-2 py-0.5 text-xs rounded-full font-semibold uppercase
|
||||
{% if report.status == 'completed' %}status-completed
|
||||
{% elif report.status == 'failed' %}status-failed
|
||||
{% elif report.status == 'generating' %}status-generating
|
||||
{% else %}status-pending{% endif %}">
|
||||
{{ report.get_status_display }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h3 class="font-semibold text-navy mb-2 line-clamp-2 group-hover:text-blue transition-colors">{{ report.indicator_title }}</h3>
|
||||
|
||||
<!-- Hospital -->
|
||||
<p class="text-sm text-slate mb-4 flex items-center gap-1">
|
||||
<i data-lucide="building-2" class="w-3 h-3 inline"></i>
|
||||
{{ report.hospital.name }}
|
||||
</p>
|
||||
|
||||
<!-- Results -->
|
||||
<div class="grid grid-cols-3 gap-2 mb-4">
|
||||
<div class="bg-light rounded-lg p-3 text-center">
|
||||
<div class="text-[10px] font-bold text-slate uppercase mb-1">{% trans "Target" %}</div>
|
||||
<div class="text-lg font-black text-navy">{{ report.target_percentage }}%</div>
|
||||
</div>
|
||||
<div class="bg-light rounded-lg p-3 text-center">
|
||||
<div class="text-[10px] font-bold text-slate uppercase mb-1">{% trans "Result" %}</div>
|
||||
<div class="text-lg font-black {% if report.overall_result >= report.target_percentage %}text-green-600{% else %}text-red-600{% endif %}">
|
||||
{{ report.overall_result }}%
|
||||
|
||||
<!-- Title -->
|
||||
<h3 class="font-semibold text-navy mb-2 line-clamp-2 group-hover:text-blue transition-colors">{{ report.indicator_title }}</h3>
|
||||
|
||||
<!-- Hospital -->
|
||||
<p class="text-sm text-slate mb-4 flex items-center gap-1">
|
||||
<i data-lucide="building-2" class="w-3 h-3 inline"></i>
|
||||
{{ report.hospital.name }}
|
||||
</p>
|
||||
|
||||
<!-- Results -->
|
||||
<div class="grid grid-cols-3 gap-2 mb-4">
|
||||
<div class="bg-light rounded-lg p-3 text-center">
|
||||
<div class="text-[10px] font-bold text-slate uppercase mb-1">{% trans "Target" %}</div>
|
||||
<div class="text-lg font-black text-navy">{{ report.target_percentage }}%</div>
|
||||
</div>
|
||||
<div class="bg-light rounded-lg p-3 text-center">
|
||||
<div class="text-[10px] font-bold text-slate uppercase mb-1">{% trans "Result" %}</div>
|
||||
<div class="text-lg font-black {% if report.overall_result >= report.target_percentage %}text-green-600{% else %}text-red-600{% endif %}">
|
||||
{{ report.overall_result }}%
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-light rounded-lg p-3 text-center">
|
||||
<div class="text-[10px] font-bold text-slate uppercase mb-1">{% trans "Cases" %}</div>
|
||||
<div class="text-lg font-black text-navy">{{ report.total_denominator }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-light rounded-lg p-3 text-center">
|
||||
<div class="text-[10px] font-bold text-slate uppercase mb-1">{% trans "Cases" %}</div>
|
||||
<div class="text-lg font-black text-navy">{{ report.total_denominator }}</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2 pt-3 border-t opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onclick="event.stopPropagation(); window.location.href='{% url 'analytics:kpi_report_detail' report.id %}'"
|
||||
class="flex-1 btn-primary text-center text-sm flex items-center justify-center gap-2">
|
||||
<i data-lucide="eye" class="w-4 h-4"></i> {% trans "View" %}
|
||||
</button>
|
||||
<button onclick="event.stopPropagation(); window.location.href='{% url 'analytics:kpi_report_pdf' report.id %}'"
|
||||
class="btn-secondary px-3" title="{% trans 'Download PDF' %}">
|
||||
<i data-lucide="file-down" class="w-4 h-4"></i>
|
||||
</button>
|
||||
{% if report.status == 'failed' or report.status == 'pending' %}
|
||||
<form method="post" action="{% url 'analytics:kpi_report_regenerate' report.id %}"
|
||||
class="inline" onclick="event.stopPropagation(); return confirm('{% trans "Regenerate this report?" %}')">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn-secondary px-3" title="{% trans 'Regenerate' %}">
|
||||
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2 pt-3 border-t opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onclick="event.stopPropagation(); window.location.href='{% url 'analytics:kpi_report_detail' report.id %}'"
|
||||
class="flex-1 btn-primary text-center text-sm flex items-center justify-center gap-2">
|
||||
<i data-lucide="eye" class="w-4 h-4"></i> {% trans "View" %}
|
||||
</button>
|
||||
<button onclick="event.stopPropagation(); window.location.href='{% url 'analytics:kpi_report_pdf' report.id %}'"
|
||||
class="btn-secondary px-3" title="{% trans 'Download PDF' %}">
|
||||
<i data-lucide="file-down" class="w-4 h-4"></i>
|
||||
</button>
|
||||
{% if report.status == 'failed' or report.status == 'pending' %}
|
||||
<form method="post" action="{% url 'analytics:kpi_report_regenerate' report.id %}"
|
||||
class="inline" onclick="event.stopPropagation(); return confirm('{% trans "Regenerate this report?" %}')">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn-secondary px-3" title="{% trans 'Regenerate' %}">
|
||||
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if page_obj.has_other_pages %}
|
||||
<div class="bg-slate-50 px-8 py-4 flex items-center justify-between border-t mt-6 rounded-lg">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-xs text-slate font-medium">
|
||||
{% trans "Showing" %} <span class="font-bold text-navy">{{ page_obj.start_index }}-{{ page_obj.end_index }}</span> {% trans "of" %} <span class="font-bold text-navy">{{ page_obj.paginator.count }}</span> {% trans "entries" %}
|
||||
</span>
|
||||
<!-- Page Size Selector -->
|
||||
<form method="get" class="flex items-center gap-2" id="pageSizeForm">
|
||||
{% for key, value in request.GET.items %}
|
||||
{% if key != 'page_size' and key != 'page' %}
|
||||
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<label class="text-xs text-slate">{% trans "Show" %}</label>
|
||||
<select name="page_size" onchange="document.getElementById('pageSizeForm').submit()" class="px-2 py-1 bg-white border border-slate-200 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-navy">
|
||||
<option value="6" {% if page_obj.paginator.per_page == 6 %}selected{% endif %}>6</option>
|
||||
<option value="12" {% if page_obj.paginator.per_page == 12 %}selected{% endif %}>12</option>
|
||||
<option value="24" {% if page_obj.paginator.per_page == 24 %}selected{% endif %}>24</option>
|
||||
<option value="48" {% if page_obj.paginator.per_page == 48 %}selected{% endif %}>48</option>
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="?page={{ page_obj.previous_page_number }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg border bg-white hover:bg-slate-50 transition">
|
||||
<i data-lucide="chevron-left" class="w-4 h-4 text-slate"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="w-8 h-8 flex items-center justify-center rounded-lg border bg-slate-100 text-slate-300 cursor-not-allowed">
|
||||
<i data-lucide="chevron-left" class="w-4 h-4"></i>
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% for num in page_obj.paginator.page_range %}
|
||||
{% if num == page_obj.number %}
|
||||
<span class="w-8 h-8 flex items-center justify-center rounded-lg bg-navy text-white text-xs font-bold">{{ num }}</span>
|
||||
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
|
||||
<a href="?page={{ num }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg border bg-white hover:bg-slate-50 text-xs font-bold text-slate transition">
|
||||
{{ num }}
|
||||
</a>
|
||||
{% elif num == 1 or num == page_obj.paginator.num_pages %}
|
||||
<a href="?page={{ num }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg border bg-white hover:bg-slate-50 text-xs font-bold text-slate transition">
|
||||
{{ num }}
|
||||
</a>
|
||||
{% elif num == page_obj.number|add:'-3' or num == page_obj.number|add:'3' %}
|
||||
<span class="w-8 h-8 flex items-center justify-center text-xs text-slate">...</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?page={{ page_obj.next_page_number }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg border bg-white hover:bg-slate-50 transition">
|
||||
<i data-lucide="chevron-right" class="w-4 h-4 text-slate"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="w-8 h-8 flex items-center justify-center rounded-lg border bg-slate-100 text-slate-300 cursor-not-allowed">
|
||||
<i data-lucide="chevron-right" class="w-4 h-4"></i>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if page_obj.has_other_pages %}
|
||||
<div class="bg-slate-50 px-8 py-4 flex items-center justify-between border-t mt-6 rounded-lg">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-xs text-slate font-medium">
|
||||
{% trans "Showing" %} <span class="font-bold text-navy">{{ page_obj.start_index }}-{{ page_obj.end_index }}</span> {% trans "of" %} <span class="font-bold text-navy">{{ page_obj.paginator.count }}</span> {% trans "entries" %}
|
||||
</span>
|
||||
<!-- Page Size Selector -->
|
||||
<form method="get" class="flex items-center gap-2" id="pageSizeForm">
|
||||
{% for key, value in request.GET.items %}
|
||||
{% if key != 'page_size' and key != 'page' %}
|
||||
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<label class="text-xs text-slate">{% trans "Show" %}</label>
|
||||
<select name="page_size" onchange="document.getElementById('pageSizeForm').submit()" class="px-2 py-1 bg-white border border-slate-200 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-navy">
|
||||
<option value="6" {% if page_obj.paginator.per_page == 6 %}selected{% endif %}>6</option>
|
||||
<option value="12" {% if page_obj.paginator.per_page == 12 %}selected{% endif %}>12</option>
|
||||
<option value="24" {% if page_obj.paginator.per_page == 24 %}selected{% endif %}>24</option>
|
||||
<option value="48" {% if page_obj.paginator.per_page == 48 %}selected{% endif %}>48</option>
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="?page={{ page_obj.previous_page_number }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg border bg-white hover:bg-slate-50 transition">
|
||||
<i data-lucide="chevron-left" class="w-4 h-4 text-slate"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="w-8 h-8 flex items-center justify-center rounded-lg border bg-slate-100 text-slate-300 cursor-not-allowed">
|
||||
<i data-lucide="chevron-left" class="w-4 h-4"></i>
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% for num in page_obj.paginator.page_range %}
|
||||
{% if num == page_obj.number %}
|
||||
<span class="w-8 h-8 flex items-center justify-center rounded-lg bg-navy text-white text-xs font-bold">{{ num }}</span>
|
||||
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
|
||||
<a href="?page={{ num }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg border bg-white hover:bg-slate-50 text-xs font-bold text-slate transition">
|
||||
{{ num }}
|
||||
</a>
|
||||
{% elif num == 1 or num == page_obj.paginator.num_pages %}
|
||||
<a href="?page={{ num }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg border bg-white hover:bg-slate-50 text-xs font-bold text-slate transition">
|
||||
{{ num }}
|
||||
</a>
|
||||
{% elif num == page_obj.number|add:'-3' or num == page_obj.number|add:'3' %}
|
||||
<span class="w-8 h-8 flex items-center justify-center text-xs text-slate">...</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?page={{ page_obj.next_page_number }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg border bg-white hover:bg-slate-50 transition">
|
||||
<i data-lucide="chevron-right" class="w-4 h-4 text-slate"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="w-8 h-8 flex items-center justify-center rounded-lg border bg-slate-100 text-slate-300 cursor-not-allowed">
|
||||
<i data-lucide="chevron-right" class="w-4 h-4"></i>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card text-center py-16">
|
||||
<div class="section-card text-center py-16">
|
||||
<i data-lucide="bar-chart-3" class="w-20 h-20 mx-auto text-slate-300 mb-4"></i>
|
||||
<h3 class="text-lg font-bold text-navy mb-2">{% trans "No KPI Reports Found" %}</h3>
|
||||
<p class="text-slate mb-6">{% trans "Generate your first KPI report to get started." %}</p>
|
||||
@ -325,7 +358,14 @@
|
||||
<script>
|
||||
function toggleFilters() {
|
||||
const filters = document.getElementById('advancedFilters');
|
||||
const icon = document.getElementById('filterToggleIcon');
|
||||
filters.classList.toggle('hidden');
|
||||
if (filters.classList.contains('hidden')) {
|
||||
icon.setAttribute('data-lucide', 'chevron-down');
|
||||
} else {
|
||||
icon.setAttribute('data-lucide', 'chevron-up');
|
||||
}
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
// Search functionality
|
||||
@ -338,4 +378,4 @@ document.getElementById('searchInput')?.addEventListener('keypress', function(e)
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@ -3,29 +3,73 @@
|
||||
|
||||
{% block title %}{% trans "Appreciation" %} - PX360{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.page-header-gradient {
|
||||
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
|
||||
color: white;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.section-card:hover {
|
||||
border-color: #005696;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
background: linear-gradient(to right, #f8fafc, #f1f5f9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Header -->
|
||||
<header class="mb-6">
|
||||
<!-- Page Header Gradient -->
|
||||
<div class="page-header-gradient">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-navy flex items-center gap-3">
|
||||
<i data-lucide="heart" class="w-7 h-7 text-rose-500"></i>
|
||||
<h1 class="text-2xl font-bold flex items-center gap-3">
|
||||
<i data-lucide="heart" class="w-7 h-7"></i>
|
||||
{% trans "Appreciation" %}
|
||||
</h1>
|
||||
<p class="text-sm text-slate mt-1">{% trans "Send appreciation to colleagues and celebrate achievements" %}</p>
|
||||
<p class="text-sm opacity-90 mt-1">{% trans "Send appreciation to colleagues and celebrate achievements" %}</p>
|
||||
</div>
|
||||
<a href="{% url 'appreciation:appreciation_send' %}" class="bg-navy text-white px-5 py-2.5 rounded-xl text-sm font-bold shadow-lg shadow-navy/20 hover:bg-blue flex items-center gap-2 transition">
|
||||
<a href="{% url 'appreciation:appreciation_send' %}" class="bg-white text-navy px-5 py-2.5 rounded-xl text-sm font-bold shadow-lg hover:bg-gray-100 flex items-center gap-2 transition">
|
||||
<i data-lucide="send" class="w-4 h-4"></i> {% trans "Send Appreciation" %}
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-white p-5 rounded-2xl shadow-sm border border-slate-100">
|
||||
<div class="section-card p-5">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="p-3 bg-blue-50 rounded-xl">
|
||||
<i data-lucide="inbox" class="w-6 h-6 text-blue-500"></i>
|
||||
<div class="section-icon bg-blue-100">
|
||||
<i data-lucide="inbox" class="w-6 h-6 text-blue-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-bold text-slate uppercase tracking-wider mb-1">{% trans "Received" %}</p>
|
||||
@ -33,10 +77,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white p-5 rounded-2xl shadow-sm border border-slate-100">
|
||||
<div class="section-card p-5">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="p-3 bg-green-50 rounded-xl">
|
||||
<i data-lucide="send" class="w-6 h-6 text-green-500"></i>
|
||||
<div class="section-icon bg-green-100">
|
||||
<i data-lucide="send" class="w-6 h-6 text-green-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-bold text-slate uppercase tracking-wider mb-1">{% trans "Sent" %}</p>
|
||||
@ -44,10 +88,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white p-5 rounded-2xl shadow-sm border border-slate-100">
|
||||
<div class="section-card p-5">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="p-3 bg-amber-50 rounded-xl">
|
||||
<i data-lucide="award" class="w-6 h-6 text-amber-500"></i>
|
||||
<div class="section-icon bg-amber-100">
|
||||
<i data-lucide="award" class="w-6 h-6 text-amber-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-bold text-slate uppercase tracking-wider mb-1">{% trans "Badges Earned" %}</p>
|
||||
@ -55,10 +99,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white p-5 rounded-2xl shadow-sm border border-slate-100">
|
||||
<div class="section-card p-5">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="p-3 bg-purple-50 rounded-xl">
|
||||
<i data-lucide="trophy" class="w-6 h-6 text-purple-500"></i>
|
||||
<div class="section-icon bg-purple-100">
|
||||
<i data-lucide="trophy" class="w-6 h-6 text-purple-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-bold text-slate uppercase tracking-wider mb-1">{% trans "Leaderboard" %}</p>
|
||||
@ -73,10 +117,10 @@
|
||||
<!-- Quick Actions -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<a href="{% url 'appreciation:leaderboard_view' %}" class="group">
|
||||
<div class="bg-white p-5 rounded-2xl shadow-sm border border-amber-200 hover:border-amber-300 hover:shadow-md transition">
|
||||
<div class="section-card p-5 hover:border-amber-400">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="p-3 bg-amber-50 rounded-xl group-hover:bg-amber-100 transition">
|
||||
<i data-lucide="trophy" class="w-6 h-6 text-amber-500"></i>
|
||||
<div class="section-icon bg-amber-100 group-hover:bg-amber-200 transition">
|
||||
<i data-lucide="trophy" class="w-6 h-6 text-amber-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-navy">{% trans "Leaderboard" %}</h3>
|
||||
@ -86,10 +130,10 @@
|
||||
</div>
|
||||
</a>
|
||||
<a href="{% url 'appreciation:my_badges_view' %}" class="group">
|
||||
<div class="bg-white p-5 rounded-2xl shadow-sm border border-blue-200 hover:border-blue-300 hover:shadow-md transition">
|
||||
<div class="section-card p-5 hover:border-blue-400">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="p-3 bg-blue-50 rounded-xl group-hover:bg-blue-100 transition">
|
||||
<i data-lucide="award" class="w-6 h-6 text-blue-500"></i>
|
||||
<div class="section-icon bg-blue-100 group-hover:bg-blue-200 transition">
|
||||
<i data-lucide="award" class="w-6 h-6 text-blue-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-navy">{% trans "My Badges" %}</h3>
|
||||
@ -99,10 +143,10 @@
|
||||
</div>
|
||||
</a>
|
||||
<a href="{% url 'appreciation:appreciation_send' %}" class="group">
|
||||
<div class="bg-white p-5 rounded-2xl shadow-sm border border-green-200 hover:border-green-300 hover:shadow-md transition">
|
||||
<div class="section-card p-5 hover:border-green-400">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="p-3 bg-green-50 rounded-xl group-hover:bg-green-100 transition">
|
||||
<i data-lucide="send" class="w-6 h-6 text-green-500"></i>
|
||||
<div class="section-icon bg-green-100 group-hover:bg-green-200 transition">
|
||||
<i data-lucide="send" class="w-6 h-6 text-green-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-navy">{% trans "Send Appreciation" %}</h3>
|
||||
@ -114,12 +158,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border-2 border-slate-200 mb-6">
|
||||
<div class="px-6 py-4 border-b-2 border-slate-200 flex justify-between items-center bg-gradient-to-r from-slate-50 to-slate-100">
|
||||
<h3 class="font-bold text-navy flex items-center gap-2">
|
||||
<div class="section-card mb-6">
|
||||
<div class="section-header">
|
||||
<div class="section-icon bg-navy/10">
|
||||
<i data-lucide="filter" class="w-5 h-5 text-navy"></i>
|
||||
{% trans "Filters" %}
|
||||
</h3>
|
||||
</div>
|
||||
<h3 class="font-bold text-navy">{% trans "Filters" %}</h3>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<form method="get" class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
@ -180,12 +224,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Appreciations List -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border-2 border-slate-200 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b-2 border-slate-200 bg-gradient-to-r from-slate-50 to-slate-100">
|
||||
<h3 class="font-bold text-navy flex items-center gap-2">
|
||||
<i data-lucide="heart" class="w-5 h-5 text-rose-500"></i>
|
||||
{% trans "Appreciations" %}
|
||||
</h3>
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-icon bg-rose-100">
|
||||
<i data-lucide="heart" class="w-5 h-5 text-rose-600"></i>
|
||||
</div>
|
||||
<h3 class="font-bold text-navy">{% trans "Appreciations" %}</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
{% if page_obj %}
|
||||
|
||||
@ -3,233 +3,304 @@
|
||||
|
||||
{% block title %}{% trans "Send Appreciation" %} - {% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.page-header-gradient {
|
||||
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
|
||||
color: white;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
|
||||
}
|
||||
|
||||
.form-section {
|
||||
background: #fff;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.form-section:hover {
|
||||
border-color: #005696;
|
||||
box-shadow: 0 4px 12px rgba(0, 86, 150, 0.1);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-control, .form-select {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.form-control:focus, .form-select:focus {
|
||||
outline: none;
|
||||
border-color: #005696;
|
||||
box-shadow: 0 0 0 3px rgba(0, 86, 150, 0.1);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #005696;
|
||||
color: white;
|
||||
border-radius: 0.75rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: #007bbd;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: white;
|
||||
color: #64748b;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 0.75rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: #005696;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<div class="p-4">
|
||||
<!-- Breadcrumb -->
|
||||
<nav aria-label="breadcrumb" class="mb-4">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{% url 'appreciation:appreciation_list' %}">{% trans "Appreciation" %}</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{% trans "Send Appreciation" %}</li>
|
||||
<ol class="flex flex-wrap items-center gap-2 text-sm">
|
||||
<li><a href="{% url 'appreciation:appreciation_list' %}" class="text-blue-600 hover:text-blue-800">{% trans "Appreciation" %}</a></li>
|
||||
<li class="text-gray-400"><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
|
||||
<li class="text-gray-600 font-medium" aria-current="page">{% trans "Send Appreciation" %}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Send Appreciation Form -->
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0">
|
||||
<i class="bi-send me-2"></i>
|
||||
{% trans "Send Appreciation" %}
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" id="appreciationForm">
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- Recipient Type -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label for="recipient_type" class="form-label">
|
||||
{% trans "Recipient Type" %} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<select class="form-select" id="recipient_type" name="recipient_type" required>
|
||||
<option value="user">{% trans "User" %}</option>
|
||||
<option value="physician">{% trans "Physician" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="hospital_id" class="form-label">
|
||||
{% trans "Hospital" %} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<select class="form-select" id="hospital_id" name="hospital_id" required>
|
||||
<option value="">-- {% trans "Select Hospital" %} --</option>
|
||||
{% for hospital in hospitals %}
|
||||
<option value="{{ hospital.id }}">{{ hospital.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Header -->
|
||||
<div class="page-header-gradient">
|
||||
<h1 class="text-2xl font-bold flex items-center gap-3">
|
||||
<i data-lucide="send" class="w-6 h-6"></i>
|
||||
{% trans "Send Appreciation" %}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Recipient -->
|
||||
<div class="mb-3">
|
||||
<label for="recipient_id" class="form-label">
|
||||
{% trans "Recipient" %} <span class="text-danger">*</span>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Send Appreciation Form -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="form-section">
|
||||
<form method="post" id="appreciationForm">
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- Recipient Type -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label for="recipient_type" class="form-label">
|
||||
{% trans "Recipient Type" %} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select class="form-select" id="recipient_id" name="recipient_id" required disabled>
|
||||
<option value="">-- {% trans "Select Recipient" %} --</option>
|
||||
</select>
|
||||
<div class="form-text" id="recipientHelp">{% trans "Select a hospital first" %}</div>
|
||||
</div>
|
||||
|
||||
<!-- Department (Optional) -->
|
||||
<div class="mb-3">
|
||||
<label for="department_id" class="form-label">{% trans "Department" %}</label>
|
||||
<select class="form-select" id="department_id" name="department_id">
|
||||
<option value="">-- {% trans "Select Department" %} --</option>
|
||||
</select>
|
||||
<div class="form-text">{% trans "Optional: Select if related to a specific department" %}</div>
|
||||
</div>
|
||||
|
||||
<!-- Category -->
|
||||
<div class="mb-3">
|
||||
<label for="category_id" class="form-label">{% trans "Category" %}</label>
|
||||
<select class="form-select" id="category_id" name="category_id">
|
||||
<option value="">-- {% trans "Select Category" %} --</option>
|
||||
{% for category in categories %}
|
||||
<option value="{{ category.id }}">
|
||||
<i class="{{ category.icon }}"></i> {{ category.name_en }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
<select class="form-select" id="recipient_type" name="recipient_type" required>
|
||||
<option value="user">{% trans "User" %}</option>
|
||||
<option value="physician">{% trans "Physician" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Message (English) -->
|
||||
<div class="mb-3">
|
||||
<label for="message_en" class="form-label">
|
||||
{% trans "Message (English)" %} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
class="form-control"
|
||||
id="message_en"
|
||||
name="message_en"
|
||||
rows="4"
|
||||
required
|
||||
placeholder="{% trans 'Write your appreciation message here...' %}"
|
||||
></textarea>
|
||||
<div class="form-text">{% trans "Required: Appreciation message in English" %}</div>
|
||||
<div>
|
||||
<input type="hidden" name="hospital_id" value="{{ current_hospital.id }}">
|
||||
<label class="form-label">{% trans "Hospital" %}</label>
|
||||
<input type="text" class="form-control" value="{{ current_hospital.name }}" readonly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message (Arabic) -->
|
||||
<div class="mb-3">
|
||||
<label for="message_ar" class="form-label">{% trans "Message (Arabic)" %}</label>
|
||||
<textarea
|
||||
class="form-control"
|
||||
id="message_ar"
|
||||
name="message_ar"
|
||||
rows="4"
|
||||
dir="rtl"
|
||||
placeholder="{% trans 'اكتب رسالة التقدير هنا...' %}"
|
||||
></textarea>
|
||||
<div class="form-text">{% trans "Optional: Appreciation message in Arabic" %}</div>
|
||||
</div>
|
||||
<!-- Recipient -->
|
||||
<div class="mb-4">
|
||||
<label for="recipient_id" class="form-label">
|
||||
{% trans "Recipient" %} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select class="form-select" id="recipient_id" name="recipient_id" required disabled>
|
||||
<option value="">-- {% trans "Select Recipient" %} --</option>
|
||||
</select>
|
||||
<p class="text-sm text-gray-500 mt-1" id="recipientHelp">{% trans "Select a hospital first" %}</p>
|
||||
</div>
|
||||
|
||||
<!-- Visibility -->
|
||||
<div class="mb-3">
|
||||
<label for="visibility" class="form-label">{% trans "Visibility" %}</label>
|
||||
<select class="form-select" id="visibility" name="visibility">
|
||||
{% for choice in visibility_choices %}
|
||||
<option value="{{ choice.0 }}" {% if choice.0 == 'private' %}selected{% endif %}>
|
||||
{{ choice.1 }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<!-- Department (Optional) -->
|
||||
<div class="mb-4">
|
||||
<label for="department_id" class="form-label">{% trans "Department" %}</label>
|
||||
<select class="form-select" id="department_id" name="department_id">
|
||||
<option value="">-- {% trans "Select Department" %} --</option>
|
||||
</select>
|
||||
<p class="text-sm text-gray-500 mt-1">{% trans "Optional: Select if related to a specific department" %}</p>
|
||||
</div>
|
||||
|
||||
<!-- Anonymous -->
|
||||
<div class="mb-4">
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="is_anonymous"
|
||||
name="is_anonymous"
|
||||
>
|
||||
<label class="form-check-label" for="is_anonymous">
|
||||
<!-- Category -->
|
||||
<div class="mb-4">
|
||||
<label for="category_id" class="form-label">{% trans "Category" %}</label>
|
||||
<select class="form-select" id="category_id" name="category_id">
|
||||
<option value="">-- {% trans "Select Category" %} --</option>
|
||||
{% for category in categories %}
|
||||
<option value="{{ category.id }}">
|
||||
<i class="{{ category.icon }}"></i> {{ category.name_en }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Message (English) -->
|
||||
<div class="mb-4">
|
||||
<label for="message_en" class="form-label">
|
||||
{% trans "Message (English)" %} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
class="form-control"
|
||||
id="message_en"
|
||||
name="message_en"
|
||||
rows="4"
|
||||
required
|
||||
placeholder="{% trans 'Write your appreciation message here...' %}"
|
||||
></textarea>
|
||||
<p class="text-sm text-gray-500 mt-1">{% trans "Required: Appreciation message in English" %}</p>
|
||||
</div>
|
||||
|
||||
<!-- Message (Arabic) -->
|
||||
<div class="mb-4">
|
||||
<label for="message_ar" class="form-label">{% trans "Message (Arabic)" %}</label>
|
||||
<textarea
|
||||
class="form-control"
|
||||
id="message_ar"
|
||||
name="message_ar"
|
||||
rows="4"
|
||||
dir="rtl"
|
||||
placeholder="{% trans 'اكتب رسالة التقدير هنا...' %}"
|
||||
></textarea>
|
||||
<p class="text-sm text-gray-500 mt-1">{% trans "Optional: Appreciation message in Arabic" %}</p>
|
||||
</div>
|
||||
|
||||
<!-- Visibility -->
|
||||
<div class="mb-4">
|
||||
<label for="visibility" class="form-label">{% trans "Visibility" %}</label>
|
||||
<select class="form-select" id="visibility" name="visibility">
|
||||
{% for choice in visibility_choices %}
|
||||
<option value="{{ choice.0 }}" {% if choice.0 == 'private' %}selected{% endif %}>
|
||||
{{ choice.1 }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Anonymous -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<input
|
||||
class="mt-1 w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
type="checkbox"
|
||||
id="is_anonymous"
|
||||
name="is_anonymous"
|
||||
>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700" for="is_anonymous">
|
||||
{% trans "Send anonymously" %}
|
||||
</label>
|
||||
<div class="form-text">{% trans "Your name will not be shown to the recipient" %}</div>
|
||||
<p class="text-sm text-gray-500">{% trans "Your name will not be shown to the recipient" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||
<a href="{% url 'appreciation:appreciation_list' %}" class="btn btn-outline-secondary">
|
||||
<i class="bi-x me-2"></i>
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="bi-send me-2"></i>
|
||||
{% trans "Send Appreciation" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Submit Button -->
|
||||
<div class="flex flex-wrap justify-end gap-3">
|
||||
<a href="{% url 'appreciation:appreciation_list' %}" class="btn-secondary">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
<button type="submit" class="btn-primary">
|
||||
<i data-lucide="send" class="w-4 h-4"></i>
|
||||
{% trans "Send Appreciation" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="col-lg-4">
|
||||
<div class="lg:col-span-1">
|
||||
<!-- Tips -->
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-light">
|
||||
<h6 class="card-title mb-0">
|
||||
<i class="bi-lightbulb me-2"></i>
|
||||
{% trans "Tips for Writing Appreciation" %}
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="list-unstyled mb-0">
|
||||
<li class="mb-2">
|
||||
<i class="bi-check text-success me-2"></i>
|
||||
{% trans "Be specific about what you appreciate" %}
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi-check text-success me-2"></i>
|
||||
{% trans "Use the person's name when addressing them" %}
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi-check text-success me-2"></i>
|
||||
{% trans "Mention the impact of their actions" %}
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi-check text-success me-2"></i>
|
||||
{% trans "Be sincere and authentic" %}
|
||||
</li>
|
||||
<li>
|
||||
<i class="bi-check text-success me-2"></i>
|
||||
{% trans "Keep it positive and uplifting" %}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<h6 class="font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<i data-lucide="lightbulb" class="w-4 h-4 text-yellow-500"></i>
|
||||
{% trans "Tips for Writing Appreciation" %}
|
||||
</h6>
|
||||
<ul class="space-y-2 text-sm">
|
||||
<li class="flex items-start gap-2">
|
||||
<i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5"></i>
|
||||
{% trans "Be specific about what you appreciate" %}
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5"></i>
|
||||
{% trans "Use the person's name when addressing them" %}
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5"></i>
|
||||
{% trans "Mention the impact of their actions" %}
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5"></i>
|
||||
{% trans "Be sincere and authentic" %}
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5"></i>
|
||||
{% trans "Keep it positive and uplifting" %}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Visibility Guide -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light">
|
||||
<h6 class="card-title mb-0">
|
||||
<i class="bi-info-circle me-2"></i>
|
||||
{% trans "Visibility Levels" %}
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="list-unstyled mb-0">
|
||||
<li class="mb-2">
|
||||
<strong>{% trans "Private:" %}</strong>
|
||||
<p class="small text-muted mb-0">
|
||||
{% trans "Only you and the recipient can see this appreciation" %}
|
||||
</p>
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>{% trans "Department:" %}</strong>
|
||||
<p class="small text-muted mb-0">
|
||||
{% trans "Visible to everyone in the selected department" %}
|
||||
</p>
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>{% trans "Hospital:" %}</strong>
|
||||
<p class="small text-muted mb-0">
|
||||
{% trans "Visible to everyone in the selected hospital" %}
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<strong>{% trans "Public:" %}</strong>
|
||||
<p class="small text-muted mb-0">
|
||||
{% trans "Visible to all PX360 users" %}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<h6 class="font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<i data-lucide="info" class="w-4 h-4 text-blue-500"></i>
|
||||
{% trans "Visibility Levels" %}
|
||||
</h6>
|
||||
<ul class="space-y-3 text-sm">
|
||||
<li>
|
||||
<strong class="text-gray-800">{% trans "Private:" %}</strong>
|
||||
<p class="text-gray-500 text-xs">
|
||||
{% trans "Only you and the recipient can see this appreciation" %}
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<strong class="text-gray-800">{% trans "Department:" %}</strong>
|
||||
<p class="text-gray-500 text-xs">
|
||||
{% trans "Visible to everyone in the selected department" %}
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<strong class="text-gray-800">{% trans "Hospital:" %}</strong>
|
||||
<p class="text-gray-500 text-xs">
|
||||
{% trans "Visible to everyone in the selected hospital" %}
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<strong class="text-gray-800">{% trans "Public:" %}</strong>
|
||||
<p class="text-gray-500 text-xs">
|
||||
{% trans "Visible to all PX360 users" %}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -240,31 +311,26 @@
|
||||
{{ block.super }}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const hospitalSelect = document.getElementById('hospital_id');
|
||||
const recipientTypeSelect = document.getElementById('recipient_type');
|
||||
const recipientSelect = document.getElementById('recipient_id');
|
||||
const departmentSelect = document.getElementById('department_id');
|
||||
const recipientHelp = document.getElementById('recipientHelp');
|
||||
|
||||
const currentHospitalId = '{{ current_hospital.id|default:"" }}';
|
||||
let recipientData = [];
|
||||
|
||||
// Load recipients when hospital changes
|
||||
hospitalSelect.addEventListener('change', function() {
|
||||
const hospitalId = this.value;
|
||||
// Load recipients and departments on page load
|
||||
function loadRecipientsAndDepartments() {
|
||||
const hospitalId = currentHospitalId;
|
||||
const recipientType = recipientTypeSelect.value;
|
||||
|
||||
if (!hospitalId) return;
|
||||
|
||||
// Load recipients
|
||||
recipientSelect.disabled = true;
|
||||
recipientSelect.innerHTML = '<option value="">Loading...</option>';
|
||||
recipientHelp.textContent = 'Loading recipients...';
|
||||
|
||||
if (!hospitalId) {
|
||||
recipientSelect.innerHTML = '<option value="">-- Select Recipient --</option>';
|
||||
recipientSelect.disabled = true;
|
||||
recipientHelp.textContent = 'Select a hospital first';
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch recipients
|
||||
const url = recipientType === 'user'
|
||||
? "{% url 'appreciation:get_users_by_hospital' %}?hospital_id=" + hospitalId
|
||||
: "{% url 'appreciation:get_physicians_by_hospital' %}?hospital_id=" + hospitalId;
|
||||
@ -290,16 +356,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
recipientSelect.innerHTML = '<option value="">Error loading recipients</option>';
|
||||
recipientHelp.textContent = 'Error loading recipients';
|
||||
});
|
||||
});
|
||||
|
||||
// Load departments when hospital changes
|
||||
hospitalSelect.addEventListener('change', function() {
|
||||
const hospitalId = this.value;
|
||||
|
||||
// Load departments
|
||||
departmentSelect.innerHTML = '<option value="">-- Select Department --</option>';
|
||||
|
||||
if (!hospitalId) return;
|
||||
|
||||
fetch("{% url 'appreciation:get_departments_by_hospital' %}?hospital_id=" + hospitalId)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
@ -313,14 +373,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh recipients when recipient type changes
|
||||
recipientTypeSelect.addEventListener('change', function() {
|
||||
if (hospitalSelect.value) {
|
||||
hospitalSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
});
|
||||
// Load on page load
|
||||
loadRecipientsAndDepartments();
|
||||
|
||||
// Refresh when recipient type changes
|
||||
recipientTypeSelect.addEventListener('change', loadRecipientsAndDepartments);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -3,209 +3,285 @@
|
||||
|
||||
{% block title %}{% if form.instance.pk %}{% trans "Edit Badge" %}{% else %}{% trans "Add Badge" %}{% endif %} - {% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.page-header-gradient {
|
||||
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
|
||||
color: white;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
|
||||
}
|
||||
|
||||
.form-section {
|
||||
background: #fff;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.form-section:hover {
|
||||
border-color: #005696;
|
||||
box-shadow: 0 4px 12px rgba(0, 86, 150, 0.1);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-control, .form-select {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.form-control:focus, .form-select:focus {
|
||||
outline: none;
|
||||
border-color: #005696;
|
||||
box-shadow: 0 0 0 3px rgba(0, 86, 150, 0.1);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #005696;
|
||||
color: white;
|
||||
border-radius: 0.75rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: #007bbd;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: white;
|
||||
color: #64748b;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 0.75rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: #005696;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<div class="p-4">
|
||||
<!-- Breadcrumb -->
|
||||
<nav aria-label="breadcrumb" class="mb-4">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{% url 'appreciation:appreciation_list' %}">{% trans "Appreciation" %}</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'appreciation:badge_list' %}">{% trans "Badges" %}</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{% if form.instance.pk %}{% trans "Edit" %}{% else %}{% trans "Add" %}{% endif %}</li>
|
||||
<ol class="flex flex-wrap items-center gap-2 text-sm">
|
||||
<li><a href="{% url 'appreciation:appreciation_list' %}" class="text-blue-600 hover:text-blue-800">{% trans "Appreciation" %}</a></li>
|
||||
<li class="text-gray-400"><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
|
||||
<li><a href="{% url 'appreciation:badge_list' %}" class="text-blue-600 hover:text-blue-800">{% trans "Badges" %}</a></li>
|
||||
<li class="text-gray-400"><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
|
||||
<li class="text-gray-600 font-medium" aria-current="page">{% if form.instance.pk %}{% trans "Edit" %}{% else %}{% trans "Add" %}{% endif %}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Form -->
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-warning text-dark">
|
||||
<h4 class="mb-0">
|
||||
<i class="bi bi-trophy me-2"></i>
|
||||
{% if form.instance.pk %}{% trans "Edit Badge" %}{% else %}{% trans "Add Badge" %}{% endif %}
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- Name (English) -->
|
||||
<div class="mb-3">
|
||||
<label for="id_name_en" class="form-label">
|
||||
{% trans "Name (English)" %} <span class="text-danger">*</span>
|
||||
<!-- Header -->
|
||||
<div class="page-header-gradient">
|
||||
<h1 class="text-2xl font-bold flex items-center gap-3">
|
||||
<i data-lucide="trophy" class="w-6 h-6"></i>
|
||||
{% if form.instance.pk %}{% trans "Edit Badge" %}{% else %}{% trans "Add Badge" %}{% endif %}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Form -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="form-section">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- Name (English) -->
|
||||
<div class="mb-4">
|
||||
<label for="id_name_en" class="form-label">
|
||||
{% trans "Name (English)" %} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="id_name_en"
|
||||
name="name_en"
|
||||
value="{{ form.name_en.value|default:'' }}"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Name (Arabic) -->
|
||||
<div class="mb-4">
|
||||
<label for="id_name_ar" class="form-label">
|
||||
{% trans "Name (Arabic)" %} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="id_name_ar"
|
||||
name="name_ar"
|
||||
value="{{ form.name_ar.value|default:'' }}"
|
||||
dir="rtl"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Description (English) -->
|
||||
<div class="mb-4">
|
||||
<label for="id_description_en" class="form-label">{% trans "Description (English)" %}</label>
|
||||
<textarea
|
||||
class="form-control"
|
||||
id="id_description_en"
|
||||
name="description_en"
|
||||
rows="3"
|
||||
>{{ form.description_en.value|default:'' }}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Description (Arabic) -->
|
||||
<div class="mb-4">
|
||||
<label for="id_description_ar" class="form-label">{% trans "Description (Arabic)" %}</label>
|
||||
<textarea
|
||||
class="form-control"
|
||||
id="id_description_ar"
|
||||
name="description_ar"
|
||||
rows="3"
|
||||
dir="rtl"
|
||||
>{{ form.description_ar.value|default:'' }}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Icon -->
|
||||
<div class="mb-4">
|
||||
<label for="id_icon" class="form-label">{% trans "Icon" %}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="id_icon"
|
||||
name="icon"
|
||||
value="{{ form.icon.value|default:'fa-trophy' }}"
|
||||
placeholder="fa-trophy"
|
||||
>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
{% trans "FontAwesome icon class (e.g., fa-trophy, fa-star, fa-medal)" %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Criteria Type -->
|
||||
<div class="mb-4">
|
||||
<label for="id_criteria_type" class="form-label">{% trans "Criteria Type" %}</label>
|
||||
<select class="form-select" id="id_criteria_type" name="criteria_type">
|
||||
{% for choice in form.fields.criteria_type.choices %}
|
||||
<option value="{{ choice.0 }}" {% if choice.0 == form.criteria_type.value|stringformat:'s' or (not form.criteria_type.value and choice.0 == 'count') %}selected{% endif %}>
|
||||
{{ choice.1 }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Criteria Value -->
|
||||
<div class="mb-4">
|
||||
<label for="id_criteria_value" class="form-label">{% trans "Criteria Value" %}</label>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control"
|
||||
id="id_criteria_value"
|
||||
name="criteria_value"
|
||||
value="{{ form.criteria_value.value|default:'' }}"
|
||||
min="1"
|
||||
required
|
||||
>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
{% trans "Number of appreciations required to earn this badge" %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Is Active -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
type="checkbox"
|
||||
id="id_is_active"
|
||||
name="is_active"
|
||||
{% if form.is_active.value %}checked{% endif %}
|
||||
>
|
||||
<label class="text-sm font-medium text-gray-700" for="id_is_active">
|
||||
{% trans "Active" %}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="id_name_en"
|
||||
name="name_en"
|
||||
value="{{ form.name_en.value|default:'' }}"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Name (Arabic) -->
|
||||
<div class="mb-3">
|
||||
<label for="id_name_ar" class="form-label">
|
||||
{% trans "Name (Arabic)" %} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="id_name_ar"
|
||||
name="name_ar"
|
||||
value="{{ form.name_ar.value|default:'' }}"
|
||||
dir="rtl"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Description (English) -->
|
||||
<div class="mb-3">
|
||||
<label for="id_description_en" class="form-label">{% trans "Description (English)" %}</label>
|
||||
<textarea
|
||||
class="form-control"
|
||||
id="id_description_en"
|
||||
name="description_en"
|
||||
rows="3"
|
||||
>{{ form.description_en.value|default:'' }}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Description (Arabic) -->
|
||||
<div class="mb-3">
|
||||
<label for="id_description_ar" class="form-label">{% trans "Description (Arabic)" %}</label>
|
||||
<textarea
|
||||
class="form-control"
|
||||
id="id_description_ar"
|
||||
name="description_ar"
|
||||
rows="3"
|
||||
dir="rtl"
|
||||
>{{ form.description_ar.value|default:'' }}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Icon -->
|
||||
<div class="mb-3">
|
||||
<label for="id_icon" class="form-label">{% trans "Icon" %}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="id_icon"
|
||||
name="icon"
|
||||
value="{{ form.icon.value|default:'fa-trophy' }}"
|
||||
placeholder="fa-trophy"
|
||||
>
|
||||
<div class="form-text">
|
||||
{% trans "FontAwesome icon class (e.g., fa-trophy, fa-star, fa-medal)" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Criteria Type -->
|
||||
<div class="mb-3">
|
||||
<label for="id_criteria_type" class="form-label">{% trans "Criteria Type" %}</label>
|
||||
<select class="form-select" id="id_criteria_type" name="criteria_type">
|
||||
{% for choice in form.fields.criteria_type.choices %}
|
||||
<option value="{{ choice.0 }}" {% if choice.0 == form.criteria_type.value|stringformat:'s' or (not form.criteria_type.value and choice.0 == 'count') %}selected{% endif %}>
|
||||
{{ choice.1 }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Criteria Value -->
|
||||
<div class="mb-3">
|
||||
<label for="id_criteria_value" class="form-label">{% trans "Criteria Value" %}</label>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control"
|
||||
id="id_criteria_value"
|
||||
name="criteria_value"
|
||||
value="{{ form.criteria_value.value|default:'' }}"
|
||||
min="1"
|
||||
required
|
||||
>
|
||||
<div class="form-text">
|
||||
{% trans "Number of appreciations required to earn this badge" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Is Active -->
|
||||
<div class="mb-4">
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="id_is_active"
|
||||
name="is_active"
|
||||
{% if form.is_active.value %}checked{% endif %}
|
||||
>
|
||||
<label class="form-check-label" for="id_is_active">
|
||||
{% trans "Active" %}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{% url 'appreciation:badge_list' %}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x me-2"></i>
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-warning text-dark">
|
||||
<i class="bi bi-save me-2"></i>
|
||||
{% if form.instance.pk %}{% trans "Update" %}{% else %}{% trans "Create" %}{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Buttons -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<a href="{% url 'appreciation:badge_list' %}" class="btn-secondary">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
<button type="submit" class="btn-primary">
|
||||
<i data-lucide="save" class="w-4 h-4"></i>
|
||||
{% if form.instance.pk %}{% trans "Update" %}{% else %}{% trans "Create" %}{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="col-lg-4">
|
||||
<div class="lg:col-span-1">
|
||||
<!-- Icon Preview -->
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-light">
|
||||
<h6 class="card-title mb-0">{% trans "Badge Preview" %}</h6>
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
<div class="badge-icon-wrapper mb-3">
|
||||
<i id="icon-preview" class="{{ form.icon.value|default:'bi-trophy' }} fa-4x text-warning"></i>
|
||||
</div>
|
||||
<p id="name-preview" class="h5 mb-2">{{ form.name_en.value|default:'Badge Name' }}</p>
|
||||
<p class="small text-muted mb-0">
|
||||
<strong>{% trans "Requires" %}: </strong>
|
||||
<span id="criteria-preview">{{ form.criteria_value.value|default:0 }}</span>
|
||||
{% trans "appreciations" %}
|
||||
</p>
|
||||
<div class="form-section text-center">
|
||||
<h6 class="font-semibold text-gray-800 mb-4">{% trans "Badge Preview" %}</h6>
|
||||
<div class="mb-4">
|
||||
<i id="icon-preview" class="{{ form.icon.value|default:'bi-trophy' }} fa-4x text-yellow-500"></i>
|
||||
</div>
|
||||
<p id="name-preview" class="text-lg font-semibold mb-2">{{ form.name_en.value|default:'Badge Name' }}</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
<strong>{% trans "Requires" %}: </strong>
|
||||
<span id="criteria-preview">{{ form.criteria_value.value|default:0 }}</span>
|
||||
{% trans "appreciations" %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Criteria Information -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light">
|
||||
<h6 class="card-title mb-0">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
{% trans "About Badge Criteria" %}
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="list-unstyled mb-0 small">
|
||||
<li class="mb-2">
|
||||
<strong>{% trans "Count:" %}</strong>
|
||||
<p class="mb-0 text-muted">
|
||||
{% trans "Badge is earned after receiving the specified number of appreciations" %}
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<strong>{% trans "Tips:" %}</strong>
|
||||
<ul class="mb-0 ps-3 text-muted">
|
||||
<li>{% trans "Set achievable criteria to encourage participation" %}</li>
|
||||
<li>{% trans "Use descriptive names and icons" %}</li>
|
||||
<li>{% trans "Create badges for different achievement levels" %}</li>
|
||||
<li>{% trans "Deactivate badges instead of deleting to preserve history" %}</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<h6 class="font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<i data-lucide="info" class="w-4 h-4 text-blue-500"></i>
|
||||
{% trans "About Badge Criteria" %}
|
||||
</h6>
|
||||
<ul class="space-y-3 text-sm">
|
||||
<li>
|
||||
<strong class="text-gray-800">{% trans "Count:" %}</strong>
|
||||
<p class="text-gray-500 text-xs">
|
||||
{% trans "Badge is earned after receiving the specified number of appreciations" %}
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<strong class="text-gray-800">{% trans "Tips:" %}</strong>
|
||||
<ul class="text-gray-500 text-xs mt-1 space-y-1">
|
||||
<li>{% trans "Set achievable criteria to encourage participation" %}</li>
|
||||
<li>{% trans "Use descriptive names and icons" %}</li>
|
||||
<li>{% trans "Create badges for different achievement levels" %}</li>
|
||||
<li>{% trans "Deactivate badges instead of deleting to preserve history" %}</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,137 +1,202 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% load i18n static %}
|
||||
|
||||
{% block title %}{% trans "Appreciation Badges" %} - {% endblock %}
|
||||
{% block title %}{% trans "Appreciation Badges" %} - PX360{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.page-header-gradient {
|
||||
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
|
||||
color: white;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.section-card:hover {
|
||||
border-color: #005696;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
background: linear-gradient(to right, #f8fafc, #f1f5f9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.badge-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.badge-card:hover {
|
||||
border-color: #f59e0b;
|
||||
box-shadow: 0 10px 25px -5px rgba(245, 158, 11, 0.2);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<!-- Breadcrumb -->
|
||||
<nav aria-label="breadcrumb" class="mb-4">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{% url 'appreciation:appreciation_list' %}">{% trans "Appreciation" %}</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{% trans "Badges" %}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>
|
||||
<i class="bi-award text-warning me-2"></i>
|
||||
{% trans "Appreciation Badges" %}
|
||||
</h2>
|
||||
<a href="{% url 'appreciation:badge_create' %}" class="btn btn-primary">
|
||||
<i class="bi-plus me-2"></i>
|
||||
{% trans "Add Badge" %}
|
||||
<!-- Page Header Gradient -->
|
||||
<div class="page-header-gradient">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold flex items-center gap-3">
|
||||
<i data-lucide="award" class="w-7 h-7"></i>
|
||||
{% trans "Appreciation Badges" %}
|
||||
</h1>
|
||||
<p class="text-sm opacity-90 mt-1">{% trans "Create and manage badges to recognize achievements" %}</p>
|
||||
</div>
|
||||
<a href="{% url 'appreciation:badge_create' %}" class="bg-white text-navy px-5 py-2.5 rounded-xl text-sm font-bold shadow-lg hover:bg-gray-100 flex items-center gap-2 transition">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "Add Badge" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Badges List -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
{% if badges %}
|
||||
<div class="row">
|
||||
{% for badge in badges %}
|
||||
<div class="col-md-6 col-lg-4 mb-4">
|
||||
<div class="card h-100 border-2 border-warning">
|
||||
<div class="card-body">
|
||||
<div class="text-center mb-3">
|
||||
<i class="{{ badge.icon }} fa-4x text-warning"></i>
|
||||
</div>
|
||||
<h5 class="card-title text-center">{{ badge.name_en }}</h5>
|
||||
<p class="card-text small text-muted text-center">
|
||||
{{ badge.description_en|truncatewords:10 }}
|
||||
</p>
|
||||
<hr>
|
||||
<ul class="list-unstyled small mb-3">
|
||||
<li class="mb-2">
|
||||
<strong>{% trans "Type:" %}</strong>
|
||||
{{ badge.get_criteria_type_display }}
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>{% trans "Value:" %}</strong>
|
||||
{{ badge.criteria_value }}
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>{% trans "Earned:" %}</strong>
|
||||
{{ badge.earned_count }} {% trans "times" %}
|
||||
</li>
|
||||
<li>
|
||||
<strong>{% trans "Status:" %}</strong>
|
||||
{% if badge.is_active %}
|
||||
<span class="badge bg-success">{% trans "Active" %}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{% trans "Inactive" %}</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
</ul>
|
||||
<div class="d-grid gap-2">
|
||||
<a href="{% url 'appreciation:badge_edit' badge.id %}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi-pencil me-2"></i>{% trans "Edit" %}
|
||||
</a>
|
||||
<a href="{% url 'appreciation:badge_delete' badge.id %}" class="btn btn-sm btn-outline-danger">
|
||||
<i class="bi-trash me-2"></i>{% trans "Delete" %}
|
||||
</a>
|
||||
</div>
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="mb-6 text-sm">
|
||||
<ol class="flex items-center gap-2 text-slate">
|
||||
<li><a href="{% url 'appreciation:appreciation_list' %}" class="hover:text-navy transition">{% trans "Appreciation" %}</a></li>
|
||||
<li><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
|
||||
<li class="text-navy font-medium">{% trans "Badges" %}</li>
|
||||
</nav>
|
||||
</nav>
|
||||
|
||||
<!-- Badges List -->
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-icon bg-amber-100">
|
||||
<i data-lucide="award" class="w-5 h-5 text-amber-600"></i>
|
||||
</div>
|
||||
<h3 class="font-bold text-navy">{% trans "All Badges" %}</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
{% if badges %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{% for badge in badges %}
|
||||
<div class="badge-card h-full flex flex-col">
|
||||
<div class="p-6 flex-1">
|
||||
<div class="text-center mb-4">
|
||||
<div class="w-20 h-20 bg-amber-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i data-lucide="{{ badge.icon|default:'award' }}" class="w-10 h-10 text-amber-600"></i>
|
||||
</div>
|
||||
<h5 class="font-bold text-navy text-lg">{{ badge.name_en }}</h5>
|
||||
<p class="text-sm text-slate mt-2">{{ badge.description_en|truncatewords:10 }}</p>
|
||||
</div>
|
||||
<hr class="border-slate-200 my-4">
|
||||
<ul class="space-y-2 text-sm">
|
||||
<li class="flex justify-between">
|
||||
<span class="text-slate">{% trans "Type:" %}</span>
|
||||
<span class="font-medium text-navy">{{ badge.get_criteria_type_display }}</span>
|
||||
</li>
|
||||
<li class="flex justify-between">
|
||||
<span class="text-slate">{% trans "Value:" %}</span>
|
||||
<span class="font-medium text-navy">{{ badge.criteria_value }}</span>
|
||||
</li>
|
||||
<li class="flex justify-between">
|
||||
<span class="text-slate">{% trans "Earned:" %}</span>
|
||||
<span class="font-medium text-navy">{{ badge.earned_count }} {% trans "times" %}</span>
|
||||
</li>
|
||||
<li class="flex justify-between items-center">
|
||||
<span class="text-slate">{% trans "Status:" %}</span>
|
||||
{% if badge.is_active %}
|
||||
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-bold bg-green-100 text-green-700">
|
||||
{% trans "Active" %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-bold bg-gray-100 text-gray-700">
|
||||
{% trans "Inactive" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="p-4 border-t border-slate-200 bg-slate-50 flex gap-2">
|
||||
<a href="{% url 'appreciation:badge_edit' badge.id %}" class="flex-1 bg-white border border-navy text-navy px-4 py-2 rounded-lg text-sm font-medium hover:bg-navy hover:text-white transition flex items-center justify-center gap-2">
|
||||
<i data-lucide="pencil" class="w-4 h-4"></i> {% trans "Edit" %}
|
||||
</a>
|
||||
<a href="{% url 'appreciation:badge_delete' badge.id %}" class="flex-1 bg-white border border-red-500 text-red-500 px-4 py-2 rounded-lg text-sm font-medium hover:bg-red-500 hover:text-white transition flex items-center justify-center gap-2">
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i> {% trans "Delete" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if page_obj.has_other_pages %}
|
||||
<nav aria-label="Page navigation" class="mt-4">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">
|
||||
{% trans "Previous" %}
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">{% trans "Previous" %}</span>
|
||||
</li>
|
||||
<!-- Pagination -->
|
||||
{% if page_obj.has_other_pages %}
|
||||
<div class="mt-8 pt-6 border-t border-slate-100 flex justify-center">
|
||||
<div class="flex items-center gap-2">
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="?page={{ page_obj.previous_page_number }}"
|
||||
class="px-3 py-2 rounded-lg border border-slate-200 text-slate hover:bg-light transition">
|
||||
{% trans "Previous" %}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="px-3 py-2 rounded-lg border border-slate-200 text-gray-300 cursor-not-allowed">
|
||||
{% trans "Previous" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% for num in page_obj.paginator.page_range %}
|
||||
{% if page_obj.number == num %}
|
||||
<span class="px-4 py-2 rounded-lg bg-navy text-white font-bold">{{ num }}</span>
|
||||
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
|
||||
<a href="?page={{ num }}"
|
||||
class="px-4 py-2 rounded-lg border border-slate-200 text-slate hover:bg-light transition">
|
||||
{{ num }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% for num in page_obj.paginator.page_range %}
|
||||
{% if page_obj.number == num %}
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ num }}</span>
|
||||
</li>
|
||||
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ num }}">{{ num }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}">
|
||||
{% trans "Next" %}
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">{% trans "Next" %}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi-award fa-4x text-muted mb-3"></i>
|
||||
<h4 class="text-muted">{% trans "No badges found" %}</h4>
|
||||
<p class="text-muted mb-3">{% trans "Create badges to motivate and recognize achievements" %}</p>
|
||||
<a href="{% url 'appreciation:badge_create' %}" class="btn btn-primary">
|
||||
<i class="bi-plus me-2"></i>
|
||||
{% trans "Add Badge" %}
|
||||
</a>
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?page={{ page_obj.next_page_number }}"
|
||||
class="px-3 py-2 rounded-lg border border-slate-200 text-slate hover:bg-light transition">
|
||||
{% trans "Next" %}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="px-3 py-2 rounded-lg border border-slate-200 text-gray-300 cursor-not-allowed">
|
||||
{% trans "Next" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-12">
|
||||
<div class="w-16 h-16 bg-amber-50 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i data-lucide="award" class="w-8 h-8 text-amber-400"></i>
|
||||
</div>
|
||||
<h4 class="text-lg font-bold text-navy mb-2">{% trans "No badges found" %}</h4>
|
||||
<p class="text-slate mb-4">{% trans "Create badges to motivate and recognize achievements" %}</p>
|
||||
<a href="{% url 'appreciation:badge_create' %}" class="bg-navy text-white px-5 py-2.5 rounded-xl text-sm font-bold hover:bg-blue transition inline-flex items-center gap-2">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "Add Badge" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@ -3,186 +3,262 @@
|
||||
|
||||
{% block title %}{% if form.instance.pk %}{% trans "Edit Category" %}{% else %}{% trans "Add Category" %}{% endif %} - {% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.page-header-gradient {
|
||||
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
|
||||
color: white;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
|
||||
}
|
||||
|
||||
.form-section {
|
||||
background: #fff;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.form-section:hover {
|
||||
border-color: #005696;
|
||||
box-shadow: 0 4px 12px rgba(0, 86, 150, 0.1);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-control, .form-select {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.form-control:focus, .form-select:focus {
|
||||
outline: none;
|
||||
border-color: #005696;
|
||||
box-shadow: 0 0 0 3px rgba(0, 86, 150, 0.1);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #005696;
|
||||
color: white;
|
||||
border-radius: 0.75rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: #007bbd;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: white;
|
||||
color: #64748b;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 0.75rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: #005696;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<div class="p-4">
|
||||
<!-- Breadcrumb -->
|
||||
<nav aria-label="breadcrumb" class="mb-4">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{% url 'appreciation:appreciation_list' %}">{% trans "Appreciation" %}</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'appreciation:category_list' %}">{% trans "Categories" %}</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{% if form.instance.pk %}{% trans "Edit" %}{% else %}{% trans "Add" %}{% endif %}</li>
|
||||
<ol class="flex flex-wrap items-center gap-2 text-sm">
|
||||
<li><a href="{% url 'appreciation:appreciation_list' %}" class="text-blue-600 hover:text-blue-800">{% trans "Appreciation" %}</a></li>
|
||||
<li class="text-gray-400"><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
|
||||
<li><a href="{% url 'appreciation:category_list' %}" class="text-blue-600 hover:text-blue-800">{% trans "Categories" %}</a></li>
|
||||
<li class="text-gray-400"><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
|
||||
<li class="text-gray-600 font-medium" aria-current="page">{% if form.instance.pk %}{% trans "Edit" %}{% else %}{% trans "Add" %}{% endif %}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Form -->
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0">
|
||||
<i class="bi bi-tag me-2"></i>
|
||||
{% if form.instance.pk %}{% trans "Edit Category" %}{% else %}{% trans "Add Category" %}{% endif %}
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- Name (English) -->
|
||||
<div class="mb-3">
|
||||
<label for="id_name_en" class="form-label">
|
||||
{% trans "Name (English)" %} <span class="text-danger">*</span>
|
||||
<!-- Header -->
|
||||
<div class="page-header-gradient">
|
||||
<h1 class="text-2xl font-bold flex items-center gap-3">
|
||||
<i data-lucide="tag" class="w-6 h-6"></i>
|
||||
{% if form.instance.pk %}{% trans "Edit Category" %}{% else %}{% trans "Add Category" %}{% endif %}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Form -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="form-section">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- Name (English) -->
|
||||
<div class="mb-4">
|
||||
<label for="id_name_en" class="form-label">
|
||||
{% trans "Name (English)" %} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="id_name_en"
|
||||
name="name_en"
|
||||
value="{{ form.name_en.value|default:'' }}"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Name (Arabic) -->
|
||||
<div class="mb-4">
|
||||
<label for="id_name_ar" class="form-label">
|
||||
{% trans "Name (Arabic)" %} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="id_name_ar"
|
||||
name="name_ar"
|
||||
value="{{ form.name_ar.value|default:'' }}"
|
||||
dir="rtl"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Description (English) -->
|
||||
<div class="mb-4">
|
||||
<label for="id_description_en" class="form-label">{% trans "Description (English)" %}</label>
|
||||
<textarea
|
||||
class="form-control"
|
||||
id="id_description_en"
|
||||
name="description_en"
|
||||
rows="3"
|
||||
>{{ form.description_en.value|default:'' }}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Description (Arabic) -->
|
||||
<div class="mb-4">
|
||||
<label for="id_description_ar" class="form-label">{% trans "Description (Arabic)" %}</label>
|
||||
<textarea
|
||||
class="form-control"
|
||||
id="id_description_ar"
|
||||
name="description_ar"
|
||||
rows="3"
|
||||
dir="rtl"
|
||||
>{{ form.description_ar.value|default:'' }}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Icon -->
|
||||
<div class="mb-4">
|
||||
<label for="id_icon" class="form-label">{% trans "Icon" %}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="id_icon"
|
||||
name="icon"
|
||||
value="{{ form.icon.value|default:'fa-heart' }}"
|
||||
placeholder="fa-heart"
|
||||
>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
{% trans "FontAwesome icon class (e.g., fa-heart, fa-star, fa-thumbs-up)" %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Color -->
|
||||
<div class="mb-4">
|
||||
<label for="id_color" class="form-label">{% trans "Color" %}</label>
|
||||
<select class="form-select" id="id_color" name="color">
|
||||
{% for choice in form.fields.color.choices %}
|
||||
<option value="{{ choice.0 }}" {% if choice.0 == form.color.value|stringformat:'s' or (not form.color.value and choice.0 == 'primary') %}selected{% endif %}>
|
||||
{{ choice.1 }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Is Active -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
type="checkbox"
|
||||
id="id_is_active"
|
||||
name="is_active"
|
||||
{% if form.is_active.value %}checked{% endif %}
|
||||
>
|
||||
<label class="text-sm font-medium text-gray-700" for="id_is_active">
|
||||
{% trans "Active" %}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="id_name_en"
|
||||
name="name_en"
|
||||
value="{{ form.name_en.value|default:'' }}"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Name (Arabic) -->
|
||||
<div class="mb-3">
|
||||
<label for="id_name_ar" class="form-label">
|
||||
{% trans "Name (Arabic)" %} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="id_name_ar"
|
||||
name="name_ar"
|
||||
value="{{ form.name_ar.value|default:'' }}"
|
||||
dir="rtl"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Description (English) -->
|
||||
<div class="mb-3">
|
||||
<label for="id_description_en" class="form-label">{% trans "Description (English)" %}</label>
|
||||
<textarea
|
||||
class="form-control"
|
||||
id="id_description_en"
|
||||
name="description_en"
|
||||
rows="3"
|
||||
>{{ form.description_en.value|default:'' }}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Description (Arabic) -->
|
||||
<div class="mb-3">
|
||||
<label for="id_description_ar" class="form-label">{% trans "Description (Arabic)" %}</label>
|
||||
<textarea
|
||||
class="form-control"
|
||||
id="id_description_ar"
|
||||
name="description_ar"
|
||||
rows="3"
|
||||
dir="rtl"
|
||||
>{{ form.description_ar.value|default:'' }}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Icon -->
|
||||
<div class="mb-3">
|
||||
<label for="id_icon" class="form-label">{% trans "Icon" %}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="id_icon"
|
||||
name="icon"
|
||||
value="{{ form.icon.value|default:'fa-heart' }}"
|
||||
placeholder="fa-heart"
|
||||
>
|
||||
<div class="form-text">
|
||||
{% trans "FontAwesome icon class (e.g., fa-heart, fa-star, fa-thumbs-up)" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Color -->
|
||||
<div class="mb-3">
|
||||
<label for="id_color" class="form-label">{% trans "Color" %}</label>
|
||||
<select class="form-select" id="id_color" name="color">
|
||||
{% for choice in form.fields.color.choices %}
|
||||
<option value="{{ choice.0 }}" {% if choice.0 == form.color.value|stringformat:'s' or (not form.color.value and choice.0 == 'primary') %}selected{% endif %}>
|
||||
{{ choice.1 }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Is Active -->
|
||||
<div class="mb-4">
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="id_is_active"
|
||||
name="is_active"
|
||||
{% if form.is_active.value %}checked{% endif %}
|
||||
>
|
||||
<label class="form-check-label" for="id_is_active">
|
||||
{% trans "Active" %}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{% url 'appreciation:category_list' %}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x me-2"></i>
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-save me-2"></i>
|
||||
{% if form.instance.pk %}{% trans "Update" %}{% else %}{% trans "Create" %}{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Buttons -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<a href="{% url 'appreciation:category_list' %}" class="btn-secondary">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
<button type="submit" class="btn-primary">
|
||||
<i data-lucide="save" class="w-4 h-4"></i>
|
||||
{% if form.instance.pk %}{% trans "Update" %}{% else %}{% trans "Create" %}{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="col-lg-4">
|
||||
<div class="lg:col-span-1">
|
||||
<!-- Icon Preview -->
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-light">
|
||||
<h6 class="card-title mb-0">{% trans "Icon Preview" %}</h6>
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
<i id="icon-preview" class="{{ form.icon.value|default:'bi-heart' }} fa-4x text-primary mb-3"></i>
|
||||
<p id="name-preview" class="h5 mb-0">{{ form.name_en.value|default:'Category Name' }}</p>
|
||||
</div>
|
||||
<div class="form-section text-center">
|
||||
<h6 class="font-semibold text-gray-800 mb-4">{% trans "Icon Preview" %}</h6>
|
||||
<i id="icon-preview" class="{{ form.icon.value|default:'bi-heart' }} fa-4x text-blue-600 mb-4"></i>
|
||||
<p id="name-preview" class="text-lg font-semibold">{{ form.name_en.value|default:'Category Name' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Tips -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light">
|
||||
<h6 class="card-title mb-0">
|
||||
<i class="bi bi-lightbulb me-2"></i>
|
||||
{% trans "Tips" %}
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="list-unstyled mb-0 small">
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-check text-success me-2"></i>
|
||||
{% trans "Use descriptive names for categories" %}
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-check text-success me-2"></i>
|
||||
{% trans "Choose appropriate icons for each category" %}
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-check text-success me-2"></i>
|
||||
{% trans "Colors help users quickly identify categories" %}
|
||||
</li>
|
||||
<li>
|
||||
<i class="bi bi-check text-success me-2"></i>
|
||||
{% trans "Deactivate unused categories instead of deleting" %}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<h6 class="font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<i data-lucide="lightbulb" class="w-4 h-4 text-yellow-500"></i>
|
||||
{% trans "Tips" %}
|
||||
</h6>
|
||||
<ul class="space-y-2 text-sm">
|
||||
<li class="flex items-start gap-2">
|
||||
<i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5"></i>
|
||||
{% trans "Use descriptive names for categories" %}
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5"></i>
|
||||
{% trans "Choose appropriate icons for each category" %}
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5"></i>
|
||||
{% trans "Colors help users quickly identify categories" %}
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5"></i>
|
||||
{% trans "Deactivate unused categories instead of deleting" %}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,85 +1,179 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% load i18n static %}
|
||||
|
||||
{% block title %}{% trans "Appreciation Categories" %} - {% endblock %}
|
||||
{% block title %}{% trans "Appreciation Categories" %} - PX360{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.page-header-gradient {
|
||||
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
|
||||
color: white;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.section-card:hover {
|
||||
border-color: #005696;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
background: linear-gradient(to right, #f8fafc, #f1f5f9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.category-table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
.category-table th {
|
||||
padding: 0.875rem 1.5rem;
|
||||
text-align: left;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
background: #f8fafc;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
}
|
||||
.category-table td {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.category-table tr:hover td {
|
||||
background: #f8fafc;
|
||||
}
|
||||
.category-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<!-- Breadcrumb -->
|
||||
<nav aria-label="breadcrumb" class="mb-4">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{% url 'appreciation:appreciation_list' %}">{% trans "Appreciation" %}</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{% trans "Categories" %}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>
|
||||
<i class="bi bi-tags text-primary me-2"></i>
|
||||
{% trans "Appreciation Categories" %}
|
||||
</h2>
|
||||
<a href="{% url 'appreciation:category_create' %}" class="btn btn-primary">
|
||||
<i class="bi-plus me-2"></i>
|
||||
{% trans "Add Category" %}
|
||||
<!-- Page Header Gradient -->
|
||||
<div class="page-header-gradient">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold flex items-center gap-3">
|
||||
<i data-lucide="tags" class="w-7 h-7"></i>
|
||||
{% trans "Appreciation Categories" %}
|
||||
</h1>
|
||||
<p class="text-sm opacity-90 mt-1">{% trans "Manage categories to organize appreciations" %}</p>
|
||||
</div>
|
||||
<a href="{% url 'appreciation:category_create' %}" class="bg-white text-navy px-5 py-2.5 rounded-xl text-sm font-bold shadow-lg hover:bg-gray-100 flex items-center gap-2 transition">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "Add Category" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Categories List -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
{% if categories %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>{% trans "Icon" %}</th>
|
||||
<th>{% trans "Name (English)" %}</th>
|
||||
<th>{% trans "Name (Arabic)" %}</th>
|
||||
<th>{% trans "Color" %}</th>
|
||||
<th>{% trans "Count" %}</th>
|
||||
<th class="text-center">{% trans "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for category in categories %}
|
||||
<tr>
|
||||
<td>
|
||||
<i class="{{ category.icon }} fa-2x text-primary"></i>
|
||||
</td>
|
||||
<td>{{ category.name_en }}</td>
|
||||
<td dir="rtl">{{ category.name_ar }}</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ category.color }}">
|
||||
{{ category.get_color_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ category.appreciation_count }}</td>
|
||||
<td class="text-center">
|
||||
<a href="{% url 'appreciation:category_edit' category.id %}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi-pencil"></i>
|
||||
</a>
|
||||
<a href="{% url 'appreciation:category_delete' category.id %}" class="btn btn-sm btn-outline-danger">
|
||||
<i class="bi-trash"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-tags fa-4x text-muted mb-3"></i>
|
||||
<h4 class="text-muted">{% trans "No categories found" %}</h4>
|
||||
<p class="text-muted mb-3">{% trans "Create categories to organize appreciations" %}</p>
|
||||
<a href="{% url 'appreciation:category_create' %}" class="btn btn-primary">
|
||||
<i class="bi-plus me-2"></i>
|
||||
{% trans "Add Category" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="mb-6 text-sm">
|
||||
<ol class="flex items-center gap-2 text-slate">
|
||||
<li><a href="{% url 'appreciation:appreciation_list' %}" class="hover:text-navy transition">{% trans "Appreciation" %}</a></li>
|
||||
<li><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
|
||||
<li class="text-navy font-medium">{% trans "Categories" %}</li>
|
||||
</nav>
|
||||
</nav>
|
||||
|
||||
<!-- Categories List -->
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-icon bg-blue-100">
|
||||
<i data-lucide="tags" class="w-5 h-5 text-blue-600"></i>
|
||||
</div>
|
||||
<h3 class="font-bold text-navy">{% trans "All Categories" %}</h3>
|
||||
</div>
|
||||
<div class="p-0">
|
||||
{% if categories %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="category-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Icon" %}</th>
|
||||
<th>{% trans "Name (English)" %}</th>
|
||||
<th>{% trans "Name (Arabic)" %}</th>
|
||||
<th>{% trans "Color" %}</th>
|
||||
<th>{% trans "Count" %}</th>
|
||||
<th class="text-center">{% trans "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for category in categories %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<i data-lucide="{{ category.icon|default:'tag' }}" class="w-5 h-5 text-blue-600"></i>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="font-medium text-navy">{{ category.name_en }}</span>
|
||||
</td>
|
||||
<td dir="rtl">
|
||||
<span class="font-medium text-navy">{{ category.name_ar }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-bold bg-{{ category.color }}-100 text-{{ category.color }}-700">
|
||||
{{ category.get_color_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="inline-flex items-center justify-center w-8 h-8 bg-slate-100 rounded-full text-sm font-bold text-slate">
|
||||
{{ category.appreciation_count }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<a href="{% url 'appreciation:category_edit' category.id %}" class="inline-flex items-center justify-center w-9 h-9 border border-navy text-navy rounded-lg hover:bg-navy hover:text-white transition">
|
||||
<i data-lucide="pencil" class="w-4 h-4"></i>
|
||||
</a>
|
||||
<a href="{% url 'appreciation:category_delete' category.id %}" class="inline-flex items-center justify-center w-9 h-9 border border-red-500 text-red-500 rounded-lg hover:bg-red-500 hover:text-white transition">
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-12">
|
||||
<div class="w-16 h-16 bg-blue-50 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i data-lucide="tags" class="w-8 h-8 text-blue-400"></i>
|
||||
</div>
|
||||
<h4 class="text-lg font-bold text-navy mb-2">{% trans "No categories found" %}</h4>
|
||||
<p class="text-slate mb-4">{% trans "Create categories to organize appreciations" %}</p>
|
||||
<a href="{% url 'appreciation:category_create' %}" class="bg-navy text-white px-5 py-2.5 rounded-xl text-sm font-bold hover:bg-blue transition inline-flex items-center gap-2">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "Add Category" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@ -47,17 +47,6 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="hospital" class="form-label">{% trans "Hospital" %}</label>
|
||||
<select class="form-select" id="hospital" name="hospital">
|
||||
<option value="">All Hospitals</option>
|
||||
{% for hospital in hospitals %}
|
||||
<option value="{{ hospital.id }}" {% if filters.hospital == hospital.id|stringformat:"s" %}selected{% endif %}>
|
||||
{{ hospital.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="department" class="form-label">{% trans "Department" %}</label>
|
||||
<select class="form-select" id="department" name="department">
|
||||
|
||||
@ -4,29 +4,80 @@
|
||||
|
||||
{% block title %}{% trans "Call Records" %} - PX360{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.page-header-gradient {
|
||||
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
|
||||
color: white;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.section-card:hover {
|
||||
border-color: #005696;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
background: linear-gradient(to right, #f8fafc, #f1f5f9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-navy">{% trans "Call Records" %}</h1>
|
||||
<p class="text-slate text-sm mt-1">{% trans "Manage and analyze imported call center recordings" %}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="{% url 'callcenter:export_call_records_template' %}"
|
||||
class="px-4 py-2 border border-slate-200 text-slate rounded-xl font-semibold hover:bg-light transition flex items-center gap-2">
|
||||
<i data-lucide="download" class="w-4 h-4"></i>
|
||||
{% trans "Download Template" %}
|
||||
</a>
|
||||
<a href="{% url 'callcenter:import_call_records' %}"
|
||||
class="px-4 py-2 bg-navy text-white rounded-xl font-semibold hover:bg-blue transition flex items-center gap-2">
|
||||
<i data-lucide="upload" class="w-4 h-4"></i>
|
||||
{% trans "Import CSV" %}
|
||||
</a>
|
||||
<!-- Page Header -->
|
||||
<div class="page-header-gradient">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="section-icon bg-white/20">
|
||||
<i data-lucide="phone-call" class="w-6 h-6"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold mb-1">{% trans "Call Records" %}</h1>
|
||||
<p class="opacity-90 mb-0">{% trans "Manage and analyze imported call center recordings" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="{% url 'callcenter:export_call_records_template' %}"
|
||||
class="px-4 py-2 border border-white/30 text-white rounded-xl font-semibold hover:bg-white/20 transition flex items-center gap-2">
|
||||
<i data-lucide="download" class="w-4 h-4"></i>
|
||||
{% trans "Download Template" %}
|
||||
</a>
|
||||
<a href="{% url 'callcenter:import_call_records' %}"
|
||||
class="px-4 py-2 bg-white text-blue-600 rounded-xl font-semibold hover:bg-blue-50 transition flex items-center gap-2">
|
||||
<i data-lucide="upload" class="w-4 h-4"></i>
|
||||
{% trans "Import CSV" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 mb-6">
|
||||
<!-- Total Calls -->
|
||||
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
@ -89,72 +140,74 @@
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 mb-6">
|
||||
<form method="get" class="flex flex-wrap gap-4">
|
||||
<div class="flex-1 min-w-48">
|
||||
<label class="block text-xs font-bold text-slate uppercase mb-1.5">{% trans "Search" %}</label>
|
||||
<input type="text" name="search" value="{{ filters.search }}"
|
||||
placeholder="{% trans 'Name, department, extension...' %}"
|
||||
class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy/20 text-sm">
|
||||
<div class="section-card mb-6">
|
||||
<div class="section-header">
|
||||
<div class="section-icon bg-blue-100">
|
||||
<i data-lucide="filter" class="w-5 h-5 text-blue-600"></i>
|
||||
</div>
|
||||
<h3 class="font-bold text-navy">{% trans "Filters" %}</h3>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<form method="get" class="flex flex-wrap gap-4">
|
||||
<div class="flex-1 min-w-48">
|
||||
<label class="block text-xs font-bold text-slate uppercase mb-1.5">{% trans "Search" %}</label>
|
||||
<input type="text" name="search" value="{{ filters.search }}"
|
||||
placeholder="{% trans 'Name, department, extension...' %}"
|
||||
class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy/20 text-sm">
|
||||
</div>
|
||||
|
||||
<div class="w-48">
|
||||
<label class="block text-xs font-bold text-slate uppercase mb-1.5">{% trans "Call Type" %}</label>
|
||||
<select name="call_type" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy/20 text-sm bg-white">
|
||||
<option value="">{% trans "All Calls" %}</option>
|
||||
<option value="inbound" {% if filters.call_type == 'inbound' %}selected{% endif %}>{% trans "Inbound" %}</option>
|
||||
<option value="outbound" {% if filters.call_type == 'outbound' %}selected{% endif %}>{% trans "Outbound" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="w-48">
|
||||
<label class="block text-xs font-bold text-slate uppercase mb-1.5">{% trans "Call Type" %}</label>
|
||||
<select name="call_type" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy/20 text-sm bg-white">
|
||||
<option value="">{% trans "All Calls" %}</option>
|
||||
<option value="inbound" {% if filters.call_type == 'inbound' %}selected{% endif %}>{% trans "Inbound" %}</option>
|
||||
<option value="outbound" {% if filters.call_type == 'outbound' %}selected{% endif %}>{% trans "Outbound" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="w-48">
|
||||
<label class="block text-xs font-bold text-slate uppercase mb-1.5">{% trans "Evaluated" %}</label>
|
||||
<select name="evaluated" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy/20 text-sm bg-white">
|
||||
<option value="">{% trans "All" %}</option>
|
||||
<option value="true" {% if filters.evaluated == 'true' %}selected{% endif %}>{% trans "Yes" %}</option>
|
||||
<option value="false" {% if filters.evaluated == 'false' %}selected{% endif %}>{% trans "No" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="w-48">
|
||||
<label class="block text-xs font-bold text-slate uppercase mb-1.5">{% trans "Evaluated" %}</label>
|
||||
<select name="evaluated" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy/20 text-sm bg-white">
|
||||
<option value="">{% trans "All" %}</option>
|
||||
<option value="true" {% if filters.evaluated == 'true' %}selected{% endif %}>{% trans "Yes" %}</option>
|
||||
<option value="false" {% if filters.evaluated == 'false' %}selected{% endif %}>{% trans "No" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="w-48">
|
||||
<label class="block text-xs font-bold text-slate uppercase mb-1.5">{% trans "Hospital" %}</label>
|
||||
<select name="hospital" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy/20 text-sm bg-white">
|
||||
<option value="">{% trans "All Hospitals" %}</option>
|
||||
{% for hospital in hospitals %}
|
||||
<option value="{{ hospital.id }}" {% if filters.hospital == hospital.id|stringformat:"s" %}selected{% endif %}>
|
||||
{{ hospital.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="w-40">
|
||||
<label class="block text-xs font-bold text-slate uppercase mb-1.5">{% trans "From Date" %}</label>
|
||||
<input type="date" name="date_from" value="{{ filters.date_from }}"
|
||||
class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy/20 text-sm">
|
||||
</div>
|
||||
|
||||
<div class="w-40">
|
||||
<label class="block text-xs font-bold text-slate uppercase mb-1.5">{% trans "From Date" %}</label>
|
||||
<input type="date" name="date_from" value="{{ filters.date_from }}"
|
||||
class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy/20 text-sm">
|
||||
</div>
|
||||
<div class="w-40">
|
||||
<label class="block text-xs font-bold text-slate uppercase mb-1.5">{% trans "To Date" %}</label>
|
||||
<input type="date" name="date_to" value="{{ filters.date_to }}"
|
||||
class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy/20 text-sm">
|
||||
</div>
|
||||
|
||||
<div class="w-40">
|
||||
<label class="block text-xs font-bold text-slate uppercase mb-1.5">{% trans "To Date" %}</label>
|
||||
<input type="date" name="date_to" value="{{ filters.date_to }}"
|
||||
class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy/20 text-sm">
|
||||
</div>
|
||||
|
||||
<div class="flex items-end">
|
||||
<button type="submit" class="px-6 py-2.5 bg-navy text-white rounded-xl font-semibold hover:bg-blue transition flex items-center gap-2">
|
||||
<i data-lucide="search" class="w-4 h-4"></i>
|
||||
{% trans "Filter" %}
|
||||
</button>
|
||||
<a href="{% url 'callcenter:call_records_list' %}"
|
||||
class="px-4 py-2.5 border border-slate-200 text-slate rounded-xl font-semibold hover:bg-light transition ml-2">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
<div class="flex items-end">
|
||||
<button type="submit" class="px-6 py-2.5 bg-navy text-white rounded-xl font-semibold hover:bg-blue transition flex items-center gap-2">
|
||||
<i data-lucide="search" class="w-4 h-4"></i>
|
||||
{% trans "Filter" %}
|
||||
</button>
|
||||
<a href="{% url 'callcenter:call_records_list' %}"
|
||||
class="px-4 py-2.5 border border-slate-200 text-slate rounded-xl font-semibold hover:bg-light transition ml-2">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Call Records Table -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-icon bg-blue-100">
|
||||
<i data-lucide="list" class="w-5 h-5 text-blue-600"></i>
|
||||
</div>
|
||||
<h3 class="font-bold text-navy">{% trans "Call Records" %}</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-slate-50 border-b border-slate-200">
|
||||
@ -268,9 +321,3 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -130,13 +130,9 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label required-field">{% trans "Hospital" %}</label>
|
||||
<select name="hospital_id" class="form-select" id="hospitalSelect" required>
|
||||
<option value="">{% trans "Select hospital..." %}</option>
|
||||
{% for hospital in hospitals %}
|
||||
<option value="{{ hospital.id }}">{{ hospital.name_en }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label class="form-label">{% trans "Hospital" %}</label>
|
||||
<input type="text" class="form-control" value="{{ current_hospital.name }}" readonly>
|
||||
<input type="hidden" name="hospital_id" value="{{ current_hospital.id }}">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
@ -283,7 +279,6 @@
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const hospitalSelect = document.getElementById('hospitalSelect');
|
||||
const departmentSelect = document.getElementById('departmentSelect');
|
||||
const physicianSelect = document.getElementById('physicianSelect');
|
||||
const patientSearch = document.getElementById('patientSearch');
|
||||
@ -293,47 +288,43 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const callerNameInput = document.getElementById('callerName');
|
||||
const callerPhoneInput = document.getElementById('callerPhone');
|
||||
|
||||
// Hospital change handler - load departments and physicians
|
||||
hospitalSelect.addEventListener('change', function() {
|
||||
const hospitalId = this.value;
|
||||
const currentHospitalId = '{{ current_hospital.id|default:"" }}';
|
||||
|
||||
// Load departments and physicians on page load
|
||||
if (currentHospitalId) {
|
||||
// Load departments
|
||||
fetch(`/callcenter/ajax/departments/?hospital_id=${currentHospitalId}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
departmentSelect.innerHTML = '<option value="">{% trans "Select department..." %}</option>';
|
||||
data.departments.forEach(dept => {
|
||||
const option = document.createElement('option');
|
||||
option.value = dept.id;
|
||||
option.textContent = dept.name_en;
|
||||
departmentSelect.appendChild(option);
|
||||
});
|
||||
})
|
||||
.catch(error => console.error('Error loading departments:', error));
|
||||
|
||||
// Clear department and physician
|
||||
departmentSelect.innerHTML = '<option value="">{% trans "Select department..." %}</option>';
|
||||
physicianSelect.innerHTML = '<option value="">{% trans "Select physician..." %}</option>';
|
||||
|
||||
if (hospitalId) {
|
||||
// Load departments
|
||||
fetch(`/callcenter/ajax/departments/?hospital_id=${hospitalId}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
data.departments.forEach(dept => {
|
||||
const option = document.createElement('option');
|
||||
option.value = dept.id;
|
||||
option.textContent = dept.name_en;
|
||||
departmentSelect.appendChild(option);
|
||||
});
|
||||
})
|
||||
.catch(error => console.error('Error loading departments:', error));
|
||||
|
||||
// Load physicians
|
||||
fetch(`/callcenter/ajax/physicians/?hospital_id=${hospitalId}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
data.physicians.forEach(physician => {
|
||||
const option = document.createElement('option');
|
||||
option.value = physician.id;
|
||||
option.textContent = `${physician.name} (${physician.specialty})`;
|
||||
physicianSelect.appendChild(option);
|
||||
});
|
||||
})
|
||||
.catch(error => console.error('Error loading physicians:', error));
|
||||
}
|
||||
});
|
||||
// Load physicians
|
||||
fetch(`/callcenter/ajax/physicians/?hospital_id=${currentHospitalId}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
physicianSelect.innerHTML = '<option value="">{% trans "Select physician..." %}</option>';
|
||||
data.physicians.forEach(physician => {
|
||||
const option = document.createElement('option');
|
||||
option.value = physician.id;
|
||||
option.textContent = `${physician.name} (${physician.specialty})`;
|
||||
physicianSelect.appendChild(option);
|
||||
});
|
||||
})
|
||||
.catch(error => console.error('Error loading physicians:', error));
|
||||
}
|
||||
|
||||
// Patient search
|
||||
function searchPatients() {
|
||||
const query = patientSearch.value.trim();
|
||||
const hospitalId = hospitalSelect.value;
|
||||
const hospitalId = currentHospitalId;
|
||||
|
||||
if (query.length < 2) {
|
||||
patientResults.innerHTML = '<div class="alert alert-warning small">{% trans "Please enter at least 2 characters to search" %}</div>';
|
||||
|
||||
@ -6,6 +6,46 @@
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.page-header-gradient {
|
||||
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
|
||||
color: white;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.section-card:hover {
|
||||
border-color: #005696;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
background: linear-gradient(to right, #f8fafc, #f1f5f9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.badge-status-open { background: #e0f2fe; color: #075985; }
|
||||
.badge-status-in_progress { background: #fef9c3; color: #854d0e; }
|
||||
.badge-status-resolved { background: #dcfce7; color: #166534; }
|
||||
@ -18,21 +58,23 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Header -->
|
||||
<header class="mb-6">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-navy flex items-center gap-3">
|
||||
<i data-lucide="phone" class="w-7 h-7 text-blue"></i>
|
||||
{% trans "Call Center Complaints" %}
|
||||
</h1>
|
||||
<p class="text-sm text-slate mt-1">{% trans "Complaints created via call center" %}</p>
|
||||
<!-- Page Header -->
|
||||
<div class="page-header-gradient">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="section-icon bg-white/20">
|
||||
<i data-lucide="phone" class="w-6 h-6"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold mb-1">{% trans "Call Center Complaints" %}</h1>
|
||||
<p class="opacity-90 mb-0">{% trans "Complaints created via call center" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="{% url 'callcenter:create_complaint' %}" class="bg-navy text-white px-5 py-2.5 rounded-xl text-sm font-bold shadow-lg shadow-navy/20 hover:bg-blue flex items-center gap-2 transition">
|
||||
<a href="{% url 'callcenter:create_complaint' %}" class="bg-white text-blue-600 px-5 py-2.5 rounded-xl text-sm font-bold hover:bg-blue-50 flex items-center gap-2 transition">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "Create Complaint" %}
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<!-- Statistics -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
@ -75,68 +117,57 @@
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border-2 border-slate-200 mb-6">
|
||||
<div class="px-6 py-4 border-b-2 border-slate-200 flex justify-between items-center bg-gradient-to-r from-slate-50 to-slate-100">
|
||||
<h3 class="font-bold text-navy flex items-center gap-2">
|
||||
<i data-lucide="filter" class="w-5 h-5 text-navy"></i>
|
||||
{% trans "Filters" %}
|
||||
</h3>
|
||||
<div class="section-card mb-6">
|
||||
<div class="section-header">
|
||||
<div class="section-icon bg-blue-100">
|
||||
<i data-lucide="filter" class="w-5 h-5 text-blue-600"></i>
|
||||
</div>
|
||||
<h3 class="font-bold text-navy">{% trans "Filters" %}</h3>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<form method="get" class="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<div class="md:col-span-1">
|
||||
<label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Search" %}</label>
|
||||
<input type="text" name="search"
|
||||
class="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy focus:border-transparent transition"
|
||||
placeholder="{% trans 'Search...' %}" value="{{ filters.search }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Status" %}</label>
|
||||
<select name="status" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy focus:border-transparent transition">
|
||||
<option value="">{% trans "All" %}</option>
|
||||
<option value="open" {% if filters.status == 'open' %}selected{% endif %}>{% trans "Open" %}</option>
|
||||
<option value="in_progress" {% if filters.status == 'in_progress' %}selected{% endif %}>{% trans "In Progress" %}</option>
|
||||
<option value="resolved" {% if filters.status == 'resolved' %}selected{% endif %}>{% trans "Resolved" %}</option>
|
||||
<option value="closed" {% if filters.status == 'closed' %}selected{% endif %}>{% trans "Closed" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Severity" %}</label>
|
||||
<select name="severity" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy focus:border-transparent transition">
|
||||
<option value="">{% trans "All" %}</option>
|
||||
<option value="critical" {% if filters.severity == 'critical' %}selected{% endif %}>{% trans "Critical" %}</option>
|
||||
<option value="high" {% if filters.severity == 'high' %}selected{% endif %}>{% trans "High" %}</option>
|
||||
<option value="medium" {% if filters.severity == 'medium' %}selected{% endif %}>{% trans "Medium" %}</option>
|
||||
<option value="low" {% if filters.severity == 'low' %}selected{% endif %}>{% trans "Low" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Hospital" %}</label>
|
||||
<select name="hospital" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy focus:border-transparent transition">
|
||||
<option value="">{% trans "All" %}</option>
|
||||
{% for hospital in hospitals %}
|
||||
<option value="{{ hospital.id }}" {% if filters.hospital == hospital.id|stringformat:"s" %}selected{% endif %}>
|
||||
{{ hospital.name_en }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button type="submit" class="w-full bg-navy text-white px-4 py-2.5 rounded-xl text-sm font-bold hover:bg-blue transition flex items-center justify-center gap-2">
|
||||
<i data-lucide="filter" class="w-4 h-4"></i> {% trans "Filter" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<form method="get" class="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<div class="md:col-span-1">
|
||||
<label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Search" %}</label>
|
||||
<input type="text" name="search"
|
||||
class="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy focus:border-transparent transition"
|
||||
placeholder="{% trans 'Search...' %}" value="{{ filters.search }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Status" %}</label>
|
||||
<select name="status" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy focus:border-transparent transition">
|
||||
<option value="">{% trans "All" %}</option>
|
||||
<option value="open" {% if filters.status == 'open' %}selected{% endif %}>{% trans "Open" %}</option>
|
||||
<option value="in_progress" {% if filters.status == 'in_progress' %}selected{% endif %}>{% trans "In Progress" %}</option>
|
||||
<option value="resolved" {% if filters.status == 'resolved' %}selected{% endif %}>{% trans "Resolved" %}</option>
|
||||
<option value="closed" {% if filters.status == 'closed' %}selected{% endif %}>{% trans "Closed" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Severity" %}</label>
|
||||
<select name="severity" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy focus:border-transparent transition">
|
||||
<option value="">{% trans "All" %}</option>
|
||||
<option value="critical" {% if filters.severity == 'critical' %}selected{% endif %}>{% trans "Critical" %}</option>
|
||||
<option value="high" {% if filters.severity == 'high' %}selected{% endif %}>{% trans "High" %}</option>
|
||||
<option value="medium" {% if filters.severity == 'medium' %}selected{% endif %}>{% trans "Medium" %}</option>
|
||||
<option value="low" {% if filters.severity == 'low' %}selected{% endif %}>{% trans "Low" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button type="submit" class="w-full bg-navy text-white px-4 py-2.5 rounded-xl text-sm font-bold hover:bg-blue transition flex items-center justify-center gap-2">
|
||||
<i data-lucide="filter" class="w-4 h-4"></i> {% trans "Filter" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Complaints Table -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border-2 border-slate-200 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b-2 border-slate-200 bg-gradient-to-r from-slate-50 to-slate-100 flex justify-between items-center">
|
||||
<h3 class="font-bold text-navy flex items-center gap-2">
|
||||
<i data-lucide="message-square-warning" class="w-5 h-5 text-red-500"></i>
|
||||
{% trans "Complaints" %}
|
||||
</h3>
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-icon bg-red-100">
|
||||
<i data-lucide="message-square-warning" class="w-5 h-5 text-red-600"></i>
|
||||
</div>
|
||||
<h3 class="font-bold text-navy">{% trans "Complaints" %}</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
|
||||
@ -134,13 +134,9 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label required-field">{% trans "Hospital" %}</label>
|
||||
<select name="hospital_id" class="form-select" id="hospitalSelect" required>
|
||||
<option value="">{% trans "Select hospital..." %}</option>
|
||||
{% for hospital in hospitals %}
|
||||
<option value="{{ hospital.id }}">{{ hospital.name_en }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label class="form-label">{% trans "Hospital" %}</label>
|
||||
<input type="text" class="form-control" value="{{ current_hospital.name }}" readonly>
|
||||
<input type="hidden" name="hospital_id" value="{{ current_hospital.id }}">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
@ -242,7 +238,6 @@
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const hospitalSelect = document.getElementById('hospitalSelect');
|
||||
const departmentSelect = document.getElementById('departmentSelect');
|
||||
const patientSearch = document.getElementById('patientSearch');
|
||||
const searchBtn = document.getElementById('searchBtn');
|
||||
@ -252,33 +247,28 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const contactPhoneInput = document.getElementById('contactPhone');
|
||||
const contactEmailInput = document.getElementById('contactEmail');
|
||||
|
||||
// Hospital change handler - load departments
|
||||
hospitalSelect.addEventListener('change', function() {
|
||||
const hospitalId = this.value;
|
||||
|
||||
// Clear department
|
||||
departmentSelect.innerHTML = '<option value="">{% trans "Select department..." %}</option>';
|
||||
|
||||
if (hospitalId) {
|
||||
// Load departments
|
||||
fetch(`/callcenter/ajax/departments/?hospital_id=${hospitalId}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
data.departments.forEach(dept => {
|
||||
const option = document.createElement('option');
|
||||
option.value = dept.id;
|
||||
option.textContent = dept.name_en;
|
||||
departmentSelect.appendChild(option);
|
||||
});
|
||||
})
|
||||
.catch(error => console.error('Error loading departments:', error));
|
||||
}
|
||||
});
|
||||
const currentHospitalId = '{{ current_hospital.id|default:"" }}';
|
||||
|
||||
// Load departments on page load
|
||||
if (currentHospitalId) {
|
||||
fetch(`/callcenter/ajax/departments/?hospital_id=${currentHospitalId}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
departmentSelect.innerHTML = '<option value="">{% trans "Select department..." %}</option>';
|
||||
data.departments.forEach(dept => {
|
||||
const option = document.createElement('option');
|
||||
option.value = dept.id;
|
||||
option.textContent = dept.name_en;
|
||||
departmentSelect.appendChild(option);
|
||||
});
|
||||
})
|
||||
.catch(error => console.error('Error loading departments:', error));
|
||||
}
|
||||
|
||||
// Patient search
|
||||
function searchPatients() {
|
||||
const query = patientSearch.value.trim();
|
||||
const hospitalId = hospitalSelect.value;
|
||||
const hospitalId = currentHospitalId;
|
||||
|
||||
if (query.length < 2) {
|
||||
patientResults.innerHTML = '<div class="alert alert-warning small">{% trans "Please enter at least 2 characters to search" %}</div>';
|
||||
|
||||
@ -6,6 +6,46 @@
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.page-header-gradient {
|
||||
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
|
||||
color: white;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.section-card:hover {
|
||||
border-color: #005696;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
background: linear-gradient(to right, #f8fafc, #f1f5f9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.badge-status-open { background: #e0f2fe; color: #075985; }
|
||||
.badge-status-in_progress { background: #fef9c3; color: #854d0e; }
|
||||
.badge-status-resolved { background: #dcfce7; color: #166534; }
|
||||
@ -14,21 +54,23 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Header -->
|
||||
<header class="mb-6">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-navy flex items-center gap-3">
|
||||
<i data-lucide="phone" class="w-7 h-7 text-cyan-500"></i>
|
||||
{% trans "Call Center Inquiries" %}
|
||||
</h1>
|
||||
<p class="text-sm text-slate mt-1">{% trans "Inquiries created via call center" %}</p>
|
||||
<!-- Page Header -->
|
||||
<div class="page-header-gradient">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="section-icon bg-white/20">
|
||||
<i data-lucide="phone" class="w-6 h-6"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold mb-1">{% trans "Call Center Inquiries" %}</h1>
|
||||
<p class="opacity-90 mb-0">{% trans "Inquiries created via call center" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="{% url 'callcenter:create_inquiry' %}" class="bg-cyan-500 text-white px-5 py-2.5 rounded-xl text-sm font-bold shadow-lg shadow-cyan-200 hover:bg-cyan-600 flex items-center gap-2 transition">
|
||||
<a href="{% url 'callcenter:create_inquiry' %}" class="bg-white text-blue-600 px-5 py-2.5 rounded-xl text-sm font-bold hover:bg-blue-50 flex items-center gap-2 transition">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "Create Inquiry" %}
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<!-- Statistics -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
@ -71,69 +113,58 @@
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border-2 border-slate-200 mb-6">
|
||||
<div class="px-6 py-4 border-b-2 border-slate-200 flex justify-between items-center bg-gradient-to-r from-slate-50 to-slate-100">
|
||||
<h3 class="font-bold text-navy flex items-center gap-2">
|
||||
<i data-lucide="filter" class="w-5 h-5 text-navy"></i>
|
||||
{% trans "Filters" %}
|
||||
</h3>
|
||||
<div class="section-card mb-6">
|
||||
<div class="section-header">
|
||||
<div class="section-icon bg-blue-100">
|
||||
<i data-lucide="filter" class="w-5 h-5 text-blue-600"></i>
|
||||
</div>
|
||||
<h3 class="font-bold text-navy">{% trans "Filters" %}</h3>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<form method="get" class="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Search" %}</label>
|
||||
<input type="text" name="search"
|
||||
class="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy focus:border-transparent transition"
|
||||
placeholder="{% trans 'Search...' %}" value="{{ filters.search }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Status" %}</label>
|
||||
<select name="status" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy focus:border-transparent transition">
|
||||
<option value="">{% trans "All" %}</option>
|
||||
<option value="open" {% if filters.status == 'open' %}selected{% endif %}>{% trans "Open" %}</option>
|
||||
<option value="in_progress" {% if filters.status == 'in_progress' %}selected{% endif %}>{% trans "In Progress" %}</option>
|
||||
<option value="resolved" {% if filters.status == 'resolved' %}selected{% endif %}>{% trans "Resolved" %}</option>
|
||||
<option value="closed" {% if filters.status == 'closed' %}selected{% endif %}>{% trans "Closed" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Category" %}</label>
|
||||
<select name="category" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy focus:border-transparent transition">
|
||||
<option value="">{% trans "All" %}</option>
|
||||
<option value="appointment" {% if filters.category == 'appointment' %}selected{% endif %}>{% trans "Appointment" %}</option>
|
||||
<option value="billing" {% if filters.category == 'billing' %}selected{% endif %}>{% trans "Billing" %}</option>
|
||||
<option value="medical_records" {% if filters.category == 'medical_records' %}selected{% endif %}>{% trans "Medical Records" %}</option>
|
||||
<option value="general" {% if filters.category == 'general' %}selected{% endif %}>{% trans "General" %}</option>
|
||||
<option value="other" {% if filters.category == 'other' %}selected{% endif %}>{% trans "Other" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Hospital" %}</label>
|
||||
<select name="hospital" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy focus:border-transparent transition">
|
||||
<option value="">{% trans "All" %}</option>
|
||||
{% for hospital in hospitals %}
|
||||
<option value="{{ hospital.id }}" {% if filters.hospital == hospital.id|stringformat:"s" %}selected{% endif %}>
|
||||
{{ hospital.name_en }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="md:col-span-5 flex justify-end">
|
||||
<button type="submit" class="bg-cyan-500 text-white px-5 py-2.5 rounded-xl text-sm font-bold hover:bg-cyan-600 transition flex items-center gap-2">
|
||||
<i data-lucide="filter" class="w-4 h-4"></i> {% trans "Filter" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<form method="get" class="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Search" %}</label>
|
||||
<input type="text" name="search"
|
||||
class="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy focus:border-transparent transition"
|
||||
placeholder="{% trans 'Search...' %}" value="{{ filters.search }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Status" %}</label>
|
||||
<select name="status" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy focus:border-transparent transition">
|
||||
<option value="">{% trans "All" %}</option>
|
||||
<option value="open" {% if filters.status == 'open' %}selected{% endif %}>{% trans "Open" %}</option>
|
||||
<option value="in_progress" {% if filters.status == 'in_progress' %}selected{% endif %}>{% trans "In Progress" %}</option>
|
||||
<option value="resolved" {% if filters.status == 'resolved' %}selected{% endif %}>{% trans "Resolved" %}</option>
|
||||
<option value="closed" {% if filters.status == 'closed' %}selected{% endif %}>{% trans "Closed" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Category" %}</label>
|
||||
<select name="category" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy focus:border-transparent transition">
|
||||
<option value="">{% trans "All" %}</option>
|
||||
<option value="appointment" {% if filters.category == 'appointment' %}selected{% endif %}>{% trans "Appointment" %}</option>
|
||||
<option value="billing" {% if filters.category == 'billing' %}selected{% endif %}>{% trans "Billing" %}</option>
|
||||
<option value="medical_records" {% if filters.category == 'medical_records' %}selected{% endif %}>{% trans "Medical Records" %}</option>
|
||||
<option value="general" {% if filters.category == 'general' %}selected{% endif %}>{% trans "General" %}</option>
|
||||
<option value="other" {% if filters.category == 'other' %}selected{% endif %}>{% trans "Other" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button type="submit" class="w-full bg-navy text-white px-4 py-2.5 rounded-xl text-sm font-bold hover:bg-blue transition flex items-center justify-center gap-2">
|
||||
<i data-lucide="filter" class="w-4 h-4"></i> {% trans "Filter" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inquiries Table -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border-2 border-slate-200 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b-2 border-slate-200 bg-gradient-to-r from-slate-50 to-slate-100 flex justify-between items-center">
|
||||
<h3 class="font-bold text-navy flex items-center gap-2">
|
||||
<i data-lucide="help-circle" class="w-5 h-5 text-cyan-500"></i>
|
||||
{% trans "Inquiries" %}
|
||||
</h3>
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-icon bg-cyan-100">
|
||||
<i data-lucide="help-circle" class="w-5 h-5 text-cyan-600"></i>
|
||||
</div>
|
||||
<h3 class="font-bold text-navy">{% trans "Inquiries" %}</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
@ -211,7 +242,7 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<span class="px-4 py-2 rounded-lg bg-cyan-500 text-white text-sm font-bold">
|
||||
<span class="px-4 py-2 rounded-lg bg-navy text-white text-sm font-bold">
|
||||
{% trans "Page" %} {{ page_obj.number }} {% trans "of" %} {{ page_obj.paginator.num_pages }}
|
||||
</span>
|
||||
|
||||
|
||||
@ -4,15 +4,62 @@
|
||||
|
||||
{% block title %}Call Center - PX360{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.page-header-gradient {
|
||||
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
|
||||
color: white;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.section-card:hover {
|
||||
border-color: #005696;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
background: linear-gradient(to right, #f8fafc, #f1f5f9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2 class="mb-1">
|
||||
<i class="bi bi-telephone text-success me-2"></i>
|
||||
Call Center
|
||||
</h2>
|
||||
<p class="text-muted mb-0">Monitor call center interactions and satisfaction</p>
|
||||
<!-- Page Header -->
|
||||
<div class="page-header-gradient">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="section-icon bg-white/20">
|
||||
<i data-lucide="phone" class="w-6 h-6"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold mb-1">{% trans "Call Center" %}</h2>
|
||||
<p class="opacity-90 mb-0">{% trans "Monitor call center interactions and satisfaction" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -45,8 +92,14 @@
|
||||
</div>
|
||||
|
||||
<!-- Interactions Table -->
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-icon bg-blue-100">
|
||||
<i data-lucide="list" class="w-5 h-5 text-blue-600"></i>
|
||||
</div>
|
||||
<h3 class="font-bold text-navy">{% trans "Interactions" %}</h3>
|
||||
</div>
|
||||
<div class="p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
@ -102,15 +155,15 @@
|
||||
<td onclick="event.stopPropagation();">
|
||||
<a href="{% url 'callcenter:interaction_detail' interaction.id %}"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i>
|
||||
<i data-lucide="eye" class="w-4 h-4"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="8" class="text-center py-5">
|
||||
<i class="bi bi-telephone" style="font-size: 3rem; color: #ccc;"></i>
|
||||
<p class="text-muted mt-3">No interactions found</p>
|
||||
<i data-lucide="phone" class="w-12 h-12 mx-auto mb-3 text-gray-300"></i>
|
||||
<p class="text-muted mt-3">{% trans "No interactions found" %}</p>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@ -127,7 +180,7 @@
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
<i data-lucide="chevron-left" class="w-4 h-4"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
@ -147,7 +200,7 @@
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
<i data-lucide="chevron-right" class="w-4 h-4"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
@ -3,19 +3,101 @@
|
||||
|
||||
{% block title %}{% if adverse_action %}{% trans "Edit Adverse Action" %}{% else %}{% trans "Report Adverse Action" %}{% endif %} - PX360{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.page-header-gradient {
|
||||
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
|
||||
color: white;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
|
||||
}
|
||||
|
||||
.form-section {
|
||||
background: #fff;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.form-section:hover {
|
||||
border-color: #005696;
|
||||
box-shadow: 0 4px 12px rgba(0, 86, 150, 0.1);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-control, .form-select {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.form-control:focus, .form-select:focus {
|
||||
outline: none;
|
||||
border-color: #005696;
|
||||
box-shadow: 0 0 0 3px rgba(0, 86, 150, 0.1);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #005696;
|
||||
color: white;
|
||||
border-radius: 0.75rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: #007bbd;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: white;
|
||||
color: #64748b;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 0.75rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: #005696;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="p-6 max-w-4xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<!-- Gradient Header -->
|
||||
<div class="page-header-gradient">
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="{% url 'complaints:complaint_detail' complaint.id %}" class="text-[#64748b] hover:text-[#005696]">
|
||||
<a href="{% url 'complaints:complaint_detail' complaint.id %}" class="text-white/80 hover:text-white transition">
|
||||
<i data-lucide="arrow-left" class="w-6 h-6"></i>
|
||||
</a>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-[#005696]">
|
||||
<h1 class="text-2xl font-bold">
|
||||
{% if adverse_action %}{% trans "Edit Adverse Action" %}{% else %}{% trans "Report Adverse Action" %}{% endif %}
|
||||
</h1>
|
||||
<p class="text-[#64748b] mt-1">
|
||||
<p class="text-white/80 mt-1">
|
||||
{% trans "Complaint" %}: {{ complaint.reference_number }}
|
||||
</p>
|
||||
</div>
|
||||
@ -23,32 +105,31 @@
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<form method="post" class="p-6">
|
||||
<div class="form-section">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- Action Type -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-bold text-gray-700 mb-2">
|
||||
<label class="form-label">
|
||||
{% trans "Action Type" %}
|
||||
<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select name="action_type" required
|
||||
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-[#005696] focus:border-transparent transition">
|
||||
<select name="action_type" required class="form-select">
|
||||
{% for value, label in action_type_choices %}
|
||||
<option value="{{ value }}" {% if adverse_action and adverse_action.action_type == value %}selected{% endif %}>
|
||||
{{ label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="mt-1 text-sm text-[#64748b]">
|
||||
<p class="mt-2 text-sm text-[#64748b]">
|
||||
{% trans "Select the type of adverse action that occurred." %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Severity -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-bold text-gray-700 mb-2">
|
||||
<label class="form-label">
|
||||
{% trans "Severity Level" %}
|
||||
<span class="text-red-500">*</span>
|
||||
</label>
|
||||
@ -77,54 +158,54 @@
|
||||
|
||||
<!-- Incident Date -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-bold text-gray-700 mb-2">
|
||||
<label class="form-label">
|
||||
{% trans "Incident Date & Time" %}
|
||||
<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="datetime-local" name="incident_date" required
|
||||
value="{% if adverse_action %}{{ adverse_action.incident_date|date:'Y-m-d\\TH:i' }}{% else %}{% now 'Y-m-d\\TH:i' %}{% endif %}"
|
||||
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-[#005696] focus:border-transparent transition">
|
||||
class="form-control">
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-bold text-gray-700 mb-2">
|
||||
<label class="form-label">
|
||||
{% trans "Location" %}
|
||||
</label>
|
||||
<input type="text" name="location"
|
||||
value="{{ adverse_action.location|default:'' }}"
|
||||
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-[#005696] focus:border-transparent transition"
|
||||
class="form-control"
|
||||
placeholder="{% trans 'e.g., Emergency Room, Clinic B, Main Reception' %}">
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-bold text-gray-700 mb-2">
|
||||
<label class="form-label">
|
||||
{% trans "Description" %}
|
||||
<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea name="description" rows="4" required
|
||||
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-[#005696] focus:border-transparent transition"
|
||||
class="form-control resize-none"
|
||||
placeholder="{% trans 'Describe what happened to the patient...' %}">{{ adverse_action.description|default:'' }}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Patient Impact -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-bold text-gray-700 mb-2">
|
||||
<label class="form-label">
|
||||
{% trans "Impact on Patient" %}
|
||||
</label>
|
||||
<textarea name="patient_impact" rows="3"
|
||||
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-[#005696] focus:border-transparent transition"
|
||||
class="form-control resize-none"
|
||||
placeholder="{% trans 'Describe the physical, emotional, or financial impact on the patient...' %}">{{ adverse_action.patient_impact|default:'' }}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Involved Staff -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-bold text-gray-700 mb-2">
|
||||
<label class="form-label">
|
||||
{% trans "Involved Staff" %}
|
||||
</label>
|
||||
<select name="involved_staff" multiple
|
||||
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-[#005696] focus:border-transparent transition"
|
||||
class="form-control"
|
||||
size="5">
|
||||
{% for staff in staff_list %}
|
||||
<option value="{{ staff.id }}" {% if staff.id in selected_staff %}selected{% endif %}>
|
||||
@ -132,7 +213,7 @@
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="mt-1 text-sm text-[#64748b]">
|
||||
<p class="mt-2 text-sm text-[#64748b]">
|
||||
{% trans "Hold Ctrl/Cmd to select multiple staff members." %}
|
||||
</p>
|
||||
</div>
|
||||
@ -152,11 +233,12 @@
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-end gap-3 pt-6 border-t border-gray-100">
|
||||
<a href="{% url 'complaints:complaint_detail' complaint.id %}" class="px-6 py-3 border border-gray-200 text-gray-700 rounded-xl font-semibold hover:bg-gray-50 transition">
|
||||
<a href="{% url 'complaints:complaint_detail' complaint.id %}" class="btn-secondary">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
<button type="submit" class="px-8 py-3 bg-[#005696] text-white rounded-xl font-semibold hover:bg-[#007bbd] transition flex items-center gap-2">
|
||||
<i data-lucide="{% if adverse_action %}save{% else %}plus{% endif %}" class="w-5 h-5"></i>
|
||||
<button type="submit" class="btn-primary">
|
||||
<i data-lucide="{% if adverse_action %}save{% else %}plus{% endif %}" class="w-4 h-4"></i>
|
||||
{% if adverse_action %}{% trans "Save Changes" %}{% else %}{% trans "Report Adverse Action" %}{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -16,27 +16,55 @@
|
||||
--hh-purple: #8b5cf6;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background: linear-gradient(135deg, var(--hh-navy) 0%, #0069a8 50%, var(--hh-blue) 100%);
|
||||
.page-header-gradient {
|
||||
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
|
||||
color: white;
|
||||
padding: 2rem 2.5rem;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
|
||||
}
|
||||
|
||||
.filter-card, .data-card {
|
||||
.section-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
|
||||
border: 2px solid #e2e8f0;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: linear-gradient(135deg, var(--hh-light), #e0f2fe);
|
||||
padding: 1.25rem 1.75rem;
|
||||
border-bottom: 1px solid #bae6fd;
|
||||
border-radius: 1rem 1rem 0 0;
|
||||
.section-card:hover {
|
||||
border-color: #005696;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
background: linear-gradient(to right, #f8fafc, #f1f5f9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.section-icon.primary {
|
||||
background: linear-gradient(135deg, #005696, #007bbd);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.section-icon.secondary {
|
||||
background: linear-gradient(135deg, #f1f5f9, #e2e8f0);
|
||||
color: #005696;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
@ -154,7 +182,7 @@
|
||||
{% block content %}
|
||||
<div class="px-4 py-6">
|
||||
<!-- Page Header -->
|
||||
<div class="page-header animate-in">
|
||||
<div class="page-header-gradient animate-in">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold mb-2">
|
||||
@ -171,12 +199,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filter-card mb-6 animate-in">
|
||||
<div class="card-header">
|
||||
<h2 class="text-lg font-bold text-navy flex items-center gap-2 m-0">
|
||||
<div class="section-card mb-6 animate-in">
|
||||
<div class="section-header">
|
||||
<div class="section-icon secondary">
|
||||
<i data-lucide="filter" class="w-5 h-5"></i>
|
||||
{% trans "Filters" %}
|
||||
</h2>
|
||||
</div>
|
||||
<h2 class="text-lg font-bold text-navy m-0">{% trans "Filters" %}</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form method="get" class="flex flex-wrap gap-4">
|
||||
@ -224,12 +252,14 @@
|
||||
</div>
|
||||
|
||||
<!-- Adverse Actions Table -->
|
||||
<div class="data-card animate-in">
|
||||
<div class="card-header flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-navy flex items-center gap-2 m-0">
|
||||
<i data-lucide="shield-alert" class="w-5 h-5"></i>
|
||||
{% trans "All Adverse Actions" %} ({{ page_obj.paginator.count }})
|
||||
</h2>
|
||||
<div class="section-card animate-in">
|
||||
<div class="section-header flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="section-icon primary">
|
||||
<i data-lucide="shield-alert" class="w-5 h-5"></i>
|
||||
</div>
|
||||
<h2 class="text-lg font-bold text-navy m-0">{% trans "All Adverse Actions" %} ({{ page_obj.paginator.count }})</h2>
|
||||
</div>
|
||||
<a href="#" class="btn-primary">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i>
|
||||
{% trans "New Adverse Action" %}
|
||||
|
||||
@ -506,6 +506,7 @@
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{% if not form.hospital.is_hidden %}
|
||||
<div>
|
||||
<label for="{{ form.hospital.id_for_label }}" class="form-label">
|
||||
{{ form.hospital.label }} <span class="required-mark">*</span>
|
||||
@ -518,6 +519,9 @@
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{{ form.hospital }}
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<label for="{{ form.department.id_for_label }}" class="form-label">
|
||||
@ -743,7 +747,10 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
|
||||
// Hospital field - could be select or hidden input depending on user role
|
||||
const hospitalSelect = document.getElementById('hospitalSelect');
|
||||
const hospitalField = hospitalSelect || document.querySelector('input[name="hospital"]');
|
||||
|
||||
const departmentSelect = document.getElementById('departmentSelect');
|
||||
const staffSelect = document.getElementById('staffSelect');
|
||||
const locationSelect = document.getElementById('locationSelect');
|
||||
@ -752,6 +759,36 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const complaintTypeCards = document.querySelectorAll('.type-card');
|
||||
const complaintTypeInput = document.getElementById('complaintTypeInput');
|
||||
|
||||
// Load departments on page load if hospital is already selected
|
||||
function loadDepartments(hospitalId) {
|
||||
if (!hospitalId || !departmentSelect) {
|
||||
if (departmentSelect) {
|
||||
departmentSelect.innerHTML = '<option value="">{% trans "Select Department" %}</option>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
departmentSelect.innerHTML = '<option value="">{% trans "Loading..." %}</option>';
|
||||
|
||||
fetch('/organizations/api/departments/?hospital=' + hospitalId)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const results = data.results || data;
|
||||
departmentSelect.innerHTML = '<option value="">{% trans "Select Department" %}</option>';
|
||||
|
||||
results.forEach(dept => {
|
||||
const option = document.createElement('option');
|
||||
option.value = dept.id;
|
||||
option.textContent = dept.name;
|
||||
departmentSelect.appendChild(option);
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading departments:', error);
|
||||
departmentSelect.innerHTML = '<option value="">{% trans "Error loading departments" %}</option>';
|
||||
});
|
||||
}
|
||||
|
||||
// Complaint type selection
|
||||
complaintTypeCards.forEach(card => {
|
||||
card.addEventListener('click', function() {
|
||||
@ -763,7 +800,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
});
|
||||
|
||||
// Hospital change handler
|
||||
// Hospital change handler (only for select, not hidden fields)
|
||||
if (hospitalSelect) {
|
||||
hospitalSelect.addEventListener('change', function() {
|
||||
const hospitalId = this.value;
|
||||
@ -775,6 +812,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
}
|
||||
|
||||
// Load departments on page load if hospital is pre-selected
|
||||
const initialHospitalId = hospitalField ? hospitalField.value : null;
|
||||
if (initialHospitalId) {
|
||||
loadDepartments(initialHospitalId);
|
||||
}
|
||||
|
||||
// Location change handler
|
||||
if (locationSelect) {
|
||||
locationSelect.addEventListener('change', function() {
|
||||
|
||||
@ -5,6 +5,63 @@
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
/* Page Header Gradient - Matching References Page */
|
||||
.page-header-gradient {
|
||||
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
|
||||
color: white;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
|
||||
}
|
||||
|
||||
/* Stat Cards */
|
||||
.stat-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
border-color: #005696;
|
||||
}
|
||||
|
||||
/* Section Cards */
|
||||
.section-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.section-card:hover {
|
||||
border-color: #005696;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
background: linear-gradient(to right, #f8fafc, #f1f5f9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Status styles */
|
||||
.status-resolved { background-color: #dcfce7; color: #166534; }
|
||||
.status-pending { background-color: #fef9c3; color: #854d0e; }
|
||||
.status-investigation { background-color: #e0f2fe; color: #075985; }
|
||||
@ -16,81 +73,89 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Header -->
|
||||
<header class="mb-6">
|
||||
<div class="flex justify-between items-start">
|
||||
<!-- Page Header with Gradient -->
|
||||
<div class="page-header-gradient">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-navy flex items-center gap-3">
|
||||
<i data-lucide="message-square-warning" class="w-7 h-7 text-red-500"></i>
|
||||
{% trans "Complaints Registry" %}
|
||||
</h1>
|
||||
<p class="text-sm text-slate mt-1">{% trans "Manage and monitor patient feedback in real-time" %}</p>
|
||||
<h1 class="text-2xl font-bold mb-1">{% trans "Complaints Registry" %}</h1>
|
||||
<p class="text-blue-100 text-sm">{% trans "Manage and monitor patient feedback in real-time" %}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative group">
|
||||
<i data-lucide="search" class="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate group-focus-within:text-navy"></i>
|
||||
<i data-lucide="search" class="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-white/70 group-focus-within:text-navy"></i>
|
||||
<input type="text" id="searchInput" placeholder="{% trans 'Search ID, Name or Dept...' %}"
|
||||
class="pl-10 pr-4 py-2.5 bg-slate-100 border-transparent border focus:border-navy/30 focus:bg-white rounded-xl text-sm outline-none w-64 transition-all">
|
||||
class="pl-10 pr-4 py-2.5 bg-white/10 border-2 border-white/30 text-white placeholder-white/70 rounded-xl text-sm outline-none w-64 transition-all focus:bg-white focus:text-navy focus:border-white">
|
||||
</div>
|
||||
{% if user.is_px_admin or user.is_hospital_admin %}
|
||||
<a href="{% url 'complaints:complaint_create' %}" class="bg-navy text-white px-5 py-2.5 rounded-xl text-sm font-bold shadow-lg shadow-navy/20 hover:bg-blue flex items-center gap-2 transition">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "New Case" %}
|
||||
<a href="{% url 'core:public_submit_landing' %}" class="inline-flex items-center px-4 py-2.5 bg-white/10 border-2 border-white/30 text-white font-medium rounded-xl hover:bg-white hover:text-navy transition" target="_blank">
|
||||
<i data-lucide="external-link" class="w-4 h-4 me-2"></i>{% trans "Public Form" %}
|
||||
</a>
|
||||
<a href="{% url 'core:public_submit_landing' %}" class="bg-white text-navy border-2 border-navy/30 px-5 py-2.5 rounded-xl text-sm font-bold hover:bg-navy hover:text-white flex items-center gap-2 transition" target="_blank">
|
||||
<i data-lucide="external-link" class="w-4 h-4"></i> {% trans "Public Form" %}
|
||||
<a href="{% url 'complaints:complaint_create' %}" class="inline-flex items-center px-4 py-2.5 bg-white text-navy font-medium rounded-xl hover:bg-blue-50 transition">
|
||||
<i data-lucide="plus" class="w-4 h-4 me-2"></i>{% trans "New Case" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="grid grid-cols-4 gap-6 mb-6">
|
||||
<div class="bg-white p-4 rounded-2xl border shadow-sm flex items-center gap-4">
|
||||
<div class="p-3 bg-blue/10 rounded-xl">
|
||||
<i data-lucide="layers" class="text-blue w-5 h-5"></i>
|
||||
<div class="stat-card bg-white rounded-2xl border-2 border-slate-200 p-6 flex items-center gap-4">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-14 h-14 bg-navy/10 rounded-xl flex items-center justify-center">
|
||||
<i data-lucide="layers" class="w-7 h-7 text-navy"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[10px] font-bold text-slate uppercase tracking-wider">{% trans "Total Received" %}</p>
|
||||
<p class="text-xl font-black text-navy leading-tight">{{ stats.total }}</p>
|
||||
<h6 class="text-slate-500 text-sm font-medium mb-1">{% trans "Total Received" %}</h6>
|
||||
<h2 class="text-3xl font-bold text-gray-800">{{ stats.total }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white p-4 rounded-2xl border shadow-sm flex items-center gap-4">
|
||||
<div class="p-3 bg-green-50 rounded-xl">
|
||||
<i data-lucide="check-circle" class="text-green-600 w-5 h-5"></i>
|
||||
<div class="stat-card bg-white rounded-2xl border-2 border-slate-200 p-6 flex items-center gap-4">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-14 h-14 bg-green-500/10 rounded-xl flex items-center justify-center">
|
||||
<i data-lucide="check-circle" class="w-7 h-7 text-green-600"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[10px] font-bold text-slate uppercase tracking-wider">{% trans "Resolved" %}</p>
|
||||
<p class="text-xl font-black text-navy leading-tight">{{ stats.resolved }} <span class="text-xs text-green-600">({{ stats.resolved_percentage|floatformat:1 }}%)</span></p>
|
||||
<h6 class="text-slate-500 text-sm font-medium mb-1">{% trans "Resolved" %}</h6>
|
||||
<h2 class="text-3xl font-bold text-gray-800">{{ stats.resolved }} <span class="text-sm text-green-600">({{ stats.resolved_percentage|floatformat:1 }}%)</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white p-4 rounded-2xl border shadow-sm flex items-center gap-4">
|
||||
<div class="p-3 bg-yellow-50 rounded-xl">
|
||||
<i data-lucide="clock" class="text-yellow-600 w-5 h-5"></i>
|
||||
<div class="stat-card bg-white rounded-2xl border-2 border-slate-200 p-6 flex items-center gap-4">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-14 h-14 bg-yellow-500/10 rounded-xl flex items-center justify-center">
|
||||
<i data-lucide="clock" class="w-7 h-7 text-yellow-600"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[10px] font-bold text-slate uppercase tracking-wider">{% trans "Pending" %}</p>
|
||||
<p class="text-xl font-black text-navy leading-tight">{{ stats.pending }}</p>
|
||||
<h6 class="text-slate-500 text-sm font-medium mb-1">{% trans "Pending" %}</h6>
|
||||
<h2 class="text-3xl font-bold text-gray-800">{{ stats.pending }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white p-4 rounded-2xl border shadow-sm flex items-center gap-4">
|
||||
<div class="p-3 bg-red-50 rounded-xl">
|
||||
<i data-lucide="alert-triangle" class="text-red-500 w-5 h-5"></i>
|
||||
<div class="stat-card bg-white rounded-2xl border-2 border-slate-200 p-6 flex items-center gap-4">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-14 h-14 bg-red-500/10 rounded-xl flex items-center justify-center">
|
||||
<i data-lucide="alert-triangle" class="w-7 h-7 text-red-500"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[10px] font-bold text-slate uppercase tracking-wider">{% trans "TAT Alert" %}</p>
|
||||
<p class="text-xl font-black text-navy leading-tight">{{ stats.overdue }} <span class="text-[10px] font-normal text-slate">{% trans "Over 72h" %}</span></p>
|
||||
<h6 class="text-slate-500 text-sm font-medium mb-1">{% trans "TAT Alert" %}</h6>
|
||||
<h2 class="text-3xl font-bold text-gray-800">{{ stats.overdue }} <span class="text-sm text-slate">{% trans "Over 72h" %}</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Tabs -->
|
||||
<div class="bg-white rounded-t-2xl shadow-sm border-2 border-slate-200">
|
||||
<div class="px-6 py-4 border-b-2 border-slate-200 flex items-center justify-between bg-gradient-to-r from-slate-50 to-slate-100">
|
||||
<h3 class="font-bold text-navy flex items-center gap-2">
|
||||
<i data-lucide="filter" class="w-5 h-5 text-navy"></i>
|
||||
{% trans "Filters" %}
|
||||
</h3>
|
||||
<!-- Complaints Section Card -->
|
||||
<div class="section-card">
|
||||
<!-- Section Header with Filters -->
|
||||
<div class="section-header flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="section-icon bg-red-500/10">
|
||||
<i data-lucide="message-square-warning" class="w-5 h-5 text-red-500"></i>
|
||||
</div>
|
||||
<h5 class="text-lg font-semibold text-gray-800">{% trans "Complaints" %}</h5>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="?" class="filter-btn px-4 py-1.5 rounded-full text-xs font-semibold transition {% if not status_filter %}active{% endif %}">
|
||||
{% trans "All Cases" %}
|
||||
@ -110,7 +175,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="px-6 py-2 text-[10px] font-bold text-slate uppercase bg-slate-50 border-b-2 border-slate-200">
|
||||
<p class="px-6 py-2 text-xs font-bold text-slate uppercase bg-slate-50 border-b-2 border-slate-200">
|
||||
{% trans "Showing:" %} <span class="text-navy">{{ complaints.start_index|default:0 }}-{{ complaints.end_index|default:0 }} {% trans "of" %} {{ complaints.paginator.count|default:0 }}</span>
|
||||
</p>
|
||||
|
||||
@ -145,13 +210,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Complaints Table -->
|
||||
<div class="bg-white rounded-b-2xl shadow-sm border-2 border-slate-200 overflow-hidden border-t-0">
|
||||
<div class="px-6 py-4 border-b-2 border-slate-200 bg-gradient-to-r from-slate-50 to-slate-100">
|
||||
<h3 class="font-bold text-navy flex items-center gap-2">
|
||||
<i data-lucide="message-square-warning" class="w-5 h-5 text-red-500"></i>
|
||||
{% trans "Complaints" %}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-0">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead class="bg-slate-50 border-b uppercase text-[10px] font-bold text-slate tracking-wider">
|
||||
<tr>
|
||||
@ -307,6 +366,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description Modal -->
|
||||
<div id="descriptionModal" class="fixed inset-0 z-50 hidden items-center justify-center p-4" onclick="closeDescriptionModal(event)">
|
||||
|
||||
@ -3,324 +3,467 @@
|
||||
|
||||
{% block title %}{{ title }} - {% translate "Complaint Thresholds" %} - PX360{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.page-header-gradient {
|
||||
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
|
||||
color: white;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
|
||||
}
|
||||
|
||||
.form-section {
|
||||
background: #fff;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.form-section:hover {
|
||||
border-color: #005696;
|
||||
box-shadow: 0 4px 12px rgba(0, 86, 150, 0.1);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-control, .form-select {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.form-control:focus, .form-select:focus {
|
||||
outline: none;
|
||||
border-color: #005696;
|
||||
box-shadow: 0 0 0 3px rgba(0, 86, 150, 0.1);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #005696;
|
||||
color: white;
|
||||
border-radius: 0.75rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: #007bbd;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: white;
|
||||
color: #64748b;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 0.75rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: #005696;
|
||||
}
|
||||
|
||||
.help-card {
|
||||
background: #f8fafc;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.form-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.form-switch-input {
|
||||
width: 3rem;
|
||||
height: 1.5rem;
|
||||
appearance: none;
|
||||
background: #cbd5e1;
|
||||
border-radius: 1rem;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.form-switch-input:checked {
|
||||
background: #005696;
|
||||
}
|
||||
.form-switch-input::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
top: 0.125rem;
|
||||
left: 0.125rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.form-switch-input:checked::after {
|
||||
left: 1.625rem;
|
||||
}
|
||||
|
||||
.invalid-feedback {
|
||||
color: #dc2626;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.form-text {
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div class="page-header-content">
|
||||
<div>
|
||||
<h1 class="page-title">
|
||||
<i class="fas fa-chart-line"></i>
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p class="page-description">
|
||||
{% if threshold %}
|
||||
{% translate "Edit complaint threshold" %}
|
||||
{% else %}
|
||||
{% translate "Create new complaint threshold" %}
|
||||
{% endif %}
|
||||
</p>
|
||||
<!-- Gradient Header -->
|
||||
<div class="page-header-gradient">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center">
|
||||
<i data-lucide="gauge" class="w-6 h-6"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{{ title }}</h1>
|
||||
<p class="text-white/80">
|
||||
{% if threshold %}{% translate "Edit complaint threshold" %}{% else %}{% translate "Create new complaint threshold" %}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="{% url 'complaints:complaint_threshold_list' %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
<a href="{% url 'complaints:complaint_threshold_list' %}" class="btn-secondary">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4"></i>
|
||||
{% translate "Back to List" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-content">
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post" class="row g-3">
|
||||
{% csrf_token %}
|
||||
|
||||
{% if request.user.is_px_admin %}
|
||||
<div class="col-md-12">
|
||||
<label for="id_hospital" class="form-label">
|
||||
{% translate "Hospital" %} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<select name="hospital" id="id_hospital" class="form-select" required>
|
||||
<option value="">{% translate "Select Hospital" %}</option>
|
||||
{% for hospital in form.hospital.field.queryset %}
|
||||
<option value="{{ hospital.id }}"
|
||||
{% if form.hospital.value == hospital.id|stringformat:"s" %}selected{% endif %}>
|
||||
{{ hospital.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if form.hospital.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ form.hospital.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="lg:col-span-2">
|
||||
<div class="form-section">
|
||||
<form method="post" class="space-y-6">
|
||||
{% csrf_token %}
|
||||
|
||||
{% if not form.hospital.is_hidden %}
|
||||
<div>
|
||||
<label for="id_hospital" class="form-label">
|
||||
{% translate "Hospital" %} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select name="hospital" id="id_hospital" class="form-select" required>
|
||||
<option value="">{% translate "Select Hospital" %}</option>
|
||||
{% for hospital in form.hospital.field.queryset %}
|
||||
<option value="{{ hospital.id }}"
|
||||
{% if form.hospital.value == hospital.id|stringformat:"s" %}selected{% endif %}>
|
||||
{{ hospital.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if form.hospital.errors %}
|
||||
<div class="invalid-feedback">
|
||||
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
|
||||
{{ form.hospital.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<label for="id_name" class="form-label">
|
||||
{% translate "Threshold Name" %} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text"
|
||||
name="name"
|
||||
id="id_name"
|
||||
class="form-control"
|
||||
value="{{ form.name.value|default:'' }}"
|
||||
required
|
||||
placeholder="{% translate 'e.g., Daily Complaint Limit' %}">
|
||||
{% if form.name.errors %}
|
||||
<div class="invalid-feedback">
|
||||
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
|
||||
{{ form.name.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="id_threshold_type" class="form-label">
|
||||
{% translate "Threshold Type" %} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select name="threshold_type" id="id_threshold_type" class="form-select" required>
|
||||
<option value="">{% translate "Select Type" %}</option>
|
||||
{% for value, label in form.threshold_type.field.choices %}
|
||||
<option value="{{ value }}"
|
||||
{% if form.threshold_type.value == value %}selected{% endif %}>
|
||||
{{ label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if form.threshold_type.errors %}
|
||||
<div class="invalid-feedback">
|
||||
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
|
||||
{{ form.threshold_type.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="col-md-12">
|
||||
<label for="id_name" class="form-label">
|
||||
{% translate "Threshold Name" %} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text"
|
||||
name="name"
|
||||
id="id_name"
|
||||
class="form-control"
|
||||
value="{{ form.name.value|default:'' }}"
|
||||
required
|
||||
placeholder="{% translate 'e.g., Daily Complaint Limit' %}">
|
||||
{% if form.name.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ form.name.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="id_metric_type" class="form-label">
|
||||
{% translate "Metric Type" %} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select name="metric_type" id="id_metric_type" class="form-select" required>
|
||||
<option value="">{% translate "Select Metric" %}</option>
|
||||
{% for value, label in form.metric_type.field.choices %}
|
||||
<option value="{{ value }}"
|
||||
{% if form.metric_type.value == value %}selected{% endif %}>
|
||||
{{ label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if form.metric_type.errors %}
|
||||
<div class="invalid-feedback">
|
||||
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
|
||||
{{ form.metric_type.errors.0 }}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="id_threshold_type" class="form-label">
|
||||
{% translate "Threshold Type" %} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<select name="threshold_type" id="id_threshold_type" class="form-select" required>
|
||||
<option value="">{% translate "Select Type" %}</option>
|
||||
{% for value, label in form.threshold_type.field.choices %}
|
||||
<option value="{{ value }}"
|
||||
{% if form.threshold_type.value == value %}selected{% endif %}>
|
||||
{{ label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if form.threshold_type.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ form.threshold_type.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="id_metric_type" class="form-label">
|
||||
{% translate "Metric Type" %} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<select name="metric_type" id="id_metric_type" class="form-select" required>
|
||||
<option value="">{% translate "Select Metric" %}</option>
|
||||
{% for value, label in form.metric_type.field.choices %}
|
||||
<option value="{{ value }}"
|
||||
{% if form.metric_type.value == value %}selected{% endif %}>
|
||||
{{ label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if form.metric_type.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ form.metric_type.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="id_threshold_value" class="form-label">
|
||||
{% translate "Threshold Value" %} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="number"
|
||||
name="threshold_value"
|
||||
id="id_threshold_value"
|
||||
class="form-control"
|
||||
value="{{ form.threshold_value.value|default:'' }}"
|
||||
min="1"
|
||||
required
|
||||
placeholder="{% translate 'e.g., 10' %}">
|
||||
{% if form.threshold_value.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ form.threshold_value.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="id_action" class="form-label">
|
||||
{% translate "Action to Take" %} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<select name="action" id="id_action" class="form-select" required>
|
||||
<option value="">{% translate "Select Action" %}</option>
|
||||
{% for value, label in form.action.field.choices %}
|
||||
<option value="{{ value }}"
|
||||
{% if form.action.value == value %}selected{% endif %}>
|
||||
{{ label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if form.action.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ form.action.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-12">
|
||||
<label for="id_complaint_category" class="form-label">
|
||||
{% translate "Complaint Category (Optional)" %}
|
||||
</label>
|
||||
<select name="complaint_category" id="id_complaint_category" class="form-select">
|
||||
<option value="">{% translate "All Categories" %}</option>
|
||||
{% for category in form.complaint_category.field.queryset %}
|
||||
<option value="{{ category.id }}"
|
||||
{% if form.complaint_category.value == category.id|stringformat:"s" %}selected{% endif %}>
|
||||
{{ category.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if form.complaint_category.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ form.complaint_category.errors.0 }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-text">
|
||||
{% translate "Leave empty to apply to all complaint categories" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-12">
|
||||
<label for="id_notify_emails" class="form-label">
|
||||
{% translate "Notify Emails (Optional)" %}
|
||||
</label>
|
||||
<input type="text"
|
||||
name="notify_emails"
|
||||
id="id_notify_emails"
|
||||
class="form-control"
|
||||
value="{{ form.notify_emails.value|default:'' }}"
|
||||
placeholder="{% translate 'email1@example.com, email2@example.com' %}">
|
||||
{% if form.notify_emails.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ form.notify_emails.errors.0 }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-text">
|
||||
{% translate "Comma-separated list of email addresses to notify when threshold is reached" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox"
|
||||
name="is_active"
|
||||
id="id_is_active"
|
||||
class="form-check-input"
|
||||
{% if form.is_active.value == 'on' or not form.is_active.value %}checked{% endif %}>
|
||||
<label class="form-check-label" for="id_is_active">
|
||||
{% translate "Active" %}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
{% translate "Only active thresholds will be monitored" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label for="id_description" class="form-label">
|
||||
{% translate "Description" %}
|
||||
</label>
|
||||
<textarea name="description"
|
||||
id="id_description"
|
||||
class="form-control"
|
||||
rows="3"
|
||||
placeholder="{% translate 'Optional notes about this threshold' %}">{{ form.description.value|default:'' }}</textarea>
|
||||
{% if form.description.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ form.description.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i>
|
||||
{{ action }}
|
||||
</button>
|
||||
<a href="{% url 'complaints:complaint_threshold_list' %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-times"></i>
|
||||
{% translate "Cancel" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="id_threshold_value" class="form-label">
|
||||
{% translate "Threshold Value" %} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="number"
|
||||
name="threshold_value"
|
||||
id="id_threshold_value"
|
||||
class="form-control"
|
||||
value="{{ form.threshold_value.value|default:'' }}"
|
||||
min="1"
|
||||
required
|
||||
placeholder="{% translate 'e.g., 10' %}">
|
||||
{% if form.threshold_value.errors %}
|
||||
<div class="invalid-feedback">
|
||||
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
|
||||
{{ form.threshold_value.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="id_action" class="form-label">
|
||||
{% translate "Action to Take" %} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select name="action" id="id_action" class="form-select" required>
|
||||
<option value="">{% translate "Select Action" %}</option>
|
||||
{% for value, label in form.action.field.choices %}
|
||||
<option value="{{ value }}"
|
||||
{% if form.action.value == value %}selected{% endif %}>
|
||||
{{ label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if form.action.errors %}
|
||||
<div class="invalid-feedback">
|
||||
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
|
||||
{{ form.action.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="id_complaint_category" class="form-label">
|
||||
{% translate "Complaint Category (Optional)" %}
|
||||
</label>
|
||||
<select name="complaint_category" id="id_complaint_category" class="form-select">
|
||||
<option value="">{% translate "All Categories" %}</option>
|
||||
{% for category in form.complaint_category.field.queryset %}
|
||||
<option value="{{ category.id }}"
|
||||
{% if form.complaint_category.value == category.id|stringformat:"s" %}selected{% endif %}>
|
||||
{{ category.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if form.complaint_category.errors %}
|
||||
<div class="invalid-feedback">
|
||||
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
|
||||
{{ form.complaint_category.errors.0 }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-text">
|
||||
{% translate "Leave empty to apply to all complaint categories" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="id_notify_emails" class="form-label">
|
||||
{% translate "Notify Emails (Optional)" %}
|
||||
</label>
|
||||
<input type="text"
|
||||
name="notify_emails"
|
||||
id="id_notify_emails"
|
||||
class="form-control"
|
||||
value="{{ form.notify_emails.value|default:'' }}"
|
||||
placeholder="{% translate 'email1@example.com, email2@example.com' %}">
|
||||
{% if form.notify_emails.errors %}
|
||||
<div class="invalid-feedback">
|
||||
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
|
||||
{{ form.notify_emails.errors.0 }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-text">
|
||||
{% translate "Comma-separated list of email addresses to notify when threshold is reached" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 p-4 bg-gray-50 rounded-xl">
|
||||
<div class="form-switch">
|
||||
<input type="checkbox"
|
||||
name="is_active"
|
||||
id="id_is_active"
|
||||
class="form-switch-input"
|
||||
{% if form.is_active.value == 'on' or not form.is_active.value %}checked{% endif %}>
|
||||
<label for="id_is_active" class="text-sm font-semibold text-gray-700">
|
||||
{% translate "Active" %}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-text ml-2">
|
||||
{% translate "Only active thresholds will be monitored" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="id_description" class="form-label">
|
||||
{% translate "Description" %}
|
||||
</label>
|
||||
<textarea name="description"
|
||||
id="id_description"
|
||||
class="form-control resize-none"
|
||||
rows="3"
|
||||
placeholder="{% translate 'Optional notes about this threshold' %}">{{ form.description.value|default:'' }}</textarea>
|
||||
{% if form.description.errors %}
|
||||
<div class="invalid-feedback">
|
||||
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
|
||||
{{ form.description.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 pt-6 border-t border-gray-100">
|
||||
<button type="submit" class="btn-primary">
|
||||
<i data-lucide="save" class="w-4 h-4"></i>
|
||||
{{ action }}
|
||||
</button>
|
||||
<a href="{% url 'complaints:complaint_threshold_list' %}" class="btn-secondary">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
{% translate "Cancel" %}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="card bg-light">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
{% translate "Help" %}
|
||||
</h5>
|
||||
<h6 class="card-subtitle mb-3 text-muted">
|
||||
{% translate "Understanding Complaint Thresholds" %}
|
||||
</h6>
|
||||
<p class="card-text">
|
||||
{% translate "Thresholds monitor complaint metrics and trigger actions when limits are exceeded." %}
|
||||
</p>
|
||||
|
||||
<h6 class="card-subtitle mb-2 text-muted">
|
||||
{% translate "Threshold Types" %}
|
||||
</h6>
|
||||
<ul class="list-unstyled">
|
||||
<li class="mb-2">
|
||||
<i class="fas fa-calendar-day text-primary me-2"></i>
|
||||
<strong>{% translate "Daily" %}</strong> - {% translate "Monitor daily complaint volume" %}
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="fas fa-calendar-week text-success me-2"></i>
|
||||
<strong>{% translate "Weekly" %}</strong> - {% translate "Monitor weekly complaint volume" %}
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="fas fa-calendar text-warning me-2"></i>
|
||||
<strong>{% translate "Monthly" %}</strong> - {% translate "Monitor monthly complaint volume" %}
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="fas fa-tags text-info me-2"></i>
|
||||
<strong>{% translate "By Category" %}</strong> - {% translate "Monitor specific complaint categories" %}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
||||
<h6 class="card-subtitle mb-2 text-muted">
|
||||
{% translate "Metric Types" %}
|
||||
</h6>
|
||||
<ul class="list-unstyled">
|
||||
<li class="mb-2">
|
||||
<i class="fas fa-list-ol text-secondary me-2"></i>
|
||||
{% translate "Count" %} - {% translate "Number of complaints" %}
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="fas fa-percentage text-secondary me-2"></i>
|
||||
{% translate "Percentage" %} - {% translate "Percentage of total complaints" %}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
||||
<h6 class="card-subtitle mb-2 text-muted">
|
||||
{% translate "Actions" %}
|
||||
</h6>
|
||||
<ul class="list-unstyled">
|
||||
<li class="mb-2">
|
||||
<i class="fas fa-bell text-warning me-2"></i>
|
||||
{% translate "Send Alert" %} - {% translate "Notify administrators" %}
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="fas fa-envelope text-info me-2"></i>
|
||||
{% translate "Send Email" %} - {% translate "Send email notifications" %}
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="fas fa-file-alt text-success me-2"></i>
|
||||
{% translate "Generate Report" %} - {% translate "Create detailed report" %}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-1">
|
||||
<div class="help-card">
|
||||
<h5 class="text-lg font-bold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<i data-lucide="info" class="w-5 h-5 text-[#005696]"></i>
|
||||
{% translate "Help" %}
|
||||
</h5>
|
||||
<h6 class="text-sm font-semibold text-gray-500 mb-3">
|
||||
{% translate "Understanding Complaint Thresholds" %}
|
||||
</h6>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
{% translate "Thresholds monitor complaint metrics and trigger actions when limits are exceeded." %}
|
||||
</p>
|
||||
|
||||
<h6 class="text-sm font-semibold text-gray-500 mb-3">
|
||||
{% translate "Threshold Types" %}
|
||||
</h6>
|
||||
<ul class="space-y-2 text-sm text-gray-600">
|
||||
<li class="flex items-center gap-2">
|
||||
<i data-lucide="calendar" class="w-4 h-4 text-[#005696]"></i>
|
||||
<strong>{% translate "Daily" %}</strong> - {% translate "Monitor daily complaint volume" %}
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<i data-lucide="calendar-days" class="w-4 h-4 text-green-500"></i>
|
||||
<strong>{% translate "Weekly" %}</strong> - {% translate "Monitor weekly complaint volume" %}
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<i data-lucide="calendar-range" class="w-4 h-4 text-orange-500"></i>
|
||||
<strong>{% translate "Monthly" %}</strong> - {% translate "Monitor monthly complaint volume" %}
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<i data-lucide="tags" class="w-4 h-4 text-purple-500"></i>
|
||||
<strong>{% translate "By Category" %}</strong> - {% translate "Monitor specific complaint categories" %}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<hr class="my-4 border-gray-200">
|
||||
|
||||
<h6 class="text-sm font-semibold text-gray-500 mb-3">
|
||||
{% translate "Metric Types" %}
|
||||
</h6>
|
||||
<ul class="space-y-2 text-sm text-gray-600">
|
||||
<li class="flex items-center gap-2">
|
||||
<i data-lucide="list-ordered" class="w-4 h-4 text-gray-500"></i>
|
||||
{% translate "Count" %} - {% translate "Number of complaints" %}
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<i data-lucide="percent" class="w-4 h-4 text-gray-500"></i>
|
||||
{% translate "Percentage" %} - {% translate "Percentage of total complaints" %}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<hr class="my-4 border-gray-200">
|
||||
|
||||
<h6 class="text-sm font-semibold text-gray-500 mb-3">
|
||||
{% translate "Actions" %}
|
||||
</h6>
|
||||
<ul class="space-y-2 text-sm text-gray-600">
|
||||
<li class="flex items-center gap-2">
|
||||
<i data-lucide="bell" class="w-4 h-4 text-orange-500"></i>
|
||||
{% translate "Send Alert" %} - {% translate "Notify administrators" %}
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<i data-lucide="mail" class="w-4 h-4 text-blue-500"></i>
|
||||
{% translate "Send Email" %} - {% translate "Send email notifications" %}
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<i data-lucide="file-text" class="w-4 h-4 text-green-500"></i>
|
||||
{% translate "Generate Report" %} - {% translate "Create detailed report" %}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -3,53 +3,209 @@
|
||||
|
||||
{% block title %}{% translate "Complaint Thresholds" %} - PX360{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div class="page-header-content">
|
||||
<div>
|
||||
<h1 class="page-title">
|
||||
<i class="fas fa-chart-line"></i>
|
||||
{% translate "Complaint Thresholds" %}
|
||||
</h1>
|
||||
<p class="page-description">
|
||||
{% translate "Configure thresholds for automatic alerts and reports" %}
|
||||
</p>
|
||||
</div>
|
||||
<a href="{% url 'complaints:complaint_threshold_create' %}" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i>
|
||||
{% translate "Create Threshold" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
:root {
|
||||
--hh-navy: #005696;
|
||||
--hh-blue: #007bbd;
|
||||
--hh-light: #eef6fb;
|
||||
--hh-slate: #64748b;
|
||||
}
|
||||
|
||||
<div class="page-content">
|
||||
<!-- Filters -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title">
|
||||
<i class="fas fa-filter"></i>
|
||||
{% translate "Filters" %}
|
||||
</h5>
|
||||
.page-header-gradient {
|
||||
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
|
||||
color: white;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.section-card:hover {
|
||||
border-color: #005696;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
background: linear-gradient(to right, #f8fafc, #f1f5f9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.section-icon.primary {
|
||||
background: linear-gradient(135deg, #005696, #007bbd);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.section-icon.secondary {
|
||||
background: linear-gradient(135deg, #f1f5f9, #e2e8f0);
|
||||
color: #005696;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background: linear-gradient(135deg, var(--hh-light), #e0f2fe);
|
||||
padding: 0.875rem 1rem;
|
||||
text-align: left;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--hh-navy);
|
||||
border-bottom: 2px solid #bae6fd;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
color: #475569;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.data-table tbody tr {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background-color: var(--hh-light);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--hh-navy) 0%, var(--hh-blue) 100%);
|
||||
color: white;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: 0.75rem;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 16px rgba(0, 86, 150, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: white;
|
||||
color: #475569;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: 0.75rem;
|
||||
font-weight: 600;
|
||||
border: 2px solid #e2e8f0;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: #cbd5e1;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background: linear-gradient(135deg, #dbeafe, #bfdbfe);
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.badge-secondary {
|
||||
background: linear-gradient(135deg, #f1f5f9, #e2e8f0);
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: linear-gradient(135deg, #fef3c7, #fde68a);
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: linear-gradient(135deg, #dcfce7, #bbf7d0);
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.animate-in {
|
||||
animation: fadeIn 0.5s ease-out forwards;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="px-4 py-6">
|
||||
<!-- Page Header -->
|
||||
<div class="page-header-gradient animate-in">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold mb-2">
|
||||
<i data-lucide="trending-up" class="w-7 h-7 inline-block me-2"></i>
|
||||
{% translate "Complaint Thresholds" %}
|
||||
</h1>
|
||||
<p class="text-white/90">{% translate "Configure thresholds for automatic alerts and reports" %}</p>
|
||||
</div>
|
||||
<a href="{% url 'complaints:complaint_threshold_create' %}" class="btn-secondary">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i>
|
||||
{% translate "Create Threshold" %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="get" class="row g-3">
|
||||
{% if request.user.is_px_admin %}
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">{% translate "Hospital" %}</label>
|
||||
<select name="hospital" class="form-select">
|
||||
<option value="">{% translate "All Hospitals" %}</option>
|
||||
{% for hospital in hospitals %}
|
||||
<option value="{{ hospital.id }}" {% if filters.hospital == hospital.id|stringformat:"s" %}selected{% endif %}>
|
||||
{{ hospital.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">{% translate "Threshold Type" %}</label>
|
||||
<select name="threshold_type" class="form-select">
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="section-card mb-6 animate-in">
|
||||
<div class="section-header">
|
||||
<div class="section-icon secondary">
|
||||
<i data-lucide="filter" class="w-5 h-5"></i>
|
||||
</div>
|
||||
<h2 class="text-lg font-bold text-navy m-0">{% translate "Filters" %}</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form method="get" class="flex flex-wrap gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate mb-1.5">{% translate "Threshold Type" %}</label>
|
||||
<select name="threshold_type" class="px-4 py-2.5 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-blue bg-white">
|
||||
<option value="">{% translate "All Types" %}</option>
|
||||
<option value="daily" {% if filters.threshold_type == "daily" %}selected{% endif %}>{% translate "Daily" %}</option>
|
||||
<option value="weekly" {% if filters.threshold_type == "weekly" %}selected{% endif %}>{% translate "Weekly" %}</option>
|
||||
@ -57,23 +213,21 @@
|
||||
<option value="category" {% if filters.threshold_type == "category" %}selected{% endif %}>{% translate "By Category" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">{% translate "Status" %}</label>
|
||||
<select name="is_active" class="form-select">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate mb-1.5">{% translate "Status" %}</label>
|
||||
<select name="is_active" class="px-4 py-2.5 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-blue bg-white">
|
||||
<option value="">{% translate "All" %}</option>
|
||||
<option value="true" {% if filters.is_active == "true" %}selected{% endif %}>{% translate "Active" %}</option>
|
||||
<option value="false" {% if filters.is_active == "false" %}selected{% endif %}>{% translate "Inactive" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 text-end">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-search"></i>
|
||||
<div class="flex items-end gap-2">
|
||||
<button type="submit" class="btn-primary h-[46px]">
|
||||
<i data-lucide="search" class="w-4 h-4"></i>
|
||||
{% translate "Apply Filters" %}
|
||||
</button>
|
||||
<a href="{% url 'complaints:complaint_threshold_list' %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-times"></i>
|
||||
<a href="{% url 'complaints:complaint_threshold_list' %}" class="btn-secondary h-[46px]">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
{% translate "Clear" %}
|
||||
</a>
|
||||
</div>
|
||||
@ -82,11 +236,17 @@
|
||||
</div>
|
||||
|
||||
<!-- Thresholds Table -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="section-card animate-in">
|
||||
<div class="section-header">
|
||||
<div class="section-icon primary">
|
||||
<i data-lucide="trending-up" class="w-5 h-5"></i>
|
||||
</div>
|
||||
<h2 class="text-lg font-bold text-navy m-0">{% translate "All Thresholds" %}</h2>
|
||||
</div>
|
||||
<div class="p-0">
|
||||
{% if thresholds %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% translate "Hospital" %}</th>
|
||||
@ -97,7 +257,7 @@
|
||||
<th>{% translate "Category" %}</th>
|
||||
<th>{% translate "Action" %}</th>
|
||||
<th>{% translate "Status" %}</th>
|
||||
<th>{% translate "Actions" %}</th>
|
||||
<th class="text-right">{% translate "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -108,10 +268,10 @@
|
||||
</td>
|
||||
<td>{{ threshold.name }}</td>
|
||||
<td>
|
||||
<span class="badge bg-info">{{ threshold.get_threshold_type_display }}</span>
|
||||
<span class="badge badge-info">{{ threshold.get_threshold_type_display }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-secondary">{{ threshold.get_metric_type_display }}</span>
|
||||
<span class="badge badge-secondary">{{ threshold.get_metric_type_display }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{{ threshold.threshold_value }}</strong>
|
||||
@ -120,25 +280,31 @@
|
||||
{% if threshold.complaint_category %}
|
||||
{{ threshold.complaint_category.name }}
|
||||
{% else %}
|
||||
<span class="text-muted">{% translate "All Categories" %}</span>
|
||||
<span class="text-slate-400">{% translate "All Categories" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-warning text-dark">{{ threshold.get_action_display }}</span>
|
||||
<span class="badge badge-warning">{{ threshold.get_action_display }}</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if threshold.is_active %}
|
||||
<span class="badge bg-success">{% translate "Active" %}</span>
|
||||
<span class="badge badge-success">
|
||||
<i data-lucide="check-circle" class="w-3 h-3"></i>
|
||||
{% translate "Active" %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{% translate "Inactive" %}</span>
|
||||
<span class="badge badge-secondary">
|
||||
<i data-lucide="x-circle" class="w-3 h-3"></i>
|
||||
{% translate "Inactive" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group">
|
||||
<td class="text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<a href="{% url 'complaints:complaint_threshold_edit' threshold.id %}"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
class="p-2 text-blue hover:bg-blue-50 rounded-lg transition"
|
||||
title="{% translate 'Edit' %}">
|
||||
<i class="fas fa-edit"></i>
|
||||
<i data-lucide="edit" class="w-4 h-4"></i>
|
||||
</a>
|
||||
<form method="post"
|
||||
action="{% url 'complaints:complaint_threshold_delete' threshold.id %}"
|
||||
@ -146,9 +312,9 @@
|
||||
onsubmit="return confirm('{% translate "Are you sure you want to delete this threshold?" %}')">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
class="btn btn-sm btn-outline-danger"
|
||||
class="p-2 text-red-500 hover:bg-red-50 rounded-lg transition"
|
||||
title="{% translate 'Delete' %}">
|
||||
<i class="fas fa-trash"></i>
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@ -161,59 +327,42 @@
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if page_obj.has_other_pages %}
|
||||
<nav class="mt-4">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page=1{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
|
||||
<i class="fas fa-angle-double-left"></i>
|
||||
<div class="p-4 border-t border-slate-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm text-slate">
|
||||
{% blocktrans with start=page_obj.start_index end=page_obj.end_index total=page_obj.paginator.count %}
|
||||
Showing {{ start }} to {{ end }} of {{ total }} thresholds
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="?page={{ page_obj.previous_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}"
|
||||
class="px-4 py-2 border border-slate-200 rounded-lg hover:bg-slate-50 transition text-sm font-medium">
|
||||
<i data-lucide="chevron-left" class="w-4 h-4 inline"></i>
|
||||
{% translate "Previous" %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
|
||||
<i class="fas fa-angle-left"></i>
|
||||
{% endif %}
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?page={{ page_obj.next_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}"
|
||||
class="px-4 py-2 border border-slate-200 rounded-lg hover:bg-slate-50 transition text-sm font-medium">
|
||||
{% translate "Next" %}
|
||||
<i data-lucide="chevron-right" class="w-4 h-4 inline"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for num in page_obj.paginator.page_range %}
|
||||
{% if page_obj.number == num %}
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ num }}</span>
|
||||
</li>
|
||||
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ num }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
|
||||
{{ num }}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
|
||||
<i class="fas fa-angle-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
|
||||
<i class="fas fa-angle-double-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-chart-line fa-3x text-muted mb-3"></i>
|
||||
<p class="text-muted">
|
||||
{% translate "No thresholds found. Create your first threshold to get started." %}
|
||||
</p>
|
||||
<a href="{% url 'complaints:complaint_threshold_create' %}" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i>
|
||||
<div class="text-center py-12">
|
||||
<div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i data-lucide="trending-up" class="w-8 h-8 text-slate-400"></i>
|
||||
</div>
|
||||
<p class="text-slate font-medium">{% translate "No thresholds found" %}</p>
|
||||
<p class="text-slate text-sm mt-1">{% translate "Create your first threshold to get started" %}</p>
|
||||
<a href="{% url 'complaints:complaint_threshold_create' %}" class="btn-primary mt-4">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i>
|
||||
{% translate "Create Threshold" %}
|
||||
</a>
|
||||
</div>
|
||||
@ -221,4 +370,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -2,13 +2,21 @@
|
||||
|
||||
عزيزي {{ recipient.get_full_name }},
|
||||
|
||||
{% if is_unassigned %}
|
||||
هام: هذا تذكير بشكوى غير معينة تحتاج إلى اهتمامكم.
|
||||
|
||||
لم يتم تعيين هذه الشكوى لأي شخص بعد. يرجى تعيينها لأحد أعضاء الفريق المناسبين في أقرب وقت ممكن لضمان معالجتها قبل موعد انتهاء اتفاقية مستوى الخدمة.
|
||||
|
||||
بصفتكم مدير مستشفى أو منسق تجربة المرضى، تتلقون هذا الإشعار لأن الشكوى لا تزال معلقة التعيين.
|
||||
{% else %}
|
||||
هذه رسالة تذكير آلية لديك شكوى معينة تقترب من موعد نهائي لاتفاقية مستوى الخدمة.
|
||||
{% endif %}
|
||||
|
||||
تفاصيل الشكوى:
|
||||
- الرقم: #{{ complaint.id|slice:":8" }}
|
||||
- العنوان: {{ complaint.title }}
|
||||
- الخطورة: {% if complaint.severity == 'critical' %}حرجة{% elif complaint.severity == 'high' %}عالية{% elif complaint.severity == 'medium' %}متوسطة{% else %}منخفضة{% endif %}
|
||||
- الأولوية: {% if complaint.priority == 'critical' %}حرجة{% elif complaint.priority == 'high' %}عالية{% elif complaint.priority == 'medium' %}متوسطة{% else %}منخفضة{% endif %}
|
||||
- الأولوية: {% if complaint.priority == 'urgent' %}عاجلة{% elif complaint.priority == 'high' %}عالية{% elif complaint.priority == 'medium' %}متوسطة{% else %}منخفضة{% endif %}
|
||||
- الفئة: {% if complaint.category %}{{ complaint.category.name_ar }}{% else %}غير متوفر{% endif %}
|
||||
- القسم: {% if complaint.department %}{{ complaint.department.name_ar }}{% else %}غير متوفر{% endif %}
|
||||
- المريض: {% if complaint.patient %}{{ complaint.patient.get_full_name }} (رقم الملف الطبي: {{ complaint.patient.mrn }}){% else %}غير متوفر{% endif %}
|
||||
@ -16,14 +24,22 @@
|
||||
معلومات اتفاقية مستوى الخدمة:
|
||||
- تاريخ الاستحقاق: {{ due_date|date:"Y F d H:i" }}
|
||||
- الوقت المتبقي: {{ hours_remaining }} ساعة
|
||||
- الحالة الحالية: {% if complaint.status == 'open' %}مفتوحة{% elif complaint.status == 'in_progress' %قيد التنفيذ{% elif complaint.status == 'resolved' %}تم الحل{% elif complaint.status == 'closed' %}مغلقة{% else %}{{ complaint.status }}{% endif %}
|
||||
- الحالة الحالية: {% if complaint.status == 'open' %}مفتوحة{% elif complaint.status == 'in_progress' %}قيد التنفيذ{% elif complaint.status == 'resolved' %}تم الحل{% elif complaint.status == 'closed' %}مغلقة{% else %}{{ complaint.status }}{% endif %}
|
||||
|
||||
الإجراء المطلوب:
|
||||
{% if is_unassigned %}
|
||||
1. تعيين هذه الشكوى لأحد الموظفين المناسبين فوراً
|
||||
2. التأكد من أن الموظف المُعيَّن على دراية بموعد انتهاء اتفاقية مستوى الخدمة
|
||||
3. متابعة التقدم لضمان الحل في الوقت المناسب
|
||||
{% else %}
|
||||
يرجى مراجعة هذه الشكوى واتخاذ الإجراء المناسب قبل الموعد النهائي لاتفاقية مستوى الخدمة لتجنب تجاوزها.
|
||||
{% endif %}
|
||||
|
||||
يمكنك عرض الشكوى على: {{ site_url }}/complaints/{{ complaint.id }}/
|
||||
|
||||
{% if not is_unassigned %}
|
||||
إذا كنت قد اتخذت بالفعل إجراءً بخصوص هذه الشكوى، يرجى تحديث حالتها في النظام.
|
||||
{% endif %}
|
||||
|
||||
هذه رسالة آلية. يرجى عدم الرد مباشرة على هذا البريد الإلكتروني.
|
||||
|
||||
|
||||
@ -2,7 +2,15 @@ SLA Reminder - Complaint #{{ complaint.id|slice:":8" }}
|
||||
|
||||
Dear {{ recipient.get_full_name }},
|
||||
|
||||
{% if is_unassigned %}
|
||||
IMPORTANT: This is a reminder about an UNASSIGNED complaint that needs your attention.
|
||||
|
||||
This complaint has not yet been assigned to anyone. Please assign it to an appropriate team member as soon as possible to ensure it is addressed before the SLA deadline.
|
||||
|
||||
As a Hospital Administrator or PX Coordinator, you are receiving this notification because the complaint is still pending assignment.
|
||||
{% else %}
|
||||
This is an automated reminder that you have an assigned complaint approaching its SLA deadline.
|
||||
{% endif %}
|
||||
|
||||
COMPLAINT DETAILS:
|
||||
- ID: #{{ complaint.id|slice:":8" }}
|
||||
@ -19,11 +27,19 @@ SLA INFORMATION:
|
||||
- Current Status: {{ complaint.get_status_display }}
|
||||
|
||||
ACTION REQUIRED:
|
||||
{% if is_unassigned %}
|
||||
1. Assign this complaint to an appropriate staff member immediately
|
||||
2. Ensure the assigned person is aware of the approaching SLA deadline
|
||||
3. Monitor progress to ensure timely resolution
|
||||
{% else %}
|
||||
Please review this complaint and take appropriate action before the SLA deadline to avoid breach.
|
||||
{% endif %}
|
||||
|
||||
You can view the complaint at: {{ site_url }}/complaints/{{ complaint.id }}/
|
||||
|
||||
{% if not is_unassigned %}
|
||||
If you have already addressed this complaint, please update its status in the system.
|
||||
{% endif %}
|
||||
|
||||
This is an automated message. Please do not reply directly to this email.
|
||||
|
||||
|
||||
@ -2,22 +2,38 @@
|
||||
|
||||
عزيزي/عزيزتي {{ recipient.get_full_name }},
|
||||
|
||||
هذه التذكير الثاني والأخير بأن لديك شكوى ستنتهي مواعيدها في اتفاقية مستوى الخدمة قريباً جداً.
|
||||
{% if is_unassigned %}
|
||||
تنبيه هام: هذا تذكير عاجل بشكوى غير معينة تتطلب اهتماماً فورياً.
|
||||
|
||||
لم يتم تعيين هذه الشكوى لأي شخص بعد وهي على وشك تجاوز موعد انتهاء اتفاقية مستوى الخدمة. بصفتكم مدير مستشفى أو منسق تجربة المرضى، يجب عليكم اتخاذ إجراء فوري.
|
||||
|
||||
هذا التذكير النهائي قبل التصعيد التلقائي.
|
||||
|
||||
الإجراء العاجل المطلوب:
|
||||
1. تعيين هذه الشكوى لأحد الموظفين المناسبين فوراً
|
||||
2. التأكد من أن الموظف المُعيَّن على دراية بالموعد النهائي الحرج
|
||||
3. متابعة التقدم بشكل مستمر حتى يتم الحل
|
||||
|
||||
عدم تعيين ومعالجة هذه الشكوى قد يؤدي إلى التصعيد التلقائي للإدارة العليا.
|
||||
{% else %}
|
||||
هذا التذكير الثاني والأخير بأن لديك شكوى معينة ستنتهي مواعيدها في اتفاقية مستوى الخدمة قريباً جداً.
|
||||
{% endif %}
|
||||
|
||||
تفاصيل الشكوى:
|
||||
- المعرف: #{{ complaint.id|slice:":8" }}
|
||||
- العنوان: {{ complaint.title }}
|
||||
- الخطورة: {{ complaint.get_severity_display_ar }}
|
||||
- الأولوية: {{ complaint.get_priority_display_ar }}
|
||||
- الخطورة: {{ complaint.get_severity_display }}
|
||||
- الأولوية: {{ complaint.get_priority_display }}
|
||||
- الفئة: {% if complaint.category %}{{ complaint.category.name_ar }}{% else %}غير متوفر{% endif %}
|
||||
- القسم: {% if complaint.department %}{{ complaint.department.name_ar }}{% else %}غير متوفر{% endif %}
|
||||
- المريض: {% if complaint.patient %}{{ patient.get_full_name }} (الرقم الطبي: {{ complaint.patient.mrn }}){% else %}غير متوفر{% endif %}
|
||||
- المريض: {% if complaint.patient %}{{ complaint.patient.get_full_name }} (الرقم الطبي: {{ complaint.patient.mrn }}){% else %}غير متوفر{% endif %}
|
||||
|
||||
معلومات اتفاقية مستوى الخدمة:
|
||||
- تاريخ الاستحقاق: {{ due_date|date:"d F Y H:i" }}
|
||||
- الوقت المتبقي: {{ hours_remaining }} ساعة
|
||||
- الحالة الحالية: {{ complaint.get_status_display_ar }}
|
||||
- الحالة الحالية: {{ complaint.get_status_display }}
|
||||
|
||||
{% if not is_unassigned %}
|
||||
إجراء عاجل مطلوب:
|
||||
هذه الشكوى تبعد {{ hours_remaining }} ساعة عن تجاوز موعد انتهاء اتفاقية مستوى الخدمة.
|
||||
يرجى المراجعة واتخاذ إجراء فوري لتجنب التصعيد وعواقب تجاوز الموعد.
|
||||
@ -26,6 +42,7 @@
|
||||
1. تحديث حالة الشكوى لتعكس التقدم الحالي
|
||||
2. إضافة تحديث على الجدول الزمني يوضح التأخير
|
||||
3. التواصل مع مدير القسم إذا كانت هناك حاجة إلى موارد إضافية
|
||||
{% endif %}
|
||||
|
||||
يمكنك عرض الشكوى على: {{ site_url }}/complaints/{{ complaint.id }}/
|
||||
|
||||
|
||||
@ -2,7 +2,22 @@ URGENT - Second SLA Reminder - Complaint #{{ complaint.id|slice:":8" }}
|
||||
|
||||
Dear {{ recipient.get_full_name }},
|
||||
|
||||
This is the second and final reminder that you have a complaint that will breach its SLA deadline very soon.
|
||||
{% if is_unassigned %}
|
||||
CRITICAL ALERT: This is an URGENT reminder about an UNASSIGNED complaint that requires IMMEDIATE attention.
|
||||
|
||||
This complaint has NOT been assigned to anyone and is about to breach its SLA deadline. As a Hospital Administrator or PX Coordinator, you must take immediate action.
|
||||
|
||||
This is the FINAL reminder before automatic escalation.
|
||||
|
||||
URGENT ACTION REQUIRED:
|
||||
1. ASSIGN this complaint to an appropriate staff member IMMEDIATELY
|
||||
2. ENSURE the assigned person is aware of the critical deadline
|
||||
3. MONITOR progress continuously until resolved
|
||||
|
||||
Failure to assign and address this complaint may result in automatic escalation to higher management.
|
||||
{% else %}
|
||||
This is the second and final reminder that you have an assigned complaint approaching its SLA deadline.
|
||||
{% endif %}
|
||||
|
||||
COMPLAINT DETAILS:
|
||||
- ID: #{{ complaint.id|slice:":8" }}
|
||||
@ -11,13 +26,14 @@ COMPLAINT DETAILS:
|
||||
- Priority: {{ complaint.get_priority_display }}
|
||||
- Category: {% if complaint.category %}{{ complaint.category.name_en }}{% else %}N/A{% endif %}
|
||||
- Department: {% if complaint.department %}{{ complaint.department.name_en }}{% else %}N/A{% endif %}
|
||||
- Patient: {% if complaint.patient %}{{ patient.get_full_name }} (MRN: {{ complaint.patient.mrn }}){% else %}N/A{% endif %}
|
||||
- Patient: {% if complaint.patient %}{{ complaint.patient.get_full_name }} (MRN: {{ complaint.patient.mrn }}){% else %}N/A{% endif %}
|
||||
|
||||
SLA INFORMATION:
|
||||
- Due Date: {{ due_date|date:"F d, Y H:i" }}
|
||||
- Time Remaining: {{ hours_remaining }} hours
|
||||
- Current Status: {{ complaint.get_status_display }}
|
||||
|
||||
{% if not is_unassigned %}
|
||||
URGENT ACTION REQUIRED:
|
||||
This complaint is {{ hours_remaining }} hours from breaching its SLA deadline.
|
||||
Please review and take immediate action to avoid escalation and SLA breach consequences.
|
||||
@ -26,6 +42,7 @@ If this complaint cannot be resolved within the remaining time, please:
|
||||
1. Update the complaint status to reflect current progress
|
||||
2. Add a timeline update explaining the delay
|
||||
3. Contact your department manager if additional resources are needed
|
||||
{% endif %}
|
||||
|
||||
You can view the complaint at: {{ site_url }}/complaints/{{ complaint.id }}/
|
||||
|
||||
|
||||
@ -3,307 +3,467 @@
|
||||
|
||||
{% block title %}{{ title }} - {% translate "Escalation Rules" %} - PX360{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.page-header-gradient {
|
||||
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
|
||||
color: white;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
|
||||
}
|
||||
|
||||
.form-section {
|
||||
background: #fff;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.form-section:hover {
|
||||
border-color: #005696;
|
||||
box-shadow: 0 4px 12px rgba(0, 86, 150, 0.1);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-control, .form-select {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.form-control:focus, .form-select:focus {
|
||||
outline: none;
|
||||
border-color: #005696;
|
||||
box-shadow: 0 0 0 3px rgba(0, 86, 150, 0.1);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #005696;
|
||||
color: white;
|
||||
border-radius: 0.75rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: #007bbd;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: white;
|
||||
color: #64748b;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 0.75rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: #005696;
|
||||
}
|
||||
|
||||
.help-card {
|
||||
background: #f8fafc;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.form-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.form-switch-input {
|
||||
width: 3rem;
|
||||
height: 1.5rem;
|
||||
appearance: none;
|
||||
background: #cbd5e1;
|
||||
border-radius: 1rem;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.form-switch-input:checked {
|
||||
background: #005696;
|
||||
}
|
||||
.form-switch-input::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
top: 0.125rem;
|
||||
left: 0.125rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.form-switch-input:checked::after {
|
||||
left: 1.625rem;
|
||||
}
|
||||
|
||||
.invalid-feedback {
|
||||
color: #dc2626;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.form-text {
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background: #dbeafe;
|
||||
border: 1px solid #93c5fd;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
.alert-info ol {
|
||||
color: #1e40af;
|
||||
font-size: 0.875rem;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
.alert-info li {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div class="page-header-content">
|
||||
<div>
|
||||
<h1 class="page-title">
|
||||
<i class="fas fa-arrow-up"></i>
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p class="page-description">
|
||||
{% if escalation_rule %}
|
||||
{% translate "Edit escalation rule" %}
|
||||
{% else %}
|
||||
{% translate "Create new escalation rule" %}
|
||||
{% endif %}
|
||||
</p>
|
||||
<!-- Gradient Header -->
|
||||
<div class="page-header-gradient">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center">
|
||||
<i data-lucide="arrow-up-circle" class="w-6 h-6"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{{ title }}</h1>
|
||||
<p class="text-white/80">
|
||||
{% if escalation_rule %}{% translate "Edit escalation rule" %}{% else %}{% translate "Create new escalation rule" %}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="{% url 'complaints:escalation_rule_list' %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
<a href="{% url 'complaints:escalation_rule_list' %}" class="btn-secondary">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4"></i>
|
||||
{% translate "Back to List" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-content">
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post" class="row g-3">
|
||||
{% csrf_token %}
|
||||
|
||||
{% if request.user.is_px_admin %}
|
||||
<div class="col-md-12">
|
||||
<label for="id_hospital" class="form-label">
|
||||
{% translate "Hospital" %} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<select name="hospital" id="id_hospital" class="form-select" required>
|
||||
<option value="">{% translate "Select Hospital" %}</option>
|
||||
{% for hospital in form.hospital.field.queryset %}
|
||||
<option value="{{ hospital.id }}"
|
||||
{% if form.hospital.value == hospital.id|stringformat:"s" %}selected{% endif %}>
|
||||
{{ hospital.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if form.hospital.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ form.hospital.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="lg:col-span-2">
|
||||
<div class="form-section">
|
||||
<form method="post" class="space-y-6">
|
||||
{% csrf_token %}
|
||||
|
||||
{% if not form.hospital.is_hidden %}
|
||||
<div>
|
||||
<label for="id_hospital" class="form-label">
|
||||
{% translate "Hospital" %} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select name="hospital" id="id_hospital" class="form-select" required>
|
||||
<option value="">{% translate "Select Hospital" %}</option>
|
||||
{% for hospital in form.hospital.field.queryset %}
|
||||
<option value="{{ hospital.id }}"
|
||||
{% if form.hospital.value == hospital.id|stringformat:"s" %}selected{% endif %}>
|
||||
{{ hospital.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if form.hospital.errors %}
|
||||
<div class="invalid-feedback">
|
||||
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
|
||||
{{ form.hospital.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<label for="id_name" class="form-label">
|
||||
{% translate "Rule Name" %} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text"
|
||||
name="name"
|
||||
id="id_name"
|
||||
class="form-control"
|
||||
value="{{ form.name.value|default:'' }}"
|
||||
required
|
||||
placeholder="{% translate 'e.g., Level 1 Escalation - High Priority' %}">
|
||||
{% if form.name.errors %}
|
||||
<div class="invalid-feedback">
|
||||
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
|
||||
{{ form.name.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="id_escalation_level" class="form-label">
|
||||
{% translate "Escalation Level" %} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select name="escalation_level" id="id_escalation_level" class="form-select" required>
|
||||
<option value="">{% translate "Select Level" %}</option>
|
||||
{% for value, label in form.escalation_level.field.choices %}
|
||||
<option value="{{ value }}"
|
||||
{% if form.escalation_level.value == value %}selected{% endif %}>
|
||||
{{ label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if form.escalation_level.errors %}
|
||||
<div class="invalid-feedback">
|
||||
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
|
||||
{{ form.escalation_level.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="col-md-12">
|
||||
<label for="id_name" class="form-label">
|
||||
{% translate "Rule Name" %} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text"
|
||||
name="name"
|
||||
id="id_name"
|
||||
class="form-control"
|
||||
value="{{ form.name.value|default:'' }}"
|
||||
required
|
||||
placeholder="{% translate 'e.g., Level 1 Escalation - High Priority' %}">
|
||||
{% if form.name.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ form.name.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="id_escalation_level" class="form-label">
|
||||
{% translate "Escalation Level" %} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<select name="escalation_level" id="id_escalation_level" class="form-select" required>
|
||||
<option value="">{% translate "Select Level" %}</option>
|
||||
{% for value, label in form.escalation_level.field.choices %}
|
||||
<option value="{{ value }}"
|
||||
{% if form.escalation_level.value == value %}selected{% endif %}>
|
||||
{{ label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if form.escalation_level.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ form.escalation_level.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="id_trigger_hours" class="form-label">
|
||||
{% translate "Trigger Hours" %} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="number"
|
||||
name="trigger_hours"
|
||||
id="id_trigger_hours"
|
||||
class="form-control"
|
||||
value="{{ form.trigger_hours.value|default:'' }}"
|
||||
min="1"
|
||||
step="0.5"
|
||||
required
|
||||
placeholder="{% translate 'e.g., 24' %}">
|
||||
{% if form.trigger_hours.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ form.trigger_hours.errors.0 }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-text">
|
||||
{% translate "Hours after complaint creation to trigger escalation" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="id_escalate_to_role" class="form-label">
|
||||
{% translate "Escalate To Role" %}
|
||||
</label>
|
||||
<select name="escalate_to_role" id="id_escalate_to_role" class="form-select">
|
||||
<option value="">{% translate "Select Role" %}</option>
|
||||
{% for value, label in form.escalate_to_role.field.choices %}
|
||||
<option value="{{ value }}"
|
||||
{% if form.escalate_to_role.value == value %}selected{% endif %}>
|
||||
{{ label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if form.escalate_to_role.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ form.escalate_to_role.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="id_escalate_to_user" class="form-label">
|
||||
{% translate "Escalate To Specific User" %}
|
||||
</label>
|
||||
<select name="escalate_to_user" id="id_escalate_to_user" class="form-select">
|
||||
<option value="">{% translate "Select User (Optional)" %}</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}"
|
||||
{% if form.escalate_to_user.value == user.id|stringformat:"s" %}selected{% endif %}>
|
||||
{{ user.get_full_name }} ({{ user.email }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if form.escalate_to_user.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ form.escalate_to_user.errors.0 }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-text">
|
||||
{% translate "Overrides role if specified" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="id_severity" class="form-label">
|
||||
{% translate "Severity (Optional)" %}
|
||||
</label>
|
||||
<select name="severity" id="id_severity" class="form-select">
|
||||
<option value="">{% translate "All Severities" %}</option>
|
||||
{% for value, label in form.severity.field.choices %}
|
||||
<option value="{{ value }}"
|
||||
{% if form.severity.value == value %}selected{% endif %}>
|
||||
{{ label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if form.severity.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ form.severity.errors.0 }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-text">
|
||||
{% translate "Leave empty to apply to all severities" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="id_priority" class="form-label">
|
||||
{% translate "Priority (Optional)" %}
|
||||
</label>
|
||||
<select name="priority" id="id_priority" class="form-select">
|
||||
<option value="">{% translate "All Priorities" %}</option>
|
||||
{% for value, label in form.priority.field.choices %}
|
||||
<option value="{{ value }}"
|
||||
{% if form.priority.value == value %}selected{% endif %}>
|
||||
{{ label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if form.priority.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ form.priority.errors.0 }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-text">
|
||||
{% translate "Leave empty to apply to all priorities" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox"
|
||||
name="is_active"
|
||||
id="id_is_active"
|
||||
class="form-check-input"
|
||||
{% if form.is_active.value == 'on' or not form.is_active.value %}checked{% endif %}>
|
||||
<label class="form-check-label" for="id_is_active">
|
||||
{% translate "Active" %}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
{% translate "Only active rules will be triggered" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label for="id_description" class="form-label">
|
||||
{% translate "Description" %}
|
||||
</label>
|
||||
<textarea name="description"
|
||||
id="id_description"
|
||||
class="form-control"
|
||||
rows="3"
|
||||
placeholder="{% translate 'Optional notes about this escalation rule' %}">{{ form.description.value|default:'' }}</textarea>
|
||||
{% if form.description.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ form.description.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i>
|
||||
{{ action }}
|
||||
</button>
|
||||
<a href="{% url 'complaints:escalation_rule_list' %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-times"></i>
|
||||
{% translate "Cancel" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="card bg-light">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
{% translate "Help" %}
|
||||
</h5>
|
||||
<h6 class="card-subtitle mb-3 text-muted">
|
||||
{% translate "Understanding Escalation Rules" %}
|
||||
</h6>
|
||||
<p class="card-text">
|
||||
{% translate "Escalation rules automatically reassign complaints to higher-level staff when they exceed specified time thresholds." %}
|
||||
</p>
|
||||
<ul class="list-unstyled">
|
||||
<li class="mb-2">
|
||||
<i class="fas fa-check text-success me-2"></i>
|
||||
{% translate "Level 1: Escalate to department head" %}
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="fas fa-check text-success me-2"></i>
|
||||
{% translate "Level 2: Escalate to hospital admin" %}
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="fas fa-check text-success me-2"></i>
|
||||
{% translate "Level 3: Escalate to PX admin" %}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h6 class="card-subtitle mb-2 text-muted">
|
||||
{% translate "Escalation Flow" %}
|
||||
</h6>
|
||||
<div class="alert alert-info">
|
||||
<ol class="mb-0">
|
||||
<li>{% translate "Complaint created" %}</li>
|
||||
<li>{% translate "Trigger hours pass" %}</li>
|
||||
<li>{% translate "Rule checks severity/priority" %}</li>
|
||||
<li>{% translate "Complaint reassigned automatically" %}</li>
|
||||
<li>{% translate "Notification sent to new assignee" %}</li>
|
||||
</ol>
|
||||
<div>
|
||||
<label for="id_trigger_hours" class="form-label">
|
||||
{% translate "Trigger Hours" %} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="number"
|
||||
name="trigger_hours"
|
||||
id="id_trigger_hours"
|
||||
class="form-control"
|
||||
value="{{ form.trigger_hours.value|default:'' }}"
|
||||
min="1"
|
||||
step="0.5"
|
||||
required
|
||||
placeholder="{% translate 'e.g., 24' %}">
|
||||
{% if form.trigger_hours.errors %}
|
||||
<div class="invalid-feedback">
|
||||
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
|
||||
{{ form.trigger_hours.errors.0 }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-text">
|
||||
{% translate "Hours after complaint creation to trigger escalation" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="id_escalate_to_role" class="form-label">
|
||||
{% translate "Escalate To Role" %}
|
||||
</label>
|
||||
<select name="escalate_to_role" id="id_escalate_to_role" class="form-select">
|
||||
<option value="">{% translate "Select Role" %}</option>
|
||||
{% for value, label in form.escalate_to_role.field.choices %}
|
||||
<option value="{{ value }}"
|
||||
{% if form.escalate_to_role.value == value %}selected{% endif %}>
|
||||
{{ label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if form.escalate_to_role.errors %}
|
||||
<div class="invalid-feedback">
|
||||
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
|
||||
{{ form.escalate_to_role.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="id_escalate_to_user" class="form-label">
|
||||
{% translate "Escalate To Specific User" %}
|
||||
</label>
|
||||
<select name="escalate_to_user" id="id_escalate_to_user" class="form-select">
|
||||
<option value="">{% translate "Select User (Optional)" %}</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}"
|
||||
{% if form.escalate_to_user.value == user.id|stringformat:"s" %}selected{% endif %}>
|
||||
{{ user.get_full_name }} ({{ user.email }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if form.escalate_to_user.errors %}
|
||||
<div class="invalid-feedback">
|
||||
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
|
||||
{{ form.escalate_to_user.errors.0 }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-text">
|
||||
{% translate "Overrides role if specified" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="id_severity" class="form-label">
|
||||
{% translate "Severity (Optional)" %}
|
||||
</label>
|
||||
<select name="severity" id="id_severity" class="form-select">
|
||||
<option value="">{% translate "All Severities" %}</option>
|
||||
{% for value, label in form.severity.field.choices %}
|
||||
<option value="{{ value }}"
|
||||
{% if form.severity.value == value %}selected{% endif %}>
|
||||
{{ label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if form.severity.errors %}
|
||||
<div class="invalid-feedback">
|
||||
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
|
||||
{{ form.severity.errors.0 }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-text">
|
||||
{% translate "Leave empty to apply to all severities" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="id_priority" class="form-label">
|
||||
{% translate "Priority (Optional)" %}
|
||||
</label>
|
||||
<select name="priority" id="id_priority" class="form-select">
|
||||
<option value="">{% translate "All Priorities" %}</option>
|
||||
{% for value, label in form.priority.field.choices %}
|
||||
<option value="{{ value }}"
|
||||
{% if form.priority.value == value %}selected{% endif %}>
|
||||
{{ label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if form.priority.errors %}
|
||||
<div class="invalid-feedback">
|
||||
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
|
||||
{{ form.priority.errors.0 }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-text">
|
||||
{% translate "Leave empty to apply to all priorities" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 p-4 bg-gray-50 rounded-xl">
|
||||
<div class="form-switch">
|
||||
<input type="checkbox"
|
||||
name="is_active"
|
||||
id="id_is_active"
|
||||
class="form-switch-input"
|
||||
{% if form.is_active.value == 'on' or not form.is_active.value %}checked{% endif %}>
|
||||
<label for="id_is_active" class="text-sm font-semibold text-gray-700">
|
||||
{% translate "Active" %}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-text ml-2">
|
||||
{% translate "Only active rules will be triggered" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="id_description" class="form-label">
|
||||
{% translate "Description" %}
|
||||
</label>
|
||||
<textarea name="description"
|
||||
id="id_description"
|
||||
class="form-control resize-none"
|
||||
rows="3"
|
||||
placeholder="{% translate 'Optional notes about this escalation rule' %}">{{ form.description.value|default:'' }}</textarea>
|
||||
{% if form.description.errors %}
|
||||
<div class="invalid-feedback">
|
||||
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
|
||||
{{ form.description.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 pt-6 border-t border-gray-100">
|
||||
<button type="submit" class="btn-primary">
|
||||
<i data-lucide="save" class="w-4 h-4"></i>
|
||||
{{ action }}
|
||||
</button>
|
||||
<a href="{% url 'complaints:escalation_rule_list' %}" class="btn-secondary">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
{% translate "Cancel" %}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-1">
|
||||
<div class="help-card">
|
||||
<h5 class="text-lg font-bold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<i data-lucide="info" class="w-5 h-5 text-[#005696]"></i>
|
||||
{% translate "Help" %}
|
||||
</h5>
|
||||
<h6 class="text-sm font-semibold text-gray-500 mb-3">
|
||||
{% translate "Understanding Escalation Rules" %}
|
||||
</h6>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
{% translate "Escalation rules automatically reassign complaints to higher-level staff when they exceed specified time thresholds." %}
|
||||
</p>
|
||||
<ul class="space-y-2 text-sm text-gray-600">
|
||||
<li class="flex items-center gap-2">
|
||||
<i data-lucide="check-circle" class="w-4 h-4 text-green-500"></i>
|
||||
{% translate "Level 1: Escalate to department head" %}
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<i data-lucide="check-circle" class="w-4 h-4 text-green-500"></i>
|
||||
{% translate "Level 2: Escalate to hospital admin" %}
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<i data-lucide="check-circle" class="w-4 h-4 text-green-500"></i>
|
||||
{% translate "Level 3: Escalate to PX admin" %}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<hr class="my-4 border-gray-200">
|
||||
|
||||
<h6 class="text-sm font-semibold text-gray-500 mb-3">
|
||||
{% translate "Escalation Flow" %}
|
||||
</h6>
|
||||
<div class="alert-info">
|
||||
<ol>
|
||||
<li>{% translate "Complaint created" %}</li>
|
||||
<li>{% translate "Trigger hours pass" %}</li>
|
||||
<li>{% translate "Rule checks severity/priority" %}</li>
|
||||
<li>{% translate "Complaint reassigned automatically" %}</li>
|
||||
<li>{% translate "Notification sent to new assignee" %}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -3,76 +3,265 @@
|
||||
|
||||
{% block title %}{% translate "Escalation Rules" %} - PX360{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div class="page-header-content">
|
||||
<div>
|
||||
<h1 class="page-title">
|
||||
<i class="fas fa-arrow-up"></i>
|
||||
{% translate "Escalation Rules" %}
|
||||
</h1>
|
||||
<p class="page-description">
|
||||
{% translate "Configure automatic complaint escalation based on time thresholds" %}
|
||||
</p>
|
||||
</div>
|
||||
<a href="{% url 'complaints:escalation_rule_create' %}" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i>
|
||||
{% translate "Create Escalation Rule" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
:root {
|
||||
--hh-navy: #005696;
|
||||
--hh-blue: #007bbd;
|
||||
--hh-light: #eef6fb;
|
||||
--hh-slate: #64748b;
|
||||
}
|
||||
|
||||
<div class="page-content">
|
||||
<!-- Filters -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title">
|
||||
<i class="fas fa-filter"></i>
|
||||
{% translate "Filters" %}
|
||||
</h5>
|
||||
.page-header-gradient {
|
||||
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
|
||||
color: white;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.section-card:hover {
|
||||
border-color: #005696;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
background: linear-gradient(to right, #f8fafc, #f1f5f9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.section-icon.primary {
|
||||
background: linear-gradient(135deg, #005696, #007bbd);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.section-icon.secondary {
|
||||
background: linear-gradient(135deg, #f1f5f9, #e2e8f0);
|
||||
color: #005696;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background: linear-gradient(135deg, var(--hh-light), #e0f2fe);
|
||||
padding: 0.875rem 1rem;
|
||||
text-align: left;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--hh-navy);
|
||||
border-bottom: 2px solid #bae6fd;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
color: #475569;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.data-table tbody tr {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background-color: var(--hh-light);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--hh-navy) 0%, var(--hh-blue) 100%);
|
||||
color: white;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: 0.75rem;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 16px rgba(0, 86, 150, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: white;
|
||||
color: #475569;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: 0.75rem;
|
||||
font-weight: 600;
|
||||
border: 2px solid #e2e8f0;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: #cbd5e1;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background: linear-gradient(135deg, #dbeafe, #bfdbfe);
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.badge-secondary {
|
||||
background: linear-gradient(135deg, #f1f5f9, #e2e8f0);
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: linear-gradient(135deg, #dcfce7, #bbf7d0);
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.severity-low {
|
||||
background: linear-gradient(135deg, #dcfce7, #bbf7d0);
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.severity-medium {
|
||||
background: linear-gradient(135deg, #fef3c7, #fde68a);
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.severity-high {
|
||||
background: linear-gradient(135deg, #fee2e2, #fecaca);
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.severity-critical {
|
||||
background: linear-gradient(135deg, #7f1d1d, #991b1b);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.priority-low {
|
||||
background: linear-gradient(135deg, #f1f5f9, #e2e8f0);
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.priority-medium {
|
||||
background: linear-gradient(135deg, #dbeafe, #bfdbfe);
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.priority-high {
|
||||
background: linear-gradient(135deg, #fef3c7, #fde68a);
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.priority-urgent {
|
||||
background: linear-gradient(135deg, #fee2e2, #fecaca);
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.animate-in {
|
||||
animation: fadeIn 0.5s ease-out forwards;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="px-4 py-6">
|
||||
<!-- Page Header -->
|
||||
<div class="page-header-gradient animate-in">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold mb-2">
|
||||
<i data-lucide="arrow-up-circle" class="w-7 h-7 inline-block me-2"></i>
|
||||
{% translate "Escalation Rules" %}
|
||||
</h1>
|
||||
<p class="text-white/90">{% translate "Configure automatic complaint escalation based on time thresholds" %}</p>
|
||||
</div>
|
||||
<a href="{% url 'complaints:escalation_rule_create' %}" class="btn-secondary">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i>
|
||||
{% translate "Create Rule" %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="get" class="row g-3">
|
||||
{% if request.user.is_px_admin %}
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">{% translate "Hospital" %}</label>
|
||||
<select name="hospital" class="form-select">
|
||||
<option value="">{% translate "All Hospitals" %}</option>
|
||||
{% for hospital in hospitals %}
|
||||
<option value="{{ hospital.id }}" {% if filters.hospital == hospital.id|stringformat:"s" %}selected{% endif %}>
|
||||
{{ hospital.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">{% translate "Escalation Level" %}</label>
|
||||
<select name="escalation_level" class="form-select">
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="section-card mb-6 animate-in">
|
||||
<div class="section-header">
|
||||
<div class="section-icon secondary">
|
||||
<i data-lucide="filter" class="w-5 h-5"></i>
|
||||
</div>
|
||||
<h2 class="text-lg font-bold text-navy m-0">{% translate "Filters" %}</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form method="get" class="flex flex-wrap gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate mb-1.5">{% translate "Escalation Level" %}</label>
|
||||
<select name="escalation_level" class="px-4 py-2.5 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-blue bg-white">
|
||||
<option value="">{% translate "All Levels" %}</option>
|
||||
<option value="1" {% if filters.escalation_level == "1" %}selected{% endif %}>1</option>
|
||||
<option value="2" {% if filters.escalation_level == "2" %}selected{% endif %}>2</option>
|
||||
<option value="3" {% if filters.escalation_level == "3" %}selected{% endif %}>3</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">{% translate "Status" %}</label>
|
||||
<select name="is_active" class="form-select">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate mb-1.5">{% translate "Status" %}</label>
|
||||
<select name="is_active" class="px-4 py-2.5 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-blue bg-white">
|
||||
<option value="">{% translate "All" %}</option>
|
||||
<option value="true" {% if filters.is_active == "true" %}selected{% endif %}>{% translate "Active" %}</option>
|
||||
<option value="false" {% if filters.is_active == "false" %}selected{% endif %}>{% translate "Inactive" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 text-end">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-search"></i>
|
||||
<div class="flex items-end gap-2">
|
||||
<button type="submit" class="btn-primary h-[46px]">
|
||||
<i data-lucide="search" class="w-4 h-4"></i>
|
||||
{% translate "Apply Filters" %}
|
||||
</button>
|
||||
<a href="{% url 'complaints:escalation_rule_list' %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-times"></i>
|
||||
<a href="{% url 'complaints:escalation_rule_list' %}" class="btn-secondary h-[46px]">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
{% translate "Clear" %}
|
||||
</a>
|
||||
</div>
|
||||
@ -81,11 +270,17 @@
|
||||
</div>
|
||||
|
||||
<!-- Escalation Rules Table -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="section-card animate-in">
|
||||
<div class="section-header">
|
||||
<div class="section-icon primary">
|
||||
<i data-lucide="arrow-up-circle" class="w-5 h-5"></i>
|
||||
</div>
|
||||
<h2 class="text-lg font-bold text-navy m-0">{% translate "All Escalation Rules" %}</h2>
|
||||
</div>
|
||||
<div class="p-0">
|
||||
{% if escalation_rules %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% translate "Hospital" %}</th>
|
||||
@ -96,7 +291,7 @@
|
||||
<th>{% translate "Severity" %}</th>
|
||||
<th>{% translate "Priority" %}</th>
|
||||
<th>{% translate "Status" %}</th>
|
||||
<th>{% translate "Actions" %}</th>
|
||||
<th class="text-right">{% translate "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -107,7 +302,7 @@
|
||||
</td>
|
||||
<td>{{ rule.name }}</td>
|
||||
<td>
|
||||
<span class="badge bg-info">
|
||||
<span class="badge badge-info">
|
||||
{% translate "Level" %} {{ rule.escalation_level }}
|
||||
</span>
|
||||
</td>
|
||||
@ -116,9 +311,9 @@
|
||||
{% if rule.escalate_to_user %}
|
||||
{{ rule.escalate_to_user.get_full_name }}
|
||||
{% elif rule.escalate_to_role %}
|
||||
<span class="badge bg-secondary">{{ rule.get_escalate_to_role_display }}</span>
|
||||
<span class="badge badge-secondary">{{ rule.get_escalate_to_role_display }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
<span class="text-slate-400">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
@ -127,7 +322,7 @@
|
||||
{{ rule.get_severity_display }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted">{% translate "All" %}</span>
|
||||
<span class="text-slate-400">{% translate "All" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
@ -136,22 +331,28 @@
|
||||
{{ rule.get_priority_display }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted">{% translate "All" %}</span>
|
||||
<span class="text-slate-400">{% translate "All" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if rule.is_active %}
|
||||
<span class="badge bg-success">{% translate "Active" %}</span>
|
||||
<span class="badge badge-success">
|
||||
<i data-lucide="check-circle" class="w-3 h-3"></i>
|
||||
{% translate "Active" %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{% translate "Inactive" %}</span>
|
||||
<span class="badge badge-secondary">
|
||||
<i data-lucide="x-circle" class="w-3 h-3"></i>
|
||||
{% translate "Inactive" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group">
|
||||
<td class="text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<a href="{% url 'complaints:escalation_rule_edit' rule.id %}"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
class="p-2 text-blue hover:bg-blue-50 rounded-lg transition"
|
||||
title="{% translate 'Edit' %}">
|
||||
<i class="fas fa-edit"></i>
|
||||
<i data-lucide="edit" class="w-4 h-4"></i>
|
||||
</a>
|
||||
<form method="post"
|
||||
action="{% url 'complaints:escalation_rule_delete' rule.id %}"
|
||||
@ -159,9 +360,9 @@
|
||||
onsubmit="return confirm('{% translate "Are you sure you want to delete this escalation rule?" %}')">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
class="btn btn-sm btn-outline-danger"
|
||||
class="p-2 text-red-500 hover:bg-red-50 rounded-lg transition"
|
||||
title="{% translate 'Delete' %}">
|
||||
<i class="fas fa-trash"></i>
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@ -174,59 +375,42 @@
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if page_obj.has_other_pages %}
|
||||
<nav class="mt-4">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page=1{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
|
||||
<i class="fas fa-angle-double-left"></i>
|
||||
<div class="p-4 border-t border-slate-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm text-slate">
|
||||
{% blocktrans with start=page_obj.start_index end=page_obj.end_index total=page_obj.paginator.count %}
|
||||
Showing {{ start }} to {{ end }} of {{ total }} rules
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="?page={{ page_obj.previous_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}"
|
||||
class="px-4 py-2 border border-slate-200 rounded-lg hover:bg-slate-50 transition text-sm font-medium">
|
||||
<i data-lucide="chevron-left" class="w-4 h-4 inline"></i>
|
||||
{% translate "Previous" %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
|
||||
<i class="fas fa-angle-left"></i>
|
||||
{% endif %}
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?page={{ page_obj.next_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}"
|
||||
class="px-4 py-2 border border-slate-200 rounded-lg hover:bg-slate-50 transition text-sm font-medium">
|
||||
{% translate "Next" %}
|
||||
<i data-lucide="chevron-right" class="w-4 h-4 inline"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for num in page_obj.paginator.page_range %}
|
||||
{% if page_obj.number == num %}
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ num }}</span>
|
||||
</li>
|
||||
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ num }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
|
||||
{{ num }}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
|
||||
<i class="fas fa-angle-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
|
||||
<i class="fas fa-angle-double-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-arrow-up fa-3x text-muted mb-3"></i>
|
||||
<p class="text-muted">
|
||||
{% translate "No escalation rules found. Create your first rule to get started." %}
|
||||
</p>
|
||||
<a href="{% url 'complaints:escalation_rule_create' %}" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i>
|
||||
<div class="text-center py-12">
|
||||
<div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i data-lucide="arrow-up-circle" class="w-8 h-8 text-slate-400"></i>
|
||||
</div>
|
||||
<p class="text-slate font-medium">{% translate "No escalation rules found" %}</p>
|
||||
<p class="text-slate text-sm mt-1">{% translate "Create your first rule to get started" %}</p>
|
||||
<a href="{% url 'complaints:escalation_rule_create' %}" class="btn-primary mt-4">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i>
|
||||
{% translate "Create Escalation Rule" %}
|
||||
</a>
|
||||
</div>
|
||||
@ -234,4 +418,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -24,23 +24,86 @@
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
body { font-family: 'Inter', sans-serif; }
|
||||
|
||||
.page-header-gradient {
|
||||
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
|
||||
color: white;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
|
||||
}
|
||||
|
||||
.form-section {
|
||||
background: #fff;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.form-section:hover {
|
||||
border-color: #005696;
|
||||
box-shadow: 0 4px 12px rgba(0, 86, 150, 0.1);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-control, .form-select {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.form-control:focus, .form-select:focus {
|
||||
outline: none;
|
||||
border-color: #005696;
|
||||
box-shadow: 0 0 0 3px rgba(0, 86, 150, 0.1);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #005696;
|
||||
color: white;
|
||||
border-radius: 0.75rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: #007bbd;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-light min-h-screen flex items-center py-8 px-4">
|
||||
<div class="w-full max-w-3xl mx-auto">
|
||||
<!-- Logo/Header -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 bg-navy rounded-2xl mb-4">
|
||||
<i data-lucide="message-square" class="w-8 h-8 text-white"></i>
|
||||
<!-- Gradient Header -->
|
||||
<div class="page-header-gradient text-center">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 bg-white/20 rounded-2xl mb-4">
|
||||
<i data-lucide="message-square" class="w-8 h-8"></i>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-navy">{% trans "Submit Your Explanation" %}</h1>
|
||||
<p class="text-slate mt-1">{% trans "PX360 Complaint Management System" %}</p>
|
||||
<h1 class="text-2xl font-bold">{% trans "Submit Your Explanation" %}</h1>
|
||||
<p class="text-white/80 mt-1">{% trans "PX360 Complaint Management System" %}</p>
|
||||
</div>
|
||||
|
||||
<!-- Main Card -->
|
||||
<div class="bg-white rounded-2xl shadow-lg border border-slate-100 overflow-hidden">
|
||||
<!-- Main Form Section -->
|
||||
<div class="form-section">
|
||||
{% if error %}
|
||||
<div class="bg-red-50 border-l-4 border-red-500 p-4 m-6 rounded-r-lg">
|
||||
<div class="bg-red-50 border-l-4 border-red-500 p-4 mb-6 rounded-r-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<i data-lucide="alert-circle" class="w-5 h-5 text-red-500 flex-shrink-0"></i>
|
||||
<p class="text-red-700 font-medium">{{ error }}</p>
|
||||
@ -50,7 +113,7 @@
|
||||
|
||||
<!-- Staff Information -->
|
||||
{% if explanation.staff %}
|
||||
<div class="bg-navy p-6 text-white">
|
||||
<div class="bg-navy p-6 text-white rounded-t-lg -mx-6 -mt-6 mb-6">
|
||||
<h2 class="text-sm font-semibold text-blue-200 mb-3 flex items-center gap-2">
|
||||
<i data-lucide="user" class="w-4 h-4"></i>
|
||||
{% trans "Requested From" %}
|
||||
@ -86,7 +149,7 @@
|
||||
|
||||
<!-- Original Staff Explanation (shown to manager during escalation) -->
|
||||
{% if original_explanation %}
|
||||
<div class="bg-orange-50 border-b border-orange-200 p-6">
|
||||
<div class="bg-orange-50 border border-orange-200 rounded-xl p-6 mb-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<div class="w-8 h-8 bg-orange-500 rounded-full flex items-center justify-center">
|
||||
<i data-lucide="arrow-up-circle" class="w-4 h-4 text-white"></i>
|
||||
@ -123,7 +186,7 @@
|
||||
{% endif %}
|
||||
|
||||
<!-- Complaint Details -->
|
||||
<div class="bg-light/50 border-b border-slate-100 p-6">
|
||||
<div class="bg-light/50 border border-slate-200 rounded-xl p-6 mb-6">
|
||||
<h2 class="text-lg font-bold text-navy mb-4 flex items-center gap-2">
|
||||
<i data-lucide="file-text" class="w-5 h-5 text-blue"></i>
|
||||
{% trans "Complaint Details" %}
|
||||
@ -137,26 +200,6 @@
|
||||
<span class="text-slate text-sm">{% trans "Title:" %}</span>
|
||||
<span class="font-bold text-navy">{{ complaint.title }}</span>
|
||||
</div>
|
||||
{% comment %} <div class="flex items-center gap-2">
|
||||
<span class="text-slate text-sm">{% trans "Severity:" %}</span>
|
||||
<span class="px-2.5 py-1 rounded-lg text-xs font-bold
|
||||
{% if complaint.severity == 'critical' %}bg-red-100 text-red-600
|
||||
{% elif complaint.severity == 'high' %}bg-orange-100 text-orange-600
|
||||
{% elif complaint.severity == 'medium' %}bg-yellow-100 text-yellow-600
|
||||
{% else %}bg-green-100 text-green-600{% endif %}">
|
||||
{{ complaint.get_severity_display }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-slate text-sm">{% trans "Priority:" %}</span>
|
||||
<span class="px-2.5 py-1 rounded-lg text-xs font-bold
|
||||
{% if complaint.priority == 'urgent' %}bg-red-100 text-red-600
|
||||
{% elif complaint.priority == 'high' %}bg-orange-100 text-orange-600
|
||||
{% elif complaint.priority == 'medium' %}bg-yellow-100 text-yellow-600
|
||||
{% else %}bg-blue-100 text-blue-600{% endif %}">
|
||||
{{ complaint.get_priority_display }}
|
||||
</span>
|
||||
</div> {% endcomment %}
|
||||
{% if complaint.patient %}
|
||||
<div class="md:col-span-2 flex items-center gap-2">
|
||||
<span class="text-slate text-sm">{% trans "Patient:" %}</span>
|
||||
@ -174,12 +217,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<form method="post" enctype="multipart/form-data" class="p-6 space-y-6">
|
||||
<form method="post" enctype="multipart/form-data" class="space-y-6">
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- Explanation Field -->
|
||||
<div>
|
||||
<label for="explanation" class="block text-sm font-bold text-navy mb-2">
|
||||
<label for="explanation" class="form-label">
|
||||
{% trans "Your Explanation" %} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<p class="text-slate text-sm mb-3">
|
||||
@ -190,14 +233,14 @@
|
||||
name="explanation"
|
||||
rows="6"
|
||||
required
|
||||
class="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition resize-none text-sm"
|
||||
class="form-control resize-none"
|
||||
placeholder="{% trans 'Write your explanation here...' %}"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Attachments -->
|
||||
<div>
|
||||
<label for="attachments" class="block text-sm font-bold text-navy mb-2">
|
||||
<label for="attachments" class="form-label">
|
||||
{% trans "Attachments (Optional)" %}
|
||||
</label>
|
||||
<p class="text-slate text-sm mb-3">
|
||||
@ -209,7 +252,7 @@
|
||||
id="attachments"
|
||||
name="attachments"
|
||||
multiple
|
||||
class="block w-full text-sm text-slate file:mr-4 file:py-2 file:px-4 file:rounded-xl file:border-0 file:text-sm file:font-semibold file:bg-light file:text-navy hover:file:bg-slate-100 file:transition cursor-pointer border border-slate-200 rounded-xl"
|
||||
class="block w-full text-sm text-slate file:mr-4 file:py-2 file:px-4 file:rounded-xl file:border-0 file:text-sm file:font-semibold file:bg-light file:text-navy hover:file:bg-slate-100 file:transition cursor-pointer border-2 border-slate-200 rounded-xl"
|
||||
>
|
||||
</div>
|
||||
<p class="text-xs text-slate mt-2">
|
||||
@ -231,14 +274,14 @@
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button type="submit" class="w-full px-6 py-4 bg-gradient-to-r from-navy to-blue text-white rounded-xl font-bold text-lg hover:opacity-90 transition flex items-center justify-center gap-2 shadow-lg">
|
||||
<button type="submit" class="btn-primary py-4 text-lg">
|
||||
<i data-lucide="send" class="w-5 h-5"></i>
|
||||
{% trans "Submit Explanation" %}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="bg-light/30 border-t border-slate-100 p-4 text-center">
|
||||
<div class="mt-6 pt-4 border-t border-slate-100 text-center">
|
||||
<p class="text-slate text-xs">{% trans "PX360 Complaint Management System" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user