update feeback crud

This commit is contained in:
Marwan Alwali 2025-12-24 13:40:01 +03:00
parent 2e15c5db7c
commit ddf3a06212
14 changed files with 3354 additions and 11 deletions

View File

@ -1,6 +1,109 @@
"""
Feedback admin
Feedback admin configuration
"""
from django.contrib import admin
# TODO: Register models for feedback
from .models import Feedback, FeedbackAttachment, FeedbackResponse
@admin.register(Feedback)
class FeedbackAdmin(admin.ModelAdmin):
"""Admin interface for Feedback model"""
list_display = [
'id', 'feedback_type', 'title', 'get_contact_name', 'hospital',
'status', 'sentiment', 'rating', 'is_featured', 'created_at'
]
list_filter = [
'feedback_type', 'status', 'sentiment', 'category',
'priority', 'is_featured', 'is_deleted', 'created_at'
]
search_fields = [
'title', 'message', 'patient__first_name', 'patient__last_name',
'patient__mrn', 'contact_name', 'contact_email'
]
readonly_fields = [
'id', 'created_at', 'updated_at', 'assigned_at', 'reviewed_at',
'acknowledged_at', 'closed_at', 'deleted_at'
]
fieldsets = (
('Basic Information', {
'fields': (
'id', 'feedback_type', 'title', 'message', 'category',
'subcategory', 'rating', 'priority'
)
}),
('Patient/Contact', {
'fields': (
'patient', 'is_anonymous', 'contact_name', 'contact_email',
'contact_phone'
)
}),
('Organization', {
'fields': ('hospital', 'department', 'physician', 'encounter_id')
}),
('Status & Workflow', {
'fields': (
'status', 'assigned_to', 'assigned_at', 'reviewed_by',
'reviewed_at', 'acknowledged_by', 'acknowledged_at',
'closed_by', 'closed_at'
)
}),
('Sentiment Analysis', {
'fields': ('sentiment', 'sentiment_score')
}),
('Flags', {
'fields': (
'is_featured', 'is_public', 'requires_follow_up',
'is_deleted', 'deleted_at', 'deleted_by'
)
}),
('Metadata', {
'fields': ('source', 'metadata', 'created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
date_hierarchy = 'created_at'
ordering = ['-created_at']
@admin.register(FeedbackAttachment)
class FeedbackAttachmentAdmin(admin.ModelAdmin):
"""Admin interface for FeedbackAttachment model"""
list_display = [
'id', 'feedback', 'filename', 'file_type', 'file_size',
'uploaded_by', 'created_at'
]
list_filter = ['file_type', 'created_at']
search_fields = ['filename', 'feedback__title']
readonly_fields = ['id', 'created_at', 'updated_at']
date_hierarchy = 'created_at'
ordering = ['-created_at']
@admin.register(FeedbackResponse)
class FeedbackResponseAdmin(admin.ModelAdmin):
"""Admin interface for FeedbackResponse model"""
list_display = [
'id', 'feedback', 'response_type', 'created_by',
'is_internal', 'created_at'
]
list_filter = ['response_type', 'is_internal', 'created_at']
search_fields = ['message', 'feedback__title']
readonly_fields = ['id', 'created_at', 'updated_at']
fieldsets = (
('Response Information', {
'fields': (
'id', 'feedback', 'response_type', 'message',
'created_by', 'is_internal'
)
}),
('Status Change', {
'fields': ('old_status', 'new_status')
}),
('Metadata', {
'fields': ('metadata', 'created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
date_hierarchy = 'created_at'
ordering = ['-created_at']

339
apps/feedback/forms.py Normal file
View File

@ -0,0 +1,339 @@
"""
Feedback forms - Forms for feedback management
"""
from django import forms
from apps.organizations.models import Department, Hospital, Patient, Physician
from .models import Feedback, FeedbackResponse, FeedbackStatus, FeedbackType, FeedbackCategory
class FeedbackForm(forms.ModelForm):
"""Form for creating and editing feedback"""
class Meta:
model = Feedback
fields = [
'patient',
'is_anonymous',
'contact_name',
'contact_email',
'contact_phone',
'hospital',
'department',
'physician',
'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'
}),
'physician': 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')
# 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 self.instance.hospital:
self.fields['department'].queryset = Department.objects.filter(
hospital=self.instance.hospital,
status='active'
)
self.fields['physician'].queryset = Physician.objects.filter(
hospital=self.instance.hospital,
status='active'
)
else:
self.fields['department'].queryset = Department.objects.none()
self.fields['physician'].queryset = Physician.objects.none()
# Make patient optional if anonymous
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')
# Validate anonymous feedback
if is_anonymous:
if not contact_name:
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."
)
# Validate 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.")
return cleaned_data
class FeedbackResponseForm(forms.ModelForm):
"""Form for adding responses to feedback"""
class Meta:
model = FeedbackResponse
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'
}),
}
class FeedbackFilterForm(forms.Form):
"""Form for filtering feedback list"""
search = forms.CharField(
required=False,
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'})
)
status = forms.ChoiceField(
required=False,
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'})
)
sentiment = forms.ChoiceField(
required=False,
choices=[
('', 'All Sentiments'),
('positive', 'Positive'),
('neutral', 'Neutral'),
('negative', 'Negative'),
],
widget=forms.Select(attrs={'class': 'form-select'})
)
priority = forms.ChoiceField(
required=False,
choices=[
('', 'All Priorities'),
('low', 'Low'),
('medium', 'Medium'),
('high', 'High'),
('urgent', 'Urgent'),
],
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'
)
department = forms.ModelChoiceField(
required=False,
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'
})
)
rating_max = forms.IntegerField(
required=False,
min_value=1,
max_value=5,
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_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'})
)
requires_follow_up = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
)
class FeedbackStatusChangeForm(forms.Form):
"""Form for changing feedback status"""
status = forms.ChoiceField(
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)...'
})
)
class FeedbackAssignForm(forms.Form):
"""Form for assigning feedback to a user"""
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)...'
})
)

View File

@ -0,0 +1,127 @@
# Generated by Django 5.0.14 on 2025-12-24 10:22
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('organizations', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Feedback',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('is_anonymous', models.BooleanField(default=False)),
('contact_name', models.CharField(blank=True, max_length=200)),
('contact_email', models.EmailField(blank=True, max_length=254)),
('contact_phone', models.CharField(blank=True, max_length=20)),
('encounter_id', models.CharField(blank=True, db_index=True, help_text='Related encounter ID if applicable', max_length=100)),
('feedback_type', models.CharField(choices=[('compliment', 'Compliment'), ('suggestion', 'Suggestion'), ('general', 'General Feedback'), ('inquiry', 'Inquiry')], db_index=True, default='general', max_length=20)),
('title', models.CharField(max_length=500)),
('message', models.TextField(help_text='Feedback message')),
('category', models.CharField(choices=[('clinical_care', 'Clinical Care'), ('staff_service', 'Staff Service'), ('facility', 'Facility & Environment'), ('communication', 'Communication'), ('appointment', 'Appointment & Scheduling'), ('billing', 'Billing & Insurance'), ('food_service', 'Food Service'), ('cleanliness', 'Cleanliness'), ('technology', 'Technology & Systems'), ('other', 'Other')], db_index=True, max_length=50)),
('subcategory', models.CharField(blank=True, max_length=100)),
('rating', models.IntegerField(blank=True, help_text='Rating from 1 to 5 stars', null=True)),
('priority', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], db_index=True, default='medium', max_length=20)),
('sentiment', models.CharField(choices=[('positive', 'Positive'), ('neutral', 'Neutral'), ('negative', 'Negative')], db_index=True, default='neutral', help_text='Sentiment analysis result', max_length=20)),
('sentiment_score', models.FloatField(blank=True, help_text='Sentiment score from -1 (negative) to 1 (positive)', null=True)),
('status', models.CharField(choices=[('submitted', 'Submitted'), ('reviewed', 'Reviewed'), ('acknowledged', 'Acknowledged'), ('closed', 'Closed')], db_index=True, default='submitted', max_length=20)),
('assigned_at', models.DateTimeField(blank=True, null=True)),
('reviewed_at', models.DateTimeField(blank=True, null=True)),
('acknowledged_at', models.DateTimeField(blank=True, null=True)),
('closed_at', models.DateTimeField(blank=True, null=True)),
('is_featured', models.BooleanField(default=False, help_text='Feature this feedback (e.g., for testimonials)')),
('is_public', models.BooleanField(default=False, help_text='Make this feedback public')),
('requires_follow_up', models.BooleanField(default=False)),
('source', models.CharField(default='web', help_text='Source of feedback (web, mobile, kiosk, etc.)', max_length=50)),
('metadata', models.JSONField(blank=True, default=dict)),
('is_deleted', models.BooleanField(db_index=True, default=False)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('acknowledged_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='acknowledged_feedbacks', to=settings.AUTH_USER_MODEL)),
('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_feedbacks', to=settings.AUTH_USER_MODEL)),
('closed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='closed_feedbacks', to=settings.AUTH_USER_MODEL)),
('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_feedbacks', to=settings.AUTH_USER_MODEL)),
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedbacks', to='organizations.department')),
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feedbacks', to='organizations.hospital')),
('patient', models.ForeignKey(blank=True, help_text='Patient who provided feedback (optional for anonymous feedback)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='feedbacks', to='organizations.patient')),
('physician', models.ForeignKey(blank=True, help_text='Physician being mentioned in feedback', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedbacks', to='organizations.physician')),
('reviewed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_feedbacks', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name_plural': 'Feedback',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='FeedbackAttachment',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('file', models.FileField(upload_to='feedback/%Y/%m/%d/')),
('filename', models.CharField(max_length=500)),
('file_type', models.CharField(blank=True, max_length=100)),
('file_size', models.IntegerField(help_text='File size in bytes')),
('description', models.TextField(blank=True)),
('feedback', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='feedback.feedback')),
('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedback_attachments', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='FeedbackResponse',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('response_type', models.CharField(choices=[('status_change', 'Status Change'), ('assignment', 'Assignment'), ('note', 'Internal Note'), ('response', 'Response to Patient'), ('acknowledgment', 'Acknowledgment')], db_index=True, max_length=50)),
('message', models.TextField()),
('old_status', models.CharField(blank=True, max_length=20)),
('new_status', models.CharField(blank=True, max_length=20)),
('is_internal', models.BooleanField(default=False, help_text='Internal note (not visible to patient)')),
('metadata', models.JSONField(blank=True, default=dict)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedback_responses', to=settings.AUTH_USER_MODEL)),
('feedback', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='feedback.feedback')),
],
options={
'ordering': ['-created_at'],
},
),
migrations.AddIndex(
model_name='feedback',
index=models.Index(fields=['status', '-created_at'], name='feedback_fe_status_212662_idx'),
),
migrations.AddIndex(
model_name='feedback',
index=models.Index(fields=['hospital', 'status', '-created_at'], name='feedback_fe_hospita_4c1146_idx'),
),
migrations.AddIndex(
model_name='feedback',
index=models.Index(fields=['feedback_type', '-created_at'], name='feedback_fe_feedbac_6b63a4_idx'),
),
migrations.AddIndex(
model_name='feedback',
index=models.Index(fields=['sentiment', '-created_at'], name='feedback_fe_sentime_443190_idx'),
),
migrations.AddIndex(
model_name='feedback',
index=models.Index(fields=['is_deleted', '-created_at'], name='feedback_fe_is_dele_f543d5_idx'),
),
migrations.AddIndex(
model_name='feedbackresponse',
index=models.Index(fields=['feedback', '-created_at'], name='feedback_fe_feedbac_bc9e33_idx'),
),
]

View File

View File

@ -1,6 +1,350 @@
"""
Feedback models
"""
from django.db import models
Feedback models - Patient feedback and suggestions management
# TODO: Add models for feedback
This module implements the feedback management system that:
- Tracks patient feedback (compliments, suggestions, general feedback)
- Manages feedback workflow (submitted reviewed acknowledged closed)
- Maintains feedback responses and timeline
- Supports attachments and ratings
"""
from django.conf import settings
from django.db import models
from django.utils import timezone
from apps.core.models import PriorityChoices, TimeStampedModel, UUIDModel
class FeedbackType(models.TextChoices):
"""Feedback type choices"""
COMPLIMENT = 'compliment', 'Compliment'
SUGGESTION = 'suggestion', 'Suggestion'
GENERAL = 'general', 'General Feedback'
INQUIRY = 'inquiry', 'Inquiry'
class FeedbackStatus(models.TextChoices):
"""Feedback status choices"""
SUBMITTED = 'submitted', 'Submitted'
REVIEWED = 'reviewed', 'Reviewed'
ACKNOWLEDGED = 'acknowledged', 'Acknowledged'
CLOSED = 'closed', 'Closed'
class FeedbackCategory(models.TextChoices):
"""Feedback category choices"""
CLINICAL_CARE = 'clinical_care', 'Clinical Care'
STAFF_SERVICE = 'staff_service', 'Staff Service'
FACILITY = 'facility', 'Facility & Environment'
COMMUNICATION = 'communication', 'Communication'
APPOINTMENT = 'appointment', 'Appointment & Scheduling'
BILLING = 'billing', 'Billing & Insurance'
FOOD_SERVICE = 'food_service', 'Food Service'
CLEANLINESS = 'cleanliness', 'Cleanliness'
TECHNOLOGY = 'technology', 'Technology & Systems'
OTHER = 'other', 'Other'
class SentimentChoices(models.TextChoices):
"""Sentiment analysis choices"""
POSITIVE = 'positive', 'Positive'
NEUTRAL = 'neutral', 'Neutral'
NEGATIVE = 'negative', 'Negative'
class Feedback(UUIDModel, TimeStampedModel):
"""
Feedback model for patient feedback, compliments, and suggestions.
Workflow:
1. SUBMITTED - Feedback received
2. REVIEWED - Being reviewed by staff
3. ACKNOWLEDGED - Response provided
4. CLOSED - Feedback closed
"""
# Patient and encounter information
patient = models.ForeignKey(
'organizations.Patient',
on_delete=models.CASCADE,
related_name='feedbacks',
null=True,
blank=True,
help_text="Patient who provided feedback (optional for anonymous feedback)"
)
# Anonymous feedback support
is_anonymous = models.BooleanField(default=False)
contact_name = models.CharField(max_length=200, blank=True)
contact_email = models.EmailField(blank=True)
contact_phone = models.CharField(max_length=20, blank=True)
encounter_id = models.CharField(
max_length=100,
blank=True,
db_index=True,
help_text="Related encounter ID if applicable"
)
# Organization
hospital = models.ForeignKey(
'organizations.Hospital',
on_delete=models.CASCADE,
related_name='feedbacks'
)
department = models.ForeignKey(
'organizations.Department',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='feedbacks'
)
physician = models.ForeignKey(
'organizations.Physician',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='feedbacks',
help_text="Physician being mentioned in feedback"
)
# Feedback details
feedback_type = models.CharField(
max_length=20,
choices=FeedbackType.choices,
default=FeedbackType.GENERAL,
db_index=True
)
title = models.CharField(max_length=500)
message = models.TextField(help_text="Feedback message")
# Classification
category = models.CharField(
max_length=50,
choices=FeedbackCategory.choices,
db_index=True
)
subcategory = models.CharField(max_length=100, blank=True)
# Rating (1-5 stars)
rating = models.IntegerField(
null=True,
blank=True,
help_text="Rating from 1 to 5 stars"
)
# Priority
priority = models.CharField(
max_length=20,
choices=PriorityChoices.choices,
default=PriorityChoices.MEDIUM,
db_index=True
)
# Sentiment analysis
sentiment = models.CharField(
max_length=20,
choices=SentimentChoices.choices,
default=SentimentChoices.NEUTRAL,
db_index=True,
help_text="Sentiment analysis result"
)
sentiment_score = models.FloatField(
null=True,
blank=True,
help_text="Sentiment score from -1 (negative) to 1 (positive)"
)
# Status and workflow
status = models.CharField(
max_length=20,
choices=FeedbackStatus.choices,
default=FeedbackStatus.SUBMITTED,
db_index=True
)
# Assignment
assigned_to = models.ForeignKey(
'accounts.User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='assigned_feedbacks'
)
assigned_at = models.DateTimeField(null=True, blank=True)
# Review tracking
reviewed_at = models.DateTimeField(null=True, blank=True)
reviewed_by = models.ForeignKey(
'accounts.User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='reviewed_feedbacks'
)
# Acknowledgment
acknowledged_at = models.DateTimeField(null=True, blank=True)
acknowledged_by = models.ForeignKey(
'accounts.User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='acknowledged_feedbacks'
)
# Closure
closed_at = models.DateTimeField(null=True, blank=True)
closed_by = models.ForeignKey(
'accounts.User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='closed_feedbacks'
)
# Flags
is_featured = models.BooleanField(
default=False,
help_text="Feature this feedback (e.g., for testimonials)"
)
is_public = models.BooleanField(
default=False,
help_text="Make this feedback public"
)
requires_follow_up = models.BooleanField(default=False)
# Metadata
source = models.CharField(
max_length=50,
default='web',
help_text="Source of feedback (web, mobile, kiosk, etc.)"
)
metadata = models.JSONField(default=dict, blank=True)
# Soft delete
is_deleted = models.BooleanField(default=False, db_index=True)
deleted_at = models.DateTimeField(null=True, blank=True)
deleted_by = models.ForeignKey(
'accounts.User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='deleted_feedbacks'
)
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['status', '-created_at']),
models.Index(fields=['hospital', 'status', '-created_at']),
models.Index(fields=['feedback_type', '-created_at']),
models.Index(fields=['sentiment', '-created_at']),
models.Index(fields=['is_deleted', '-created_at']),
]
verbose_name_plural = 'Feedback'
def __str__(self):
if self.patient:
return f"{self.title} - {self.patient.get_full_name()} ({self.feedback_type})"
return f"{self.title} - Anonymous ({self.feedback_type})"
def get_contact_name(self):
"""Get contact name (patient or anonymous)"""
if self.patient:
return self.patient.get_full_name()
return self.contact_name or "Anonymous"
def soft_delete(self, user=None):
"""Soft delete feedback"""
self.is_deleted = True
self.deleted_at = timezone.now()
self.deleted_by = user
self.save(update_fields=['is_deleted', 'deleted_at', 'deleted_by'])
class FeedbackAttachment(UUIDModel, TimeStampedModel):
"""Feedback attachment (images, documents, etc.)"""
feedback = models.ForeignKey(
Feedback,
on_delete=models.CASCADE,
related_name='attachments'
)
file = models.FileField(upload_to='feedback/%Y/%m/%d/')
filename = models.CharField(max_length=500)
file_type = models.CharField(max_length=100, blank=True)
file_size = models.IntegerField(help_text="File size in bytes")
uploaded_by = models.ForeignKey(
'accounts.User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='feedback_attachments'
)
description = models.TextField(blank=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return f"{self.feedback} - {self.filename}"
class FeedbackResponse(UUIDModel, TimeStampedModel):
"""
Feedback response/timeline entry.
Tracks all responses, status changes, and communications.
"""
feedback = models.ForeignKey(
Feedback,
on_delete=models.CASCADE,
related_name='responses'
)
# Response details
response_type = models.CharField(
max_length=50,
choices=[
('status_change', 'Status Change'),
('assignment', 'Assignment'),
('note', 'Internal Note'),
('response', 'Response to Patient'),
('acknowledgment', 'Acknowledgment'),
],
db_index=True
)
message = models.TextField()
# User who made the response
created_by = models.ForeignKey(
'accounts.User',
on_delete=models.SET_NULL,
null=True,
related_name='feedback_responses'
)
# Status change tracking
old_status = models.CharField(max_length=20, blank=True)
new_status = models.CharField(max_length=20, blank=True)
# Visibility
is_internal = models.BooleanField(
default=False,
help_text="Internal note (not visible to patient)"
)
# Metadata
metadata = models.JSONField(default=dict, blank=True)
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['feedback', '-created_at']),
]
def __str__(self):
return f"{self.feedback} - {self.response_type} - {self.created_at.strftime('%Y-%m-%d %H:%M')}"

View File

@ -1,7 +1,28 @@
"""
Feedback URL Configuration
"""
from django.urls import path
from . import views
app_name = 'feedback'
urlpatterns = [
# TODO: Add URL patterns
# List and detail views
path('', views.feedback_list, name='feedback_list'),
path('<uuid:pk>/', views.feedback_detail, name='feedback_detail'),
# CRUD operations
path('create/', views.feedback_create, name='feedback_create'),
path('<uuid:pk>/update/', views.feedback_update, name='feedback_update'),
path('<uuid:pk>/delete/', views.feedback_delete, name='feedback_delete'),
# Workflow actions
path('<uuid:pk>/assign/', views.feedback_assign, name='feedback_assign'),
path('<uuid:pk>/change-status/', views.feedback_change_status, name='feedback_change_status'),
path('<uuid:pk>/add-response/', views.feedback_add_response, name='feedback_add_response'),
# Toggle actions
path('<uuid:pk>/toggle-featured/', views.feedback_toggle_featured, name='feedback_toggle_featured'),
path('<uuid:pk>/toggle-follow-up/', views.feedback_toggle_follow_up, name='feedback_toggle_follow_up'),
]

View File

@ -1,6 +1,589 @@
"""
Feedback views
Feedback views - Server-rendered templates for feedback management
"""
from django.shortcuts import render
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Q, Count, Avg
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.views.decorators.http import require_http_methods
# TODO: Add views for feedback
from apps.accounts.models import User
from apps.core.services import AuditService
from apps.organizations.models import Department, Hospital, Patient, Physician
from .models import (
Feedback,
FeedbackAttachment,
FeedbackResponse,
FeedbackStatus,
FeedbackType,
FeedbackCategory,
)
from .forms import (
FeedbackForm,
FeedbackResponseForm,
FeedbackStatusChangeForm,
)
@login_required
def feedback_list(request):
"""
Feedback list view with advanced filters and pagination.
Features:
- Server-side pagination
- Advanced filters (status, type, sentiment, category, hospital, etc.)
- Search by title, message, patient name
- Statistics dashboard
- Export capability
"""
# Base queryset with optimizations
queryset = Feedback.objects.select_related(
'patient', 'hospital', 'department', 'physician',
'assigned_to', 'reviewed_by', 'acknowledged_by', 'closed_by'
).filter(is_deleted=False)
# Apply RBAC filters
user = request.user
if user.is_px_admin():
pass # See all
elif user.is_hospital_admin() and user.hospital:
queryset = queryset.filter(hospital=user.hospital)
elif user.is_department_manager() and user.department:
queryset = queryset.filter(department=user.department)
elif user.hospital:
queryset = queryset.filter(hospital=user.hospital)
else:
queryset = queryset.none()
# Apply filters from request
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')
if status_filter:
queryset = queryset.filter(status=status_filter)
category_filter = request.GET.get('category')
if category_filter:
queryset = queryset.filter(category=category_filter)
sentiment_filter = request.GET.get('sentiment')
if sentiment_filter:
queryset = queryset.filter(sentiment=sentiment_filter)
priority_filter = request.GET.get('priority')
if priority_filter:
queryset = queryset.filter(priority=priority_filter)
hospital_filter = request.GET.get('hospital')
if hospital_filter:
queryset = queryset.filter(hospital_id=hospital_filter)
department_filter = request.GET.get('department')
if department_filter:
queryset = queryset.filter(department_id=department_filter)
physician_filter = request.GET.get('physician')
if physician_filter:
queryset = queryset.filter(physician_id=physician_filter)
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')
if rating_min:
queryset = queryset.filter(rating__gte=rating_min)
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':
queryset = queryset.filter(is_featured=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')
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)
)
# Date range filters
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')
if date_to:
queryset = queryset.filter(created_at__lte=date_to)
# Ordering
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))
paginator = Paginator(queryset, page_size)
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:
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(),
'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,
}
return render(request, 'feedback/feedback_list.html', context)
@login_required
def feedback_detail(request, pk):
"""
Feedback detail view with timeline, attachments, and actions.
Features:
- Full feedback details
- Timeline of all responses
- Attachments management
- Workflow actions (assign, status change, add response)
"""
feedback = get_object_or_404(
Feedback.objects.select_related(
'patient', 'hospital', 'department', 'physician',
'assigned_to', 'reviewed_by', 'acknowledged_by', 'closed_by'
).prefetch_related(
'attachments',
'responses__created_by'
),
pk=pk,
is_deleted=False
)
# Check access
user = request.user
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')
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')
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')
# Get timeline (responses)
timeline = feedback.responses.all().order_by('-created_at')
# Get attachments
attachments = feedback.attachments.all().order_by('-created_at')
# Get assignable users
assignable_users = User.objects.filter(is_active=True)
if feedback.hospital:
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(),
}
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 form.is_valid():
try:
feedback = form.save(commit=False)
# Set default sentiment if not set
if not feedback.sentiment:
feedback.sentiment = 'neutral'
feedback.save()
# Create initial response
FeedbackResponse.objects.create(
feedback=feedback,
response_type='note',
message=f"Feedback submitted by {request.user.get_full_name()}",
created_by=request.user,
is_internal=True
)
# Log audit
AuditService.log_event(
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
}
)
messages.success(request, f"Feedback #{feedback.id} created successfully.")
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)
# Get patients for selection
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,
}
return render(request, 'feedback/feedback_form.html', context)
@login_required
@require_http_methods(["GET", "POST"])
def feedback_update(request, pk):
"""Update existing feedback"""
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
# 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 edit feedback.")
return redirect('feedback:feedback_detail', pk=pk)
if request.method == 'POST':
form = FeedbackForm(request.POST, instance=feedback, user=request.user)
if form.is_valid():
try:
feedback = form.save()
# Create update response
FeedbackResponse.objects.create(
feedback=feedback,
response_type='note',
message=f"Feedback updated by {request.user.get_full_name()}",
created_by=request.user,
is_internal=True
)
# Log audit
AuditService.log_event(
event_type='feedback_updated',
description=f"Feedback updated: {feedback.title}",
user=request.user,
content_object=feedback
)
messages.success(request, "Feedback updated successfully.")
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)
# Get patients for selection
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,
}
return render(request, 'feedback/feedback_form.html', context)
@login_required
@require_http_methods(["GET", "POST"])
def feedback_delete(request, pk):
"""Soft delete feedback"""
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
# 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 delete feedback.")
return redirect('feedback:feedback_detail', pk=pk)
if request.method == 'POST':
try:
feedback.soft_delete(user=request.user)
# Log audit
AuditService.log_event(
event_type='feedback_deleted',
description=f"Feedback deleted: {feedback.title}",
user=request.user,
content_object=feedback
)
messages.success(request, "Feedback deleted successfully.")
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)
context = {
'feedback': feedback,
}
return render(request, 'feedback/feedback_delete_confirm.html', context)
@login_required
@require_http_methods(["POST"])
def feedback_assign(request, pk):
"""Assign feedback to user"""
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
# 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 feedback.")
return redirect('feedback:feedback_detail', pk=pk)
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)
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'])
# Create response
message = f"Assigned to {assignee.get_full_name()}"
if note:
message += f"\nNote: {note}"
FeedbackResponse.objects.create(
feedback=feedback,
response_type='assignment',
message=message,
created_by=request.user,
is_internal=True
)
# Log audit
AuditService.log_event(
event_type='assignment',
description=f"Feedback assigned to {assignee.get_full_name()}",
user=request.user,
content_object=feedback
)
messages.success(request, f"Feedback assigned to {assignee.get_full_name()}.")
except User.DoesNotExist:
messages.error(request, "User not found.")
return redirect('feedback:feedback_detail', pk=pk)
@login_required
@require_http_methods(["POST"])
def feedback_change_status(request, pk):
"""Change feedback status"""
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
# 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 change feedback status.")
return redirect('feedback:feedback_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('feedback:feedback_detail', pk=pk)
old_status = feedback.status
feedback.status = new_status
# Handle status-specific logic
if new_status == FeedbackStatus.REVIEWED:
feedback.reviewed_at = timezone.now()
feedback.reviewed_by = request.user
elif new_status == FeedbackStatus.ACKNOWLEDGED:
feedback.acknowledged_at = timezone.now()
feedback.acknowledged_by = request.user
elif new_status == FeedbackStatus.CLOSED:
feedback.closed_at = timezone.now()
feedback.closed_by = request.user
feedback.save()
# Create response
message = note or f"Status changed from {old_status} to {new_status}"
FeedbackResponse.objects.create(
feedback=feedback,
response_type='status_change',
message=message,
created_by=request.user,
old_status=old_status,
new_status=new_status,
is_internal=True
)
# Log audit
AuditService.log_event(
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}
)
messages.success(request, f"Feedback status changed to {new_status}.")
return redirect('feedback:feedback_detail', pk=pk)
@login_required
@require_http_methods(["POST"])
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'
if not message:
messages.error(request, "Please enter a response message.")
return redirect('feedback:feedback_detail', pk=pk)
# Create response
FeedbackResponse.objects.create(
feedback=feedback,
response_type=response_type,
message=message,
created_by=request.user,
is_internal=is_internal
)
messages.success(request, "Response added successfully.")
return redirect('feedback:feedback_detail', pk=pk)
@login_required
@require_http_methods(["POST"])
def feedback_toggle_featured(request, pk):
"""Toggle featured status"""
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
# 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 feature feedback.")
return redirect('feedback:feedback_detail', pk=pk)
feedback.is_featured = not feedback.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)
@login_required
@require_http_methods(["POST"])
def feedback_toggle_follow_up(request, pk):
"""Toggle follow-up required status"""
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
# 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 modify feedback.")
return redirect('feedback:feedback_detail', pk=pk)
feedback.requires_follow_up = not feedback.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)

View File

@ -23,6 +23,7 @@ urlpatterns = [
# UI Pages
path('complaints/', include('apps.complaints.urls')),
path('feedback/', include('apps.feedback.urls')),
path('actions/', include('apps.px_action_center.urls')),
path('journeys/', include('apps.journeys.urls')),
path('surveys/', include('apps.surveys.urls')),
@ -35,7 +36,6 @@ urlpatterns = [
# API endpoints
path('api/auth/', include('apps.accounts.urls')),
path('api/feedback/', include('apps.feedback.urls')),
path('api/physicians/', include('apps.physicians.urls')),
path('api/integrations/', include('apps.integrations.urls')),
path('api/notifications/', include('apps.notifications.urls')),

View File

@ -99,6 +99,72 @@ COMPLAINT_TITLES_AR = [
]
def clear_existing_data():
"""Clear all existing data from the database"""
print("\n" + "="*60)
print("Clearing Existing Data...")
print("="*60 + "\n")
from apps.feedback.models import Feedback, FeedbackAttachment, FeedbackResponse
from apps.social.models import SocialMention
from apps.callcenter.models import CallCenterInteraction
# Delete in reverse order of dependencies
print("Deleting survey instances...")
SurveyResponse.objects.all().delete()
SurveyInstance.objects.all().delete()
print("Deleting journey instances...")
PatientJourneyStageInstance.objects.all().delete()
PatientJourneyInstance.objects.all().delete()
print("Deleting PX actions...")
PXAction.objects.all().delete()
print("Deleting QI projects...")
QIProject.objects.all().delete()
print("Deleting social mentions...")
SocialMention.objects.all().delete()
print("Deleting call center interactions...")
CallCenterInteraction.objects.all().delete()
print("Deleting feedback...")
FeedbackResponse.objects.all().delete()
FeedbackAttachment.objects.all().delete()
Feedback.objects.all().delete()
print("Deleting complaints...")
ComplaintUpdate.objects.all().delete()
Complaint.objects.all().delete()
print("Deleting survey templates...")
SurveyQuestion.objects.all().delete()
SurveyTemplate.objects.all().delete()
print("Deleting journey templates...")
PatientJourneyStageTemplate.objects.all().delete()
PatientJourneyTemplate.objects.all().delete()
print("Deleting patients...")
Patient.objects.all().delete()
print("Deleting physicians...")
Physician.objects.all().delete()
print("Deleting departments...")
Department.objects.all().delete()
print("Deleting hospitals...")
Hospital.objects.all().delete()
print("Deleting users (except superusers)...")
User.objects.filter(is_superuser=False).delete()
print("\n✓ All existing data cleared successfully!\n")
def generate_saudi_phone():
"""Generate Saudi phone number"""
return f"+966{random.choice(['50', '53', '54', '55', '56', '58'])}{random.randint(1000000, 9999999)}"
@ -300,6 +366,92 @@ def create_complaints(patients, hospitals, physicians, users):
return complaints
def create_feedback(patients, hospitals, physicians, users):
"""Create sample feedback"""
print("Creating feedback...")
from apps.feedback.models import Feedback, FeedbackResponse
feedback_titles = [
'Excellent care from Dr. Ahmed',
'Very satisfied with the service',
'Suggestion to improve waiting area',
'Great experience at the hospital',
'Staff was very helpful and kind',
'Clean and well-maintained facility',
'Quick and efficient service',
'Appreciate the professionalism',
'Suggestion for better parking',
'Outstanding nursing care',
]
feedback_messages = [
'I had a wonderful experience. The staff was very professional and caring.',
'The doctor took time to explain everything clearly. Very satisfied.',
'I suggest adding more seating in the waiting area for better comfort.',
'Everything was excellent from registration to discharge.',
'The nurses were extremely helpful and answered all my questions.',
'The facility is very clean and well-organized.',
'I was seen quickly and the process was very smooth.',
'I appreciate the high level of professionalism shown by all staff.',
'The parking area could be improved with better signage.',
'The nursing staff provided outstanding care during my stay.',
]
feedbacks = []
for i in range(40):
patient = random.choice(patients)
hospital = patient.primary_hospital or random.choice(hospitals)
is_anonymous = random.random() < 0.2 # 20% anonymous
feedback = Feedback.objects.create(
patient=None if is_anonymous else patient,
is_anonymous=is_anonymous,
contact_name=f"{random.choice(ENGLISH_FIRST_NAMES_MALE)} {random.choice(ENGLISH_LAST_NAMES)}" if is_anonymous else '',
contact_email=f"anonymous{i}@example.com" if is_anonymous else '',
contact_phone=generate_saudi_phone() if is_anonymous else '',
hospital=hospital,
department=random.choice(hospital.departments.all()) if hospital.departments.exists() and random.random() > 0.5 else None,
physician=random.choice(physicians) if random.random() > 0.6 else None,
feedback_type=random.choice(['compliment', 'suggestion', 'general', 'inquiry']),
title=random.choice(feedback_titles),
message=random.choice(feedback_messages),
category=random.choice(['clinical_care', 'staff_service', 'facility', 'communication', 'appointment', 'cleanliness']),
rating=random.randint(3, 5) if random.random() > 0.3 else None,
priority=random.choice(['low', 'medium', 'high']),
sentiment=random.choice(['positive', 'neutral', 'negative']),
sentiment_score=random.uniform(0.3, 1.0) if random.random() > 0.7 else None,
status=random.choice(['submitted', 'reviewed', 'acknowledged', 'closed']),
encounter_id=f"ENC{random.randint(100000, 999999)}" if random.random() > 0.5 else '',
assigned_to=random.choice(users) if random.random() > 0.5 else None,
is_featured=random.random() < 0.15, # 15% featured
requires_follow_up=random.random() < 0.2, # 20% require follow-up
)
# Add initial response
FeedbackResponse.objects.create(
feedback=feedback,
response_type='note',
message=f"Feedback received and logged in the system.",
created_by=random.choice(users),
is_internal=True,
)
# Add additional responses for some feedback
if feedback.status in ['reviewed', 'acknowledged', 'closed'] and random.random() > 0.5:
FeedbackResponse.objects.create(
feedback=feedback,
response_type='response',
message="Thank you for your feedback. We appreciate your input and will work to improve our services.",
created_by=random.choice(users),
is_internal=False,
)
feedbacks.append(feedback)
print(f" Created {len(feedbacks)} feedback items")
return feedbacks
def create_survey_templates(hospitals):
"""Create survey templates"""
print("Creating survey templates...")
@ -580,6 +732,9 @@ def main():
print("PX360 - Saudi-Influenced Data Generator")
print("="*60 + "\n")
# Clear existing data first
clear_existing_data()
# Create base data
hospitals = create_hospitals()
departments = create_departments(hospitals)
@ -592,6 +747,7 @@ def main():
# Create operational data
complaints = create_complaints(patients, hospitals, physicians, users)
feedbacks = create_feedback(patients, hospitals, physicians, users)
create_survey_templates(hospitals)
create_journey_templates(hospitals)
projects = create_qi_projects(hospitals)
@ -611,6 +767,7 @@ def main():
print(f" - {len(patients)} Patients")
print(f" - {len(users)} Users")
print(f" - {len(complaints)} Complaints")
print(f" - {len(feedbacks)} Feedback Items")
print(f" - {len(actions)} PX Actions")
print(f" - {len(journey_instances)} Journey Instances")
print(f" - {len(survey_instances)} Survey Instances")

View File

@ -0,0 +1,191 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% load static %}
{% block title %}Delete Feedback - PX360{% endblock %}
{% block extra_css %}
<style>
.delete-card {
border: 2px solid #dc3545;
border-radius: 8px;
background: #fff;
}
.delete-card-header {
background: #dc3545;
color: white;
padding: 20px;
border-radius: 6px 6px 0 0;
}
.delete-card-body {
padding: 30px;
}
.feedback-info {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
}
.info-row {
display: flex;
padding: 8px 0;
border-bottom: 1px solid #e9ecef;
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
font-weight: 600;
color: #666;
min-width: 120px;
}
.info-value {
flex: 1;
color: #333;
}
.warning-icon {
font-size: 4rem;
color: #dc3545;
margin-bottom: 20px;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- Page Header -->
<div class="mb-4">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-2">
<li class="breadcrumb-item"><a href="{% url 'feedback:feedback_list' %}">Feedback</a></li>
<li class="breadcrumb-item"><a href="{% url 'feedback:feedback_detail' feedback.id %}">Detail</a></li>
<li class="breadcrumb-item active">Delete</li>
</ol>
</nav>
</div>
<div class="row">
<div class="col-lg-6 mx-auto">
<div class="delete-card">
<div class="delete-card-header text-center">
<h3 class="mb-0">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
Delete Feedback
</h3>
</div>
<div class="delete-card-body">
<div class="text-center">
<i class="bi bi-trash warning-icon"></i>
<h4 class="mb-3">Are you sure you want to delete this feedback?</h4>
<p class="text-muted mb-4">
This action will soft delete the feedback. The feedback will be marked as deleted
but will remain in the database for audit purposes.
</p>
</div>
<!-- Feedback Information -->
<div class="feedback-info">
<h5 class="mb-3">Feedback Information</h5>
<div class="info-row">
<div class="info-label">ID:</div>
<div class="info-value">
<code>{{ feedback.id|slice:":8" }}...</code>
</div>
</div>
<div class="info-row">
<div class="info-label">Type:</div>
<div class="info-value">
<span class="badge bg-primary">{{ feedback.get_feedback_type_display }}</span>
</div>
</div>
<div class="info-row">
<div class="info-label">Title:</div>
<div class="info-value">
<strong>{{ feedback.title }}</strong>
</div>
</div>
<div class="info-row">
<div class="info-label">Patient/Contact:</div>
<div class="info-value">
{{ feedback.get_contact_name }}
</div>
</div>
<div class="info-row">
<div class="info-label">Hospital:</div>
<div class="info-value">
{{ feedback.hospital.name }}
</div>
</div>
<div class="info-row">
<div class="info-label">Status:</div>
<div class="info-value">
<span class="badge bg-secondary">{{ feedback.get_status_display }}</span>
</div>
</div>
<div class="info-row">
<div class="info-label">Created:</div>
<div class="info-value">
{{ feedback.created_at|date:"F d, Y H:i" }}
</div>
</div>
{% if feedback.rating %}
<div class="info-row">
<div class="info-label">Rating:</div>
<div class="info-value">
<span style="color: #ffc107;">
{% for i in "12345" %}
{% if forloop.counter <= feedback.rating %}
<i class="bi bi-star-fill"></i>
{% else %}
<i class="bi bi-star"></i>
{% endif %}
{% endfor %}
</span>
({{ feedback.rating }}/5)
</div>
</div>
{% endif %}
</div>
<!-- Warning Message -->
<div class="alert alert-warning d-flex align-items-center mb-4" role="alert">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
<div>
<strong>Warning:</strong> This feedback will be marked as deleted.
All associated responses and timeline entries will be preserved for audit purposes.
</div>
</div>
<!-- Action Buttons -->
<form method="post" class="d-flex justify-content-between gap-3">
{% csrf_token %}
<a href="{% url 'feedback:feedback_detail' feedback.id %}" class="btn btn-outline-secondary flex-fill">
<i class="bi bi-x-circle me-1"></i> Cancel
</a>
<button type="submit" class="btn btn-danger flex-fill">
<i class="bi bi-trash me-1"></i> Yes, Delete Feedback
</button>
</form>
<!-- Additional Info -->
<div class="text-center mt-4">
<small class="text-muted">
<i class="bi bi-info-circle me-1"></i>
Deleted feedback can be restored by system administrators if needed.
</small>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,605 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% load static %}
{% block title %}Feedback Detail - PX360{% endblock %}
{% block extra_css %}
<style>
.detail-card {
border: 1px solid #dee2e6;
border-radius: 8px;
margin-bottom: 20px;
}
.detail-card-header {
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
padding: 15px 20px;
font-weight: 600;
}
.detail-card-body {
padding: 20px;
}
.info-row {
display: flex;
padding: 10px 0;
border-bottom: 1px solid #f0f0f0;
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
font-weight: 600;
color: #666;
min-width: 150px;
}
.info-value {
flex: 1;
color: #333;
}
.status-badge {
padding: 6px 16px;
border-radius: 12px;
font-size: 0.9rem;
font-weight: 500;
}
.status-submitted { background: #e3f2fd; color: #1976d2; }
.status-reviewed { background: #fff3e0; color: #f57c00; }
.status-acknowledged { background: #e8f5e9; color: #388e3c; }
.status-closed { background: #f5f5f5; color: #616161; }
.type-badge {
padding: 6px 16px;
border-radius: 12px;
font-size: 0.9rem;
font-weight: 500;
}
.type-compliment { background: #e8f5e9; color: #2e7d32; }
.type-suggestion { background: #e3f2fd; color: #1565c0; }
.type-general { background: #f5f5f5; color: #616161; }
.type-inquiry { background: #fff3e0; color: #ef6c00; }
.sentiment-badge {
padding: 6px 16px;
border-radius: 12px;
font-size: 0.9rem;
font-weight: 500;
}
.sentiment-positive { background: #e8f5e9; color: #2e7d32; }
.sentiment-neutral { background: #f5f5f5; color: #616161; }
.sentiment-negative { background: #ffebee; color: #c62828; }
.rating-stars {
color: #ffc107;
font-size: 1.5rem;
}
.timeline {
position: relative;
padding-left: 30px;
}
.timeline::before {
content: '';
position: absolute;
left: 8px;
top: 0;
bottom: 0;
width: 2px;
background: #dee2e6;
}
.timeline-item {
position: relative;
padding-bottom: 30px;
}
.timeline-item::before {
content: '';
position: absolute;
left: -26px;
top: 5px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #fff;
border: 2px solid #0d6efd;
}
.timeline-content {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 15px;
}
.timeline-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.timeline-user {
font-weight: 600;
color: #0d6efd;
}
.timeline-date {
font-size: 0.85rem;
color: #666;
}
.timeline-message {
color: #333;
white-space: pre-wrap;
}
.action-buttons {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.featured-badge {
background: #ffd700;
color: #000;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 600;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- Page Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-2">
<li class="breadcrumb-item"><a href="{% url 'feedback:feedback_list' %}">Feedback</a></li>
<li class="breadcrumb-item active">Detail</li>
</ol>
</nav>
<h2 class="mb-0">
<i class="bi bi-chat-heart-fill text-primary me-2"></i>
Feedback Detail
</h2>
</div>
<div class="action-buttons">
{% if can_edit %}
<a href="{% url 'feedback:feedback_update' feedback.id %}" class="btn btn-outline-primary">
<i class="bi bi-pencil me-1"></i> Edit
</a>
<a href="{% url 'feedback:feedback_delete' feedback.id %}" class="btn btn-outline-danger">
<i class="bi bi-trash me-1"></i> Delete
</a>
{% endif %}
<a href="{% url 'feedback:feedback_list' %}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i> Back to List
</a>
</div>
</div>
<div class="row">
<!-- Main Content -->
<div class="col-lg-8">
<!-- Feedback Details -->
<div class="detail-card">
<div class="detail-card-header">
<div class="d-flex justify-content-between align-items-center">
<span>Feedback Information</span>
<div class="d-flex gap-2">
<span class="type-badge type-{{ feedback.feedback_type }}">
{{ feedback.get_feedback_type_display }}
</span>
<span class="status-badge status-{{ feedback.status }}">
{{ feedback.get_status_display }}
</span>
{% if feedback.is_featured %}
<span class="featured-badge">★ FEATURED</span>
{% endif %}
</div>
</div>
</div>
<div class="detail-card-body">
<div class="info-row">
<div class="info-label">ID:</div>
<div class="info-value">
<code>{{ feedback.id }}</code>
</div>
</div>
<div class="info-row">
<div class="info-label">Title:</div>
<div class="info-value">
<strong>{{ feedback.title }}</strong>
</div>
</div>
<div class="info-row">
<div class="info-label">Message:</div>
<div class="info-value">
<p class="mb-0" style="white-space: pre-wrap;">{{ feedback.message }}</p>
</div>
</div>
<div class="info-row">
<div class="info-label">Category:</div>
<div class="info-value">
<span class="badge bg-secondary">{{ feedback.get_category_display }}</span>
{% if feedback.subcategory %}
<span class="text-muted ms-2">/ {{ feedback.subcategory }}</span>
{% endif %}
</div>
</div>
{% if feedback.rating %}
<div class="info-row">
<div class="info-label">Rating:</div>
<div class="info-value">
<span class="rating-stars">
{% for i in "12345" %}
{% if forloop.counter <= feedback.rating %}
<i class="bi bi-star-fill"></i>
{% else %}
<i class="bi bi-star"></i>
{% endif %}
{% endfor %}
</span>
<span class="ms-2">({{ feedback.rating }}/5)</span>
</div>
</div>
{% endif %}
<div class="info-row">
<div class="info-label">Sentiment:</div>
<div class="info-value">
<span class="sentiment-badge sentiment-{{ feedback.sentiment }}">
{{ feedback.get_sentiment_display }}
</span>
{% if feedback.sentiment_score %}
<span class="text-muted ms-2">(Score: {{ feedback.sentiment_score|floatformat:2 }})</span>
{% endif %}
</div>
</div>
<div class="info-row">
<div class="info-label">Priority:</div>
<div class="info-value">
<span class="badge bg-info">{{ feedback.get_priority_display }}</span>
</div>
</div>
<div class="info-row">
<div class="info-label">Created:</div>
<div class="info-value">
{{ feedback.created_at|date:"F d, Y H:i" }}
</div>
</div>
</div>
</div>
<!-- Patient/Contact Information -->
<div class="detail-card">
<div class="detail-card-header">Patient/Contact Information</div>
<div class="detail-card-body">
{% if feedback.patient %}
<div class="info-row">
<div class="info-label">Patient:</div>
<div class="info-value">
<strong>{{ feedback.patient.get_full_name }}</strong>
</div>
</div>
<div class="info-row">
<div class="info-label">MRN:</div>
<div class="info-value">{{ feedback.patient.mrn }}</div>
</div>
{% if feedback.patient.phone %}
<div class="info-row">
<div class="info-label">Phone:</div>
<div class="info-value">{{ feedback.patient.phone }}</div>
</div>
{% endif %}
{% if feedback.patient.email %}
<div class="info-row">
<div class="info-label">Email:</div>
<div class="info-value">{{ feedback.patient.email }}</div>
</div>
{% endif %}
{% else %}
<div class="info-row">
<div class="info-label">Type:</div>
<div class="info-value">
<span class="badge bg-secondary">Anonymous Feedback</span>
</div>
</div>
{% if feedback.contact_name %}
<div class="info-row">
<div class="info-label">Contact Name:</div>
<div class="info-value">{{ feedback.contact_name }}</div>
</div>
{% endif %}
{% if feedback.contact_email %}
<div class="info-row">
<div class="info-label">Email:</div>
<div class="info-value">{{ feedback.contact_email }}</div>
</div>
{% endif %}
{% if feedback.contact_phone %}
<div class="info-row">
<div class="info-label">Phone:</div>
<div class="info-value">{{ feedback.contact_phone }}</div>
</div>
{% endif %}
{% endif %}
</div>
</div>
<!-- Organization Information -->
<div class="detail-card">
<div class="detail-card-header">Organization Information</div>
<div class="detail-card-body">
<div class="info-row">
<div class="info-label">Hospital:</div>
<div class="info-value">{{ feedback.hospital.name }}</div>
</div>
{% if feedback.department %}
<div class="info-row">
<div class="info-label">Department:</div>
<div class="info-value">{{ feedback.department.name }}</div>
</div>
{% endif %}
{% if feedback.physician %}
<div class="info-row">
<div class="info-label">Physician:</div>
<div class="info-value">{{ feedback.physician.get_full_name }}</div>
</div>
{% endif %}
{% if feedback.encounter_id %}
<div class="info-row">
<div class="info-label">Encounter ID:</div>
<div class="info-value"><code>{{ feedback.encounter_id }}</code></div>
</div>
{% endif %}
</div>
</div>
<!-- Timeline -->
<div class="detail-card">
<div class="detail-card-header">Timeline & Responses</div>
<div class="detail-card-body">
{% if timeline %}
<div class="timeline">
{% for response in timeline %}
<div class="timeline-item">
<div class="timeline-content">
<div class="timeline-header">
<div>
<span class="timeline-user">
{% if response.created_by %}
{{ response.created_by.get_full_name }}
{% else %}
System
{% endif %}
</span>
<span class="badge bg-secondary ms-2">{{ response.get_response_type_display }}</span>
{% if response.is_internal %}
<span class="badge bg-warning ms-1">Internal</span>
{% endif %}
</div>
<span class="timeline-date">
{{ response.created_at|date:"M d, Y H:i" }}
</span>
</div>
<div class="timeline-message">{{ response.message }}</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted text-center py-3">No responses yet</p>
{% endif %}
</div>
</div>
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<!-- Actions -->
{% if can_edit %}
<div class="detail-card">
<div class="detail-card-header">Actions</div>
<div class="detail-card-body">
<!-- Change Status -->
<form method="post" action="{% url 'feedback:feedback_change_status' feedback.id %}" class="mb-3">
{% csrf_token %}
<label class="form-label fw-bold">Change Status</label>
<select name="status" class="form-select mb-2">
{% for value, label in status_choices %}
<option value="{{ value }}" {% if feedback.status == value %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
<textarea name="note" class="form-control mb-2" rows="2"
placeholder="Add a note (optional)..."></textarea>
<button type="submit" class="btn btn-primary btn-sm w-100">
<i class="bi bi-arrow-repeat me-1"></i> Update Status
</button>
</form>
<hr>
<!-- Assign -->
<form method="post" action="{% url 'feedback:feedback_assign' feedback.id %}" class="mb-3">
{% csrf_token %}
<label class="form-label fw-bold">Assign To</label>
<select name="user_id" class="form-select mb-2">
<option value="">Select user...</option>
{% for user in assignable_users %}
<option value="{{ user.id }}" {% if feedback.assigned_to.id == user.id %}selected{% endif %}>
{{ user.get_full_name }}
</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-primary btn-sm w-100">
<i class="bi bi-person-check me-1"></i> Assign
</button>
</form>
<hr>
<!-- Add Response -->
<form method="post" action="{% url 'feedback:feedback_add_response' feedback.id %}">
{% csrf_token %}
<label class="form-label fw-bold">Add Response</label>
<select name="response_type" class="form-select mb-2">
<option value="response">Response to Patient</option>
<option value="note">Internal Note</option>
<option value="acknowledgment">Acknowledgment</option>
</select>
<textarea name="message" class="form-control mb-2" rows="3"
placeholder="Enter your response..." required></textarea>
<div class="form-check mb-2">
<input type="checkbox" class="form-check-input" name="is_internal" id="is_internal">
<label class="form-check-label" for="is_internal">
Internal only (not visible to patient)
</label>
</div>
<button type="submit" class="btn btn-success btn-sm w-100">
<i class="bi bi-chat-left-text me-1"></i> Add Response
</button>
</form>
<hr>
<!-- Toggle Actions -->
<div class="d-grid gap-2">
<form method="post" action="{% url 'feedback:feedback_toggle_featured' feedback.id %}" class="d-inline">
{% csrf_token %}
<button type="submit" class="btn btn-outline-warning btn-sm w-100">
<i class="bi bi-star me-1"></i>
{% if feedback.is_featured %}Unfeature{% else %}Feature{% endif %}
</button>
</form>
<form method="post" action="{% url 'feedback:feedback_toggle_follow_up' feedback.id %}" class="d-inline">
{% csrf_token %}
<button type="submit" class="btn btn-outline-info btn-sm w-100">
<i class="bi bi-flag me-1"></i>
{% if feedback.requires_follow_up %}Remove Follow-up{% else %}Mark for Follow-up{% endif %}
</button>
</form>
</div>
</div>
</div>
{% endif %}
<!-- Assignment Info -->
<div class="detail-card">
<div class="detail-card-header">Assignment & Tracking</div>
<div class="detail-card-body">
<div class="info-row">
<div class="info-label">Assigned To:</div>
<div class="info-value">
{% if feedback.assigned_to %}
{{ feedback.assigned_to.get_full_name }}
<br><small class="text-muted">{{ feedback.assigned_at|date:"M d, Y H:i" }}</small>
{% else %}
<span class="text-muted">Unassigned</span>
{% endif %}
</div>
</div>
{% if feedback.reviewed_by %}
<div class="info-row">
<div class="info-label">Reviewed By:</div>
<div class="info-value">
{{ feedback.reviewed_by.get_full_name }}
<br><small class="text-muted">{{ feedback.reviewed_at|date:"M d, Y H:i" }}</small>
</div>
</div>
{% endif %}
{% if feedback.acknowledged_by %}
<div class="info-row">
<div class="info-label">Acknowledged By:</div>
<div class="info-value">
{{ feedback.acknowledged_by.get_full_name }}
<br><small class="text-muted">{{ feedback.acknowledged_at|date:"M d, Y H:i" }}</small>
</div>
</div>
{% endif %}
{% if feedback.closed_by %}
<div class="info-row">
<div class="info-label">Closed By:</div>
<div class="info-value">
{{ feedback.closed_by.get_full_name }}
<br><small class="text-muted">{{ feedback.closed_at|date:"M d, Y H:i" }}</small>
</div>
</div>
{% endif %}
</div>
</div>
<!-- Flags -->
<div class="detail-card">
<div class="detail-card-header">Flags & Settings</div>
<div class="detail-card-body">
<div class="info-row">
<div class="info-label">Featured:</div>
<div class="info-value">
{% if feedback.is_featured %}
<i class="bi bi-check-circle-fill text-success"></i> Yes
{% else %}
<i class="bi bi-x-circle text-muted"></i> No
{% endif %}
</div>
</div>
<div class="info-row">
<div class="info-label">Public:</div>
<div class="info-value">
{% if feedback.is_public %}
<i class="bi bi-check-circle-fill text-success"></i> Yes
{% else %}
<i class="bi bi-x-circle text-muted"></i> No
{% endif %}
</div>
</div>
<div class="info-row">
<div class="info-label">Follow-up Required:</div>
<div class="info-value">
{% if feedback.requires_follow_up %}
<i class="bi bi-check-circle-fill text-warning"></i> Yes
{% else %}
<i class="bi bi-x-circle text-muted"></i> No
{% endif %}
</div>
</div>
<div class="info-row">
<div class="info-label">Source:</div>
<div class="info-value">
<span class="badge bg-info">{{ feedback.source }}</span>
</div>
</div>
</div>
</div>
<!-- Attachments -->
{% if attachments %}
<div class="detail-card">
<div class="detail-card-header">Attachments ({{ attachments.count }})</div>
<div class="detail-card-body">
<div class="list-group list-group-flush">
{% for attachment in attachments %}
<div class="list-group-item px-0">
<div class="d-flex justify-content-between align-items-center">
<div>
<i class="bi bi-paperclip me-2"></i>
<strong>{{ attachment.filename }}</strong>
<br>
<small class="text-muted">
{{ attachment.file_size|filesizeformat }} •
{{ attachment.created_at|date:"M d, Y" }}
</small>
</div>
<a href="{{ attachment.file.url }}" class="btn btn-sm btn-outline-primary"
target="_blank" download>
<i class="bi bi-download"></i>
</a>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,380 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% load static %}
{% block title %}{% if is_create %}Create{% else %}Edit{% endif %} Feedback - PX360{% endblock %}
{% block extra_css %}
<style>
.form-card {
border: 1px solid #dee2e6;
border-radius: 8px;
margin-bottom: 20px;
}
.form-card-header {
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
padding: 15px 20px;
font-weight: 600;
}
.form-card-body {
padding: 20px;
}
.required-field::after {
content: " *";
color: #dc3545;
}
.form-help-text {
font-size: 0.875rem;
color: #6c757d;
margin-top: 0.25rem;
}
.rating-input {
display: flex;
gap: 10px;
align-items: center;
}
.rating-input input[type="radio"] {
display: none;
}
.rating-input label {
font-size: 2rem;
color: #ddd;
cursor: pointer;
transition: color 0.2s;
}
.rating-input input[type="radio"]:checked ~ label,
.rating-input label:hover,
.rating-input label:hover ~ label {
color: #ffc107;
}
.rating-input {
flex-direction: row-reverse;
justify-content: flex-end;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- Page Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-2">
<li class="breadcrumb-item"><a href="{% url 'feedback:feedback_list' %}">Feedback</a></li>
<li class="breadcrumb-item active">{% if is_create %}Create{% else %}Edit{% endif %}</li>
</ol>
</nav>
<h2 class="mb-0">
<i class="bi bi-chat-heart-fill text-primary me-2"></i>
{% if is_create %}Create New Feedback{% else %}Edit Feedback{% endif %}
</h2>
</div>
<div>
<a href="{% url 'feedback:feedback_list' %}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle me-1"></i> Cancel
</a>
</div>
</div>
<div class="row">
<div class="col-lg-8 mx-auto">
<form method="post" id="feedbackForm">
{% csrf_token %}
<!-- Display form errors -->
{% if form.non_field_errors %}
<div class="alert alert-danger alert-dismissible fade show" role="alert">
{{ form.non_field_errors }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endif %}
<!-- Patient/Contact Information -->
<div class="form-card">
<div class="form-card-header">
<i class="bi bi-person me-2"></i>Patient/Contact Information
</div>
<div class="form-card-body">
<!-- Anonymous Checkbox -->
<div class="mb-3">
<div class="form-check">
{{ form.is_anonymous }}
<label class="form-check-label" for="{{ form.is_anonymous.id_for_label }}">
Submit as Anonymous Feedback
</label>
</div>
{% if form.is_anonymous.errors %}
<div class="text-danger small">{{ form.is_anonymous.errors }}</div>
{% endif %}
</div>
<!-- Patient Selection (shown when not anonymous) -->
<div class="mb-3" id="patientField">
<label for="{{ form.patient.id_for_label }}" class="form-label">Patient</label>
{{ form.patient }}
{% if form.patient.help_text %}
<div class="form-help-text">{{ form.patient.help_text }}</div>
{% endif %}
{% if form.patient.errors %}
<div class="text-danger small">{{ form.patient.errors }}</div>
{% endif %}
</div>
<!-- Anonymous Contact Fields (shown when anonymous) -->
<div id="anonymousFields" style="display: none;">
<div class="mb-3">
<label for="{{ form.contact_name.id_for_label }}" class="form-label required-field">Contact Name</label>
{{ form.contact_name }}
{% if form.contact_name.errors %}
<div class="text-danger small">{{ form.contact_name.errors }}</div>
{% endif %}
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.contact_email.id_for_label }}" class="form-label">Email</label>
{{ form.contact_email }}
{% if form.contact_email.errors %}
<div class="text-danger small">{{ form.contact_email.errors }}</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.contact_phone.id_for_label }}" class="form-label">Phone</label>
{{ form.contact_phone }}
{% if form.contact_phone.errors %}
<div class="text-danger small">{{ form.contact_phone.errors }}</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Feedback Details -->
<div class="form-card">
<div class="form-card-header">
<i class="bi bi-chat-text me-2"></i>Feedback Details
</div>
<div class="form-card-body">
<!-- Feedback Type -->
<div class="mb-3">
<label for="{{ form.feedback_type.id_for_label }}" class="form-label required-field">Feedback Type</label>
{{ form.feedback_type }}
{% if form.feedback_type.errors %}
<div class="text-danger small">{{ form.feedback_type.errors }}</div>
{% endif %}
</div>
<!-- Title -->
<div class="mb-3">
<label for="{{ form.title.id_for_label }}" class="form-label required-field">Title</label>
{{ form.title }}
{% if form.title.errors %}
<div class="text-danger small">{{ form.title.errors }}</div>
{% endif %}
</div>
<!-- Message -->
<div class="mb-3">
<label for="{{ form.message.id_for_label }}" class="form-label required-field">Message</label>
{{ form.message }}
<div class="form-help-text">Please provide detailed feedback</div>
{% if form.message.errors %}
<div class="text-danger small">{{ form.message.errors }}</div>
{% endif %}
</div>
<!-- Category and Subcategory -->
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.category.id_for_label }}" class="form-label required-field">Category</label>
{{ form.category }}
{% if form.category.errors %}
<div class="text-danger small">{{ form.category.errors }}</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.subcategory.id_for_label }}" class="form-label">Subcategory</label>
{{ form.subcategory }}
{% if form.subcategory.errors %}
<div class="text-danger small">{{ form.subcategory.errors }}</div>
{% endif %}
</div>
</div>
<!-- Rating -->
<div class="mb-3">
<label class="form-label">Rating (Optional)</label>
<div class="rating-input">
<input type="radio" name="rating" value="5" id="rating5" {% if form.rating.value == 5 %}checked{% endif %}>
<label for="rating5"><i class="bi bi-star-fill"></i></label>
<input type="radio" name="rating" value="4" id="rating4" {% if form.rating.value == 4 %}checked{% endif %}>
<label for="rating4"><i class="bi bi-star-fill"></i></label>
<input type="radio" name="rating" value="3" id="rating3" {% if form.rating.value == 3 %}checked{% endif %}>
<label for="rating3"><i class="bi bi-star-fill"></i></label>
<input type="radio" name="rating" value="2" id="rating2" {% if form.rating.value == 2 %}checked{% endif %}>
<label for="rating2"><i class="bi bi-star-fill"></i></label>
<input type="radio" name="rating" value="1" id="rating1" {% if form.rating.value == 1 %}checked{% endif %}>
<label for="rating1"><i class="bi bi-star-fill"></i></label>
</div>
<div class="form-help-text">Rate your experience from 1 to 5 stars</div>
{% if form.rating.errors %}
<div class="text-danger small">{{ form.rating.errors }}</div>
{% endif %}
</div>
<!-- Priority -->
<div class="mb-3">
<label for="{{ form.priority.id_for_label }}" class="form-label">Priority</label>
{{ form.priority }}
{% if form.priority.errors %}
<div class="text-danger small">{{ form.priority.errors }}</div>
{% endif %}
</div>
</div>
</div>
<!-- Organization Information -->
<div class="form-card">
<div class="form-card-header">
<i class="bi bi-building me-2"></i>Organization Information
</div>
<div class="form-card-body">
<!-- Hospital -->
<div class="mb-3">
<label for="{{ form.hospital.id_for_label }}" class="form-label required-field">Hospital</label>
{{ form.hospital }}
{% if form.hospital.errors %}
<div class="text-danger small">{{ form.hospital.errors }}</div>
{% endif %}
</div>
<!-- Department -->
<div class="mb-3">
<label for="{{ form.department.id_for_label }}" class="form-label">Department</label>
{{ form.department }}
<div class="form-help-text">Select the department related to this feedback (optional)</div>
{% if form.department.errors %}
<div class="text-danger small">{{ form.department.errors }}</div>
{% endif %}
</div>
<!-- Physician -->
<div class="mb-3">
<label for="{{ form.physician.id_for_label }}" class="form-label">Physician</label>
{{ form.physician }}
<div class="form-help-text">Select the physician mentioned in this feedback (optional)</div>
{% if form.physician.errors %}
<div class="text-danger small">{{ form.physician.errors }}</div>
{% endif %}
</div>
<!-- Encounter ID -->
<div class="mb-3">
<label for="{{ form.encounter_id.id_for_label }}" class="form-label">Encounter ID</label>
{{ form.encounter_id }}
<div class="form-help-text">Related encounter ID if applicable (optional)</div>
{% if form.encounter_id.errors %}
<div class="text-danger small">{{ form.encounter_id.errors }}</div>
{% endif %}
</div>
</div>
</div>
<!-- Form Actions -->
<div class="d-flex justify-content-between align-items-center mb-4">
<a href="{% url 'feedback:feedback_list' %}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle me-1"></i> Cancel
</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle me-1"></i>
{% if is_create %}Create Feedback{% else %}Update Feedback{% endif %}
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const anonymousCheckbox = document.getElementById('{{ form.is_anonymous.id_for_label }}');
const patientField = document.getElementById('patientField');
const anonymousFields = document.getElementById('anonymousFields');
function toggleAnonymousFields() {
if (anonymousCheckbox.checked) {
patientField.style.display = 'none';
anonymousFields.style.display = 'block';
} else {
patientField.style.display = 'block';
anonymousFields.style.display = 'none';
}
}
// Initial state
toggleAnonymousFields();
// Listen for changes
anonymousCheckbox.addEventListener('change', toggleAnonymousFields);
// Rating stars interaction
const ratingLabels = document.querySelectorAll('.rating-input label');
ratingLabels.forEach(label => {
label.addEventListener('mouseenter', function() {
const rating = this.previousElementSibling.value;
highlightStars(rating);
});
});
document.querySelector('.rating-input').addEventListener('mouseleave', function() {
const checkedInput = document.querySelector('.rating-input input:checked');
if (checkedInput) {
highlightStars(checkedInput.value);
} else {
resetStars();
}
});
function highlightStars(rating) {
ratingLabels.forEach((label, index) => {
if (5 - index <= rating) {
label.style.color = '#ffc107';
} else {
label.style.color = '#ddd';
}
});
}
function resetStars() {
ratingLabels.forEach(label => {
label.style.color = '#ddd';
});
}
// Form validation
const form = document.getElementById('feedbackForm');
form.addEventListener('submit', function(e) {
const isAnonymous = anonymousCheckbox.checked;
const patient = document.getElementById('{{ form.patient.id_for_label }}').value;
const contactName = document.getElementById('{{ form.contact_name.id_for_label }}').value;
if (!isAnonymous && !patient) {
e.preventDefault();
alert('Please select a patient or mark as anonymous.');
return false;
}
if (isAnonymous && !contactName) {
e.preventDefault();
alert('Please enter a contact name for anonymous feedback.');
return false;
}
});
});
</script>
{% endblock %}

View File

@ -0,0 +1,483 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% load static %}
{% block title %}Feedback Console - PX360{% endblock %}
{% block extra_css %}
<style>
.filter-panel {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.filter-panel.collapsed {
padding: 10px 20px;
}
.filter-panel.collapsed .filter-body {
display: none;
}
.table-toolbar {
background: #fff;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 15px;
margin-bottom: 15px;
display: flex;
justify-content: space-between;
align-items: center;
}
.status-badge {
padding: 4px 12px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 500;
}
.status-submitted { background: #e3f2fd; color: #1976d2; }
.status-reviewed { background: #fff3e0; color: #f57c00; }
.status-acknowledged { background: #e8f5e9; color: #388e3c; }
.status-closed { background: #f5f5f5; color: #616161; }
.type-badge {
padding: 4px 12px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 500;
}
.type-compliment { background: #e8f5e9; color: #2e7d32; }
.type-suggestion { background: #e3f2fd; color: #1565c0; }
.type-general { background: #f5f5f5; color: #616161; }
.type-inquiry { background: #fff3e0; color: #ef6c00; }
.sentiment-badge {
padding: 4px 12px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 500;
}
.sentiment-positive { background: #e8f5e9; color: #2e7d32; }
.sentiment-neutral { background: #f5f5f5; color: #616161; }
.sentiment-negative { background: #ffebee; color: #c62828; }
.rating-stars {
color: #ffc107;
}
.feedback-row:hover {
background: #f8f9fa;
cursor: pointer;
}
.stat-card {
border-left: 4px solid;
transition: transform 0.2s;
}
.stat-card:hover {
transform: translateY(-2px);
}
.featured-badge {
background: #ffd700;
color: #000;
padding: 2px 8px;
border-radius: 10px;
font-size: 0.75rem;
font-weight: 600;
margin-left: 8px;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- Page Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 class="mb-1">
<i class="bi bi-chat-heart-fill text-primary me-2"></i>
Feedback Console
</h2>
<p class="text-muted mb-0">Manage patient feedback, compliments, and suggestions</p>
</div>
<div>
<a href="{% url 'feedback:feedback_create' %}" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i> New Feedback
</a>
</div>
</div>
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card stat-card border-primary">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-1">Total Feedback</h6>
<h3 class="mb-0">{{ stats.total }}</h3>
</div>
<div class="text-primary">
<i class="bi bi-chat-dots" style="font-size: 2rem;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card border-success">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-1">Compliments</h6>
<h3 class="mb-0">{{ stats.compliments }}</h3>
</div>
<div class="text-success">
<i class="bi bi-hand-thumbs-up-fill" style="font-size: 2rem;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card border-info">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-1">Avg Rating</h6>
<h3 class="mb-0">{{ stats.avg_rating|floatformat:1 }} <small class="text-muted">/5</small></h3>
</div>
<div class="text-warning">
<i class="bi bi-star-fill" style="font-size: 2rem;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card border-warning">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-1">Pending Review</h6>
<h3 class="mb-0">{{ stats.submitted }}</h3>
</div>
<div class="text-warning">
<i class="bi bi-hourglass-split" style="font-size: 2rem;"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Filter Panel -->
<div class="filter-panel" id="filterPanel">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0">
<i class="bi bi-funnel me-2"></i>Filters
</h5>
<button class="btn btn-sm btn-outline-secondary" onclick="toggleFilters()">
<i class="bi bi-chevron-up" id="filterToggleIcon"></i>
</button>
</div>
<div class="filter-body">
<form method="get" action="{% url 'feedback:feedback_list' %}" id="filterForm">
<div class="row g-3">
<!-- Search -->
<div class="col-md-4">
<label class="form-label">Search</label>
<input type="text" class="form-control" name="search"
placeholder="Title, message, patient..."
value="{{ filters.search }}">
</div>
<!-- Feedback Type -->
<div class="col-md-4">
<label class="form-label">Type</label>
<select class="form-select" name="feedback_type">
<option value="">All Types</option>
{% for value, label in type_choices %}
<option value="{{ value }}" {% if filters.feedback_type == value %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
</div>
<!-- Status -->
<div class="col-md-4">
<label class="form-label">Status</label>
<select class="form-select" name="status">
<option value="">All Statuses</option>
{% for value, label in status_choices %}
<option value="{{ value }}" {% if filters.status == value %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
</div>
<!-- Category -->
<div class="col-md-4">
<label class="form-label">Category</label>
<select class="form-select" name="category">
<option value="">All Categories</option>
{% for value, label in category_choices %}
<option value="{{ value }}" {% if filters.category == value %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
</div>
<!-- Sentiment -->
<div class="col-md-4">
<label class="form-label">Sentiment</label>
<select class="form-select" name="sentiment">
<option value="">All Sentiments</option>
<option value="positive" {% if filters.sentiment == 'positive' %}selected{% endif %}>Positive</option>
<option value="neutral" {% if filters.sentiment == 'neutral' %}selected{% endif %}>Neutral</option>
<option value="negative" {% if filters.sentiment == 'negative' %}selected{% endif %}>Negative</option>
</select>
</div>
<!-- Hospital -->
<div class="col-md-4">
<label class="form-label">Hospital</label>
<select class="form-select" 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>
<!-- Rating Range -->
<div class="col-md-3">
<label class="form-label">Min Rating</label>
<input type="number" class="form-control" name="rating_min"
min="1" max="5" value="{{ filters.rating_min }}" placeholder="1-5">
</div>
<div class="col-md-3">
<label class="form-label">Max Rating</label>
<input type="number" class="form-control" name="rating_max"
min="1" max="5" value="{{ filters.rating_max }}" placeholder="1-5">
</div>
<!-- Date Range -->
<div class="col-md-3">
<label class="form-label">Date From</label>
<input type="date" class="form-control" name="date_from" value="{{ filters.date_from }}">
</div>
<div class="col-md-3">
<label class="form-label">Date To</label>
<input type="date" class="form-control" name="date_to" value="{{ filters.date_to }}">
</div>
</div>
<div class="mt-3 d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-search me-1"></i> Apply Filters
</button>
<a href="{% url 'feedback:feedback_list' %}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle me-1"></i> Clear
</a>
</div>
</form>
</div>
</div>
<!-- Table Toolbar -->
<div class="table-toolbar">
<div>
<span class="text-muted">
Showing {{ page_obj.start_index }} to {{ page_obj.end_index }} of {{ page_obj.paginator.count }} feedback items
</span>
</div>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-primary">
<i class="bi bi-file-earmark-spreadsheet me-1"></i> Export CSV
</button>
</div>
</div>
<!-- Feedback Table -->
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th style="width: 50px;">
<input type="checkbox" class="form-check-input" id="selectAll">
</th>
<th>ID</th>
<th>Type</th>
<th>Patient/Contact</th>
<th>Title</th>
<th>Category</th>
<th>Rating</th>
<th>Sentiment</th>
<th>Status</th>
<th>Hospital</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for feedback in feedbacks %}
<tr class="feedback-row" onclick="window.location='{% url 'feedback:feedback_detail' feedback.id %}'">
<td onclick="event.stopPropagation();">
<input type="checkbox" class="form-check-input feedback-checkbox"
value="{{ feedback.id }}">
</td>
<td>
<small class="text-muted">#{{ feedback.id|slice:":8" }}</small>
</td>
<td>
<span class="type-badge type-{{ feedback.feedback_type }}">
{{ feedback.get_feedback_type_display }}
</span>
</td>
<td>
<strong>{{ feedback.get_contact_name }}</strong><br>
{% if feedback.patient %}
<small class="text-muted">MRN: {{ feedback.patient.mrn }}</small>
{% else %}
<small class="text-muted">Anonymous</small>
{% endif %}
</td>
<td>
<div class="d-flex align-items-center">
{{ feedback.title|truncatewords:8 }}
{% if feedback.is_featured %}
<span class="featured-badge">★ FEATURED</span>
{% endif %}
</div>
</td>
<td>
<span class="badge bg-secondary">{{ feedback.get_category_display }}</span>
</td>
<td>
{% if feedback.rating %}
<span class="rating-stars">
{% for i in "12345" %}
{% if forloop.counter <= feedback.rating %}
<i class="bi bi-star-fill"></i>
{% else %}
<i class="bi bi-star"></i>
{% endif %}
{% endfor %}
</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
<span class="sentiment-badge sentiment-{{ feedback.sentiment }}">
{{ feedback.get_sentiment_display }}
</span>
</td>
<td>
<span class="status-badge status-{{ feedback.status }}">
{{ feedback.get_status_display }}
</span>
</td>
<td>
<small>{{ feedback.hospital.name|truncatewords:3 }}</small>
</td>
<td>
<small class="text-muted">{{ feedback.created_at|date:"M d, Y" }}</small>
</td>
<td onclick="event.stopPropagation();">
<div class="btn-group btn-group-sm">
<a href="{% url 'feedback:feedback_detail' feedback.id %}"
class="btn btn-outline-primary" title="View">
<i class="bi bi-eye"></i>
</a>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="12" class="text-center py-5">
<i class="bi bi-inbox" style="font-size: 3rem; color: #ccc;"></i>
<p class="text-muted mt-3">No feedback found</p>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Pagination -->
{% if page_obj.has_other_pages %}
<nav aria-label="Feedback pagination" 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="bi bi-chevron-double-left"></i>
</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="bi bi-chevron-left"></i>
</a>
</li>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<li class="page-item active"><span class="page-link">{{ num }}</span></li>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<li class="page-item">
<a class="page-link" href="?page={{ num }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
{{ num }}
</a>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
<i class="bi bi-chevron-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="bi bi-chevron-double-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
<script>
function toggleFilters() {
const panel = document.getElementById('filterPanel');
const icon = document.getElementById('filterToggleIcon');
panel.classList.toggle('collapsed');
icon.classList.toggle('bi-chevron-up');
icon.classList.toggle('bi-chevron-down');
}
// Select all checkbox
document.getElementById('selectAll')?.addEventListener('change', function() {
const checkboxes = document.querySelectorAll('.feedback-checkbox');
checkboxes.forEach(cb => cb.checked = this.checked);
});
</script>
{% endblock %}

View File

@ -29,6 +29,16 @@
</a>
</li>
<!-- Feedback -->
<li class="nav-item">
<a class="nav-link {% if 'feedback' in request.path %}active{% endif %}"
href="{% url 'feedback:feedback_list' %}">
<i class="bi bi-chat-heart"></i>
{% trans "Feedback" %}
<span class="badge bg-success">{{ feedback_count|default:0 }}</span>
</a>
</li>
<!-- PX Actions -->
<li class="nav-item">
<a class="nav-link {% if 'actions' in request.path %}active{% endif %}"