update feeback crud
This commit is contained in:
parent
2e15c5db7c
commit
ddf3a06212
@ -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
339
apps/feedback/forms.py
Normal 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)...'
|
||||
})
|
||||
)
|
||||
127
apps/feedback/migrations/0001_initial.py
Normal file
127
apps/feedback/migrations/0001_initial.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
0
apps/feedback/migrations/__init__.py
Normal file
0
apps/feedback/migrations/__init__.py
Normal 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')}"
|
||||
|
||||
@ -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'),
|
||||
]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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')),
|
||||
|
||||
@ -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")
|
||||
|
||||
191
templates/feedback/feedback_delete_confirm.html
Normal file
191
templates/feedback/feedback_delete_confirm.html
Normal 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 %}
|
||||
605
templates/feedback/feedback_detail.html
Normal file
605
templates/feedback/feedback_detail.html
Normal 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 %}
|
||||
380
templates/feedback/feedback_form.html
Normal file
380
templates/feedback/feedback_form.html
Normal 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 %}
|
||||
483
templates/feedback/feedback_list.html
Normal file
483
templates/feedback/feedback_list.html
Normal 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 %}
|
||||
@ -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 %}"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user