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
|
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
|
Feedback models - Patient feedback and suggestions management
|
||||||
"""
|
|
||||||
from django.db import models
|
|
||||||
|
|
||||||
# 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 django.urls import path
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
app_name = 'feedback'
|
app_name = 'feedback'
|
||||||
|
|
||||||
urlpatterns = [
|
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
|
# UI Pages
|
||||||
path('complaints/', include('apps.complaints.urls')),
|
path('complaints/', include('apps.complaints.urls')),
|
||||||
|
path('feedback/', include('apps.feedback.urls')),
|
||||||
path('actions/', include('apps.px_action_center.urls')),
|
path('actions/', include('apps.px_action_center.urls')),
|
||||||
path('journeys/', include('apps.journeys.urls')),
|
path('journeys/', include('apps.journeys.urls')),
|
||||||
path('surveys/', include('apps.surveys.urls')),
|
path('surveys/', include('apps.surveys.urls')),
|
||||||
@ -35,7 +36,6 @@ urlpatterns = [
|
|||||||
|
|
||||||
# API endpoints
|
# API endpoints
|
||||||
path('api/auth/', include('apps.accounts.urls')),
|
path('api/auth/', include('apps.accounts.urls')),
|
||||||
path('api/feedback/', include('apps.feedback.urls')),
|
|
||||||
path('api/physicians/', include('apps.physicians.urls')),
|
path('api/physicians/', include('apps.physicians.urls')),
|
||||||
path('api/integrations/', include('apps.integrations.urls')),
|
path('api/integrations/', include('apps.integrations.urls')),
|
||||||
path('api/notifications/', include('apps.notifications.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():
|
def generate_saudi_phone():
|
||||||
"""Generate Saudi phone number"""
|
"""Generate Saudi phone number"""
|
||||||
return f"+966{random.choice(['50', '53', '54', '55', '56', '58'])}{random.randint(1000000, 9999999)}"
|
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
|
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):
|
def create_survey_templates(hospitals):
|
||||||
"""Create survey templates"""
|
"""Create survey templates"""
|
||||||
print("Creating survey templates...")
|
print("Creating survey templates...")
|
||||||
@ -580,6 +732,9 @@ def main():
|
|||||||
print("PX360 - Saudi-Influenced Data Generator")
|
print("PX360 - Saudi-Influenced Data Generator")
|
||||||
print("="*60 + "\n")
|
print("="*60 + "\n")
|
||||||
|
|
||||||
|
# Clear existing data first
|
||||||
|
clear_existing_data()
|
||||||
|
|
||||||
# Create base data
|
# Create base data
|
||||||
hospitals = create_hospitals()
|
hospitals = create_hospitals()
|
||||||
departments = create_departments(hospitals)
|
departments = create_departments(hospitals)
|
||||||
@ -592,6 +747,7 @@ def main():
|
|||||||
|
|
||||||
# Create operational data
|
# Create operational data
|
||||||
complaints = create_complaints(patients, hospitals, physicians, users)
|
complaints = create_complaints(patients, hospitals, physicians, users)
|
||||||
|
feedbacks = create_feedback(patients, hospitals, physicians, users)
|
||||||
create_survey_templates(hospitals)
|
create_survey_templates(hospitals)
|
||||||
create_journey_templates(hospitals)
|
create_journey_templates(hospitals)
|
||||||
projects = create_qi_projects(hospitals)
|
projects = create_qi_projects(hospitals)
|
||||||
@ -611,6 +767,7 @@ def main():
|
|||||||
print(f" - {len(patients)} Patients")
|
print(f" - {len(patients)} Patients")
|
||||||
print(f" - {len(users)} Users")
|
print(f" - {len(users)} Users")
|
||||||
print(f" - {len(complaints)} Complaints")
|
print(f" - {len(complaints)} Complaints")
|
||||||
|
print(f" - {len(feedbacks)} Feedback Items")
|
||||||
print(f" - {len(actions)} PX Actions")
|
print(f" - {len(actions)} PX Actions")
|
||||||
print(f" - {len(journey_instances)} Journey Instances")
|
print(f" - {len(journey_instances)} Journey Instances")
|
||||||
print(f" - {len(survey_instances)} Survey 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>
|
</a>
|
||||||
</li>
|
</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 -->
|
<!-- PX Actions -->
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if 'actions' in request.path %}active{% endif %}"
|
<a class="nav-link {% if 'actions' in request.path %}active{% endif %}"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user