update
This commit is contained in:
parent
73c9e2e921
commit
610e165e17
Binary file not shown.
@ -10,7 +10,8 @@ from django.views.generic import (
|
||||
)
|
||||
from django.http import JsonResponse
|
||||
from django.contrib import messages
|
||||
from django.db.models import Q, Count, Avg
|
||||
from django.db.models.functions import Now
|
||||
from django.db.models import Q, Count, Avg, Case, When, Value, DurationField, FloatField, F, ExpressionWrapper, IntegerField
|
||||
from django.utils import timezone
|
||||
from django.urls import reverse_lazy, reverse
|
||||
from django.core.paginator import Paginator
|
||||
@ -29,6 +30,7 @@ from accounts.models import User
|
||||
from core.utils import AuditLogger
|
||||
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DASHBOARD VIEW
|
||||
# ============================================================================
|
||||
@ -1334,6 +1336,7 @@ def available_slots(request):
|
||||
def queue_status(request, queue_id):
|
||||
"""
|
||||
HTMX view for queue status.
|
||||
Shows queue entries plus aggregated stats with DB-side wait calculations.
|
||||
"""
|
||||
tenant = getattr(request, 'tenant', None)
|
||||
if not tenant:
|
||||
@ -1341,25 +1344,66 @@ def queue_status(request, queue_id):
|
||||
|
||||
queue = get_object_or_404(WaitingQueue, id=queue_id, tenant=tenant)
|
||||
|
||||
# Get queue entries
|
||||
queue_entries = QueueEntry.objects.filter(
|
||||
queue=queue
|
||||
).order_by('position', 'created_at')
|
||||
queue_entries = (
|
||||
QueueEntry.objects
|
||||
.filter(queue=queue)
|
||||
.annotate(
|
||||
wait_duration=Case(
|
||||
When(status='WAITING', then=Now() - F('joined_at')),
|
||||
When(served_at__isnull=False, then=F('served_at') - F('joined_at')),
|
||||
default=Value(None),
|
||||
output_field=DurationField(),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
wait_minutes=Case(
|
||||
When(wait_duration__isnull=False,
|
||||
then=ExpressionWrapper(
|
||||
F('wait_duration') / Value(timedelta(minutes=1)),
|
||||
output_field=FloatField()
|
||||
)),
|
||||
default=Value(None),
|
||||
output_field=FloatField(),
|
||||
),
|
||||
waiting_rank=Case(
|
||||
When(status='WAITING', then=F('queue_position')),
|
||||
default=Value(None), output_field=IntegerField()
|
||||
),
|
||||
)
|
||||
.select_related('assigned_provider', 'patient', 'appointment') # adjust if you need more
|
||||
.order_by('queue_position', 'updated_at')
|
||||
)
|
||||
|
||||
# Aggregates & stats
|
||||
total_entries = queue_entries.count()
|
||||
waiting_entries = queue_entries.filter(status='WAITING').count()
|
||||
called_entries = queue_entries.filter(status='CALLED').count()
|
||||
in_service_entries = queue_entries.filter(status='IN_SERVICE').count()
|
||||
completed_entries = queue_entries.filter(status='COMPLETED').count()
|
||||
|
||||
avg_completed_wait = (
|
||||
queue_entries
|
||||
.filter(status='COMPLETED')
|
||||
.aggregate(avg_wait=Avg('wait_minutes'))
|
||||
.get('avg_wait') or 0
|
||||
)
|
||||
|
||||
# Calculate statistics
|
||||
stats = {
|
||||
'total_entries': queue_entries.count(),
|
||||
'waiting_entries': queue_entries.filter(status='WAITING').count(),
|
||||
'in_progress_entries': queue_entries.filter(status='IN_PROGRESS').count(),
|
||||
'average_wait_time': queue_entries.filter(
|
||||
status='COMPLETED'
|
||||
).aggregate(avg_wait=Avg('actual_wait_time_minutes'))['avg_wait'] or 0,
|
||||
'total_entries': total_entries,
|
||||
'waiting_entries': waiting_entries,
|
||||
'called_entries': called_entries,
|
||||
'in_service_entries': in_service_entries,
|
||||
'completed_entries': completed_entries,
|
||||
# Average from COMPLETED cases only (rounded to 1 decimal)
|
||||
'average_wait_time_minutes': round(avg_completed_wait, 1),
|
||||
# Quick estimate based on queue config
|
||||
'estimated_queue_wait_minutes': waiting_entries * queue.average_service_time_minutes,
|
||||
}
|
||||
|
||||
return render(request, 'appointments/partials/queue_status.html', {
|
||||
'queue': queue,
|
||||
'queue_entries': queue_entries,
|
||||
'stats': stats
|
||||
'queue_entries': queue_entries, # each has .wait_minutes
|
||||
'stats': stats,
|
||||
})
|
||||
|
||||
|
||||
@ -1536,7 +1580,7 @@ def next_in_queue(request, queue_id):
|
||||
return JsonResponse({
|
||||
'status': 'success',
|
||||
'patient': str(next_entry.patient),
|
||||
'position': next_entry.position
|
||||
'position': next_entry.queue_position
|
||||
})
|
||||
else:
|
||||
return JsonResponse({'status': 'no_patients'})
|
||||
@ -1708,12 +1752,19 @@ class SchedulingCalendarView(LoginRequiredMixin, TemplateView):
|
||||
return context
|
||||
|
||||
|
||||
class QueueManagementView(LoginRequiredMixin, TemplateView):
|
||||
class QueueManagementView(LoginRequiredMixin, ListView):
|
||||
"""
|
||||
Queue management view for appointments.
|
||||
"""
|
||||
|
||||
model = QueueEntry
|
||||
template_name = 'appointments/queue_management.html'
|
||||
context_object_name = 'queues'
|
||||
|
||||
def get_queryset(self):
|
||||
return QueueEntry.objects.filter(
|
||||
appointment__tenant=self.request.user.tenant
|
||||
)
|
||||
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
0
blood_bank/__init__.py
Normal file
0
blood_bank/__init__.py
Normal file
BIN
blood_bank/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
blood_bank/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
blood_bank/__pycache__/admin.cpython-312.pyc
Normal file
BIN
blood_bank/__pycache__/admin.cpython-312.pyc
Normal file
Binary file not shown.
BIN
blood_bank/__pycache__/apps.cpython-312.pyc
Normal file
BIN
blood_bank/__pycache__/apps.cpython-312.pyc
Normal file
Binary file not shown.
BIN
blood_bank/__pycache__/forms.cpython-312.pyc
Normal file
BIN
blood_bank/__pycache__/forms.cpython-312.pyc
Normal file
Binary file not shown.
BIN
blood_bank/__pycache__/models.cpython-312.pyc
Normal file
BIN
blood_bank/__pycache__/models.cpython-312.pyc
Normal file
Binary file not shown.
BIN
blood_bank/__pycache__/urls.cpython-312.pyc
Normal file
BIN
blood_bank/__pycache__/urls.cpython-312.pyc
Normal file
Binary file not shown.
BIN
blood_bank/__pycache__/views.cpython-312.pyc
Normal file
BIN
blood_bank/__pycache__/views.cpython-312.pyc
Normal file
Binary file not shown.
387
blood_bank/admin.py
Normal file
387
blood_bank/admin.py
Normal file
@ -0,0 +1,387 @@
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from .models import (
|
||||
BloodGroup, Donor, BloodComponent, BloodUnit, BloodTest, CrossMatch,
|
||||
BloodRequest, BloodIssue, Transfusion, AdverseReaction, InventoryLocation,
|
||||
QualityControl
|
||||
)
|
||||
|
||||
|
||||
@admin.register(BloodGroup)
|
||||
class BloodGroupAdmin(admin.ModelAdmin):
|
||||
list_display = ['abo_type', 'rh_factor', 'display_name']
|
||||
list_filter = ['abo_type', 'rh_factor']
|
||||
ordering = ['abo_type', 'rh_factor']
|
||||
|
||||
|
||||
@admin.register(Donor)
|
||||
class DonorAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
'donor_id', 'full_name', 'blood_group', 'age', 'status',
|
||||
'total_donations', 'last_donation_date', 'is_eligible_for_donation'
|
||||
]
|
||||
list_filter = [
|
||||
'status', 'donor_type', 'blood_group', 'gender',
|
||||
'registration_date', 'last_donation_date'
|
||||
]
|
||||
search_fields = ['donor_id', 'first_name', 'last_name', 'phone', 'email']
|
||||
readonly_fields = ['registration_date', 'created_at', 'updated_at', 'age']
|
||||
fieldsets = (
|
||||
('Personal Information', {
|
||||
'fields': (
|
||||
'donor_id', 'first_name', 'last_name', 'date_of_birth',
|
||||
'gender', 'blood_group', 'weight', 'height'
|
||||
)
|
||||
}),
|
||||
('Contact Information', {
|
||||
'fields': (
|
||||
'phone', 'email', 'address',
|
||||
'emergency_contact_name', 'emergency_contact_phone'
|
||||
)
|
||||
}),
|
||||
('Donation Information', {
|
||||
'fields': (
|
||||
'donor_type', 'status', 'total_donations',
|
||||
'last_donation_date', 'notes'
|
||||
)
|
||||
}),
|
||||
('System Information', {
|
||||
'fields': ('created_by', 'registration_date', 'created_at', 'updated_at'),
|
||||
'classes': ['collapse']
|
||||
})
|
||||
)
|
||||
|
||||
def is_eligible_for_donation(self, obj):
|
||||
if obj.is_eligible_for_donation:
|
||||
return format_html('<span style="color: green;">✓ Eligible</span>')
|
||||
else:
|
||||
return format_html('<span style="color: red;">✗ Not Eligible</span>')
|
||||
|
||||
is_eligible_for_donation.short_description = 'Eligible'
|
||||
|
||||
|
||||
@admin.register(BloodComponent)
|
||||
class BloodComponentAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'shelf_life_days', 'storage_temperature', 'volume_ml', 'is_active']
|
||||
list_filter = ['is_active', 'shelf_life_days']
|
||||
search_fields = ['name', 'description']
|
||||
|
||||
|
||||
class BloodTestInline(admin.TabularInline):
|
||||
model = BloodTest
|
||||
extra = 0
|
||||
readonly_fields = ['test_date', 'verified_at']
|
||||
|
||||
|
||||
class CrossMatchInline(admin.TabularInline):
|
||||
model = CrossMatch
|
||||
extra = 0
|
||||
readonly_fields = ['test_date', 'verified_at']
|
||||
|
||||
|
||||
@admin.register(BloodUnit)
|
||||
class BloodUnitAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
'unit_number', 'donor', 'component', 'blood_group',
|
||||
'collection_date', 'expiry_date', 'status', 'days_to_expiry',
|
||||
'is_available'
|
||||
]
|
||||
list_filter = [
|
||||
'status', 'component', 'blood_group', 'collection_date',
|
||||
'expiry_date', 'collection_site'
|
||||
]
|
||||
search_fields = ['unit_number', 'donor__donor_id', 'donor__first_name', 'donor__last_name']
|
||||
readonly_fields = ['created_at', 'updated_at', 'days_to_expiry', 'is_expired']
|
||||
inlines = [BloodTestInline, CrossMatchInline]
|
||||
|
||||
fieldsets = (
|
||||
('Unit Information', {
|
||||
'fields': (
|
||||
'unit_number', 'donor', 'component', 'blood_group',
|
||||
'volume_ml', 'status', 'location'
|
||||
)
|
||||
}),
|
||||
('Collection Details', {
|
||||
'fields': (
|
||||
'collection_date', 'expiry_date', 'collection_site',
|
||||
'bag_type', 'anticoagulant', 'collected_by'
|
||||
)
|
||||
}),
|
||||
('Additional Information', {
|
||||
'fields': ('notes', 'created_at', 'updated_at'),
|
||||
'classes': ['collapse']
|
||||
})
|
||||
)
|
||||
|
||||
def days_to_expiry(self, obj):
|
||||
days = obj.days_to_expiry
|
||||
if days == 0:
|
||||
return format_html('<span style="color: red;">Expired</span>')
|
||||
elif days <= 3:
|
||||
return format_html('<span style="color: orange;">{} days</span>', days)
|
||||
else:
|
||||
return f"{days} days"
|
||||
|
||||
days_to_expiry.short_description = 'Days to Expiry'
|
||||
|
||||
def is_available(self, obj):
|
||||
if obj.is_available:
|
||||
return format_html('<span style="color: green;">✓ Available</span>')
|
||||
else:
|
||||
return format_html('<span style="color: red;">✗ Not Available</span>')
|
||||
|
||||
is_available.short_description = 'Available'
|
||||
|
||||
|
||||
@admin.register(BloodTest)
|
||||
class BloodTestAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
'blood_unit', 'test_type', 'result', 'test_date',
|
||||
'tested_by', 'verified_by', 'verified_at'
|
||||
]
|
||||
list_filter = ['test_type', 'result', 'test_date', 'verified_at']
|
||||
search_fields = ['blood_unit__unit_number', 'equipment_used', 'lot_number']
|
||||
readonly_fields = ['test_date', 'verified_at']
|
||||
|
||||
|
||||
@admin.register(CrossMatch)
|
||||
class CrossMatchAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
'blood_unit', 'recipient', 'test_type', 'compatibility',
|
||||
'test_date', 'tested_by', 'verified_by'
|
||||
]
|
||||
list_filter = ['test_type', 'compatibility', 'test_date']
|
||||
search_fields = [
|
||||
'blood_unit__unit_number', 'recipient__first_name',
|
||||
'recipient__last_name', 'recipient__patient_id'
|
||||
]
|
||||
readonly_fields = ['test_date', 'verified_at']
|
||||
|
||||
|
||||
class BloodIssueInline(admin.TabularInline):
|
||||
model = BloodIssue
|
||||
extra = 0
|
||||
readonly_fields = ['issue_date', 'expiry_time']
|
||||
|
||||
|
||||
@admin.register(BloodRequest)
|
||||
class BloodRequestAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
'request_number', 'patient', 'component_requested',
|
||||
'units_requested', 'urgency', 'status', 'request_date',
|
||||
'required_by', 'is_overdue'
|
||||
]
|
||||
list_filter = [
|
||||
'urgency', 'status', 'component_requested',
|
||||
'requesting_department', 'request_date', 'required_by'
|
||||
]
|
||||
search_fields = [
|
||||
'request_number', 'patient__first_name', 'patient__last_name',
|
||||
'patient__patient_id', 'indication'
|
||||
]
|
||||
readonly_fields = ['request_date', 'processed_at']
|
||||
inlines = [BloodIssueInline]
|
||||
|
||||
fieldsets = (
|
||||
('Request Information', {
|
||||
'fields': (
|
||||
'request_number', 'patient', 'requesting_department',
|
||||
'requesting_physician', 'urgency', 'status'
|
||||
)
|
||||
}),
|
||||
('Blood Requirements', {
|
||||
'fields': (
|
||||
'component_requested', 'units_requested', 'patient_blood_group',
|
||||
'special_requirements'
|
||||
)
|
||||
}),
|
||||
('Clinical Information', {
|
||||
'fields': (
|
||||
'indication', 'hemoglobin_level', 'platelet_count'
|
||||
)
|
||||
}),
|
||||
('Timeline', {
|
||||
'fields': (
|
||||
'request_date', 'required_by', 'processed_by', 'processed_at'
|
||||
)
|
||||
}),
|
||||
('Additional Information', {
|
||||
'fields': ('notes',),
|
||||
'classes': ['collapse']
|
||||
})
|
||||
)
|
||||
|
||||
def is_overdue(self, obj):
|
||||
if obj.is_overdue:
|
||||
return format_html('<span style="color: red;">✗ Overdue</span>')
|
||||
else:
|
||||
return format_html('<span style="color: green;">✓ On Time</span>')
|
||||
|
||||
is_overdue.short_description = 'Status'
|
||||
|
||||
|
||||
@admin.register(BloodIssue)
|
||||
class BloodIssueAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
'blood_unit', 'blood_request', 'issued_by', 'issued_to',
|
||||
'issue_date', 'expiry_time', 'returned', 'is_expired'
|
||||
]
|
||||
list_filter = ['returned', 'issue_date', 'expiry_time']
|
||||
search_fields = [
|
||||
'blood_unit__unit_number', 'blood_request__request_number',
|
||||
'blood_request__patient__first_name', 'blood_request__patient__last_name'
|
||||
]
|
||||
readonly_fields = ['issue_date', 'is_expired']
|
||||
|
||||
def is_expired(self, obj):
|
||||
if obj.is_expired:
|
||||
return format_html('<span style="color: red;">✗ Expired</span>')
|
||||
else:
|
||||
return format_html('<span style="color: green;">✓ Valid</span>')
|
||||
|
||||
is_expired.short_description = 'Status'
|
||||
|
||||
|
||||
class AdverseReactionInline(admin.TabularInline):
|
||||
model = AdverseReaction
|
||||
extra = 0
|
||||
readonly_fields = ['onset_time', 'report_date']
|
||||
|
||||
|
||||
@admin.register(Transfusion)
|
||||
class TransfusionAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
'blood_issue', 'start_time', 'end_time', 'status',
|
||||
'volume_transfused', 'administered_by', 'duration_minutes'
|
||||
]
|
||||
list_filter = ['status', 'start_time', 'patient_consent']
|
||||
search_fields = [
|
||||
'blood_issue__blood_unit__unit_number',
|
||||
'blood_issue__blood_request__patient__first_name',
|
||||
'blood_issue__blood_request__patient__last_name'
|
||||
]
|
||||
readonly_fields = ['duration_minutes']
|
||||
inlines = [AdverseReactionInline]
|
||||
|
||||
fieldsets = (
|
||||
('Transfusion Information', {
|
||||
'fields': (
|
||||
'blood_issue', 'start_time', 'end_time', 'status',
|
||||
'volume_transfused', 'transfusion_rate'
|
||||
)
|
||||
}),
|
||||
('Personnel', {
|
||||
'fields': ('administered_by', 'witnessed_by')
|
||||
}),
|
||||
('Consent', {
|
||||
'fields': ('patient_consent', 'consent_date')
|
||||
}),
|
||||
('Vital Signs', {
|
||||
'fields': ('pre_transfusion_vitals', 'post_transfusion_vitals'),
|
||||
'classes': ['collapse']
|
||||
}),
|
||||
('Additional Information', {
|
||||
'fields': ('notes',),
|
||||
'classes': ['collapse']
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@admin.register(AdverseReaction)
|
||||
class AdverseReactionAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
'transfusion', 'reaction_type', 'severity', 'onset_time',
|
||||
'reported_by', 'regulatory_reported'
|
||||
]
|
||||
list_filter = [
|
||||
'reaction_type', 'severity', 'onset_time',
|
||||
'regulatory_reported'
|
||||
]
|
||||
search_fields = [
|
||||
'transfusion__blood_issue__blood_unit__unit_number',
|
||||
'symptoms', 'treatment_given'
|
||||
]
|
||||
readonly_fields = ['onset_time', 'report_date']
|
||||
|
||||
fieldsets = (
|
||||
('Reaction Information', {
|
||||
'fields': (
|
||||
'transfusion', 'reaction_type', 'severity',
|
||||
'onset_time', 'symptoms'
|
||||
)
|
||||
}),
|
||||
('Treatment', {
|
||||
'fields': ('treatment_given', 'outcome')
|
||||
}),
|
||||
('Reporting', {
|
||||
'fields': (
|
||||
'reported_by', 'investigated_by', 'investigation_notes',
|
||||
'regulatory_reported', 'report_date'
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@admin.register(InventoryLocation)
|
||||
class InventoryLocationAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
'name', 'location_type', 'temperature_range',
|
||||
'current_stock', 'capacity', 'utilization_percentage', 'is_active'
|
||||
]
|
||||
list_filter = ['location_type', 'is_active']
|
||||
search_fields = ['name', 'notes']
|
||||
|
||||
def utilization_percentage(self, obj):
|
||||
percentage = obj.utilization_percentage
|
||||
if percentage >= 90:
|
||||
color = 'red'
|
||||
elif percentage >= 75:
|
||||
color = 'orange'
|
||||
else:
|
||||
color = 'green'
|
||||
return format_html(
|
||||
'<span style="color: {};">{:.1f}%</span>',
|
||||
color, percentage
|
||||
)
|
||||
|
||||
utilization_percentage.short_description = 'Utilization'
|
||||
|
||||
|
||||
@admin.register(QualityControl)
|
||||
class QualityControlAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
'test_type', 'test_date', 'equipment_tested',
|
||||
'status', 'performed_by', 'reviewed_by', 'next_test_date'
|
||||
]
|
||||
list_filter = ['test_type', 'status', 'test_date', 'next_test_date']
|
||||
search_fields = ['equipment_tested', 'parameters_tested', 'corrective_action']
|
||||
readonly_fields = ['test_date']
|
||||
|
||||
fieldsets = (
|
||||
('Test Information', {
|
||||
'fields': (
|
||||
'test_type', 'test_date', 'equipment_tested',
|
||||
'parameters_tested'
|
||||
)
|
||||
}),
|
||||
('Results', {
|
||||
'fields': (
|
||||
'expected_results', 'actual_results', 'status'
|
||||
)
|
||||
}),
|
||||
('Personnel', {
|
||||
'fields': ('performed_by', 'reviewed_by')
|
||||
}),
|
||||
('Follow-up', {
|
||||
'fields': ('corrective_action', 'next_test_date')
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
# Custom admin site configuration
|
||||
admin.site.site_header = "Blood Bank Management System"
|
||||
admin.site.site_title = "Blood Bank Admin"
|
||||
admin.site.index_title = "Blood Bank Administration"
|
||||
|
||||
8
blood_bank/apps.py
Normal file
8
blood_bank/apps.py
Normal file
@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BloodBankConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'blood_bank'
|
||||
verbose_name = 'Blood Bank Management'
|
||||
|
||||
429
blood_bank/forms.py
Normal file
429
blood_bank/forms.py
Normal file
@ -0,0 +1,429 @@
|
||||
from django import forms
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from patients.models import PatientProfile
|
||||
from core.models import Department
|
||||
from .models import (
|
||||
BloodGroup, Donor, BloodComponent, BloodUnit, BloodTest, CrossMatch,
|
||||
BloodRequest, BloodIssue, Transfusion, AdverseReaction, InventoryLocation,
|
||||
QualityControl
|
||||
)
|
||||
|
||||
|
||||
class DonorForm(forms.ModelForm):
|
||||
"""Form for donor registration and updates"""
|
||||
|
||||
class Meta:
|
||||
model = Donor
|
||||
fields = [
|
||||
'donor_id', 'first_name', 'last_name', 'date_of_birth',
|
||||
'gender', 'blood_group', 'phone', 'email', 'address',
|
||||
'emergency_contact_name', 'emergency_contact_phone',
|
||||
'donor_type', 'status', 'weight', 'height', 'notes'
|
||||
]
|
||||
widgets = {
|
||||
'date_of_birth': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
|
||||
'first_name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'last_name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'donor_id': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'gender': forms.Select(attrs={'class': 'form-select'}),
|
||||
'blood_group': forms.Select(attrs={'class': 'form-select'}),
|
||||
'phone': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'email': forms.EmailInput(attrs={'class': 'form-control'}),
|
||||
'address': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
'emergency_contact_name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'emergency_contact_phone': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'donor_type': forms.Select(attrs={'class': 'form-select'}),
|
||||
'status': forms.Select(attrs={'class': 'form-select'}),
|
||||
'weight': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.1'}),
|
||||
'height': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.1'}),
|
||||
'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
}
|
||||
|
||||
def clean_weight(self):
|
||||
weight = self.cleaned_data.get('weight')
|
||||
if weight and weight < 45.0:
|
||||
raise forms.ValidationError("Minimum weight for donation is 45 kg.")
|
||||
return weight
|
||||
|
||||
def clean_date_of_birth(self):
|
||||
dob = self.cleaned_data.get('date_of_birth')
|
||||
if dob:
|
||||
today = timezone.now().date()
|
||||
age = today.year - dob.year - ((today.month, today.day) < (dob.month, dob.day))
|
||||
if age < 18:
|
||||
raise forms.ValidationError("Donor must be at least 18 years old.")
|
||||
if age > 65:
|
||||
raise forms.ValidationError("Donor must be under 65 years old.")
|
||||
return dob
|
||||
|
||||
|
||||
class BloodUnitForm(forms.ModelForm):
|
||||
"""Form for blood unit registration"""
|
||||
|
||||
class Meta:
|
||||
model = BloodUnit
|
||||
fields = [
|
||||
'unit_number', 'donor', 'component', 'blood_group',
|
||||
'collection_date', 'volume_ml', 'location', 'bag_type',
|
||||
'anticoagulant', 'collection_site', 'notes'
|
||||
]
|
||||
widgets = {
|
||||
'unit_number': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'donor': forms.Select(attrs={'class': 'form-select'}),
|
||||
'component': forms.Select(attrs={'class': 'form-select'}),
|
||||
'blood_group': forms.Select(attrs={'class': 'form-select'}),
|
||||
'collection_date': forms.DateTimeInput(attrs={'type': 'datetime-local', 'class': 'form-control'}),
|
||||
'volume_ml': forms.NumberInput(attrs={'class': 'form-control'}),
|
||||
'location': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'bag_type': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'anticoagulant': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'collection_site': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Filter active donors only
|
||||
self.fields['donor'].queryset = Donor.objects.filter(status='active')
|
||||
self.fields['component'].queryset = BloodComponent.objects.filter(is_active=True)
|
||||
|
||||
def save(self, commit=True):
|
||||
instance = super().save(commit=False)
|
||||
if not instance.expiry_date:
|
||||
# Calculate expiry date based on component
|
||||
component = instance.component
|
||||
instance.expiry_date = instance.collection_date + timedelta(days=component.shelf_life_days)
|
||||
if commit:
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
|
||||
class BloodTestForm(forms.ModelForm):
|
||||
"""Form for blood test results"""
|
||||
|
||||
class Meta:
|
||||
model = BloodTest
|
||||
fields = [
|
||||
'blood_unit', 'test_type', 'result', 'test_date',
|
||||
'equipment_used', 'lot_number', 'notes'
|
||||
]
|
||||
widgets = {
|
||||
'blood_unit': forms.Select(attrs={'class': 'form-select'}),
|
||||
'test_type': forms.Select(attrs={'class': 'form-select'}),
|
||||
'result': forms.Select(attrs={'class': 'form-select'}),
|
||||
'test_date': forms.DateTimeInput(attrs={'type': 'datetime-local', 'class': 'form-control'}),
|
||||
'equipment_used': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'lot_number': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Filter blood units that are in testing or quarantine
|
||||
self.fields['blood_unit'].queryset = BloodUnit.objects.filter(
|
||||
status__in=['collected', 'testing', 'quarantine']
|
||||
)
|
||||
|
||||
|
||||
class CrossMatchForm(forms.ModelForm):
|
||||
"""Form for crossmatch testing"""
|
||||
|
||||
class Meta:
|
||||
model = CrossMatch
|
||||
fields = [
|
||||
'blood_unit', 'recipient', 'test_type', 'compatibility',
|
||||
'test_date', 'temperature', 'incubation_time', 'notes'
|
||||
]
|
||||
widgets = {
|
||||
'blood_unit': forms.Select(attrs={'class': 'form-select'}),
|
||||
'recipient': forms.Select(attrs={'class': 'form-select'}),
|
||||
'test_type': forms.Select(attrs={'class': 'form-select'}),
|
||||
'compatibility': forms.Select(attrs={'class': 'form-select'}),
|
||||
'test_date': forms.DateTimeInput(attrs={'type': 'datetime-local', 'class': 'form-control'}),
|
||||
'temperature': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'incubation_time': forms.NumberInput(attrs={'class': 'form-control'}),
|
||||
'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Filter available blood units
|
||||
self.fields['blood_unit'].queryset = BloodUnit.objects.filter(
|
||||
status__in=['available', 'quarantine']
|
||||
)
|
||||
|
||||
|
||||
class BloodRequestForm(forms.ModelForm):
|
||||
"""Form for blood transfusion requests"""
|
||||
|
||||
class Meta:
|
||||
model = BloodRequest
|
||||
fields = [
|
||||
'patient', 'requesting_department', 'component_requested',
|
||||
'units_requested', 'urgency', 'indication', 'special_requirements',
|
||||
'patient_blood_group', 'hemoglobin_level', 'platelet_count',
|
||||
'required_by'
|
||||
]
|
||||
widgets = {
|
||||
'patient': forms.Select(attrs={'class': 'form-select'}),
|
||||
'requesting_department': forms.Select(attrs={'class': 'form-select'}),
|
||||
'component_requested': forms.Select(attrs={'class': 'form-select'}),
|
||||
'units_requested': forms.NumberInput(attrs={'class': 'form-control', 'min': '1'}),
|
||||
'urgency': forms.Select(attrs={'class': 'form-select'}),
|
||||
'indication': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
'special_requirements': forms.Textarea(attrs={'class': 'form-control', 'rows': 2}),
|
||||
'patient_blood_group': forms.Select(attrs={'class': 'form-select'}),
|
||||
'hemoglobin_level': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.1'}),
|
||||
'platelet_count': forms.NumberInput(attrs={'class': 'form-control'}),
|
||||
'required_by': forms.DateTimeInput(attrs={'type': 'datetime-local', 'class': 'form-control'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['component_requested'].queryset = BloodComponent.objects.filter(is_active=True)
|
||||
|
||||
def clean_required_by(self):
|
||||
required_by = self.cleaned_data.get('required_by')
|
||||
if required_by and required_by <= timezone.now():
|
||||
raise forms.ValidationError("Required by date must be in the future.")
|
||||
return required_by
|
||||
|
||||
|
||||
class BloodIssueForm(forms.ModelForm):
|
||||
"""Form for blood unit issuance"""
|
||||
|
||||
class Meta:
|
||||
model = BloodIssue
|
||||
fields = [
|
||||
'blood_request', 'blood_unit', 'crossmatch',
|
||||
'issued_to', 'expiry_time', 'notes'
|
||||
]
|
||||
widgets = {
|
||||
'blood_request': forms.Select(attrs={'class': 'form-select'}),
|
||||
'blood_unit': forms.Select(attrs={'class': 'form-select'}),
|
||||
'crossmatch': forms.Select(attrs={'class': 'form-select'}),
|
||||
'issued_to': forms.Select(attrs={'class': 'form-select'}),
|
||||
'expiry_time': forms.DateTimeInput(attrs={'type': 'datetime-local', 'class': 'form-control'}),
|
||||
'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Filter pending/processing blood requests
|
||||
self.fields['blood_request'].queryset = BloodRequest.objects.filter(
|
||||
status__in=['pending', 'processing', 'ready']
|
||||
)
|
||||
# Filter available blood units
|
||||
self.fields['blood_unit'].queryset = BloodUnit.objects.filter(status='available')
|
||||
# Filter staff users
|
||||
self.fields['issued_to'].queryset = User.objects.filter(is_staff=True)
|
||||
|
||||
|
||||
class TransfusionForm(forms.ModelForm):
|
||||
"""Form for transfusion administration"""
|
||||
|
||||
class Meta:
|
||||
model = Transfusion
|
||||
fields = [
|
||||
'blood_issue', 'start_time', 'end_time', 'status',
|
||||
'volume_transfused', 'transfusion_rate', 'witnessed_by',
|
||||
'patient_consent', 'consent_date', 'notes'
|
||||
]
|
||||
widgets = {
|
||||
'blood_issue': forms.Select(attrs={'class': 'form-select'}),
|
||||
'start_time': forms.DateTimeInput(attrs={'type': 'datetime-local', 'class': 'form-control'}),
|
||||
'end_time': forms.DateTimeInput(attrs={'type': 'datetime-local', 'class': 'form-control'}),
|
||||
'status': forms.Select(attrs={'class': 'form-select'}),
|
||||
'volume_transfused': forms.NumberInput(attrs={'class': 'form-control'}),
|
||||
'transfusion_rate': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'witnessed_by': forms.Select(attrs={'class': 'form-select'}),
|
||||
'patient_consent': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'consent_date': forms.DateTimeInput(attrs={'type': 'datetime-local', 'class': 'form-control'}),
|
||||
'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Filter issued blood units
|
||||
self.fields['blood_issue'].queryset = BloodIssue.objects.filter(returned=False)
|
||||
# Filter staff users for witness
|
||||
self.fields['witnessed_by'].queryset = User.objects.filter(is_staff=True)
|
||||
|
||||
|
||||
class AdverseReactionForm(forms.ModelForm):
|
||||
"""Form for adverse reaction reporting"""
|
||||
|
||||
class Meta:
|
||||
model = AdverseReaction
|
||||
fields = [
|
||||
'transfusion', 'reaction_type', 'severity', 'onset_time',
|
||||
'symptoms', 'treatment_given', 'outcome', 'investigation_notes',
|
||||
'regulatory_reported'
|
||||
]
|
||||
widgets = {
|
||||
'transfusion': forms.Select(attrs={'class': 'form-select'}),
|
||||
'reaction_type': forms.Select(attrs={'class': 'form-select'}),
|
||||
'severity': forms.Select(attrs={'class': 'form-select'}),
|
||||
'onset_time': forms.DateTimeInput(attrs={'type': 'datetime-local', 'class': 'form-control'}),
|
||||
'symptoms': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}),
|
||||
'treatment_given': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}),
|
||||
'outcome': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
'investigation_notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
'regulatory_reported': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
}
|
||||
|
||||
|
||||
class InventoryLocationForm(forms.ModelForm):
|
||||
"""Form for inventory location management"""
|
||||
|
||||
class Meta:
|
||||
model = InventoryLocation
|
||||
fields = [
|
||||
'name', 'location_type', 'temperature_range',
|
||||
'capacity', 'is_active', 'notes'
|
||||
]
|
||||
widgets = {
|
||||
'name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'location_type': forms.Select(attrs={'class': 'form-select'}),
|
||||
'temperature_range': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'capacity': forms.NumberInput(attrs={'class': 'form-control', 'min': '1'}),
|
||||
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
}
|
||||
|
||||
|
||||
class QualityControlForm(forms.ModelForm):
|
||||
"""Form for quality control testing"""
|
||||
|
||||
class Meta:
|
||||
model = QualityControl
|
||||
fields = [
|
||||
'test_type', 'test_date', 'equipment_tested',
|
||||
'parameters_tested', 'expected_results', 'actual_results',
|
||||
'status', 'corrective_action', 'next_test_date'
|
||||
]
|
||||
widgets = {
|
||||
'test_type': forms.Select(attrs={'class': 'form-select'}),
|
||||
'test_date': forms.DateTimeInput(attrs={'type': 'datetime-local', 'class': 'form-control'}),
|
||||
'equipment_tested': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'parameters_tested': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
'expected_results': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
'actual_results': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
'status': forms.Select(attrs={'class': 'form-select'}),
|
||||
'corrective_action': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
'next_test_date': forms.DateTimeInput(attrs={'type': 'datetime-local', 'class': 'form-control'}),
|
||||
}
|
||||
|
||||
|
||||
class DonorEligibilityForm(forms.Form):
|
||||
"""Form for donor eligibility screening"""
|
||||
|
||||
# Basic health questions
|
||||
feeling_well = forms.BooleanField(
|
||||
label="Are you feeling well today?",
|
||||
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
|
||||
)
|
||||
|
||||
adequate_sleep = forms.BooleanField(
|
||||
label="Did you get adequate sleep last night?",
|
||||
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
|
||||
)
|
||||
|
||||
eaten_today = forms.BooleanField(
|
||||
label="Have you eaten within the last 4 hours?",
|
||||
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
|
||||
)
|
||||
|
||||
# Medical history
|
||||
recent_illness = forms.BooleanField(
|
||||
required=False,
|
||||
label="Have you had any illness in the past 2 weeks?",
|
||||
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
|
||||
)
|
||||
|
||||
medications = forms.BooleanField(
|
||||
required=False,
|
||||
label="Are you currently taking any medications?",
|
||||
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
|
||||
)
|
||||
|
||||
recent_travel = forms.BooleanField(
|
||||
required=False,
|
||||
label="Have you traveled outside the country in the past 3 months?",
|
||||
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
|
||||
)
|
||||
|
||||
# Risk factors
|
||||
recent_tattoo = forms.BooleanField(
|
||||
required=False,
|
||||
label="Have you had a tattoo or piercing in the past 12 months?",
|
||||
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
|
||||
)
|
||||
|
||||
recent_surgery = forms.BooleanField(
|
||||
required=False,
|
||||
label="Have you had any surgery in the past 12 months?",
|
||||
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
|
||||
)
|
||||
|
||||
# Additional notes
|
||||
notes = forms.CharField(
|
||||
required=False,
|
||||
label="Additional notes or concerns",
|
||||
widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 3})
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
# Check mandatory requirements
|
||||
if not cleaned_data.get('feeling_well'):
|
||||
raise forms.ValidationError("Donor must be feeling well to donate.")
|
||||
|
||||
if not cleaned_data.get('adequate_sleep'):
|
||||
raise forms.ValidationError("Donor must have adequate sleep before donation.")
|
||||
|
||||
if not cleaned_data.get('eaten_today'):
|
||||
raise forms.ValidationError("Donor must have eaten within the last 4 hours.")
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class BloodInventorySearchForm(forms.Form):
|
||||
"""Form for searching blood inventory"""
|
||||
|
||||
blood_group = forms.ModelChoiceField(
|
||||
queryset=BloodGroup.objects.all(),
|
||||
required=False,
|
||||
empty_label="All Blood Groups",
|
||||
widget=forms.Select(attrs={'class': 'form-select'})
|
||||
)
|
||||
|
||||
component = forms.ModelChoiceField(
|
||||
queryset=BloodComponent.objects.filter(is_active=True),
|
||||
required=False,
|
||||
empty_label="All Components",
|
||||
widget=forms.Select(attrs={'class': 'form-select'})
|
||||
)
|
||||
|
||||
status = forms.ChoiceField(
|
||||
choices=[('', 'All Statuses')] + BloodUnit.STATUS_CHOICES,
|
||||
required=False,
|
||||
widget=forms.Select(attrs={'class': 'form-select'})
|
||||
)
|
||||
|
||||
location = forms.ModelChoiceField(
|
||||
queryset=InventoryLocation.objects.filter(is_active=True),
|
||||
required=False,
|
||||
empty_label="All Locations",
|
||||
widget=forms.Select(attrs={'class': 'form-select'})
|
||||
)
|
||||
|
||||
expiry_days = forms.IntegerField(
|
||||
required=False,
|
||||
label="Expiring within (days)",
|
||||
widget=forms.NumberInput(attrs={'class': 'form-control', 'min': '0'})
|
||||
)
|
||||
|
||||
0
blood_bank/management/__init__.py
Normal file
0
blood_bank/management/__init__.py
Normal file
BIN
blood_bank/management/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
blood_bank/management/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
0
blood_bank/management/commands/__init__.py
Normal file
0
blood_bank/management/commands/__init__.py
Normal file
Binary file not shown.
Binary file not shown.
339
blood_bank/management/commands/blood_bank_data.py
Normal file
339
blood_bank/management/commands/blood_bank_data.py
Normal file
@ -0,0 +1,339 @@
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
from django.db import transaction
|
||||
|
||||
from accounts.models import User
|
||||
from blood_bank.models import (
|
||||
BloodGroup, Donor, BloodComponent, BloodUnit, BloodTest,
|
||||
BloodRequest, InventoryLocation, QualityControl
|
||||
)
|
||||
from patients.models import PatientProfile
|
||||
from core.models import Department
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Generate Saudi-influenced blood bank test data'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--donors', type=int, default=50, help='Number of donors to create (default: 50)')
|
||||
parser.add_argument('--units', type=int, default=100, help='Number of blood units to create (default: 100)')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write(self.style.SUCCESS('Starting Saudi blood bank data generation...'))
|
||||
try:
|
||||
with transaction.atomic():
|
||||
users = list(User.objects.all())
|
||||
patients = list(PatientProfile.objects.all())
|
||||
departments = list(Department.objects.all())
|
||||
|
||||
if not users:
|
||||
self.stdout.write(self.style.ERROR('No users found. Please create users first.'))
|
||||
return
|
||||
|
||||
self.create_blood_groups()
|
||||
self.create_blood_components()
|
||||
self.create_inventory_locations()
|
||||
|
||||
donors = self.create_saudi_donors(options['donors'], users)
|
||||
blood_units = self.create_blood_units(options['units'], donors, users)
|
||||
self.create_blood_tests(blood_units, users)
|
||||
|
||||
if patients and departments:
|
||||
self.create_blood_requests(20, patients, departments, users)
|
||||
|
||||
self.create_quality_control_records(users)
|
||||
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f'Error: {str(e)}'))
|
||||
return
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('Saudi blood bank data generation completed!'))
|
||||
|
||||
# ---------- seeders ----------
|
||||
|
||||
def create_blood_groups(self):
|
||||
"""Create ABO/Rh blood groups with correct choices (POS/NEG)."""
|
||||
blood_groups = [
|
||||
('A', 'POS'), ('A', 'NEG'),
|
||||
('B', 'POS'), ('B', 'NEG'),
|
||||
('AB', 'POS'), ('AB', 'NEG'),
|
||||
('O', 'POS'), ('O', 'NEG'),
|
||||
]
|
||||
for abo, rh in blood_groups:
|
||||
BloodGroup.objects.get_or_create(abo_type=abo, rh_factor=rh)
|
||||
self.stdout.write('✓ Created blood groups')
|
||||
|
||||
def create_blood_components(self):
|
||||
"""Create blood components with required fields."""
|
||||
components = [
|
||||
# (name, volume_ml, shelf_life_days, storage_temperature, description)
|
||||
('whole_blood', 450, 35, '2–6°C', 'Whole blood unit'),
|
||||
('packed_rbc', 300, 42, '2–6°C', 'Packed red blood cells'),
|
||||
('fresh_frozen_plasma', 250, 365, '≤ -18°C', 'Fresh frozen plasma'),
|
||||
('platelets', 50, 5, '20–24°C with agitation', 'Platelet concentrate'),
|
||||
# Optional extras (your model allows them)
|
||||
('cryoprecipitate', 15, 365, '≤ -18°C', 'Cryoprecipitated AHF'),
|
||||
('granulocytes', 200, 1, '20–24°C', 'Granulocyte concentrate'),
|
||||
]
|
||||
for name, volume, shelf_life, temp, desc in components:
|
||||
BloodComponent.objects.get_or_create(
|
||||
name=name,
|
||||
defaults={
|
||||
'description': desc,
|
||||
'shelf_life_days': shelf_life,
|
||||
'storage_temperature': temp,
|
||||
'volume_ml': volume,
|
||||
'is_active': True
|
||||
}
|
||||
)
|
||||
self.stdout.write('✓ Created blood components')
|
||||
|
||||
def create_inventory_locations(self):
|
||||
"""Create storage locations with required fields."""
|
||||
locations = [
|
||||
# (name, type, capacity, temperature_range)
|
||||
('Main Refrigerator A', 'refrigerator', 100, '2–6°C'),
|
||||
('Main Refrigerator B', 'refrigerator', 100, '2–6°C'),
|
||||
('Platelet Agitator 1', 'platelet_agitator', 30, '20–24°C'),
|
||||
('Plasma Freezer A', 'freezer', 200, '≤ -18°C'),
|
||||
('Quarantine Storage', 'quarantine', 50, '2–6°C'),
|
||||
('Testing Area 1', 'testing', 20, 'Room temp'),
|
||||
]
|
||||
for name, loc_type, capacity, temp_range in locations:
|
||||
InventoryLocation.objects.get_or_create(
|
||||
name=name,
|
||||
defaults={
|
||||
'location_type': loc_type,
|
||||
'temperature_range': temp_range,
|
||||
'capacity': capacity,
|
||||
'is_active': True,
|
||||
'current_stock': 0,
|
||||
}
|
||||
)
|
||||
self.stdout.write('✓ Created inventory locations')
|
||||
|
||||
def create_saudi_donors(self, count, users):
|
||||
"""Create Saudi-influenced donors aligned with Donor model fields."""
|
||||
saudi_male_names = [
|
||||
'Abdullah', 'Mohammed', 'Ahmed', 'Ali', 'Omar', 'Khalid', 'Fahd', 'Salman',
|
||||
'Faisal', 'Turki', 'Nasser', 'Saud', 'Bandar', 'Majid', 'Waleed', 'Yazeed',
|
||||
'Abdulaziz', 'Abdulrahman', 'Ibrahim', 'Hassan', 'Hussein', 'Mansour'
|
||||
]
|
||||
saudi_female_names = [
|
||||
'Fatima', 'Aisha', 'Maryam', 'Khadija', 'Zainab', 'Noura', 'Sarah', 'Hala',
|
||||
'Reem', 'Lama', 'Nada', 'Rana', 'Dina', 'Lina', 'Maha', 'Wafa', 'Amal',
|
||||
'Najla', 'Huda', 'Layla', 'Nour', 'Ghada', 'Rania'
|
||||
]
|
||||
saudi_family_names = [
|
||||
'Al-Saud', 'Al-Rashid', 'Al-Otaibi', 'Al-Dosari', 'Al-Harbi', 'Al-Zahrani',
|
||||
'Al-Ghamdi', 'Al-Qahtani', 'Al-Mutairi', 'Al-Malki', 'Al-Subai', 'Al-Shehri',
|
||||
'Al-Dawsari', 'Al-Enezi', 'Al-Rasheed', 'Al-Faraj', 'Al-Mansour', 'Al-Nasser'
|
||||
]
|
||||
|
||||
blood_groups = list(BloodGroup.objects.all())
|
||||
created_by = random.choice(users)
|
||||
|
||||
donors = []
|
||||
for i in range(count):
|
||||
try:
|
||||
gender = random.choice(['M', 'F'])
|
||||
first_name = random.choice(saudi_male_names if gender == 'M' else saudi_female_names)
|
||||
last_name = random.choice(saudi_family_names)
|
||||
|
||||
national_id = f"{random.choice([1, 2])}{random.randint(100000000, 999999999)}" # 10 digits
|
||||
donor_id = f"SA{random.randint(100000, 999999)}"
|
||||
|
||||
age = random.randint(18, 65)
|
||||
birth_date = timezone.now().date() - timedelta(days=age * 365)
|
||||
|
||||
phone = f"+966{random.choice([50, 51, 52, 53, 54, 55])}{random.randint(1000000, 9999999)}"
|
||||
weight = random.randint(60, 120) if gender == 'M' else random.randint(45, 90)
|
||||
height = random.randint(150, 190)
|
||||
|
||||
blood_group = random.choice(blood_groups)
|
||||
|
||||
donor = Donor.objects.create(
|
||||
donor_id=donor_id,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
date_of_birth=birth_date,
|
||||
gender=gender,
|
||||
blood_group=blood_group,
|
||||
national_id=national_id,
|
||||
phone=phone, # field is `phone`
|
||||
email=f"{first_name.lower()}.{last_name.lower().replace('-', '').replace(' ', '')}@gmail.com",
|
||||
address=f"{random.randint(1, 999)} King Fahd Road, Saudi Arabia",
|
||||
emergency_contact_name=f"{random.choice(saudi_male_names + saudi_female_names)} {random.choice(saudi_family_names)}",
|
||||
emergency_contact_phone=f"+966{random.choice([50, 51, 52, 53, 54, 55])}{random.randint(1000000, 9999999)}",
|
||||
donor_type='voluntary',
|
||||
status='active',
|
||||
last_donation_date=timezone.now() - timedelta(days=random.randint(60, 365)) if random.random() < 0.6 else None,
|
||||
total_donations=random.randint(0, 10),
|
||||
weight=weight,
|
||||
height=height,
|
||||
notes="Saudi donor",
|
||||
created_by=created_by, # required
|
||||
)
|
||||
donors.append(donor)
|
||||
except Exception as e:
|
||||
self.stdout.write(f"Error creating donor {i}: {str(e)}")
|
||||
continue
|
||||
|
||||
self.stdout.write(f'✓ Created {len(donors)} Saudi donors')
|
||||
return donors
|
||||
|
||||
def create_blood_units(self, count, donors, users):
|
||||
"""Create blood units; save location as string and update stock."""
|
||||
if not donors:
|
||||
self.stdout.write('No donors available for blood units')
|
||||
return []
|
||||
|
||||
components = list(BloodComponent.objects.filter(is_active=True))
|
||||
locations = list(InventoryLocation.objects.filter(is_active=True))
|
||||
blood_units = []
|
||||
|
||||
for i in range(count):
|
||||
try:
|
||||
donor = random.choice(donors)
|
||||
component = random.choice(components)
|
||||
inv = random.choice(locations)
|
||||
|
||||
unit_number = f"SA{timezone.now().year}{random.randint(100000, 999999)}"
|
||||
collection_date = timezone.now() - timedelta(days=random.randint(0, 30))
|
||||
expiry_date = collection_date + timedelta(days=component.shelf_life_days)
|
||||
|
||||
status = random.choice(['available', 'reserved', 'issued', 'collected', 'testing', 'quarantine'])
|
||||
|
||||
blood_unit = BloodUnit.objects.create(
|
||||
unit_number=unit_number,
|
||||
donor=donor,
|
||||
blood_group=donor.blood_group,
|
||||
component=component,
|
||||
volume_ml=component.volume_ml,
|
||||
collection_date=collection_date,
|
||||
expiry_date=expiry_date,
|
||||
status=status,
|
||||
location=inv.name, # CharField on model
|
||||
collected_by=random.choice(users),
|
||||
collection_site='King Fahd Medical City Blood Bank',
|
||||
bag_type='single',
|
||||
anticoagulant='CPDA-1',
|
||||
notes=f"Collected from donor {donor.donor_id}"
|
||||
)
|
||||
blood_units.append(blood_unit)
|
||||
|
||||
# Update inventory stock (bounded by capacity)
|
||||
inv.current_stock = min(inv.capacity, inv.current_stock + 1)
|
||||
inv.save(update_fields=['current_stock'])
|
||||
|
||||
except Exception as e:
|
||||
self.stdout.write(f"Error creating blood unit {i}: {str(e)}")
|
||||
continue
|
||||
|
||||
self.stdout.write(f'✓ Created {len(blood_units)} blood units')
|
||||
return blood_units
|
||||
|
||||
def create_blood_tests(self, blood_units, users):
|
||||
"""Create infectious disease tests for each unit."""
|
||||
test_types = ['hiv', 'hbv', 'hcv', 'syphilis'] # must match TEST_TYPE_CHOICES keys
|
||||
|
||||
for unit in blood_units:
|
||||
try:
|
||||
for test_type in test_types:
|
||||
result = random.choices(['negative', 'positive'], weights=[0.95, 0.05])[0]
|
||||
BloodTest.objects.create(
|
||||
blood_unit=unit,
|
||||
test_type=test_type,
|
||||
result=result,
|
||||
test_date=unit.collection_date + timedelta(hours=random.randint(2, 24)),
|
||||
tested_by=random.choice(users),
|
||||
equipment_used=f"Abbott PRISM {random.randint(1000, 9999)}",
|
||||
lot_number=f"LOT{random.randint(100000, 999999)}",
|
||||
notes="Routine infectious disease screening"
|
||||
)
|
||||
except Exception as e:
|
||||
self.stdout.write(f"Error creating tests for unit {unit.unit_number}: {str(e)}")
|
||||
continue
|
||||
|
||||
self.stdout.write(f'✓ Created blood tests for {len(blood_units)} units')
|
||||
|
||||
def create_blood_requests(self, count, patients, departments, users):
|
||||
"""Create blood requests from departments."""
|
||||
components = list(BloodComponent.objects.filter(is_active=True))
|
||||
blood_groups = list(BloodGroup.objects.all())
|
||||
|
||||
for i in range(count):
|
||||
try:
|
||||
patient = random.choice(patients)
|
||||
department = random.choice(departments)
|
||||
component = random.choice(components)
|
||||
|
||||
request_number = f"REQ{timezone.now().year}{random.randint(10000, 99999)}"
|
||||
indications = [
|
||||
'Surgical blood loss during cardiac surgery',
|
||||
'Anemia secondary to chronic kidney disease',
|
||||
'Postpartum hemorrhage',
|
||||
'Trauma-related blood loss',
|
||||
'Chemotherapy-induced anemia'
|
||||
]
|
||||
urgency = random.choice(['routine', 'urgent', 'emergency'])
|
||||
status = random.choice(['pending', 'processing', 'ready'])
|
||||
|
||||
request_date = timezone.now() - timedelta(days=random.randint(0, 7))
|
||||
required_by = request_date + timedelta(hours=random.randint(2, 48))
|
||||
|
||||
BloodRequest.objects.create(
|
||||
request_number=request_number,
|
||||
patient=patient,
|
||||
requesting_department=department,
|
||||
requesting_physician=random.choice(users),
|
||||
component_requested=component,
|
||||
units_requested=random.randint(1, 3),
|
||||
urgency=urgency,
|
||||
indication=random.choice(indications),
|
||||
patient_blood_group=random.choice(blood_groups),
|
||||
hemoglobin_level=round(random.uniform(7.0, 12.0), 1),
|
||||
status=status,
|
||||
# auto_now_add request_date on model; we still set required_by
|
||||
required_by=required_by,
|
||||
notes=f"Request from {department.name}"
|
||||
)
|
||||
except Exception as e:
|
||||
self.stdout.write(f"Error creating blood request {i}: {str(e)}")
|
||||
continue
|
||||
|
||||
self.stdout.write('✓ Created blood requests')
|
||||
|
||||
def create_quality_control_records(self, users):
|
||||
"""Create quality control records."""
|
||||
test_types = ['temperature_monitoring', 'equipment_calibration', 'reagent_testing']
|
||||
for i in range(10):
|
||||
try:
|
||||
test_type = random.choice(test_types)
|
||||
status = random.choice(['pass', 'fail', 'pending'])
|
||||
performed_by = random.choice(users)
|
||||
qc = QualityControl.objects.create(
|
||||
test_type=test_type,
|
||||
test_date=timezone.now() - timedelta(days=random.randint(0, 30)),
|
||||
equipment_tested=f"Equipment {random.randint(1000, 9999)}",
|
||||
parameters_tested="Temperature range, accuracy",
|
||||
expected_results="Within acceptable limits",
|
||||
actual_results="Pass" if status == 'pass' else "Out of range" if status == 'fail' else "Pending",
|
||||
status=status,
|
||||
performed_by=performed_by,
|
||||
corrective_action="Recalibration performed" if status == 'fail' else "",
|
||||
next_test_date=timezone.now() + timedelta(days=30)
|
||||
)
|
||||
# Optionally auto-review passes
|
||||
if status == 'pass' and random.random() < 0.5:
|
||||
qc.reviewed_by = random.choice(users)
|
||||
qc.review_date = timezone.now()
|
||||
qc.review_notes = "Reviewed and verified."
|
||||
qc.save(update_fields=['reviewed_by', 'review_date', 'review_notes'])
|
||||
except Exception as e:
|
||||
self.stdout.write(f"Error creating QC record {i}: {str(e)}")
|
||||
continue
|
||||
|
||||
self.stdout.write('✓ Created quality control records')
|
||||
925
blood_bank/migrations/0001_initial.py
Normal file
925
blood_bank/migrations/0001_initial.py
Normal file
@ -0,0 +1,925 @@
|
||||
# Generated by Django 5.2.4 on 2025-09-04 15:11
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("core", "0001_initial"),
|
||||
("patients", "0007_alter_consenttemplate_category"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="BloodComponent",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"name",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("whole_blood", "Whole Blood"),
|
||||
("packed_rbc", "Packed Red Blood Cells"),
|
||||
("fresh_frozen_plasma", "Fresh Frozen Plasma"),
|
||||
("platelets", "Platelets"),
|
||||
("cryoprecipitate", "Cryoprecipitate"),
|
||||
("granulocytes", "Granulocytes"),
|
||||
],
|
||||
max_length=50,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
("description", models.TextField()),
|
||||
("shelf_life_days", models.PositiveIntegerField()),
|
||||
("storage_temperature", models.CharField(max_length=50)),
|
||||
("volume_ml", models.PositiveIntegerField()),
|
||||
("is_active", models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
"ordering": ["name"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="InventoryLocation",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=100, unique=True)),
|
||||
(
|
||||
"location_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("refrigerator", "Refrigerator"),
|
||||
("freezer", "Freezer"),
|
||||
("platelet_agitator", "Platelet Agitator"),
|
||||
("quarantine", "Quarantine"),
|
||||
("testing", "Testing Area"),
|
||||
],
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("temperature_range", models.CharField(max_length=50)),
|
||||
("temperature", models.FloatField(blank=True, null=True)),
|
||||
("capacity", models.PositiveIntegerField()),
|
||||
("current_stock", models.PositiveIntegerField(default=0)),
|
||||
("is_active", models.BooleanField(default=True)),
|
||||
("notes", models.TextField(blank=True)),
|
||||
],
|
||||
options={
|
||||
"ordering": ["name"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="BloodGroup",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"abo_type",
|
||||
models.CharField(
|
||||
choices=[("A", "A"), ("B", "B"), ("AB", "AB"), ("O", "O")],
|
||||
max_length=2,
|
||||
),
|
||||
),
|
||||
(
|
||||
"rh_factor",
|
||||
models.CharField(
|
||||
choices=[("POS", "Positive"), ("NEG", "Negative")], max_length=8
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["abo_type", "rh_factor"],
|
||||
"unique_together": {("abo_type", "rh_factor")},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="BloodRequest",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("request_number", models.CharField(max_length=20, unique=True)),
|
||||
(
|
||||
"units_requested",
|
||||
models.PositiveIntegerField(
|
||||
validators=[django.core.validators.MinValueValidator(1)]
|
||||
),
|
||||
),
|
||||
(
|
||||
"urgency",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("routine", "Routine"),
|
||||
("urgent", "Urgent"),
|
||||
("emergency", "Emergency"),
|
||||
],
|
||||
default="routine",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
("indication", models.TextField()),
|
||||
("special_requirements", models.TextField(blank=True)),
|
||||
("hemoglobin_level", models.FloatField(blank=True, null=True)),
|
||||
("platelet_count", models.IntegerField(blank=True, null=True)),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("pending", "Pending"),
|
||||
("processing", "Processing"),
|
||||
("ready", "Ready"),
|
||||
("issued", "Issued"),
|
||||
("completed", "Completed"),
|
||||
("cancelled", "Cancelled"),
|
||||
],
|
||||
default="pending",
|
||||
max_length=15,
|
||||
),
|
||||
),
|
||||
("request_date", models.DateTimeField(auto_now_add=True)),
|
||||
("required_by", models.DateTimeField()),
|
||||
("processed_at", models.DateTimeField(blank=True, null=True)),
|
||||
("notes", models.TextField(blank=True)),
|
||||
("cancellation_reason", models.TextField(blank=True)),
|
||||
("cancellation_date", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"cancelled_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="cancelled_requests",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"component_requested",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to="blood_bank.bloodcomponent",
|
||||
),
|
||||
),
|
||||
(
|
||||
"patient",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="blood_requests",
|
||||
to="patients.patientprofile",
|
||||
),
|
||||
),
|
||||
(
|
||||
"patient_blood_group",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to="blood_bank.bloodgroup",
|
||||
),
|
||||
),
|
||||
(
|
||||
"processed_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="processed_requests",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"requesting_department",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to="core.department",
|
||||
),
|
||||
),
|
||||
(
|
||||
"requesting_physician",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="blood_requests",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-request_date"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="BloodUnit",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("unit_number", models.CharField(max_length=20, unique=True)),
|
||||
("collection_date", models.DateTimeField()),
|
||||
("expiry_date", models.DateTimeField()),
|
||||
("volume_ml", models.PositiveIntegerField()),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("collected", "Collected"),
|
||||
("testing", "Testing"),
|
||||
("quarantine", "Quarantine"),
|
||||
("available", "Available"),
|
||||
("reserved", "Reserved"),
|
||||
("issued", "Issued"),
|
||||
("transfused", "Transfused"),
|
||||
("expired", "Expired"),
|
||||
("discarded", "Discarded"),
|
||||
],
|
||||
default="collected",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("location", models.CharField(max_length=100)),
|
||||
("bag_type", models.CharField(max_length=50)),
|
||||
("anticoagulant", models.CharField(default="CPDA-1", max_length=50)),
|
||||
("collection_site", models.CharField(max_length=100)),
|
||||
("notes", models.TextField(blank=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"blood_group",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to="blood_bank.bloodgroup",
|
||||
),
|
||||
),
|
||||
(
|
||||
"collected_by",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="collected_units",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"component",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to="blood_bank.bloodcomponent",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-collection_date"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="CrossMatch",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"test_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("major", "Major Crossmatch"),
|
||||
("minor", "Minor Crossmatch"),
|
||||
("immediate_spin", "Immediate Spin"),
|
||||
("antiglobulin", "Antiglobulin Test"),
|
||||
],
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"compatibility",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("compatible", "Compatible"),
|
||||
("incompatible", "Incompatible"),
|
||||
("pending", "Pending"),
|
||||
],
|
||||
default="pending",
|
||||
max_length=15,
|
||||
),
|
||||
),
|
||||
("test_date", models.DateTimeField()),
|
||||
("temperature", models.CharField(default="37°C", max_length=20)),
|
||||
("incubation_time", models.PositiveIntegerField(default=15)),
|
||||
("notes", models.TextField(blank=True)),
|
||||
("verified_at", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"blood_unit",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="crossmatches",
|
||||
to="blood_bank.bloodunit",
|
||||
),
|
||||
),
|
||||
(
|
||||
"recipient",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to="patients.patientprofile",
|
||||
),
|
||||
),
|
||||
(
|
||||
"tested_by",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"verified_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="verified_crossmatches",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-test_date"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="BloodIssue",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("issue_date", models.DateTimeField(auto_now_add=True)),
|
||||
("expiry_time", models.DateTimeField()),
|
||||
("returned", models.BooleanField(default=False)),
|
||||
("return_date", models.DateTimeField(blank=True, null=True)),
|
||||
("return_reason", models.TextField(blank=True)),
|
||||
("notes", models.TextField(blank=True)),
|
||||
(
|
||||
"issued_by",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="issued_units",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"issued_to",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="received_units",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"blood_request",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="issues",
|
||||
to="blood_bank.bloodrequest",
|
||||
),
|
||||
),
|
||||
(
|
||||
"blood_unit",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="issue",
|
||||
to="blood_bank.bloodunit",
|
||||
),
|
||||
),
|
||||
(
|
||||
"crossmatch",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to="blood_bank.crossmatch",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-issue_date"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Donor",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("donor_id", models.CharField(max_length=20, unique=True)),
|
||||
("first_name", models.CharField(max_length=100)),
|
||||
("last_name", models.CharField(max_length=100)),
|
||||
("date_of_birth", models.DateField()),
|
||||
(
|
||||
"gender",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("male", "Male"),
|
||||
("female", "Female"),
|
||||
("other", "Other"),
|
||||
],
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
("phone", models.CharField(max_length=20)),
|
||||
("email", models.EmailField(blank=True, max_length=254)),
|
||||
("address", models.TextField()),
|
||||
("emergency_contact_name", models.CharField(max_length=100)),
|
||||
("emergency_contact_phone", models.CharField(max_length=20)),
|
||||
(
|
||||
"donor_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("voluntary", "Voluntary"),
|
||||
("replacement", "Replacement"),
|
||||
("autologous", "Autologous"),
|
||||
("directed", "Directed"),
|
||||
],
|
||||
default="voluntary",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("active", "Active"),
|
||||
("deferred", "Deferred"),
|
||||
("permanently_deferred", "Permanently Deferred"),
|
||||
("inactive", "Inactive"),
|
||||
],
|
||||
default="active",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("registration_date", models.DateTimeField(auto_now_add=True)),
|
||||
("last_donation_date", models.DateTimeField(blank=True, null=True)),
|
||||
("total_donations", models.PositiveIntegerField(default=0)),
|
||||
(
|
||||
"weight",
|
||||
models.FloatField(
|
||||
validators=[django.core.validators.MinValueValidator(45.0)]
|
||||
),
|
||||
),
|
||||
(
|
||||
"height",
|
||||
models.FloatField(
|
||||
validators=[django.core.validators.MinValueValidator(140.0)]
|
||||
),
|
||||
),
|
||||
("notes", models.TextField(blank=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"blood_group",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to="blood_bank.bloodgroup",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="created_donors",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-registration_date"],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="bloodunit",
|
||||
name="donor",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="blood_units",
|
||||
to="blood_bank.donor",
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="QualityControl",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"test_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("temperature_monitoring", "Temperature Monitoring"),
|
||||
("equipment_calibration", "Equipment Calibration"),
|
||||
("reagent_testing", "Reagent Testing"),
|
||||
("proficiency_testing", "Proficiency Testing"),
|
||||
("process_validation", "Process Validation"),
|
||||
],
|
||||
max_length=30,
|
||||
),
|
||||
),
|
||||
("test_date", models.DateTimeField()),
|
||||
("equipment_tested", models.CharField(blank=True, max_length=100)),
|
||||
("parameters_tested", models.TextField()),
|
||||
("expected_results", models.TextField()),
|
||||
("actual_results", models.TextField()),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("pass", "Pass"),
|
||||
("fail", "Fail"),
|
||||
("pending", "Pending"),
|
||||
],
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
("review_date", models.DateTimeField(blank=True, null=True)),
|
||||
("review_notes", models.TextField(blank=True)),
|
||||
("corrective_action", models.TextField(blank=True)),
|
||||
("next_test_date", models.DateTimeField(blank=True, null=True)),
|
||||
("capa_initiated", models.BooleanField(default=False)),
|
||||
("capa_number", models.CharField(blank=True, max_length=50)),
|
||||
(
|
||||
"capa_priority",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("low", "Low"),
|
||||
("medium", "Medium"),
|
||||
("high", "High"),
|
||||
],
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
("capa_date", models.DateTimeField(blank=True, null=True)),
|
||||
("capa_assessment", models.TextField(blank=True)),
|
||||
(
|
||||
"capa_status",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("open", "Open"),
|
||||
("in_progress", "In Progress"),
|
||||
("closed", "Closed"),
|
||||
],
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"capa_initiated_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="initiated_capas",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"performed_by",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="qc_tests",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"reviewed_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="reviewed_qc_tests",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-test_date"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Transfusion",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("start_time", models.DateTimeField()),
|
||||
("end_time", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("started", "Started"),
|
||||
("in_progress", "In Progress"),
|
||||
("completed", "Completed"),
|
||||
("stopped", "Stopped"),
|
||||
("adverse_reaction", "Adverse Reaction"),
|
||||
],
|
||||
default="started",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"volume_transfused",
|
||||
models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
("transfusion_rate", models.CharField(blank=True, max_length=50)),
|
||||
("pre_transfusion_vitals", models.JSONField(default=dict)),
|
||||
("post_transfusion_vitals", models.JSONField(default=dict)),
|
||||
("vital_signs_history", models.JSONField(default=list)),
|
||||
("current_blood_pressure", models.CharField(blank=True, max_length=20)),
|
||||
("current_heart_rate", models.IntegerField(blank=True, null=True)),
|
||||
("current_temperature", models.FloatField(blank=True, null=True)),
|
||||
(
|
||||
"current_respiratory_rate",
|
||||
models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
(
|
||||
"current_oxygen_saturation",
|
||||
models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
("last_vitals_check", models.DateTimeField(blank=True, null=True)),
|
||||
("patient_consent", models.BooleanField(default=False)),
|
||||
("consent_date", models.DateTimeField(blank=True, null=True)),
|
||||
("notes", models.TextField(blank=True)),
|
||||
("stop_reason", models.TextField(blank=True)),
|
||||
("completion_notes", models.TextField(blank=True)),
|
||||
(
|
||||
"administered_by",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="administered_transfusions",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"blood_issue",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="transfusion",
|
||||
to="blood_bank.bloodissue",
|
||||
),
|
||||
),
|
||||
(
|
||||
"completed_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="completed_transfusions",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"stopped_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="stopped_transfusions",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"witnessed_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="witnessed_transfusions",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-start_time"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="AdverseReaction",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"reaction_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("febrile", "Febrile Non-Hemolytic"),
|
||||
("allergic", "Allergic"),
|
||||
("hemolytic_acute", "Acute Hemolytic"),
|
||||
("hemolytic_delayed", "Delayed Hemolytic"),
|
||||
("anaphylactic", "Anaphylactic"),
|
||||
("septic", "Septic"),
|
||||
("circulatory_overload", "Circulatory Overload"),
|
||||
("lung_injury", "Transfusion-Related Acute Lung Injury"),
|
||||
("other", "Other"),
|
||||
],
|
||||
max_length=30,
|
||||
),
|
||||
),
|
||||
(
|
||||
"severity",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("mild", "Mild"),
|
||||
("moderate", "Moderate"),
|
||||
("severe", "Severe"),
|
||||
("life_threatening", "Life Threatening"),
|
||||
],
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("onset_time", models.DateTimeField()),
|
||||
("symptoms", models.TextField()),
|
||||
("treatment_given", models.TextField()),
|
||||
("outcome", models.TextField()),
|
||||
("investigation_notes", models.TextField(blank=True)),
|
||||
("regulatory_reported", models.BooleanField(default=False)),
|
||||
("report_date", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"investigated_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="investigated_reactions",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"reported_by",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="reported_reactions",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"transfusion",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="adverse_reactions",
|
||||
to="blood_bank.transfusion",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-onset_time"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="BloodTest",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"test_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("abo_rh", "ABO/Rh Typing"),
|
||||
("antibody_screen", "Antibody Screening"),
|
||||
("hiv", "HIV"),
|
||||
("hbv", "Hepatitis B"),
|
||||
("hcv", "Hepatitis C"),
|
||||
("syphilis", "Syphilis"),
|
||||
("htlv", "HTLV"),
|
||||
("cmv", "CMV"),
|
||||
("malaria", "Malaria"),
|
||||
],
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"result",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("positive", "Positive"),
|
||||
("negative", "Negative"),
|
||||
("indeterminate", "Indeterminate"),
|
||||
("pending", "Pending"),
|
||||
],
|
||||
default="pending",
|
||||
max_length=15,
|
||||
),
|
||||
),
|
||||
("test_date", models.DateTimeField()),
|
||||
("equipment_used", models.CharField(blank=True, max_length=100)),
|
||||
("lot_number", models.CharField(blank=True, max_length=50)),
|
||||
("notes", models.TextField(blank=True)),
|
||||
("verified_at", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"tested_by",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"verified_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="verified_tests",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"blood_unit",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="tests",
|
||||
to="blood_bank.bloodunit",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-test_date"],
|
||||
"unique_together": {("blood_unit", "test_type")},
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,26 @@
|
||||
# Generated by Django 5.2.4 on 2025-09-04 15:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("blood_bank", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="donor",
|
||||
name="national_id",
|
||||
field=models.CharField(default=1129632798, max_length=10, unique=True),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="donor",
|
||||
name="gender",
|
||||
field=models.CharField(
|
||||
choices=[("M", "Male"), ("F", "Female"), ("O", "Other")], max_length=10
|
||||
),
|
||||
),
|
||||
]
|
||||
0
blood_bank/migrations/__init__.py
Normal file
0
blood_bank/migrations/__init__.py
Normal file
BIN
blood_bank/migrations/__pycache__/0001_initial.cpython-312.pyc
Normal file
BIN
blood_bank/migrations/__pycache__/0001_initial.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
blood_bank/migrations/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
blood_bank/migrations/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
525
blood_bank/models.py
Normal file
525
blood_bank/models.py
Normal file
@ -0,0 +1,525 @@
|
||||
from django.db import models
|
||||
# from django.contrib.auth.models import User
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from core.models import Department
|
||||
from patients.models import PatientProfile
|
||||
from accounts.models import User
|
||||
|
||||
class BloodGroup(models.Model):
|
||||
"""Blood group types (A, B, AB, O) with Rh factor"""
|
||||
ABO_CHOICES = [
|
||||
('A', 'A'),
|
||||
('B', 'B'),
|
||||
('AB', 'AB'),
|
||||
('O', 'O'),
|
||||
]
|
||||
|
||||
RH_CHOICES = [
|
||||
('POS', 'Positive'),
|
||||
('NEG', 'Negative'),
|
||||
]
|
||||
|
||||
abo_type = models.CharField(max_length=2, choices=ABO_CHOICES)
|
||||
rh_factor = models.CharField(max_length=8, choices=RH_CHOICES)
|
||||
|
||||
class Meta:
|
||||
unique_together = ['abo_type', 'rh_factor']
|
||||
ordering = ['abo_type', 'rh_factor']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.abo_type} {self.rh_factor.capitalize()}"
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
rh_symbol = '+' if self.rh_factor == 'POS' else '-'
|
||||
return f"{self.abo_type}{rh_symbol}"
|
||||
|
||||
|
||||
class Donor(models.Model):
|
||||
"""Blood donor information and eligibility"""
|
||||
DONOR_TYPE_CHOICES = [
|
||||
('voluntary', 'Voluntary'),
|
||||
('replacement', 'Replacement'),
|
||||
('autologous', 'Autologous'),
|
||||
('directed', 'Directed'),
|
||||
]
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('active', 'Active'),
|
||||
('deferred', 'Deferred'),
|
||||
('permanently_deferred', 'Permanently Deferred'),
|
||||
('inactive', 'Inactive'),
|
||||
]
|
||||
|
||||
donor_id = models.CharField(max_length=20, unique=True)
|
||||
first_name = models.CharField(max_length=100)
|
||||
last_name = models.CharField(max_length=100)
|
||||
date_of_birth = models.DateField()
|
||||
gender = models.CharField(max_length=10, choices=[('M', 'Male'), ('F', 'Female'), ('O', 'Other')])
|
||||
national_id = models.CharField(max_length=10, unique=True)
|
||||
blood_group = models.ForeignKey(BloodGroup, on_delete=models.PROTECT)
|
||||
phone = models.CharField(max_length=20)
|
||||
email = models.EmailField(blank=True)
|
||||
address = models.TextField()
|
||||
emergency_contact_name = models.CharField(max_length=100)
|
||||
emergency_contact_phone = models.CharField(max_length=20)
|
||||
donor_type = models.CharField(max_length=20, choices=DONOR_TYPE_CHOICES, default='voluntary')
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='active')
|
||||
registration_date = models.DateTimeField(auto_now_add=True)
|
||||
last_donation_date = models.DateTimeField(null=True, blank=True)
|
||||
total_donations = models.PositiveIntegerField(default=0)
|
||||
weight = models.FloatField(validators=[MinValueValidator(45.0)]) # Minimum weight for donation
|
||||
height = models.FloatField(validators=[MinValueValidator(140.0)]) # In cm
|
||||
notes = models.TextField(blank=True)
|
||||
created_by = models.ForeignKey(User, on_delete=models.PROTECT, related_name='created_donors')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-registration_date']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.donor_id} - {self.first_name} {self.last_name}"
|
||||
|
||||
@property
|
||||
def full_name(self):
|
||||
return f"{self.first_name} {self.last_name}"
|
||||
|
||||
@property
|
||||
def age(self):
|
||||
today = timezone.now().date()
|
||||
return today.year - self.date_of_birth.year - (
|
||||
(today.month, today.day) < (self.date_of_birth.month, self.date_of_birth.day))
|
||||
|
||||
@property
|
||||
def is_eligible_for_donation(self):
|
||||
"""Check if donor is eligible for donation based on last donation date"""
|
||||
if self.status != 'active':
|
||||
return False
|
||||
|
||||
if not self.last_donation_date:
|
||||
return True
|
||||
|
||||
# Minimum 56 days between whole blood donations
|
||||
days_since_last = (timezone.now() - self.last_donation_date).days
|
||||
return days_since_last >= 56
|
||||
|
||||
@property
|
||||
def next_eligible_date(self):
|
||||
"""Calculate next eligible donation date"""
|
||||
if not self.last_donation_date:
|
||||
return timezone.now().date()
|
||||
|
||||
return (self.last_donation_date + timedelta(days=56)).date()
|
||||
|
||||
|
||||
class BloodComponent(models.Model):
|
||||
"""Types of blood components (Whole Blood, RBC, Plasma, Platelets, etc.)"""
|
||||
COMPONENT_CHOICES = [
|
||||
('whole_blood', 'Whole Blood'),
|
||||
('packed_rbc', 'Packed Red Blood Cells'),
|
||||
('fresh_frozen_plasma', 'Fresh Frozen Plasma'),
|
||||
('platelets', 'Platelets'),
|
||||
('cryoprecipitate', 'Cryoprecipitate'),
|
||||
('granulocytes', 'Granulocytes'),
|
||||
]
|
||||
|
||||
name = models.CharField(max_length=50, choices=COMPONENT_CHOICES, unique=True)
|
||||
description = models.TextField()
|
||||
shelf_life_days = models.PositiveIntegerField() # Storage duration in days
|
||||
storage_temperature = models.CharField(max_length=50) # Storage requirements
|
||||
volume_ml = models.PositiveIntegerField() # Standard volume in ml
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return self.get_name_display()
|
||||
|
||||
|
||||
class BloodUnit(models.Model):
|
||||
"""Individual blood unit from donation to disposal"""
|
||||
STATUS_CHOICES = [
|
||||
('collected', 'Collected'),
|
||||
('testing', 'Testing'),
|
||||
('quarantine', 'Quarantine'),
|
||||
('available', 'Available'),
|
||||
('reserved', 'Reserved'),
|
||||
('issued', 'Issued'),
|
||||
('transfused', 'Transfused'),
|
||||
('expired', 'Expired'),
|
||||
('discarded', 'Discarded'),
|
||||
]
|
||||
|
||||
unit_number = models.CharField(max_length=20, unique=True)
|
||||
donor = models.ForeignKey(Donor, on_delete=models.PROTECT, related_name='blood_units')
|
||||
component = models.ForeignKey(BloodComponent, on_delete=models.PROTECT)
|
||||
blood_group = models.ForeignKey(BloodGroup, on_delete=models.PROTECT)
|
||||
collection_date = models.DateTimeField()
|
||||
expiry_date = models.DateTimeField()
|
||||
volume_ml = models.PositiveIntegerField()
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='collected')
|
||||
location = models.CharField(max_length=100) # Storage location/refrigerator
|
||||
bag_type = models.CharField(max_length=50)
|
||||
anticoagulant = models.CharField(max_length=50, default='CPDA-1')
|
||||
collection_site = models.CharField(max_length=100)
|
||||
collected_by = models.ForeignKey(User, on_delete=models.PROTECT, related_name='collected_units')
|
||||
notes = models.TextField(blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-collection_date']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.unit_number} - {self.component} ({self.blood_group})"
|
||||
|
||||
@property
|
||||
def is_expired(self):
|
||||
return timezone.now() > self.expiry_date
|
||||
|
||||
@property
|
||||
def days_to_expiry(self):
|
||||
delta = self.expiry_date - timezone.now()
|
||||
return delta.days if delta.days > 0 else 0
|
||||
|
||||
@property
|
||||
def is_available(self):
|
||||
return self.status == 'available' and not self.is_expired
|
||||
|
||||
|
||||
class BloodTest(models.Model):
|
||||
"""Blood testing results for infectious diseases and compatibility"""
|
||||
TEST_TYPE_CHOICES = [
|
||||
('abo_rh', 'ABO/Rh Typing'),
|
||||
('antibody_screen', 'Antibody Screening'),
|
||||
('hiv', 'HIV'),
|
||||
('hbv', 'Hepatitis B'),
|
||||
('hcv', 'Hepatitis C'),
|
||||
('syphilis', 'Syphilis'),
|
||||
('htlv', 'HTLV'),
|
||||
('cmv', 'CMV'),
|
||||
('malaria', 'Malaria'),
|
||||
]
|
||||
|
||||
RESULT_CHOICES = [
|
||||
('positive', 'Positive'),
|
||||
('negative', 'Negative'),
|
||||
('indeterminate', 'Indeterminate'),
|
||||
('pending', 'Pending'),
|
||||
]
|
||||
|
||||
blood_unit = models.ForeignKey(BloodUnit, on_delete=models.CASCADE, related_name='tests')
|
||||
test_type = models.CharField(max_length=20, choices=TEST_TYPE_CHOICES)
|
||||
result = models.CharField(max_length=15, choices=RESULT_CHOICES, default='pending')
|
||||
test_date = models.DateTimeField()
|
||||
tested_by = models.ForeignKey(User, on_delete=models.PROTECT)
|
||||
equipment_used = models.CharField(max_length=100, blank=True)
|
||||
lot_number = models.CharField(max_length=50, blank=True)
|
||||
notes = models.TextField(blank=True)
|
||||
verified_by = models.ForeignKey(User, on_delete=models.PROTECT, related_name='verified_tests', null=True,
|
||||
blank=True)
|
||||
verified_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ['blood_unit', 'test_type']
|
||||
ordering = ['-test_date']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.blood_unit.unit_number} - {self.get_test_type_display()}: {self.result}"
|
||||
|
||||
|
||||
class CrossMatch(models.Model):
|
||||
"""Cross-matching tests between donor blood and recipient"""
|
||||
COMPATIBILITY_CHOICES = [
|
||||
('compatible', 'Compatible'),
|
||||
('incompatible', 'Incompatible'),
|
||||
('pending', 'Pending'),
|
||||
]
|
||||
|
||||
TEST_TYPE_CHOICES = [
|
||||
('major', 'Major Crossmatch'),
|
||||
('minor', 'Minor Crossmatch'),
|
||||
('immediate_spin', 'Immediate Spin'),
|
||||
('antiglobulin', 'Antiglobulin Test'),
|
||||
]
|
||||
|
||||
blood_unit = models.ForeignKey(BloodUnit, on_delete=models.CASCADE, related_name='crossmatches')
|
||||
recipient = models.ForeignKey(PatientProfile, on_delete=models.PROTECT)
|
||||
test_type = models.CharField(max_length=20, choices=TEST_TYPE_CHOICES)
|
||||
compatibility = models.CharField(max_length=15, choices=COMPATIBILITY_CHOICES, default='pending')
|
||||
test_date = models.DateTimeField()
|
||||
tested_by = models.ForeignKey(User, on_delete=models.PROTECT)
|
||||
temperature = models.CharField(max_length=20, default='37°C')
|
||||
incubation_time = models.PositiveIntegerField(default=15) # minutes
|
||||
notes = models.TextField(blank=True)
|
||||
verified_by = models.ForeignKey(User, on_delete=models.PROTECT, related_name='verified_crossmatches', null=True,
|
||||
blank=True)
|
||||
verified_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-test_date']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.blood_unit.unit_number} x {self.recipient} - {self.compatibility}"
|
||||
|
||||
|
||||
class BloodRequest(models.Model):
|
||||
"""Blood transfusion requests from clinical departments"""
|
||||
URGENCY_CHOICES = [
|
||||
('routine', 'Routine'),
|
||||
('urgent', 'Urgent'),
|
||||
('emergency', 'Emergency'),
|
||||
]
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('pending', 'Pending'),
|
||||
('processing', 'Processing'),
|
||||
('ready', 'Ready'),
|
||||
('issued', 'Issued'),
|
||||
('completed', 'Completed'),
|
||||
('cancelled', 'Cancelled'),
|
||||
]
|
||||
|
||||
request_number = models.CharField(max_length=20, unique=True)
|
||||
patient = models.ForeignKey(PatientProfile, on_delete=models.PROTECT, related_name='blood_requests')
|
||||
requesting_department = models.ForeignKey(Department, on_delete=models.PROTECT)
|
||||
requesting_physician = models.ForeignKey(User, on_delete=models.PROTECT, related_name='blood_requests')
|
||||
component_requested = models.ForeignKey(BloodComponent, on_delete=models.PROTECT)
|
||||
units_requested = models.PositiveIntegerField(validators=[MinValueValidator(1)])
|
||||
urgency = models.CharField(max_length=10, choices=URGENCY_CHOICES, default='routine')
|
||||
indication = models.TextField() # Clinical indication for transfusion
|
||||
special_requirements = models.TextField(blank=True) # CMV negative, irradiated, etc.
|
||||
patient_blood_group = models.ForeignKey(BloodGroup, on_delete=models.PROTECT)
|
||||
hemoglobin_level = models.FloatField(null=True, blank=True)
|
||||
platelet_count = models.IntegerField(null=True, blank=True)
|
||||
status = models.CharField(max_length=15, choices=STATUS_CHOICES, default='pending')
|
||||
request_date = models.DateTimeField(auto_now_add=True)
|
||||
required_by = models.DateTimeField()
|
||||
processed_by = models.ForeignKey(User, on_delete=models.PROTECT, related_name='processed_requests', null=True,
|
||||
blank=True)
|
||||
processed_at = models.DateTimeField(null=True, blank=True)
|
||||
notes = models.TextField(blank=True)
|
||||
|
||||
# Cancellation fields
|
||||
cancellation_reason = models.TextField(blank=True)
|
||||
cancelled_by = models.ForeignKey(User, on_delete=models.PROTECT, related_name='cancelled_requests', null=True,
|
||||
blank=True)
|
||||
cancellation_date = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-request_date']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.request_number} - {self.patient} ({self.component_requested})"
|
||||
|
||||
@property
|
||||
def is_overdue(self):
|
||||
return timezone.now() > self.required_by and self.status not in ['completed', 'cancelled']
|
||||
|
||||
|
||||
class BloodIssue(models.Model):
|
||||
"""Blood unit issuance to patients"""
|
||||
blood_request = models.ForeignKey(BloodRequest, on_delete=models.PROTECT, related_name='issues')
|
||||
blood_unit = models.OneToOneField(BloodUnit, on_delete=models.PROTECT, related_name='issue')
|
||||
crossmatch = models.ForeignKey(CrossMatch, on_delete=models.PROTECT, null=True, blank=True)
|
||||
issued_by = models.ForeignKey(User, on_delete=models.PROTECT, related_name='issued_units')
|
||||
issued_to = models.ForeignKey(User, on_delete=models.PROTECT, related_name='received_units') # Nurse/physician
|
||||
issue_date = models.DateTimeField(auto_now_add=True)
|
||||
expiry_time = models.DateTimeField() # 4 hours from issue for RBC
|
||||
returned = models.BooleanField(default=False)
|
||||
return_date = models.DateTimeField(null=True, blank=True)
|
||||
return_reason = models.TextField(blank=True)
|
||||
notes = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-issue_date']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.blood_unit.unit_number} issued to {self.blood_request.patient}"
|
||||
|
||||
@property
|
||||
def is_expired(self):
|
||||
return timezone.now() > self.expiry_time and not self.returned
|
||||
|
||||
|
||||
class Transfusion(models.Model):
|
||||
"""Blood transfusion administration records"""
|
||||
STATUS_CHOICES = [
|
||||
('started', 'Started'),
|
||||
('in_progress', 'In Progress'),
|
||||
('completed', 'Completed'),
|
||||
('stopped', 'Stopped'),
|
||||
('adverse_reaction', 'Adverse Reaction'),
|
||||
]
|
||||
|
||||
blood_issue = models.OneToOneField(BloodIssue, on_delete=models.PROTECT, related_name='transfusion')
|
||||
start_time = models.DateTimeField()
|
||||
end_time = models.DateTimeField(null=True, blank=True)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='started')
|
||||
volume_transfused = models.PositiveIntegerField(null=True, blank=True) # ml
|
||||
transfusion_rate = models.CharField(max_length=50, blank=True) # ml/hour
|
||||
administered_by = models.ForeignKey(User, on_delete=models.PROTECT, related_name='administered_transfusions')
|
||||
witnessed_by = models.ForeignKey(User, on_delete=models.PROTECT, related_name='witnessed_transfusions', null=True,
|
||||
blank=True)
|
||||
pre_transfusion_vitals = models.JSONField(default=dict) # BP, HR, Temp, etc.
|
||||
post_transfusion_vitals = models.JSONField(default=dict)
|
||||
vital_signs_history = models.JSONField(default=list) # Array of vital signs during transfusion
|
||||
current_blood_pressure = models.CharField(max_length=20, blank=True)
|
||||
current_heart_rate = models.IntegerField(null=True, blank=True)
|
||||
current_temperature = models.FloatField(null=True, blank=True)
|
||||
current_respiratory_rate = models.IntegerField(null=True, blank=True)
|
||||
current_oxygen_saturation = models.IntegerField(null=True, blank=True)
|
||||
last_vitals_check = models.DateTimeField(null=True, blank=True)
|
||||
patient_consent = models.BooleanField(default=False)
|
||||
consent_date = models.DateTimeField(null=True, blank=True)
|
||||
notes = models.TextField(blank=True)
|
||||
|
||||
# Completion/Stop fields
|
||||
stop_reason = models.TextField(blank=True)
|
||||
stopped_by = models.ForeignKey(User, on_delete=models.PROTECT, related_name='stopped_transfusions', null=True,
|
||||
blank=True)
|
||||
completed_by = models.ForeignKey(User, on_delete=models.PROTECT, related_name='completed_transfusions', null=True,
|
||||
blank=True)
|
||||
completion_notes = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-start_time']
|
||||
|
||||
def __str__(self):
|
||||
return f"Transfusion: {self.blood_issue.blood_unit.unit_number} to {self.blood_issue.blood_request.patient}"
|
||||
|
||||
@property
|
||||
def duration_minutes(self):
|
||||
if self.end_time:
|
||||
return int((self.end_time - self.start_time).total_seconds() / 60)
|
||||
return None
|
||||
|
||||
|
||||
class AdverseReaction(models.Model):
|
||||
"""Adverse transfusion reactions"""
|
||||
SEVERITY_CHOICES = [
|
||||
('mild', 'Mild'),
|
||||
('moderate', 'Moderate'),
|
||||
('severe', 'Severe'),
|
||||
('life_threatening', 'Life Threatening'),
|
||||
]
|
||||
|
||||
REACTION_TYPE_CHOICES = [
|
||||
('febrile', 'Febrile Non-Hemolytic'),
|
||||
('allergic', 'Allergic'),
|
||||
('hemolytic_acute', 'Acute Hemolytic'),
|
||||
('hemolytic_delayed', 'Delayed Hemolytic'),
|
||||
('anaphylactic', 'Anaphylactic'),
|
||||
('septic', 'Septic'),
|
||||
('circulatory_overload', 'Circulatory Overload'),
|
||||
('lung_injury', 'Transfusion-Related Acute Lung Injury'),
|
||||
('other', 'Other'),
|
||||
]
|
||||
|
||||
transfusion = models.ForeignKey(Transfusion, on_delete=models.CASCADE, related_name='adverse_reactions')
|
||||
reaction_type = models.CharField(max_length=30, choices=REACTION_TYPE_CHOICES)
|
||||
severity = models.CharField(max_length=20, choices=SEVERITY_CHOICES)
|
||||
onset_time = models.DateTimeField()
|
||||
symptoms = models.TextField()
|
||||
treatment_given = models.TextField()
|
||||
outcome = models.TextField()
|
||||
reported_by = models.ForeignKey(User, on_delete=models.PROTECT, related_name='reported_reactions')
|
||||
investigated_by = models.ForeignKey(User, on_delete=models.PROTECT, related_name='investigated_reactions',
|
||||
null=True, blank=True)
|
||||
investigation_notes = models.TextField(blank=True)
|
||||
regulatory_reported = models.BooleanField(default=False)
|
||||
report_date = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-onset_time']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_reaction_type_display()} - {self.severity} ({self.transfusion.blood_issue.blood_request.patient})"
|
||||
|
||||
|
||||
class InventoryLocation(models.Model):
|
||||
"""Blood bank storage locations"""
|
||||
LOCATION_TYPE_CHOICES = [
|
||||
('refrigerator', 'Refrigerator'),
|
||||
('freezer', 'Freezer'),
|
||||
('platelet_agitator', 'Platelet Agitator'),
|
||||
('quarantine', 'Quarantine'),
|
||||
('testing', 'Testing Area'),
|
||||
]
|
||||
|
||||
name = models.CharField(max_length=100, unique=True)
|
||||
location_type = models.CharField(max_length=20, choices=LOCATION_TYPE_CHOICES)
|
||||
temperature_range = models.CharField(max_length=50)
|
||||
temperature = models.FloatField(null=True, blank=True) # Current temperature
|
||||
capacity = models.PositiveIntegerField() # Number of units
|
||||
current_stock = models.PositiveIntegerField(default=0)
|
||||
is_active = models.BooleanField(default=True)
|
||||
notes = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.get_location_type_display()})"
|
||||
|
||||
@property
|
||||
def utilization_percentage(self):
|
||||
if self.capacity == 0:
|
||||
return 0
|
||||
return (self.current_stock / self.capacity) * 100
|
||||
|
||||
|
||||
class QualityControl(models.Model):
|
||||
"""Quality control tests and monitoring"""
|
||||
TEST_TYPE_CHOICES = [
|
||||
('temperature_monitoring', 'Temperature Monitoring'),
|
||||
('equipment_calibration', 'Equipment Calibration'),
|
||||
('reagent_testing', 'Reagent Testing'),
|
||||
('proficiency_testing', 'Proficiency Testing'),
|
||||
('process_validation', 'Process Validation'),
|
||||
]
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('pass', 'Pass'),
|
||||
('fail', 'Fail'),
|
||||
('pending', 'Pending'),
|
||||
]
|
||||
|
||||
test_type = models.CharField(max_length=30, choices=TEST_TYPE_CHOICES)
|
||||
test_date = models.DateTimeField()
|
||||
equipment_tested = models.CharField(max_length=100, blank=True)
|
||||
parameters_tested = models.TextField()
|
||||
expected_results = models.TextField()
|
||||
actual_results = models.TextField()
|
||||
status = models.CharField(max_length=10, choices=STATUS_CHOICES)
|
||||
performed_by = models.ForeignKey(User, on_delete=models.PROTECT, related_name='qc_tests')
|
||||
reviewed_by = models.ForeignKey(User, on_delete=models.PROTECT, related_name='reviewed_qc_tests', null=True,
|
||||
blank=True)
|
||||
review_date = models.DateTimeField(null=True, blank=True)
|
||||
review_notes = models.TextField(blank=True)
|
||||
corrective_action = models.TextField(blank=True)
|
||||
next_test_date = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# CAPA (Corrective and Preventive Action) fields
|
||||
capa_initiated = models.BooleanField(default=False)
|
||||
capa_number = models.CharField(max_length=50, blank=True)
|
||||
capa_priority = models.CharField(max_length=10, choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High')],
|
||||
blank=True)
|
||||
capa_initiated_by = models.ForeignKey(User, on_delete=models.PROTECT, related_name='initiated_capas', null=True,
|
||||
blank=True)
|
||||
capa_date = models.DateTimeField(null=True, blank=True)
|
||||
capa_assessment = models.TextField(blank=True)
|
||||
capa_status = models.CharField(max_length=20,
|
||||
choices=[('open', 'Open'), ('in_progress', 'In Progress'), ('closed', 'Closed')],
|
||||
blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-test_date']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_test_type_display()} - {self.test_date.strftime('%Y-%m-%d')} ({self.status})"
|
||||
|
||||
3
blood_bank/tests.py
Normal file
3
blood_bank/tests.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
78
blood_bank/urls.py
Normal file
78
blood_bank/urls.py
Normal file
@ -0,0 +1,78 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = 'blood_bank'
|
||||
|
||||
urlpatterns = [
|
||||
# Dashboard
|
||||
path('', views.dashboard, name='dashboard'),
|
||||
|
||||
# Donor Management
|
||||
path('donors/', views.donor_list, name='donor_list'),
|
||||
path('donors/<int:donor_id>/', views.donor_detail, name='donor_detail'),
|
||||
path('donors/create/', views.donor_create, name='donor_create'),
|
||||
path('donors/<int:donor_id>/update/', views.donor_update, name='donor_update'),
|
||||
path('donors/<int:donor_id>/eligibility/', views.donor_eligibility_check, name='donor_eligibility'),
|
||||
|
||||
# Blood Unit Management
|
||||
path('units/', views.blood_unit_list, name='blood_unit_list'),
|
||||
path('units/<int:unit_id>/', views.blood_unit_detail, name='blood_unit_detail'),
|
||||
path('units/create/', views.blood_unit_create, name='blood_unit_create'),
|
||||
path('units/create/<int:donor_id>/', views.blood_unit_create, name='blood_unit_create_for_donor'),
|
||||
|
||||
# Blood Testing
|
||||
path('units/<int:unit_id>/test/', views.blood_test_create, name='blood_test_create'),
|
||||
path('units/<int:unit_id>/crossmatch/<int:patient_id>/', views.crossmatch_create, name='crossmatch_create'),
|
||||
|
||||
# Blood Requests
|
||||
path('requests/', views.blood_request_list, name='blood_request_list'),
|
||||
path('requests/<int:request_id>/', views.blood_request_detail, name='blood_request_detail'),
|
||||
path('requests/create/', views.blood_request_create, name='blood_request_create'),
|
||||
|
||||
# Blood Issue and Transfusion
|
||||
path('requests/<int:request_id>/issue/', views.blood_issue_create, name='blood_issue_create'),
|
||||
path('transfusions/', views.transfusion_list, name='transfusion_list'),
|
||||
path('transfusions/<int:transfusion_id>/', views.transfusion_detail, name='transfusion_detail'),
|
||||
path('issues/<int:issue_id>/transfusion/', views.transfusion_create, name='transfusion_create'),
|
||||
|
||||
# Inventory Management
|
||||
path('inventory/', views.inventory_overview, name='inventory_overview'),
|
||||
|
||||
# Quality Control
|
||||
path('quality-control/', views.quality_control_list, name='quality_control_list'),
|
||||
path('quality-control/create/', views.quality_control_create, name='quality_control_create'),
|
||||
|
||||
# Reports
|
||||
path('reports/', views.reports_dashboard, name='reports_dashboard'),
|
||||
|
||||
# API Endpoints
|
||||
path('api/blood-availability/', views.api_blood_availability, name='api_blood_availability'),
|
||||
path('api/donor-search/', views.api_donor_search, name='api_donor_search'),
|
||||
|
||||
# Blood Unit Management APIs
|
||||
path('api/units/<int:unit_id>/move/', views.api_move_unit, name='api_move_unit'),
|
||||
path('api/expiry-report/', views.api_expiry_report, name='api_expiry_report'),
|
||||
|
||||
# Blood Request Management APIs
|
||||
path('api/requests/<int:request_id>/cancel/', views.api_cancel_request, name='api_cancel_request'),
|
||||
path('api/check-availability/', views.api_check_availability, name='api_check_availability'),
|
||||
path('api/urgency-report/', views.api_urgency_report, name='api_urgency_report'),
|
||||
|
||||
# Quality Control APIs
|
||||
path('api/initiate-capa/', views.api_initiate_capa, name='api_initiate_capa'),
|
||||
path('api/review-results/', views.api_review_results, name='api_review_results'),
|
||||
|
||||
# Transfusion Management APIs
|
||||
path('api/record-vital-signs/', views.api_record_vital_signs, name='api_record_vital_signs'),
|
||||
path('api/transfusions/<int:transfusion_id>/stop/', views.api_stop_transfusion, name='api_stop_transfusion'),
|
||||
path('api/transfusions/<int:transfusion_id>/complete/', views.api_complete_transfusion,
|
||||
name='api_complete_transfusion'),
|
||||
|
||||
# Inventory Management APIs
|
||||
path('api/inventory-locations/', views.api_inventory_locations, name='api_inventory_locations'),
|
||||
path('api/locations/<int:location_id>/update/', views.api_update_location, name='api_update_location'),
|
||||
|
||||
# Export and Utility APIs
|
||||
path('api/export-csv/', views.api_export_csv, name='api_export_csv'),
|
||||
]
|
||||
|
||||
1228
blood_bank/views.py
Normal file
1228
blood_bank/views.py
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@ -3,6 +3,11 @@ from django.db.models import Sum, F
|
||||
from decimal import Decimal
|
||||
import operator
|
||||
from functools import reduce
|
||||
from django.utils import timezone
|
||||
from datetime import datetime, date
|
||||
from dateutil.relativedelta import relativedelta # pip install python-dateutil
|
||||
import math
|
||||
|
||||
|
||||
|
||||
register = template.Library()
|
||||
@ -320,3 +325,112 @@ def calculate_stock_value(inventory_items):
|
||||
return Decimal('0.00')
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _to_datetime(d):
|
||||
"""Coerce date or datetime to an aware datetime in current timezone."""
|
||||
if isinstance(d, datetime):
|
||||
dt = d
|
||||
elif isinstance(d, date):
|
||||
dt = datetime(d.year, d.month, d.day)
|
||||
else:
|
||||
return None
|
||||
if timezone.is_naive(dt):
|
||||
dt = timezone.make_aware(dt, timezone.get_current_timezone())
|
||||
return dt
|
||||
|
||||
|
||||
def _age_breakdown(dob, now=None):
|
||||
"""
|
||||
Returns (value:int, unit:str) choosing one of hours/days/months/years.
|
||||
Auto logic:
|
||||
- < 48 hours -> hours
|
||||
- < 60 days -> days
|
||||
- < 24 months -> months
|
||||
- otherwise -> years
|
||||
"""
|
||||
dt_dob = _to_datetime(dob)
|
||||
if not dt_dob:
|
||||
return (0, "days")
|
||||
|
||||
now = now or timezone.now()
|
||||
if dt_dob > now: # future DOB guard
|
||||
return (0, "days")
|
||||
|
||||
# Raw time diff
|
||||
delta = now - dt_dob
|
||||
total_hours = delta.total_seconds() / 3600.0
|
||||
total_days = delta.days
|
||||
|
||||
# Months/years via relativedelta for calendar accuracy
|
||||
rd = relativedelta(now, dt_dob)
|
||||
total_months = rd.years * 12 + rd.months
|
||||
years = rd.years
|
||||
|
||||
if total_hours < 24:
|
||||
return (int(math.floor(total_hours)), "hours")
|
||||
if total_days < 30:
|
||||
return (int(total_days), "days")
|
||||
if total_months < 12:
|
||||
return (int(total_months), "months")
|
||||
return (int(years), "years")
|
||||
|
||||
|
||||
def _age_in_unit(dob, unit, now=None):
|
||||
"""Force a specific unit: hours/days/months/years."""
|
||||
dt_dob = _to_datetime(dob)
|
||||
if not dt_dob:
|
||||
return 0, unit
|
||||
|
||||
now = now or timezone.now()
|
||||
if dt_dob > now:
|
||||
return 0, unit
|
||||
|
||||
delta = now - dt_dob
|
||||
if unit == "hours":
|
||||
val = int(delta.total_seconds() // 3600)
|
||||
elif unit == "days":
|
||||
val = delta.days
|
||||
elif unit == "months":
|
||||
rd = relativedelta(now, dt_dob)
|
||||
val = rd.years * 12 + rd.months
|
||||
elif unit == "years":
|
||||
rd = relativedelta(now, dt_dob)
|
||||
val = rd.years
|
||||
else:
|
||||
# Fallback to auto if unit invalid
|
||||
return _age_breakdown(dob, now)
|
||||
return val, unit
|
||||
|
||||
|
||||
@register.filter
|
||||
def age_label(dob, mode="auto"):
|
||||
"""
|
||||
Returns a human-friendly string like: "12 hours", "17 days", "3 months", "5 years".
|
||||
Usage:
|
||||
{{ person.dob|age_label }} -> auto unit
|
||||
{{ person.dob|age_label:"months" }} -> force months
|
||||
"""
|
||||
if mode in ("hours", "days", "months", "years"):
|
||||
value, unit = _age_in_unit(dob, mode)
|
||||
else:
|
||||
value, unit = _age_breakdown(dob)
|
||||
return f"{value} {unit}"
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def age_parts(dob, mode="auto"):
|
||||
"""
|
||||
Returns a dict with separate value and unit for flexible rendering.
|
||||
Usage:
|
||||
{% age_parts person.dob as a %}
|
||||
{{ a.value }} {{ a.unit }}
|
||||
# Or force a unit:
|
||||
{% age_parts person.dob "months" as a %}
|
||||
"""
|
||||
if mode in ("hours", "days", "months", "years"):
|
||||
value, unit = _age_in_unit(dob, mode)
|
||||
else:
|
||||
value, unit = _age_breakdown(dob)
|
||||
return {"value": value, "unit": unit}
|
||||
BIN
db.sqlite3
BIN
db.sqlite3
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -52,6 +52,7 @@ THIRD_PARTY_APPS = [
|
||||
LOCAL_APPS = [
|
||||
'core',
|
||||
'accounts',
|
||||
'blood_bank',
|
||||
'patients',
|
||||
'appointments',
|
||||
'inpatients',
|
||||
|
||||
@ -37,6 +37,7 @@ urlpatterns += i18n_patterns(
|
||||
path('admin/', admin.site.urls),
|
||||
path('', include('core.urls')),
|
||||
path('accounts/', include('accounts.urls')),
|
||||
path('blood-bank/', include('blood_bank.urls')),
|
||||
path('patients/', include('patients.urls')),
|
||||
path('appointments/', include('appointments.urls')),
|
||||
path('inpatients/', include('inpatients.urls')),
|
||||
|
||||
Binary file not shown.
@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.4 on 2025-09-03 15:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("inpatients", "0003_alter_bed_bed_position"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="bed",
|
||||
name="is_active",
|
||||
field=models.BooleanField(default=True, help_text="Active status"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="bed",
|
||||
name="is_active_out_of_service",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="bed",
|
||||
name="is_operational",
|
||||
field=models.BooleanField(default=True, help_text="Operational status"),
|
||||
),
|
||||
]
|
||||
Binary file not shown.
@ -340,7 +340,17 @@ class Bed(models.Model):
|
||||
default='STANDARD',
|
||||
help_text='Type of bed'
|
||||
)
|
||||
|
||||
is_operational = models.BooleanField(
|
||||
default=True,
|
||||
help_text='Operational status'
|
||||
)
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
help_text='Active status'
|
||||
)
|
||||
is_active_out_of_service = models.BooleanField(
|
||||
default=True,
|
||||
)
|
||||
room_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=ROOM_TYPE_CHOICES,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -7,7 +7,7 @@ from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils import timezone
|
||||
from datetime import datetime, time, timedelta
|
||||
|
||||
import json, re
|
||||
from accounts.models import User
|
||||
from patients.models import PatientProfile
|
||||
from .models import (
|
||||
@ -20,92 +20,157 @@ from .models import (
|
||||
# OPERATING ROOM FORMS
|
||||
# ============================================================================
|
||||
|
||||
def _list_to_text(value):
|
||||
if isinstance(value, list):
|
||||
return "\n".join(str(v) for v in value)
|
||||
return "" if value in (None, "", []) else str(value)
|
||||
|
||||
def _text_to_list(value):
|
||||
if value is None:
|
||||
return []
|
||||
raw = str(value).strip()
|
||||
if not raw:
|
||||
return []
|
||||
# Try JSON list first
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
if isinstance(parsed, list):
|
||||
return [str(x).strip() for x in parsed if str(x).strip()]
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback: newline / comma split
|
||||
items = []
|
||||
for line in raw.splitlines():
|
||||
for piece in line.split(","):
|
||||
p = piece.strip()
|
||||
if p:
|
||||
items.append(p)
|
||||
return items
|
||||
|
||||
class OperatingRoomForm(forms.ModelForm):
|
||||
"""
|
||||
Form for creating and updating operating rooms.
|
||||
Full form aligned to the panel template:
|
||||
- exposes environment, capabilities, scheduling fields
|
||||
- maps equipment_list & special_features (JSON) <-> textarea
|
||||
- enforces tenant-scoped uniqueness of room_number
|
||||
- validates temp/humidity ranges, and simple numeric sanity checks
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = OperatingRoom
|
||||
fields = [
|
||||
'room_number', 'room_name', 'room_type', 'floor_number', 'building',
|
||||
'room_size', 'equipment_list', 'special_features',
|
||||
'status', 'is_active', 'wing'
|
||||
# Basic
|
||||
'room_number', 'room_name', 'room_type', 'status',
|
||||
# Physical
|
||||
'floor_number', 'building', 'wing', 'room_size', 'ceiling_height',
|
||||
# Environment
|
||||
'temperature_min', 'temperature_max',
|
||||
'humidity_min', 'humidity_max',
|
||||
'air_changes_per_hour', 'positive_pressure',
|
||||
# Capabilities & equipment
|
||||
'supports_robotic', 'supports_laparoscopic',
|
||||
'supports_microscopy', 'supports_laser',
|
||||
'has_c_arm', 'has_ct', 'has_mri', 'has_ultrasound', 'has_neuromonitoring',
|
||||
'equipment_list', 'special_features',
|
||||
# Scheduling / staffing
|
||||
'max_case_duration', 'turnover_time', 'cleaning_time',
|
||||
'required_nurses', 'required_techs',
|
||||
'is_active', 'accepts_emergency',
|
||||
]
|
||||
widgets = {
|
||||
'room_number': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'e.g., OR-01'
|
||||
}),
|
||||
'room_name': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'e.g., Main Operating Room 1'
|
||||
}),
|
||||
'room_type': forms.Select(attrs={'class': 'form-control'}),
|
||||
'floor_number': forms.NumberInput(attrs={
|
||||
'class': 'form-control',
|
||||
'min': 1,
|
||||
'max': 50
|
||||
}),
|
||||
'building': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'e.g., Main Building'
|
||||
}),
|
||||
'room_size': forms.NumberInput(attrs={
|
||||
'class': 'form-control',
|
||||
'min': 1,
|
||||
'max': 1000
|
||||
}),
|
||||
'equipment_list': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 4,
|
||||
'placeholder': 'List available equipment...'
|
||||
}),
|
||||
'special_features': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 3,
|
||||
'placeholder': 'Special capabilities and features...'
|
||||
}),
|
||||
'status': forms.Select(attrs={'class': 'form-control'}),
|
||||
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'wing': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'e.g., East Wing'
|
||||
}),
|
||||
# Basic
|
||||
'room_number': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g., OR-01', 'required': True}),
|
||||
'room_name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g., Main Operating Room 1', 'required': True}),
|
||||
'room_type': forms.Select(attrs={'class': 'form-control', 'required': True}),
|
||||
'status': forms.Select(attrs={'class': 'form-control', 'required': True}),
|
||||
# Physical
|
||||
'floor_number': forms.NumberInput(attrs={'class': 'form-control', 'min': 0, 'max': 200, 'required': True}),
|
||||
'building': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g., Main Building'}),
|
||||
'wing': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g., East Wing'}),
|
||||
'room_size': forms.NumberInput(attrs={'class': 'form-control', 'min': 1, 'max': 2000}),
|
||||
'ceiling_height': forms.NumberInput(attrs={'class': 'form-control', 'min': 1, 'max': 20}),
|
||||
# Environment
|
||||
'temperature_min': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.1'}),
|
||||
'temperature_max': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.1'}),
|
||||
'humidity_min': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.1'}),
|
||||
'humidity_max': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.1'}),
|
||||
'air_changes_per_hour':forms.NumberInput(attrs={'class': 'form-control', 'min': 0, 'max': 120}),
|
||||
'positive_pressure': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
# Capabilities & imaging
|
||||
'supports_robotic': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'supports_laparoscopic':forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'supports_microscopy': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'supports_laser': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'has_c_arm': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'has_ct': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'has_mri': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'has_ultrasound': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'has_neuromonitoring': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'equipment_list': forms.Textarea(attrs={'class': 'form-control', 'rows': 4, 'placeholder': 'One per line, or comma-separated…'}),
|
||||
'special_features': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Hybrid OR, laminar flow, etc.'}),
|
||||
# Scheduling / staffing
|
||||
'max_case_duration': forms.NumberInput(attrs={'class': 'form-control', 'min': 30, 'max': 1440}),
|
||||
'turnover_time': forms.NumberInput(attrs={'class': 'form-control', 'min': 0, 'max': 240}),
|
||||
'cleaning_time': forms.NumberInput(attrs={'class': 'form-control', 'min': 0, 'max': 240}),
|
||||
'required_nurses': forms.NumberInput(attrs={'class': 'form-control', 'min': 0, 'max': 20}),
|
||||
'required_techs': forms.NumberInput(attrs={'class': 'form-control', 'min': 0, 'max': 20}),
|
||||
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'accepts_emergency':forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
}
|
||||
help_texts = {
|
||||
'room_number': 'Unique identifier for the operating room',
|
||||
'room_type': 'Type of procedures this room is designed for',
|
||||
'room_size': 'Size of the room in square meters',
|
||||
'equipment_list': 'List of permanently installed equipment',
|
||||
'special_features': 'Special features like imaging, robotics, etc.',
|
||||
'room_number': 'Unique identifier (per tenant). Letters, numbers, dashes.',
|
||||
'room_size': 'Square meters.',
|
||||
'temperature_min': 'Typical ORs: 18–26°C.',
|
||||
'humidity_min': 'Typical ORs: 30–60%.',
|
||||
'air_changes_per_hour': '20+ is common in OR standards.',
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.tenant = kwargs.pop('tenant', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.instance and self.instance.pk:
|
||||
self.fields['equipment_list'].initial = _list_to_text(self.instance.equipment_list)
|
||||
self.fields['special_features'].initial = _list_to_text(self.instance.special_features)
|
||||
|
||||
# JSONField <-> textarea mapping
|
||||
def clean_equipment_list(self):
|
||||
return _text_to_list(self.cleaned_data.get('equipment_list'))
|
||||
|
||||
def clean_special_features(self):
|
||||
return _text_to_list(self.cleaned_data.get('special_features'))
|
||||
|
||||
def clean_room_number(self):
|
||||
room_number = self.cleaned_data['room_number']
|
||||
|
||||
# Check for uniqueness within tenant
|
||||
queryset = OperatingRoom.objects.filter(room_number=room_number)
|
||||
room_number = (self.cleaned_data.get('room_number') or '').strip()
|
||||
if not room_number:
|
||||
return room_number
|
||||
if not re.match(r'^[A-Za-z0-9\-]+$', room_number):
|
||||
raise ValidationError('Room number may contain only letters, numbers, and dashes.')
|
||||
# tenant-scoped uniqueness
|
||||
qs = OperatingRoom.objects.all()
|
||||
tenant = self.tenant or getattr(self.instance, 'tenant', None)
|
||||
if tenant is not None:
|
||||
qs = qs.filter(tenant=tenant)
|
||||
qs = qs.filter(room_number__iexact=room_number)
|
||||
if self.instance.pk:
|
||||
queryset = queryset.exclude(pk=self.instance.pk)
|
||||
|
||||
if queryset.exists():
|
||||
raise ValidationError('Room number must be unique.')
|
||||
|
||||
qs = qs.exclude(pk=self.instance.pk)
|
||||
if qs.exists():
|
||||
raise ValidationError('Room number must be unique within the tenant.')
|
||||
return room_number
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
status = cleaned_data.get('status')
|
||||
is_active = cleaned_data.get('is_active')
|
||||
|
||||
# Validate status and active state consistency
|
||||
if not is_active and status not in ['OUT_OF_SERVICE', 'MAINTENANCE']:
|
||||
raise ValidationError(
|
||||
'Inactive rooms must have status "Out of Service" or "Maintenance".'
|
||||
)
|
||||
|
||||
return cleaned_data
|
||||
cleaned = super().clean()
|
||||
# Temperature/humidity ranges
|
||||
tmin, tmax = cleaned.get('temperature_min'), cleaned.get('temperature_max')
|
||||
hmin, hmax = cleaned.get('humidity_min'), cleaned.get('humidity_max')
|
||||
if tmin is not None and tmax is not None and tmin >= tmax:
|
||||
self.add_error('temperature_max', 'Maximum temperature must be greater than minimum temperature.')
|
||||
if hmin is not None and hmax is not None and hmin >= hmax:
|
||||
self.add_error('humidity_max', 'Maximum humidity must be greater than minimum humidity.')
|
||||
# Simple sanity checks
|
||||
for field, minv in [('max_case_duration', 1), ('turnover_time', 0), ('cleaning_time', 0)]:
|
||||
v = cleaned.get(field)
|
||||
if v is not None and v < minv:
|
||||
self.add_error(field, f'{field.replace("_", " ").title()} must be ≥ {minv}.')
|
||||
return cleaned
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,36 @@
|
||||
# Generated by Django 5.2.4 on 2025-09-04 15:07
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("operating_theatre", "0001_initial"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="surgicalnote",
|
||||
name="surgeon",
|
||||
field=models.ForeignKey(
|
||||
help_text="Operating surgeon",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="surgeon_surgical_notes",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="surgicalnote",
|
||||
name="surgical_case",
|
||||
field=models.OneToOneField(
|
||||
help_text="Related surgical case",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="surgical_notes",
|
||||
to="operating_theatre.surgicalcase",
|
||||
),
|
||||
),
|
||||
]
|
||||
Binary file not shown.
@ -261,16 +261,25 @@ class OperatingRoom(models.Model):
|
||||
"""
|
||||
return self.status == 'AVAILABLE' and self.is_active
|
||||
|
||||
@property
|
||||
def surgical_cases(self):
|
||||
"""
|
||||
All surgical cases scheduled/assigned to this operating room
|
||||
via its OR blocks.
|
||||
"""
|
||||
return SurgicalCase.objects.filter(or_block__operating_room=self)
|
||||
|
||||
# (Optional) a clearer alias if you prefer not to shadow the term "surgical_cases"
|
||||
@property
|
||||
def cases(self):
|
||||
return self.surgical_cases
|
||||
|
||||
@property
|
||||
def current_case(self):
|
||||
"""
|
||||
Get current surgical case if room is occupied.
|
||||
Get the in-progress surgical case for this room (if any).
|
||||
"""
|
||||
if self.status == 'OCCUPIED':
|
||||
return self.surgical_cases.filter(
|
||||
status='IN_PROGRESS'
|
||||
).first()
|
||||
return None
|
||||
return self.surgical_cases.filter(status='IN_PROGRESS').order_by('-scheduled_start').first()
|
||||
|
||||
|
||||
class ORBlock(models.Model):
|
||||
@ -812,7 +821,7 @@ class SurgicalNote(models.Model):
|
||||
surgical_case = models.OneToOneField(
|
||||
SurgicalCase,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='surgical_note',
|
||||
related_name='surgical_notes',
|
||||
help_text='Related surgical case'
|
||||
)
|
||||
|
||||
@ -828,7 +837,7 @@ class SurgicalNote(models.Model):
|
||||
surgeon = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='surgical_notes',
|
||||
related_name='surgeon_surgical_notes',
|
||||
help_text='Operating surgeon'
|
||||
)
|
||||
|
||||
|
||||
@ -21,6 +21,7 @@ urlpatterns = [
|
||||
path('rooms/<int:pk>/', views.OperatingRoomDetailView.as_view(), name='operating_room_detail'),
|
||||
path('rooms/<int:pk>/update/', views.OperatingRoomUpdateView.as_view(), name='operating_room_update'),
|
||||
path('rooms/<int:pk>/delete/', views.OperatingRoomDeleteView.as_view(), name='operating_room_delete'),
|
||||
path("availability/check/", views.check_room_availability, name="check_room_availability"),
|
||||
|
||||
# ============================================================================
|
||||
# SURGICAL NOTE TEMPLATE URLS (FULL CRUD - Master Data)
|
||||
@ -55,6 +56,8 @@ urlpatterns = [
|
||||
path('notes/', views.SurgicalNoteListView.as_view(), name='surgical_note_list'),
|
||||
path('notes/create/', views.SurgicalNoteCreateView.as_view(), name='surgical_note_create'),
|
||||
path('notes/<int:pk>/', views.SurgicalNoteDetailView.as_view(), name='surgical_note_detail'),
|
||||
path('notes/<int:pk>/preview/', views.surgical_note_preview, name='surgical_note_preview'),
|
||||
|
||||
# Note: No update/delete views for surgical notes - append-only for clinical records
|
||||
|
||||
# ============================================================================
|
||||
@ -75,8 +78,10 @@ urlpatterns = [
|
||||
# ============================================================================
|
||||
# ACTION URLS FOR WORKFLOW OPERATIONS
|
||||
# ============================================================================
|
||||
path('cases/<int:case_id>/start/', views.start_case, name='start_case'),
|
||||
path('cases/<int:case_id>/complete/', views.complete_case, name='complete_case'),
|
||||
# path('cases/<int:case_id>/start/', views.start_case, name='start_case'),
|
||||
# path('cases/<int:case_id>/complete/', views.complete_case, name='complete_case'),
|
||||
path('cases/<int:pk>/start/', views.StartCaseView.as_view(), name='start_case'),
|
||||
path('cases/<int:pk>/complete/', views.CompleteCaseView.as_view(), name='complete_case'),
|
||||
path('notes/<int:note_id>/sign/', views.sign_note, name='sign_note'),
|
||||
path('rooms/<int:room_id>/update-status/', views.update_room_status, name='update_room_status'),
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
2076
or_data.py
2076
or_data.py
File diff suppressed because it is too large
Load Diff
BIN
patients/.DS_Store
vendored
Normal file
BIN
patients/.DS_Store
vendored
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -4,10 +4,7 @@ Admin configuration for patients app.
|
||||
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from .models import (
|
||||
PatientProfile, EmergencyContact, InsuranceInfo,
|
||||
ConsentTemplate, ConsentForm, PatientNote
|
||||
)
|
||||
from .models import *
|
||||
|
||||
|
||||
class EmergencyContactInline(admin.TabularInline):
|
||||
@ -425,3 +422,136 @@ class PatientNoteAdmin(admin.ModelAdmin):
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).select_related('patient', 'created_by')
|
||||
|
||||
class ClaimDocumentInline(admin.TabularInline):
|
||||
"""Inline admin for claim documents."""
|
||||
model = ClaimDocument
|
||||
extra = 0
|
||||
readonly_fields = ['uploaded_at', 'file_size']
|
||||
|
||||
|
||||
class ClaimStatusHistoryInline(admin.TabularInline):
|
||||
"""Inline admin for claim status history."""
|
||||
model = ClaimStatusHistory
|
||||
extra = 0
|
||||
readonly_fields = ['changed_at']
|
||||
ordering = ['-changed_at']
|
||||
fields = [ 'changed_at']
|
||||
|
||||
|
||||
@admin.register(InsuranceClaim)
|
||||
class InsuranceClaimAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for InsuranceClaim."""
|
||||
|
||||
list_display = [
|
||||
'claim_number', 'patient', 'claim_type', 'priority',
|
||||
'billed_amount', 'approved_amount', 'service_date', 'submitted_date'
|
||||
]
|
||||
list_filter = [
|
||||
'claim_type', 'priority', 'service_date',
|
||||
'submitted_date', 'processed_date'
|
||||
]
|
||||
search_fields = [
|
||||
'claim_number', 'patient__first_name', 'patient__last_name',
|
||||
'service_provider', 'facility_name', 'primary_diagnosis_code'
|
||||
]
|
||||
ordering = ['-created_at']
|
||||
|
||||
fieldsets = (
|
||||
('Claim Information', {
|
||||
'fields': ('claim_number', 'patient', 'insurance_info', 'claim_type', 'status', 'priority')
|
||||
}),
|
||||
('Service Information', {
|
||||
'fields': (
|
||||
'service_date', 'service_provider', 'service_provider_license',
|
||||
'facility_name', 'facility_license'
|
||||
)
|
||||
}),
|
||||
('Medical Codes', {
|
||||
'fields': (
|
||||
'primary_diagnosis_code', 'primary_diagnosis_description',
|
||||
'secondary_diagnosis_codes', 'procedure_codes'
|
||||
)
|
||||
}),
|
||||
('Financial Information', {
|
||||
'fields': (
|
||||
'billed_amount', 'approved_amount', 'paid_amount',
|
||||
'patient_responsibility', 'discount_amount'
|
||||
)
|
||||
}),
|
||||
('Processing Dates', {
|
||||
'fields': ('submitted_date', 'processed_date', 'payment_date')
|
||||
}),
|
||||
('Saudi-specific Information', {
|
||||
'fields': ('saudi_id_number', 'insurance_card_number', 'authorization_number')
|
||||
}),
|
||||
('Denial/Appeal Information', {
|
||||
'fields': ('denial_reason', 'denial_code', 'appeal_date', 'appeal_reason'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Additional Information', {
|
||||
'fields': ('notes', 'attachments'),
|
||||
'classes': ('collapse',)
|
||||
})
|
||||
)
|
||||
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
inlines = [ClaimDocumentInline, ClaimStatusHistoryInline]
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Optimize queryset with select_related."""
|
||||
return super().get_queryset(request).select_related('patient', 'insurance_info')
|
||||
|
||||
def formfield_for_foreignkey(self, db_field, request, **kwargs):
|
||||
"""Optimize foreign key fields."""
|
||||
if db_field.name == "patient":
|
||||
kwargs["queryset"] = PatientProfile.objects.select_related('tenant')
|
||||
elif db_field.name == "insurance_info":
|
||||
kwargs["queryset"] = InsuranceInfo.objects.select_related('patient')
|
||||
return super().formfield_for_foreignkey(db_field, request, **kwargs)
|
||||
|
||||
|
||||
@admin.register(ClaimDocument)
|
||||
class ClaimDocumentAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for ClaimDocument."""
|
||||
|
||||
list_display = [
|
||||
'title', 'claim', 'document_type', 'file_size_display',
|
||||
'mime_type', 'uploaded_at', 'uploaded_by'
|
||||
]
|
||||
list_filter = ['document_type', 'mime_type', 'uploaded_at']
|
||||
search_fields = ['title', 'claim__claim_number', 'description']
|
||||
ordering = ['-uploaded_at']
|
||||
|
||||
def file_size_display(self, obj):
|
||||
"""Display file size in human readable format."""
|
||||
size = obj.file_size
|
||||
for unit in ['B', 'KB', 'MB', 'GB']:
|
||||
if size < 1024.0:
|
||||
return f"{size:.1f} {unit}"
|
||||
size /= 1024.0
|
||||
return f"{size:.1f} TB"
|
||||
|
||||
file_size_display.short_description = 'File Size'
|
||||
|
||||
|
||||
@admin.register(ClaimStatusHistory)
|
||||
class ClaimStatusHistoryAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for ClaimStatusHistory."""
|
||||
|
||||
list_display = [
|
||||
'claim', 'from_status', 'to_status', 'changed_at', 'changed_by'
|
||||
]
|
||||
list_filter = ['from_status', 'to_status', 'changed_at']
|
||||
search_fields = ['claim__claim_number', 'reason', 'notes']
|
||||
ordering = ['-changed_at']
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Optimize queryset with select_related."""
|
||||
return super().get_queryset(request).select_related('claim', 'changed_by')
|
||||
|
||||
|
||||
# Custom admin site configuration
|
||||
admin.site.site_header = "Hospital Management System - Patients"
|
||||
admin.site.site_title = "HMS Patients Admin"
|
||||
admin.site.index_title = "Patients Administration"
|
||||
|
||||
|
||||
0
patients/data_generators/__init__.py
Normal file
0
patients/data_generators/__init__.py
Normal file
BIN
patients/data_generators/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
patients/data_generators/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
592
patients/data_generators/saudi_claims_generator.py
Normal file
592
patients/data_generators/saudi_claims_generator.py
Normal file
@ -0,0 +1,592 @@
|
||||
"""
|
||||
Saudi-influenced Insurance Claims Data Generator
|
||||
|
||||
This module generates realistic insurance claims data tailored for the Saudi healthcare system,
|
||||
including local insurance providers, medical facilities, and healthcare practices.
|
||||
"""
|
||||
|
||||
import random
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from django.utils import timezone
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class SaudiClaimsDataGenerator:
|
||||
"""
|
||||
Generates realistic insurance claims data for Saudi healthcare system.
|
||||
"""
|
||||
|
||||
# Saudi Insurance Companies
|
||||
SAUDI_INSURANCE_COMPANIES = [
|
||||
'Tawuniya (The Company for Cooperative Insurance)',
|
||||
'Bupa Arabia for Cooperative Insurance',
|
||||
'Malath Cooperative Insurance & Reinsurance Company',
|
||||
'Saudi Enaya Cooperative Insurance Company',
|
||||
'Allianz Saudi Fransi Cooperative Insurance Company',
|
||||
'AXA Cooperative Insurance Company',
|
||||
'Arabian Shield Cooperative Insurance Company',
|
||||
'Gulf Union Alahlia Cooperative Insurance Company',
|
||||
'Solidarity Saudi Takaful Company',
|
||||
'Al Rajhi Takaful',
|
||||
'Weqaya Takaful Insurance & Reinsurance Company',
|
||||
'Sanad Cooperative Insurance & Reinsurance Company',
|
||||
'United Cooperative Assurance Company',
|
||||
'Buruj Cooperative Insurance Company',
|
||||
'Wataniya Insurance Company',
|
||||
'Saudi Re for Cooperative Reinsurance Company',
|
||||
'Amana Cooperative Insurance Company',
|
||||
'Ace Arabia Cooperative Insurance Company',
|
||||
'Al-Ahlia Insurance Company',
|
||||
'Mediterranean & Gulf Insurance & Reinsurance Company',
|
||||
]
|
||||
|
||||
# Saudi Healthcare Providers (Doctors)
|
||||
SAUDI_HEALTHCARE_PROVIDERS = [
|
||||
'د. أحمد محمد العبدالله', # Dr. Ahmed Mohammed Al-Abdullah
|
||||
'د. فاطمة علي الزهراني', # Dr. Fatima Ali Al-Zahrani
|
||||
'د. محمد عبدالرحمن القحطاني', # Dr. Mohammed Abdulrahman Al-Qahtani
|
||||
'د. نورا سعد الغامدي', # Dr. Nora Saad Al-Ghamdi
|
||||
'د. خالد يوسف الشهري', # Dr. Khalid Youssef Al-Shahri
|
||||
'د. عائشة حسن الحربي', # Dr. Aisha Hassan Al-Harbi
|
||||
'د. عبدالله سليمان المطيري', # Dr. Abdullah Sulaiman Al-Mutairi
|
||||
'د. مريم أحمد الدوسري', # Dr. Maryam Ahmed Al-Dosari
|
||||
'د. سعد محمد العتيبي', # Dr. Saad Mohammed Al-Otaibi
|
||||
'د. هند عبدالعزيز الراشد', # Dr. Hind Abdulaziz Al-Rashid
|
||||
'د. عمر فهد الخالدي', # Dr. Omar Fahad Al-Khalidi
|
||||
'د. سارة عبدالرحمن الفيصل', # Dr. Sarah Abdulrahman Al-Faisal
|
||||
'د. يوسف علي البقمي', # Dr. Youssef Ali Al-Baqami
|
||||
'د. ليلى محمد الجبير', # Dr. Layla Mohammed Al-Jubair
|
||||
'د. فيصل عبدالله السديري', # Dr. Faisal Abdullah Al-Sudairi
|
||||
'د. رنا سعود الأحمد', # Dr. Rana Saud Al-Ahmad
|
||||
'د. طارق حسام الدين الأنصاري', # Dr. Tariq Hussamuddin Al-Ansari
|
||||
'د. إيمان عبدالمحسن الشمري', # Dr. Iman Abdulmohsen Al-Shamri
|
||||
'د. ماجد فواز العسيري', # Dr. Majed Fawaz Al-Asiri
|
||||
'د. دانا محمد الفهد', # Dr. Dana Mohammed Al-Fahad
|
||||
]
|
||||
|
||||
# Saudi Healthcare Facilities
|
||||
SAUDI_HEALTHCARE_FACILITIES = [
|
||||
'مستشفى الملك فيصل التخصصي ومركز الأبحاث', # King Faisal Specialist Hospital & Research Centre
|
||||
'مستشفى الملك فهد الطبي', # King Fahad Medical City
|
||||
'مستشفى الملك عبدالعزيز الجامعي', # King Abdulaziz University Hospital
|
||||
'مستشفى الملك خالد الجامعي', # King Khalid University Hospital
|
||||
'مستشفى الأمير سلطان العسكري', # Prince Sultan Military Hospital
|
||||
'المستشفى السعودي الألماني', # Saudi German Hospital
|
||||
'مستشفى دلة', # Dallah Hospital
|
||||
'مستشفى الحبيب', # Al Habib Medical Group
|
||||
'مستشفى المملكة', # Al Mamlaka Hospital
|
||||
'مستشفى الدكتور سليمان الحبيب', # Dr. Sulaiman Al Habib Hospital
|
||||
'مستشفى الموسى التخصصي', # Al Mouwasat Hospital
|
||||
'مستشفى بقشان', # Bagshan Hospital
|
||||
'مستشفى الأهلي', # Al Ahli Hospital
|
||||
'مستشفى سعد التخصصي', # Saad Specialist Hospital
|
||||
'مستشفى الملك فهد للحرس الوطني', # King Fahad Hospital - National Guard
|
||||
'مستشفى الأمير محمد بن عبدالعزيز', # Prince Mohammed bin Abdulaziz Hospital
|
||||
'مستشفى الملك سعود', # King Saud Hospital
|
||||
'مستشفى الولادة والأطفال', # Maternity and Children Hospital
|
||||
'مستشفى العيون التخصصي', # Specialized Eye Hospital
|
||||
'مركز الأورام الطبي', # Medical Oncology Center
|
||||
]
|
||||
|
||||
# Common Saudi Medical Conditions (ICD-10 codes with Arabic descriptions)
|
||||
SAUDI_MEDICAL_CONDITIONS = [
|
||||
{
|
||||
'code': 'E11.9',
|
||||
'description_en': 'Type 2 diabetes mellitus without complications',
|
||||
'description_ar': 'داء السكري من النوع الثاني بدون مضاعفات',
|
||||
'prevalence': 0.25 # High prevalence in Saudi Arabia
|
||||
},
|
||||
{
|
||||
'code': 'I10',
|
||||
'description_en': 'Essential hypertension',
|
||||
'description_ar': 'ارتفاع ضغط الدم الأساسي',
|
||||
'prevalence': 0.20
|
||||
},
|
||||
{
|
||||
'code': 'E78.5',
|
||||
'description_en': 'Hyperlipidemia',
|
||||
'description_ar': 'ارتفاع الدهون في الدم',
|
||||
'prevalence': 0.18
|
||||
},
|
||||
{
|
||||
'code': 'M79.3',
|
||||
'description_en': 'Panniculitis, unspecified',
|
||||
'description_ar': 'التهاب النسيج الشحمي',
|
||||
'prevalence': 0.15
|
||||
},
|
||||
{
|
||||
'code': 'J45.9',
|
||||
'description_en': 'Asthma, unspecified',
|
||||
'description_ar': 'الربو غير المحدد',
|
||||
'prevalence': 0.12
|
||||
},
|
||||
{
|
||||
'code': 'K21.9',
|
||||
'description_en': 'Gastro-esophageal reflux disease',
|
||||
'description_ar': 'مرض الارتجاع المعدي المريئي',
|
||||
'prevalence': 0.10
|
||||
},
|
||||
{
|
||||
'code': 'M25.50',
|
||||
'description_en': 'Pain in unspecified joint',
|
||||
'description_ar': 'ألم في المفصل غير المحدد',
|
||||
'prevalence': 0.08
|
||||
},
|
||||
{
|
||||
'code': 'N18.6',
|
||||
'description_en': 'End stage renal disease',
|
||||
'description_ar': 'المرحلة الأخيرة من مرض الكلى',
|
||||
'prevalence': 0.06
|
||||
},
|
||||
{
|
||||
'code': 'F32.9',
|
||||
'description_en': 'Major depressive disorder',
|
||||
'description_ar': 'اضطراب الاكتئاب الشديد',
|
||||
'prevalence': 0.05
|
||||
},
|
||||
{
|
||||
'code': 'Z51.11',
|
||||
'description_en': 'Encounter for antineoplastic chemotherapy',
|
||||
'description_ar': 'مواجهة للعلاج الكيميائي المضاد للأورام',
|
||||
'prevalence': 0.04
|
||||
},
|
||||
]
|
||||
|
||||
# Common Procedures (CPT codes)
|
||||
SAUDI_MEDICAL_PROCEDURES = [
|
||||
{
|
||||
'code': '99213',
|
||||
'description': 'Office visit - established patient',
|
||||
'description_ar': 'زيارة العيادة - مريض منتظم',
|
||||
'cost_range': (150, 300)
|
||||
},
|
||||
{
|
||||
'code': '99214',
|
||||
'description': 'Office visit - established patient, moderate complexity',
|
||||
'description_ar': 'زيارة العيادة - مريض منتظم، تعقيد متوسط',
|
||||
'cost_range': (200, 400)
|
||||
},
|
||||
{
|
||||
'code': '80053',
|
||||
'description': 'Comprehensive metabolic panel',
|
||||
'description_ar': 'فحص الأيض الشامل',
|
||||
'cost_range': (100, 200)
|
||||
},
|
||||
{
|
||||
'code': '85025',
|
||||
'description': 'Blood count; complete (CBC)',
|
||||
'description_ar': 'تعداد الدم الكامل',
|
||||
'cost_range': (50, 120)
|
||||
},
|
||||
{
|
||||
'code': '71020',
|
||||
'description': 'Chest X-ray',
|
||||
'description_ar': 'أشعة سينية للصدر',
|
||||
'cost_range': (80, 150)
|
||||
},
|
||||
{
|
||||
'code': '93000',
|
||||
'description': 'Electrocardiogram',
|
||||
'description_ar': 'تخطيط القلب الكهربائي',
|
||||
'cost_range': (75, 150)
|
||||
},
|
||||
{
|
||||
'code': '76700',
|
||||
'description': 'Abdominal ultrasound',
|
||||
'description_ar': 'الموجات فوق الصوتية للبطن',
|
||||
'cost_range': (200, 400)
|
||||
},
|
||||
{
|
||||
'code': '45378',
|
||||
'description': 'Colonoscopy',
|
||||
'description_ar': 'تنظير القولون',
|
||||
'cost_range': (800, 1500)
|
||||
},
|
||||
{
|
||||
'code': '47562',
|
||||
'description': 'Laparoscopic cholecystectomy',
|
||||
'description_ar': 'استئصال المرارة بالمنظار',
|
||||
'cost_range': (5000, 8000)
|
||||
},
|
||||
{
|
||||
'code': '66984',
|
||||
'description': 'Cataract surgery',
|
||||
'description_ar': 'جراحة الساد',
|
||||
'cost_range': (3000, 6000)
|
||||
},
|
||||
]
|
||||
|
||||
# Saudi Names for generating realistic patient data
|
||||
SAUDI_FIRST_NAMES_MALE = [
|
||||
'محمد', 'أحمد', 'عبدالله', 'عبدالرحمن', 'علي', 'سعد', 'فهد', 'خالد',
|
||||
'عبدالعزيز', 'سلطان', 'فيصل', 'عمر', 'يوسف', 'إبراهيم', 'حسن', 'طارق',
|
||||
'ماجد', 'نواف', 'بندر', 'تركي', 'مشعل', 'وليد', 'صالح', 'عادل'
|
||||
]
|
||||
|
||||
SAUDI_FIRST_NAMES_FEMALE = [
|
||||
'فاطمة', 'عائشة', 'نورا', 'سارة', 'مريم', 'هند', 'ليلى', 'رنا', 'دانا',
|
||||
'ريم', 'أمل', 'منى', 'سمر', 'لمى', 'غادة', 'نهى', 'إيمان', 'خديجة',
|
||||
'زينب', 'رقية', 'جواهر', 'شهد', 'روان', 'لين'
|
||||
]
|
||||
|
||||
SAUDI_FAMILY_NAMES = [
|
||||
'العبدالله', 'الأحمد', 'المحمد', 'العلي', 'الزهراني', 'الغامدي', 'القحطاني',
|
||||
'الشهري', 'الحربي', 'المطيري', 'الدوسري', 'العتيبي', 'الراشد', 'الخالدي',
|
||||
'الفيصل', 'البقمي', 'الجبير', 'السديري', 'الأنصاري', 'الشمري', 'العسيري',
|
||||
'الفهد', 'السعود', 'آل سعود', 'الملك', 'الأمير', 'الشيخ', 'العثمان',
|
||||
'الصالح', 'الحسن', 'الحسين', 'الطيار', 'الرشيد', 'الفارس'
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the Saudi claims data generator."""
|
||||
self.generated_claim_numbers = set()
|
||||
|
||||
def generate_saudi_id(self):
|
||||
"""Generate a realistic Saudi ID or Iqama number."""
|
||||
# Saudi ID: 1 for Saudi, 2 for resident
|
||||
prefix = random.choice(['1', '2'])
|
||||
# Next 9 digits
|
||||
middle = ''.join([str(random.randint(0, 9)) for _ in range(8)])
|
||||
# Check digit (simplified)
|
||||
check_digit = str(random.randint(0, 9))
|
||||
return prefix + middle + check_digit
|
||||
|
||||
def generate_claim_number(self):
|
||||
"""Generate a unique claim number."""
|
||||
while True:
|
||||
year = datetime.now().year
|
||||
sequence = random.randint(100000, 999999)
|
||||
claim_number = f"CLM{year}{sequence}"
|
||||
if claim_number not in self.generated_claim_numbers:
|
||||
self.generated_claim_numbers.add(claim_number)
|
||||
return claim_number
|
||||
|
||||
def generate_authorization_number(self):
|
||||
"""Generate a prior authorization number."""
|
||||
return f"AUTH{random.randint(100000, 999999)}"
|
||||
|
||||
def generate_provider_license(self):
|
||||
"""Generate a Saudi medical license number."""
|
||||
return f"SML{random.randint(10000, 99999)}"
|
||||
|
||||
def generate_facility_license(self):
|
||||
"""Generate a MOH facility license number."""
|
||||
return f"MOH{random.randint(100000, 999999)}"
|
||||
|
||||
def select_weighted_condition(self):
|
||||
"""Select a medical condition based on prevalence weights."""
|
||||
conditions = self.SAUDI_MEDICAL_CONDITIONS.copy()
|
||||
weights = [condition['prevalence'] for condition in conditions]
|
||||
return random.choices(conditions, weights=weights)[0]
|
||||
|
||||
def generate_secondary_diagnoses(self, primary_condition, count=None):
|
||||
"""Generate secondary diagnoses related to primary condition."""
|
||||
if count is None:
|
||||
count = random.choices([0, 1, 2, 3], weights=[0.4, 0.3, 0.2, 0.1])[0]
|
||||
|
||||
secondary = []
|
||||
available_conditions = [c for c in self.SAUDI_MEDICAL_CONDITIONS
|
||||
if c['code'] != primary_condition['code']]
|
||||
|
||||
for _ in range(count):
|
||||
if available_conditions:
|
||||
condition = random.choice(available_conditions)
|
||||
secondary.append({
|
||||
'code': condition['code'],
|
||||
'description': condition['description_en'],
|
||||
'description_ar': condition['description_ar']
|
||||
})
|
||||
available_conditions.remove(condition)
|
||||
|
||||
return secondary
|
||||
|
||||
def generate_procedures(self, condition, count=None):
|
||||
"""Generate procedures based on the medical condition."""
|
||||
if count is None:
|
||||
count = random.choices([1, 2, 3], weights=[0.6, 0.3, 0.1])[0]
|
||||
|
||||
procedures = []
|
||||
for _ in range(count):
|
||||
procedure = random.choice(self.SAUDI_MEDICAL_PROCEDURES)
|
||||
procedures.append({
|
||||
'code': procedure['code'],
|
||||
'description': procedure['description'],
|
||||
'description_ar': procedure['description_ar'],
|
||||
'cost': random.uniform(*procedure['cost_range'])
|
||||
})
|
||||
|
||||
return procedures
|
||||
|
||||
def calculate_saudi_costs(self, procedures, claim_type='MEDICAL'):
|
||||
"""Calculate costs in Saudi Riyals with realistic pricing."""
|
||||
base_cost = sum(proc['cost'] for proc in procedures)
|
||||
|
||||
# Adjust for claim type
|
||||
multipliers = {
|
||||
'EMERGENCY': 1.5,
|
||||
'INPATIENT': 2.0,
|
||||
'SURGICAL': 3.0,
|
||||
'MATERNITY': 2.5,
|
||||
'DENTAL': 0.8,
|
||||
'VISION': 0.6,
|
||||
'PHARMACY': 0.3,
|
||||
'PREVENTIVE': 0.5,
|
||||
}
|
||||
|
||||
multiplier = multipliers.get(claim_type, 1.0)
|
||||
billed_amount = Decimal(str(base_cost * multiplier))
|
||||
|
||||
# Insurance approval rates (realistic for Saudi market)
|
||||
approval_rates = {
|
||||
'PREVENTIVE': (0.95, 1.0),
|
||||
'MEDICAL': (0.80, 0.95),
|
||||
'EMERGENCY': (0.90, 1.0),
|
||||
'INPATIENT': (0.85, 0.95),
|
||||
'SURGICAL': (0.75, 0.90),
|
||||
'DENTAL': (0.70, 0.85),
|
||||
'VISION': (0.60, 0.80),
|
||||
'PHARMACY': (0.85, 0.95),
|
||||
'MATERNITY': (0.90, 1.0),
|
||||
}
|
||||
|
||||
min_rate, max_rate = approval_rates.get(claim_type, (0.75, 0.90))
|
||||
approval_rate = random.uniform(min_rate, max_rate)
|
||||
approved_amount = billed_amount * Decimal(str(approval_rate))
|
||||
|
||||
# Patient responsibility (copay/deductible)
|
||||
copay_percentage = random.uniform(0.10, 0.25) # 10-25% patient responsibility
|
||||
patient_responsibility = approved_amount * Decimal(str(copay_percentage))
|
||||
|
||||
# Paid amount (usually same as approved for Saudi insurance)
|
||||
paid_amount = approved_amount - patient_responsibility
|
||||
|
||||
return {
|
||||
'billed_amount': round(billed_amount, 2),
|
||||
'approved_amount': round(approved_amount, 2),
|
||||
'paid_amount': round(paid_amount, 2),
|
||||
'patient_responsibility': round(patient_responsibility, 2),
|
||||
'discount_amount': round(billed_amount - approved_amount, 2)
|
||||
}
|
||||
|
||||
def generate_claim_status_progression(self):
|
||||
"""Generate realistic claim status progression with dates."""
|
||||
statuses = ['DRAFT', 'SUBMITTED', 'UNDER_REVIEW', 'APPROVED', 'PAID']
|
||||
|
||||
# Some claims may be denied or require appeals
|
||||
if random.random() < 0.15: # 15% denial rate
|
||||
statuses = ['DRAFT', 'SUBMITTED', 'UNDER_REVIEW', 'DENIED']
|
||||
if random.random() < 0.3: # 30% of denied claims are appealed
|
||||
statuses.extend(['APPEALED', 'UNDER_REVIEW', 'APPROVED', 'PAID'])
|
||||
|
||||
# Generate dates for each status
|
||||
base_date = datetime.now() - timedelta(days=random.randint(1, 180))
|
||||
status_dates = {}
|
||||
|
||||
for i, status in enumerate(statuses):
|
||||
if i == 0:
|
||||
status_dates[status] = base_date
|
||||
else:
|
||||
days_increment = random.randint(1, 14) # 1-14 days between status changes
|
||||
status_dates[status] = status_dates[statuses[i-1]] + timedelta(days=days_increment)
|
||||
|
||||
return statuses[-1], status_dates
|
||||
|
||||
def generate_denial_info(self):
|
||||
"""Generate denial information for denied claims."""
|
||||
denial_reasons = [
|
||||
'خدمة غير مغطاة بالبوليصة', # Service not covered by policy
|
||||
'مطلوب تصريح مسبق', # Prior authorization required
|
||||
'معلومات ناقصة', # Incomplete information
|
||||
'مقدم خدمة خارج الشبكة', # Out of network provider
|
||||
'تجاوز الحد الأقصى السنوي', # Annual limit exceeded
|
||||
'خدمة تجميلية غير ضرورية طبياً', # Cosmetic service not medically necessary
|
||||
'تكرار في المطالبة', # Duplicate claim
|
||||
'انتهاء صلاحية البوليصة', # Policy expired
|
||||
'خدمة مستثناة', # Excluded service
|
||||
'مطلوب تقرير طبي إضافي', # Additional medical report required
|
||||
]
|
||||
|
||||
denial_codes = ['D001', 'D002', 'D003', 'D004', 'D005', 'D006', 'D007', 'D008', 'D009', 'D010']
|
||||
|
||||
return {
|
||||
'reason': random.choice(denial_reasons),
|
||||
'code': random.choice(denial_codes)
|
||||
}
|
||||
|
||||
def generate_attachments(self, claim_type):
|
||||
"""Generate realistic document attachments for claims."""
|
||||
base_attachments = [
|
||||
{'type': 'MEDICAL_REPORT', 'name': 'تقرير طبي.pdf'},
|
||||
{'type': 'INVOICE', 'name': 'فاتورة.pdf'},
|
||||
{'type': 'INSURANCE_CARD', 'name': 'بطاقة التأمين.pdf'},
|
||||
]
|
||||
|
||||
type_specific_attachments = {
|
||||
'SURGICAL': [
|
||||
{'type': 'OPERATIVE_REPORT', 'name': 'تقرير العملية.pdf'},
|
||||
{'type': 'AUTHORIZATION', 'name': 'تصريح مسبق.pdf'},
|
||||
],
|
||||
'EMERGENCY': [
|
||||
{'type': 'DISCHARGE_SUMMARY', 'name': 'ملخص الخروج.pdf'},
|
||||
],
|
||||
'PHARMACY': [
|
||||
{'type': 'PRESCRIPTION', 'name': 'وصفة طبية.pdf'},
|
||||
],
|
||||
'RADIOLOGY': [
|
||||
{'type': 'RADIOLOGY_REPORT', 'name': 'تقرير الأشعة.pdf'},
|
||||
],
|
||||
'DIAGNOSTIC': [
|
||||
{'type': 'LAB_RESULT', 'name': 'نتائج المختبر.pdf'},
|
||||
],
|
||||
}
|
||||
|
||||
attachments = base_attachments.copy()
|
||||
if claim_type in type_specific_attachments:
|
||||
attachments.extend(type_specific_attachments[claim_type])
|
||||
|
||||
# Add random additional documents
|
||||
additional_docs = [
|
||||
{'type': 'REFERRAL', 'name': 'خطاب تحويل.pdf'},
|
||||
{'type': 'ID_COPY', 'name': 'نسخة الهوية.pdf'},
|
||||
{'type': 'OTHER', 'name': 'مستند إضافي.pdf'},
|
||||
]
|
||||
|
||||
num_additional = random.randint(0, 2)
|
||||
attachments.extend(random.sample(additional_docs, num_additional))
|
||||
|
||||
return attachments
|
||||
|
||||
def generate_single_claim(self, patient, insurance_info, created_by=None):
|
||||
"""Generate a single realistic insurance claim."""
|
||||
# Select claim type with Saudi healthcare patterns
|
||||
claim_types = [
|
||||
('MEDICAL', 0.35),
|
||||
('OUTPATIENT', 0.25),
|
||||
('PHARMACY', 0.15),
|
||||
('DIAGNOSTIC', 0.10),
|
||||
('EMERGENCY', 0.05),
|
||||
('INPATIENT', 0.04),
|
||||
('PREVENTIVE', 0.03),
|
||||
('DENTAL', 0.02),
|
||||
('SURGICAL', 0.01),
|
||||
]
|
||||
|
||||
claim_type = random.choices(
|
||||
[ct[0] for ct in claim_types],
|
||||
weights=[ct[1] for ct in claim_types]
|
||||
)[0]
|
||||
|
||||
# Generate medical information
|
||||
primary_condition = self.select_weighted_condition()
|
||||
secondary_conditions = self.generate_secondary_diagnoses(primary_condition)
|
||||
procedures = self.generate_procedures(primary_condition)
|
||||
|
||||
# Calculate costs
|
||||
costs = self.calculate_saudi_costs(procedures, claim_type)
|
||||
|
||||
# Generate status and dates
|
||||
final_status, status_dates = self.generate_claim_status_progression()
|
||||
|
||||
# Service date (1-180 days ago)
|
||||
service_date = datetime.now().date() - timedelta(days=random.randint(1, 180))
|
||||
|
||||
# Generate claim data
|
||||
claim_data = {
|
||||
'claim_number': self.generate_claim_number(),
|
||||
'patient': patient,
|
||||
'insurance_info': insurance_info,
|
||||
'claim_type': claim_type,
|
||||
'status': final_status,
|
||||
'priority': random.choices(
|
||||
['LOW', 'NORMAL', 'HIGH', 'URGENT', 'EMERGENCY'],
|
||||
weights=[0.1, 0.6, 0.2, 0.08, 0.02]
|
||||
)[0],
|
||||
'service_date': service_date,
|
||||
'service_provider': random.choice(self.SAUDI_HEALTHCARE_PROVIDERS),
|
||||
'service_provider_license': self.generate_provider_license(),
|
||||
'facility_name': random.choice(self.SAUDI_HEALTHCARE_FACILITIES),
|
||||
'facility_license': self.generate_facility_license(),
|
||||
'primary_diagnosis_code': primary_condition['code'],
|
||||
'primary_diagnosis_description': f"{primary_condition['description_en']} / {primary_condition['description_ar']}",
|
||||
'secondary_diagnosis_codes': secondary_conditions,
|
||||
'procedure_codes': procedures,
|
||||
'saudi_id_number': self.generate_saudi_id(),
|
||||
'insurance_card_number': f"IC{random.randint(100000000, 999999999)}",
|
||||
'authorization_number': self.generate_authorization_number() if random.random() < 0.3 else None,
|
||||
'notes': f"مطالبة تأمينية لـ {claim_type.lower()} - تم إنشاؤها تلقائياً",
|
||||
'attachments': self.generate_attachments(claim_type),
|
||||
'created_by': created_by,
|
||||
**costs
|
||||
}
|
||||
|
||||
# Add status-specific dates
|
||||
if 'SUBMITTED' in status_dates:
|
||||
claim_data['submitted_date'] = timezone.make_aware(
|
||||
datetime.combine(status_dates['SUBMITTED'].date(), datetime.min.time())
|
||||
)
|
||||
|
||||
if final_status in ['APPROVED', 'PARTIALLY_APPROVED', 'DENIED'] and 'UNDER_REVIEW' in status_dates:
|
||||
claim_data['processed_date'] = timezone.make_aware(
|
||||
datetime.combine(status_dates['UNDER_REVIEW'].date(), datetime.min.time())
|
||||
) + timedelta(days=random.randint(1, 7))
|
||||
|
||||
if final_status == 'PAID' and 'PAID' in status_dates:
|
||||
claim_data['payment_date'] = timezone.make_aware(
|
||||
datetime.combine(status_dates['PAID'].date(), datetime.min.time())
|
||||
)
|
||||
|
||||
# Add denial information if claim is denied
|
||||
if final_status == 'DENIED':
|
||||
denial_info = self.generate_denial_info()
|
||||
claim_data['denial_reason'] = denial_info['reason']
|
||||
claim_data['denial_code'] = denial_info['code']
|
||||
|
||||
return claim_data
|
||||
|
||||
def generate_multiple_claims(self, patients_with_insurance, num_claims=100, created_by=None):
|
||||
"""Generate multiple realistic insurance claims."""
|
||||
claims_data = []
|
||||
|
||||
for _ in range(num_claims):
|
||||
# Select random patient with insurance
|
||||
patient, insurance_info = random.choice(patients_with_insurance)
|
||||
|
||||
# Generate claim
|
||||
claim_data = self.generate_single_claim(patient, insurance_info, created_by)
|
||||
claims_data.append(claim_data)
|
||||
|
||||
return claims_data
|
||||
|
||||
def get_saudi_insurance_statistics(self, claims):
|
||||
"""Generate statistics specific to Saudi insurance market."""
|
||||
total_claims = len(claims)
|
||||
if total_claims == 0:
|
||||
return {}
|
||||
|
||||
# Calculate statistics
|
||||
approved_claims = len([c for c in claims if c['status'] in ['APPROVED', 'PARTIALLY_APPROVED', 'PAID']])
|
||||
denied_claims = len([c for c in claims if c['status'] == 'DENIED'])
|
||||
pending_claims = len([c for c in claims if c['status'] in ['SUBMITTED', 'UNDER_REVIEW']])
|
||||
|
||||
total_billed = sum(float(c['billed_amount']) for c in claims)
|
||||
total_approved = sum(float(c['approved_amount']) for c in claims)
|
||||
total_paid = sum(float(c['paid_amount']) for c in claims)
|
||||
|
||||
return {
|
||||
'total_claims': total_claims,
|
||||
'approved_claims': approved_claims,
|
||||
'denied_claims': denied_claims,
|
||||
'pending_claims': pending_claims,
|
||||
'approval_rate': (approved_claims / total_claims) * 100,
|
||||
'denial_rate': (denied_claims / total_claims) * 100,
|
||||
'total_billed_sar': total_billed,
|
||||
'total_approved_sar': total_approved,
|
||||
'total_paid_sar': total_paid,
|
||||
'average_claim_amount_sar': total_billed / total_claims,
|
||||
'average_processing_time_days': 7.5, # Typical for Saudi market
|
||||
}
|
||||
|
||||
@ -163,7 +163,7 @@ class ConsentTemplateForm(forms.ModelForm):
|
||||
fields = [
|
||||
'name', 'description', 'category', 'content', 'version',
|
||||
'is_active', 'requires_signature', 'requires_witness', 'requires_guardian',
|
||||
'effective_date', 'expiry_date'
|
||||
'effective_date', 'expiry_date',
|
||||
]
|
||||
widgets = {
|
||||
'name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
|
||||
BIN
patients/management/.DS_Store
vendored
Normal file
BIN
patients/management/.DS_Store
vendored
Normal file
Binary file not shown.
0
patients/management/__init__.py
Normal file
0
patients/management/__init__.py
Normal file
BIN
patients/management/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
patients/management/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
0
patients/management/commands/__init__.py
Normal file
0
patients/management/commands/__init__.py
Normal file
Binary file not shown.
Binary file not shown.
398
patients/management/commands/generate_saudi_claims.py
Normal file
398
patients/management/commands/generate_saudi_claims.py
Normal file
@ -0,0 +1,398 @@
|
||||
"""
|
||||
Django management command to generate Saudi-influenced insurance claims data.
|
||||
|
||||
Usage:
|
||||
python manage.py generate_saudi_claims --count 100 --tenant-id 1
|
||||
python manage.py generate_saudi_claims --count 500 --all-tenants
|
||||
python manage.py generate_saudi_claims --count 50 --patient-id 123
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from patients.models import PatientProfile, InsuranceInfo, InsuranceClaim, ClaimStatusHistory
|
||||
from patients.data_generators.saudi_claims_generator import SaudiClaimsDataGenerator
|
||||
from core.models import Tenant
|
||||
import random
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Generate Saudi-influenced insurance claims data for testing and development'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--count',
|
||||
type=int,
|
||||
default=50,
|
||||
help='Number of claims to generate (default: 50)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--tenant-id',
|
||||
type=int,
|
||||
help='Specific tenant ID to generate claims for'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--all-tenants',
|
||||
action='store_true',
|
||||
help='Generate claims for all tenants'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--patient-id',
|
||||
type=int,
|
||||
help='Generate claims for a specific patient'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--insurance-id',
|
||||
type=int,
|
||||
help='Generate claims for a specific insurance policy'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--claim-type',
|
||||
choices=['MEDICAL', 'DENTAL', 'VISION', 'PHARMACY', 'EMERGENCY',
|
||||
'INPATIENT', 'OUTPATIENT', 'PREVENTIVE', 'MATERNITY',
|
||||
'MENTAL_HEALTH', 'REHABILITATION', 'DIAGNOSTIC', 'SURGICAL', 'CHRONIC_CARE'],
|
||||
help='Generate claims of specific type only'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--status',
|
||||
choices=['DRAFT', 'SUBMITTED', 'UNDER_REVIEW', 'APPROVED',
|
||||
'PARTIALLY_APPROVED', 'DENIED', 'PAID', 'CANCELLED',
|
||||
'APPEALED', 'RESUBMITTED'],
|
||||
help='Generate claims with specific status only'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--days-back',
|
||||
type=int,
|
||||
default=180,
|
||||
help='Generate claims from this many days back (default: 180)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Show what would be generated without creating records'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--verbose',
|
||||
action='store_true',
|
||||
help='Show detailed output'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--clear-existing',
|
||||
action='store_true',
|
||||
help='Clear existing claims before generating new ones (USE WITH CAUTION)'
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Main command handler."""
|
||||
self.verbosity = options.get('verbosity', 1)
|
||||
self.verbose = options.get('verbose', False)
|
||||
|
||||
try:
|
||||
# Validate options
|
||||
self._validate_options(options)
|
||||
|
||||
# Get patients with insurance
|
||||
patients_with_insurance = self._get_patients_with_insurance(options)
|
||||
|
||||
if not patients_with_insurance:
|
||||
raise CommandError("No patients with insurance found. Please create patients and insurance records first.")
|
||||
|
||||
# Clear existing claims if requested
|
||||
if options['clear_existing']:
|
||||
self._clear_existing_claims(options)
|
||||
|
||||
# Generate claims
|
||||
if options['dry_run']:
|
||||
self._dry_run(patients_with_insurance, options)
|
||||
else:
|
||||
self._generate_claims(patients_with_insurance, options)
|
||||
|
||||
except Exception as e:
|
||||
raise CommandError(f"Error generating claims: {str(e)}")
|
||||
|
||||
def _validate_options(self, options):
|
||||
"""Validate command options."""
|
||||
if options['tenant_id'] and options['all_tenants']:
|
||||
raise CommandError("Cannot specify both --tenant-id and --all-tenants")
|
||||
|
||||
if options['count'] <= 0:
|
||||
raise CommandError("Count must be a positive integer")
|
||||
|
||||
if options['days_back'] <= 0:
|
||||
raise CommandError("Days back must be a positive integer")
|
||||
|
||||
def _get_patients_with_insurance(self, options):
|
||||
"""Get patients with insurance based on options."""
|
||||
patients_with_insurance = []
|
||||
|
||||
# Base queryset
|
||||
patients_query = PatientProfile.objects.select_related('tenant')
|
||||
insurance_query = InsuranceInfo.objects.select_related('patient', 'patient__tenant')
|
||||
|
||||
# Filter by tenant
|
||||
if options['tenant_id']:
|
||||
try:
|
||||
tenant = Tenant.objects.get(id=options['tenant_id'])
|
||||
patients_query = patients_query.filter(tenant=tenant)
|
||||
insurance_query = insurance_query.filter(patient__tenant=tenant)
|
||||
if self.verbose:
|
||||
self.stdout.write(f"Filtering by tenant: {tenant.name}")
|
||||
except Tenant.DoesNotExist:
|
||||
raise CommandError(f"Tenant with ID {options['tenant_id']} not found")
|
||||
|
||||
elif not options['all_tenants']:
|
||||
# Default to first tenant if no specific tenant specified
|
||||
first_tenant = Tenant.objects.first()
|
||||
if first_tenant:
|
||||
patients_query = patients_query.filter(tenant=first_tenant)
|
||||
insurance_query = insurance_query.filter(patient__tenant=first_tenant)
|
||||
if self.verbose:
|
||||
self.stdout.write(f"Using default tenant: {first_tenant.name}")
|
||||
|
||||
# Filter by specific patient
|
||||
if options['patient_id']:
|
||||
try:
|
||||
patient = patients_query.get(id=options['patient_id'])
|
||||
insurance_query = insurance_query.filter(patient=patient)
|
||||
if self.verbose:
|
||||
self.stdout.write(f"Filtering by patient: {patient.get_full_name()}")
|
||||
except PatientProfile.DoesNotExist:
|
||||
raise CommandError(f"Patient with ID {options['patient_id']} not found")
|
||||
|
||||
# Filter by specific insurance
|
||||
if options['insurance_id']:
|
||||
try:
|
||||
insurance = insurance_query.get(id=options['insurance_id'])
|
||||
patients_with_insurance = [(insurance.patient, insurance)]
|
||||
if self.verbose:
|
||||
self.stdout.write(f"Using specific insurance: {insurance.insurance_provider}")
|
||||
except InsuranceInfo.DoesNotExist:
|
||||
raise CommandError(f"Insurance with ID {options['insurance_id']} not found")
|
||||
else:
|
||||
# Get all patients with insurance
|
||||
for insurance in insurance_query:
|
||||
patients_with_insurance.append((insurance.patient, insurance))
|
||||
|
||||
return patients_with_insurance
|
||||
|
||||
def _clear_existing_claims(self, options):
|
||||
"""Clear existing claims if requested."""
|
||||
if not options['clear_existing']:
|
||||
return
|
||||
|
||||
self.stdout.write(
|
||||
self.style.WARNING("Clearing existing claims...")
|
||||
)
|
||||
|
||||
# Build filter for claims to delete
|
||||
claims_query = InsuranceClaim.objects.all()
|
||||
|
||||
if options['tenant_id']:
|
||||
claims_query = claims_query.filter(patient__tenant_id=options['tenant_id'])
|
||||
elif not options['all_tenants']:
|
||||
first_tenant = Tenant.objects.first()
|
||||
if first_tenant:
|
||||
claims_query = claims_query.filter(patient__tenant=first_tenant)
|
||||
|
||||
if options['patient_id']:
|
||||
claims_query = claims_query.filter(patient_id=options['patient_id'])
|
||||
|
||||
if options['insurance_id']:
|
||||
claims_query = claims_query.filter(insurance_info_id=options['insurance_id'])
|
||||
|
||||
deleted_count = claims_query.count()
|
||||
claims_query.delete()
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Cleared {deleted_count} existing claims")
|
||||
)
|
||||
|
||||
def _dry_run(self, patients_with_insurance, options):
|
||||
"""Show what would be generated without creating records."""
|
||||
self.stdout.write(
|
||||
self.style.WARNING("DRY RUN - No records will be created")
|
||||
)
|
||||
|
||||
generator = SaudiClaimsDataGenerator()
|
||||
|
||||
# Generate sample claims data
|
||||
sample_claims = generator.generate_multiple_claims(
|
||||
patients_with_insurance[:min(5, len(patients_with_insurance))],
|
||||
min(5, options['count'])
|
||||
)
|
||||
|
||||
self.stdout.write(f"\nWould generate {options['count']} claims")
|
||||
self.stdout.write(f"Available patients with insurance: {len(patients_with_insurance)}")
|
||||
|
||||
if sample_claims:
|
||||
self.stdout.write(f"\nSample claim data:")
|
||||
for i, claim in enumerate(sample_claims[:3], 1):
|
||||
self.stdout.write(f"\nClaim {i}:")
|
||||
self.stdout.write(f" - Number: {claim['claim_number']}")
|
||||
self.stdout.write(f" - Patient: {claim['patient'].get_full_name()}")
|
||||
self.stdout.write(f" - Type: {claim['claim_type']}")
|
||||
self.stdout.write(f" - Status: {claim['status']}")
|
||||
self.stdout.write(f" - Amount: {claim['billed_amount']} SAR")
|
||||
self.stdout.write(f" - Provider: {claim['service_provider']}")
|
||||
self.stdout.write(f" - Facility: {claim['facility_name']}")
|
||||
|
||||
# Show statistics
|
||||
stats = generator.get_saudi_insurance_statistics(sample_claims)
|
||||
if stats:
|
||||
self.stdout.write(f"\nSample statistics:")
|
||||
self.stdout.write(f" - Total claims: {stats['total_claims']}")
|
||||
self.stdout.write(f" - Approval rate: {stats['approval_rate']:.1f}%")
|
||||
self.stdout.write(f" - Average amount: {stats['average_claim_amount_sar']:.2f} SAR")
|
||||
|
||||
def _generate_claims(self, patients_with_insurance, options):
|
||||
"""Generate the actual claims records."""
|
||||
generator = SaudiClaimsDataGenerator()
|
||||
created_by = self._get_system_user()
|
||||
|
||||
self.stdout.write(f"Generating {options['count']} insurance claims...")
|
||||
|
||||
if self.verbose:
|
||||
self.stdout.write(f"Available patients with insurance: {len(patients_with_insurance)}")
|
||||
|
||||
# Generate claims data
|
||||
claims_data = generator.generate_multiple_claims(
|
||||
patients_with_insurance,
|
||||
options['count'],
|
||||
created_by
|
||||
)
|
||||
|
||||
# Filter by claim type if specified
|
||||
if options['claim_type']:
|
||||
claims_data = [c for c in claims_data if c['claim_type'] == options['claim_type']]
|
||||
if self.verbose:
|
||||
self.stdout.write(f"Filtered to {len(claims_data)} claims of type {options['claim_type']}")
|
||||
|
||||
# Filter by status if specified
|
||||
if options['status']:
|
||||
claims_data = [c for c in claims_data if c['status'] == options['status']]
|
||||
if self.verbose:
|
||||
self.stdout.write(f"Filtered to {len(claims_data)} claims with status {options['status']}")
|
||||
|
||||
if not claims_data:
|
||||
self.stdout.write(
|
||||
self.style.WARNING("No claims data generated after filtering")
|
||||
)
|
||||
return
|
||||
|
||||
# Create claims in database
|
||||
created_claims = []
|
||||
created_count = 0
|
||||
|
||||
with transaction.atomic():
|
||||
for claim_data in claims_data:
|
||||
try:
|
||||
# Create the claim
|
||||
claim = InsuranceClaim.objects.create(**claim_data)
|
||||
created_claims.append(claim)
|
||||
created_count += 1
|
||||
|
||||
# Create status history
|
||||
self._create_status_history(claim, created_by)
|
||||
|
||||
if self.verbose and created_count % 10 == 0:
|
||||
self.stdout.write(f"Created {created_count} claims...")
|
||||
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f"Error creating claim: {str(e)}")
|
||||
)
|
||||
continue
|
||||
|
||||
# Show results
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Successfully created {created_count} insurance claims")
|
||||
)
|
||||
|
||||
# Show statistics
|
||||
if created_claims:
|
||||
stats = generator.get_saudi_insurance_statistics(claims_data)
|
||||
self.stdout.write(f"\nGenerated claims statistics:")
|
||||
self.stdout.write(f" - Total claims: {stats['total_claims']}")
|
||||
self.stdout.write(f" - Approved claims: {stats['approved_claims']}")
|
||||
self.stdout.write(f" - Denied claims: {stats['denied_claims']}")
|
||||
self.stdout.write(f" - Pending claims: {stats['pending_claims']}")
|
||||
self.stdout.write(f" - Approval rate: {stats['approval_rate']:.1f}%")
|
||||
self.stdout.write(f" - Total billed: {stats['total_billed_sar']:,.2f} SAR")
|
||||
self.stdout.write(f" - Total approved: {stats['total_approved_sar']:,.2f} SAR")
|
||||
self.stdout.write(f" - Average claim: {stats['average_claim_amount_sar']:,.2f} SAR")
|
||||
|
||||
# Show sample claims
|
||||
if self.verbose and created_claims:
|
||||
self.stdout.write(f"\nSample created claims:")
|
||||
for claim in created_claims[:3]:
|
||||
self.stdout.write(f" - {claim.claim_number}: {claim.patient.get_full_name()} - {claim.billed_amount} SAR ({claim.status})")
|
||||
|
||||
def _create_status_history(self, claim, created_by):
|
||||
"""Create status history for the claim."""
|
||||
# Create initial status history entry
|
||||
ClaimStatusHistory.objects.create(
|
||||
claim=claim,
|
||||
from_status=None,
|
||||
to_status='DRAFT',
|
||||
reason='Initial claim creation',
|
||||
changed_by=created_by
|
||||
)
|
||||
|
||||
# If claim has progressed beyond draft, create additional history
|
||||
if claim.status != 'DRAFT':
|
||||
statuses = ['DRAFT', 'SUBMITTED', 'UNDER_REVIEW']
|
||||
|
||||
if claim.status in ['APPROVED', 'PARTIALLY_APPROVED', 'PAID']:
|
||||
statuses.append('APPROVED')
|
||||
if claim.status == 'PAID':
|
||||
statuses.append('PAID')
|
||||
elif claim.status == 'DENIED':
|
||||
statuses.append('DENIED')
|
||||
|
||||
# Create history entries for each status transition
|
||||
for i in range(1, len(statuses)):
|
||||
ClaimStatusHistory.objects.create(
|
||||
claim=claim,
|
||||
from_status=statuses[i-1],
|
||||
to_status=statuses[i],
|
||||
reason=f'Automatic status progression to {statuses[i]}',
|
||||
changed_by=created_by
|
||||
)
|
||||
|
||||
def _get_system_user(self):
|
||||
"""Get or create a system user for created_by field."""
|
||||
try:
|
||||
return User.objects.get(username='system')
|
||||
except User.DoesNotExist:
|
||||
# Try to get the first superuser
|
||||
superuser = User.objects.filter(is_superuser=True).first()
|
||||
if superuser:
|
||||
return superuser
|
||||
|
||||
# Try to get any user
|
||||
user = User.objects.first()
|
||||
if user:
|
||||
return user
|
||||
|
||||
# Create a system user if none exists
|
||||
return User.objects.create_user(
|
||||
username='system',
|
||||
email='system@hospital.local',
|
||||
first_name='System',
|
||||
last_name='Generator',
|
||||
is_active=True
|
||||
)
|
||||
|
||||
228
patients/management/commands/populate_saudi_insurance.py
Normal file
228
patients/management/commands/populate_saudi_insurance.py
Normal file
@ -0,0 +1,228 @@
|
||||
"""
|
||||
Django management command to populate Saudi insurance providers and create sample patients.
|
||||
|
||||
Usage:
|
||||
python manage.py populate_saudi_insurance --tenant-id 1
|
||||
python manage.py populate_saudi_insurance --create-patients 50
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from patients.models import PatientProfile, InsuranceInfo
|
||||
from patients.data_generators.saudi_claims_generator import SaudiClaimsDataGenerator
|
||||
from core.models import Tenant
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Populate Saudi insurance providers and create sample patients with insurance'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--tenant-id',
|
||||
type=int,
|
||||
required=True,
|
||||
help='Tenant ID to create data for'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--create-patients',
|
||||
type=int,
|
||||
default=20,
|
||||
help='Number of patients to create (default: 20)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Show what would be created without creating records'
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Main command handler."""
|
||||
try:
|
||||
# Get tenant
|
||||
tenant = Tenant.objects.get(id=options['tenant_id'])
|
||||
|
||||
if options['dry_run']:
|
||||
self._dry_run(tenant, options)
|
||||
else:
|
||||
self._create_data(tenant, options)
|
||||
|
||||
except Tenant.DoesNotExist:
|
||||
raise CommandError(f"Tenant with ID {options['tenant_id']} not found")
|
||||
except Exception as e:
|
||||
raise CommandError(f"Error: {str(e)}")
|
||||
|
||||
def _dry_run(self, tenant, options):
|
||||
"""Show what would be created."""
|
||||
self.stdout.write(
|
||||
self.style.WARNING("DRY RUN - No records will be created")
|
||||
)
|
||||
|
||||
generator = SaudiClaimsDataGenerator()
|
||||
|
||||
self.stdout.write(f"\nWould create for tenant: {tenant.name}")
|
||||
self.stdout.write(f"Number of patients: {options['create_patients']}")
|
||||
self.stdout.write(f"Insurance companies available: {len(generator.SAUDI_INSURANCE_COMPANIES)}")
|
||||
|
||||
# Show sample data
|
||||
self.stdout.write(f"\nSample insurance companies:")
|
||||
for company in generator.SAUDI_INSURANCE_COMPANIES[:5]:
|
||||
self.stdout.write(f" - {company}")
|
||||
|
||||
self.stdout.write(f"\nSample patient names:")
|
||||
for i in range(3):
|
||||
male_name = f"{random.choice(generator.SAUDI_FIRST_NAMES_MALE)} {random.choice(generator.SAUDI_FAMILY_NAMES)}"
|
||||
female_name = f"{random.choice(generator.SAUDI_FIRST_NAMES_FEMALE)} {random.choice(generator.SAUDI_FAMILY_NAMES)}"
|
||||
self.stdout.write(f" - {male_name}")
|
||||
self.stdout.write(f" - {female_name}")
|
||||
|
||||
def _create_data(self, tenant, options):
|
||||
"""Create the actual data."""
|
||||
generator = SaudiClaimsDataGenerator()
|
||||
created_by = self._get_system_user()
|
||||
|
||||
self.stdout.write(f"Creating data for tenant: {tenant.name}")
|
||||
|
||||
patients_created = 0
|
||||
insurance_created = 0
|
||||
|
||||
with transaction.atomic():
|
||||
# Create patients with insurance
|
||||
for i in range(options['create_patients']):
|
||||
try:
|
||||
# Generate patient data
|
||||
is_male = random.choice([True, False])
|
||||
|
||||
if is_male:
|
||||
first_name = random.choice(generator.SAUDI_FIRST_NAMES_MALE)
|
||||
gender = 'M'
|
||||
else:
|
||||
first_name = random.choice(generator.SAUDI_FIRST_NAMES_FEMALE)
|
||||
gender = 'F'
|
||||
|
||||
last_name = random.choice(generator.SAUDI_FAMILY_NAMES)
|
||||
|
||||
# Generate birth date (18-80 years old)
|
||||
birth_date = datetime.now().date() - timedelta(
|
||||
days=random.randint(18*365, 80*365)
|
||||
)
|
||||
|
||||
# Create patient
|
||||
patient = PatientProfile.objects.create(
|
||||
tenant=tenant,
|
||||
mrn=f"MRN{random.randint(100000, 999999)}",
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
date_of_birth=birth_date,
|
||||
gender=gender,
|
||||
phone_number=f"+966{random.randint(500000000, 599999999)}",
|
||||
email=f"{first_name.lower()}.{last_name.lower().replace(' ', '')}@example.com",
|
||||
address=f"الرياض، المملكة العربية السعودية",
|
||||
city="الرياض",
|
||||
country="SA",
|
||||
created_by=created_by
|
||||
)
|
||||
patients_created += 1
|
||||
|
||||
# Create 1-2 insurance policies per patient
|
||||
num_insurances = random.choices([1, 2], weights=[0.7, 0.3])[0]
|
||||
|
||||
for j in range(num_insurances):
|
||||
insurance_company = random.choice(generator.SAUDI_INSURANCE_COMPANIES)
|
||||
|
||||
# Generate policy dates
|
||||
effective_date = datetime.now().date() - timedelta(
|
||||
days=random.randint(30, 365)
|
||||
)
|
||||
expiration_date = effective_date + timedelta(days=365)
|
||||
|
||||
# Create insurance
|
||||
insurance = InsuranceInfo.objects.create(
|
||||
patient=patient,
|
||||
insurance_type='PRIMARY' if j == 0 else 'SECONDARY',
|
||||
insurance_company=insurance_company,
|
||||
plan_name=f"{insurance_company.split()[0]} Premium Plan",
|
||||
plan_type=random.choice(['HMO', 'PPO', 'EPO']),
|
||||
policy_number=f"POL{random.randint(100000000, 999999999)}",
|
||||
member_id=f"MEM{random.randint(100000000, 999999999)}",
|
||||
group_number=f"GRP{random.randint(10000, 99999)}",
|
||||
effective_date=effective_date,
|
||||
expiration_date=expiration_date,
|
||||
copay=random.choice([50, 75, 100, 150, 200]),
|
||||
deductible=random.choice([500, 1000, 1500, 2000, 2500]),
|
||||
subscriber_name=patient.get_full_name(),
|
||||
subscriber_relationship='SELF',
|
||||
subscriber_dob=patient.date_of_birth,
|
||||
created_by=created_by
|
||||
)
|
||||
insurance_created += 1
|
||||
|
||||
if (i + 1) % 10 == 0:
|
||||
self.stdout.write(f"Created {i + 1} patients...")
|
||||
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f"Error creating patient {i + 1}: {str(e)}")
|
||||
)
|
||||
continue
|
||||
|
||||
# Show results
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Successfully created {patients_created} patients and {insurance_created} insurance policies"
|
||||
)
|
||||
)
|
||||
|
||||
# Show sample data
|
||||
sample_patients = PatientProfile.objects.filter(tenant=tenant).order_by('-created_at')[:5]
|
||||
if sample_patients:
|
||||
self.stdout.write(f"\nSample created patients:")
|
||||
for patient in sample_patients:
|
||||
insurance_count = patient.insurance_info.count()
|
||||
self.stdout.write(
|
||||
f" - {patient.get_full_name()} (MRN: {patient.mrn}, Insurance policies: {insurance_count})"
|
||||
)
|
||||
|
||||
# Show insurance companies used
|
||||
used_companies = InsuranceInfo.objects.filter(
|
||||
patient__tenant=tenant
|
||||
).values_list('insurance_company', flat=True).distinct()
|
||||
|
||||
self.stdout.write(f"\nInsurance companies used ({len(used_companies)}):")
|
||||
for company in sorted(used_companies)[:10]:
|
||||
self.stdout.write(f" - {company}")
|
||||
|
||||
if len(used_companies) > 10:
|
||||
self.stdout.write(f" ... and {len(used_companies) - 10} more")
|
||||
|
||||
def _get_system_user(self):
|
||||
"""Get or create a system user for created_by field."""
|
||||
try:
|
||||
return User.objects.get(username='system')
|
||||
except User.DoesNotExist:
|
||||
# Try to get the first superuser
|
||||
superuser = User.objects.filter(is_superuser=True).first()
|
||||
if superuser:
|
||||
return superuser
|
||||
|
||||
# Try to get any user
|
||||
user = User.objects.first()
|
||||
if user:
|
||||
return user
|
||||
|
||||
# Create a system user if none exists
|
||||
return User.objects.create_user(
|
||||
username='system',
|
||||
email='system@hospital.local',
|
||||
first_name='System',
|
||||
last_name='Generator',
|
||||
is_active=True
|
||||
)
|
||||
|
||||
18
patients/migrations/0003_insuranceinfo_is_primary.py
Normal file
18
patients/migrations/0003_insuranceinfo_is_primary.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.4 on 2025-09-03 12:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("patients", "0002_emergencycontact_authorization_number_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="insuranceinfo",
|
||||
name="is_primary",
|
||||
field=models.BooleanField(default=False, help_text="Primary insurance"),
|
||||
),
|
||||
]
|
||||
27
patients/migrations/0004_insuranceinfo_status.py
Normal file
27
patients/migrations/0004_insuranceinfo_status.py
Normal file
@ -0,0 +1,27 @@
|
||||
# Generated by Django 5.2.4 on 2025-09-03 13:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("patients", "0003_insuranceinfo_is_primary"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="insuranceinfo",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("PENDING", "Pending"),
|
||||
("APPROVED", "Approved"),
|
||||
("DENIED", "Denied"),
|
||||
],
|
||||
default="PENDING",
|
||||
help_text="Consent status",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
]
|
||||
27
patients/migrations/0005_alter_insuranceinfo_status.py
Normal file
27
patients/migrations/0005_alter_insuranceinfo_status.py
Normal file
@ -0,0 +1,27 @@
|
||||
# Generated by Django 5.2.4 on 2025-09-03 13:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("patients", "0004_insuranceinfo_status"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="insuranceinfo",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("PENDING", "Pending"),
|
||||
("APPROVED", "Approved"),
|
||||
("DENIED", "Denied"),
|
||||
],
|
||||
default="PENDING",
|
||||
help_text="Insurance status",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,532 @@
|
||||
# Generated by Django 5.2.4 on 2025-09-03 14:41
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("patients", "0005_alter_insuranceinfo_status"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="InsuranceClaim",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"claim_number",
|
||||
models.CharField(
|
||||
help_text="Unique claim number", max_length=50, unique=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"claim_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("MEDICAL", "Medical"),
|
||||
("DENTAL", "Dental"),
|
||||
("VISION", "Vision"),
|
||||
("PHARMACY", "Pharmacy"),
|
||||
("EMERGENCY", "Emergency"),
|
||||
("INPATIENT", "Inpatient"),
|
||||
("OUTPATIENT", "Outpatient"),
|
||||
("PREVENTIVE", "Preventive Care"),
|
||||
("MATERNITY", "Maternity"),
|
||||
("MENTAL_HEALTH", "Mental Health"),
|
||||
("REHABILITATION", "Rehabilitation"),
|
||||
("DIAGNOSTIC", "Diagnostic"),
|
||||
("SURGICAL", "Surgical"),
|
||||
("CHRONIC_CARE", "Chronic Care"),
|
||||
],
|
||||
default="MEDICAL",
|
||||
help_text="Type of claim",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("DRAFT", "Draft"),
|
||||
("SUBMITTED", "Submitted"),
|
||||
("UNDER_REVIEW", "Under Review"),
|
||||
("APPROVED", "Approved"),
|
||||
("PARTIALLY_APPROVED", "Partially Approved"),
|
||||
("DENIED", "Denied"),
|
||||
("PAID", "Paid"),
|
||||
("CANCELLED", "Cancelled"),
|
||||
("APPEALED", "Appealed"),
|
||||
("RESUBMITTED", "Resubmitted"),
|
||||
],
|
||||
default="DRAFT",
|
||||
help_text="Current claim status",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"priority",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("LOW", "Low"),
|
||||
("NORMAL", "Normal"),
|
||||
("HIGH", "High"),
|
||||
("URGENT", "Urgent"),
|
||||
("EMERGENCY", "Emergency"),
|
||||
],
|
||||
default="NORMAL",
|
||||
help_text="Claim priority",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
(
|
||||
"service_date",
|
||||
models.DateField(help_text="Date when service was provided"),
|
||||
),
|
||||
(
|
||||
"service_provider",
|
||||
models.CharField(
|
||||
help_text="Healthcare provider who provided the service",
|
||||
max_length=200,
|
||||
),
|
||||
),
|
||||
(
|
||||
"service_provider_license",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Provider license number (Saudi Medical License)",
|
||||
max_length=50,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"facility_name",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Healthcare facility name",
|
||||
max_length=200,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"facility_license",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Facility license number (MOH License)",
|
||||
max_length=50,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"primary_diagnosis_code",
|
||||
models.CharField(
|
||||
help_text="Primary diagnosis code (ICD-10)", max_length=20
|
||||
),
|
||||
),
|
||||
(
|
||||
"primary_diagnosis_description",
|
||||
models.TextField(help_text="Primary diagnosis description"),
|
||||
),
|
||||
(
|
||||
"secondary_diagnosis_codes",
|
||||
models.JSONField(
|
||||
blank=True,
|
||||
default=list,
|
||||
help_text="Secondary diagnosis codes and descriptions",
|
||||
),
|
||||
),
|
||||
(
|
||||
"procedure_codes",
|
||||
models.JSONField(
|
||||
blank=True,
|
||||
default=list,
|
||||
help_text="Procedure codes (CPT/HCPCS) and descriptions",
|
||||
),
|
||||
),
|
||||
(
|
||||
"billed_amount",
|
||||
models.DecimalField(
|
||||
decimal_places=2,
|
||||
help_text="Total amount billed (SAR)",
|
||||
max_digits=12,
|
||||
),
|
||||
),
|
||||
(
|
||||
"approved_amount",
|
||||
models.DecimalField(
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
help_text="Amount approved by insurance (SAR)",
|
||||
max_digits=12,
|
||||
),
|
||||
),
|
||||
(
|
||||
"paid_amount",
|
||||
models.DecimalField(
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
help_text="Amount actually paid (SAR)",
|
||||
max_digits=12,
|
||||
),
|
||||
),
|
||||
(
|
||||
"patient_responsibility",
|
||||
models.DecimalField(
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
help_text="Patient copay/deductible amount (SAR)",
|
||||
max_digits=12,
|
||||
),
|
||||
),
|
||||
(
|
||||
"discount_amount",
|
||||
models.DecimalField(
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
help_text="Discount applied (SAR)",
|
||||
max_digits=12,
|
||||
),
|
||||
),
|
||||
(
|
||||
"submitted_date",
|
||||
models.DateTimeField(
|
||||
blank=True,
|
||||
help_text="Date claim was submitted to insurance",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"processed_date",
|
||||
models.DateTimeField(
|
||||
blank=True, help_text="Date claim was processed", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"payment_date",
|
||||
models.DateTimeField(
|
||||
blank=True, help_text="Date payment was received", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"saudi_id_number",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Saudi National ID or Iqama number",
|
||||
max_length=10,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"insurance_card_number",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Insurance card number",
|
||||
max_length=50,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"authorization_number",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Prior authorization number if required",
|
||||
max_length=50,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"denial_reason",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
help_text="Reason for denial if applicable",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"denial_code",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Insurance denial code",
|
||||
max_length=20,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"appeal_date",
|
||||
models.DateTimeField(
|
||||
blank=True, help_text="Date appeal was filed", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"appeal_reason",
|
||||
models.TextField(
|
||||
blank=True, help_text="Reason for appeal", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"notes",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
help_text="Additional notes about the claim",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"attachments",
|
||||
models.JSONField(
|
||||
blank=True, default=list, help_text="List of attached documents"
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="created_claims",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"insurance_info",
|
||||
models.ForeignKey(
|
||||
help_text="Insurance policy used for this claim",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="claims",
|
||||
to="patients.insuranceinfo",
|
||||
),
|
||||
),
|
||||
(
|
||||
"patient",
|
||||
models.ForeignKey(
|
||||
help_text="Patient associated with this claim",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="insurance_claims",
|
||||
to="patients.patientprofile",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Insurance Claim",
|
||||
"verbose_name_plural": "Insurance Claims",
|
||||
"db_table": "patients_insurance_claim",
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ClaimStatusHistory",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"from_status",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("DRAFT", "Draft"),
|
||||
("SUBMITTED", "Submitted"),
|
||||
("UNDER_REVIEW", "Under Review"),
|
||||
("APPROVED", "Approved"),
|
||||
("PARTIALLY_APPROVED", "Partially Approved"),
|
||||
("DENIED", "Denied"),
|
||||
("PAID", "Paid"),
|
||||
("CANCELLED", "Cancelled"),
|
||||
("APPEALED", "Appealed"),
|
||||
("RESUBMITTED", "Resubmitted"),
|
||||
],
|
||||
help_text="Previous status",
|
||||
max_length=20,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"to_status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("DRAFT", "Draft"),
|
||||
("SUBMITTED", "Submitted"),
|
||||
("UNDER_REVIEW", "Under Review"),
|
||||
("APPROVED", "Approved"),
|
||||
("PARTIALLY_APPROVED", "Partially Approved"),
|
||||
("DENIED", "Denied"),
|
||||
("PAID", "Paid"),
|
||||
("CANCELLED", "Cancelled"),
|
||||
("APPEALED", "Appealed"),
|
||||
("RESUBMITTED", "Resubmitted"),
|
||||
],
|
||||
help_text="New status",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"reason",
|
||||
models.TextField(
|
||||
blank=True, help_text="Reason for status change", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"notes",
|
||||
models.TextField(
|
||||
blank=True, help_text="Additional notes", null=True
|
||||
),
|
||||
),
|
||||
("changed_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"changed_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"claim",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="status_history",
|
||||
to="patients.insuranceclaim",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Claim Status History",
|
||||
"verbose_name_plural": "Claim Status Histories",
|
||||
"db_table": "patients_claim_status_history",
|
||||
"ordering": ["-changed_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ClaimDocument",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"document_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("MEDICAL_REPORT", "Medical Report"),
|
||||
("LAB_RESULT", "Laboratory Result"),
|
||||
("RADIOLOGY_REPORT", "Radiology Report"),
|
||||
("PRESCRIPTION", "Prescription"),
|
||||
("INVOICE", "Invoice"),
|
||||
("RECEIPT", "Receipt"),
|
||||
("AUTHORIZATION", "Prior Authorization"),
|
||||
("REFERRAL", "Referral Letter"),
|
||||
("DISCHARGE_SUMMARY", "Discharge Summary"),
|
||||
("OPERATIVE_REPORT", "Operative Report"),
|
||||
("PATHOLOGY_REPORT", "Pathology Report"),
|
||||
("INSURANCE_CARD", "Insurance Card Copy"),
|
||||
("ID_COPY", "ID Copy"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
help_text="Type of document",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("title", models.CharField(help_text="Document title", max_length=200)),
|
||||
(
|
||||
"description",
|
||||
models.TextField(
|
||||
blank=True, help_text="Document description", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"file_path",
|
||||
models.CharField(
|
||||
help_text="Path to the document file", max_length=500
|
||||
),
|
||||
),
|
||||
(
|
||||
"file_size",
|
||||
models.PositiveIntegerField(help_text="File size in bytes"),
|
||||
),
|
||||
(
|
||||
"mime_type",
|
||||
models.CharField(help_text="MIME type of the file", max_length=100),
|
||||
),
|
||||
("uploaded_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"uploaded_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"claim",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="documents",
|
||||
to="patients.insuranceclaim",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Claim Document",
|
||||
"verbose_name_plural": "Claim Documents",
|
||||
"db_table": "patients_claim_document",
|
||||
"ordering": ["-uploaded_at"],
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="insuranceclaim",
|
||||
index=models.Index(
|
||||
fields=["claim_number"], name="patients_in_claim_n_3b3114_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="insuranceclaim",
|
||||
index=models.Index(
|
||||
fields=["patient", "service_date"],
|
||||
name="patients_in_patient_5d8b72_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="insuranceclaim",
|
||||
index=models.Index(
|
||||
fields=["status", "priority"], name="patients_in_status_41f139_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="insuranceclaim",
|
||||
index=models.Index(
|
||||
fields=["submitted_date"], name="patients_in_submitt_75aa55_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="insuranceclaim",
|
||||
index=models.Index(
|
||||
fields=["insurance_info"], name="patients_in_insuran_f48b26_idx"
|
||||
),
|
||||
),
|
||||
]
|
||||
33
patients/migrations/0007_alter_consenttemplate_category.py
Normal file
33
patients/migrations/0007_alter_consenttemplate_category.py
Normal file
@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.4 on 2025-09-04 15:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("patients", "0006_insuranceclaim_claimstatushistory_claimdocument_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="consenttemplate",
|
||||
name="category",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("TREATMENT", "Treatment Consent"),
|
||||
("PROCEDURE", "Procedure Consent"),
|
||||
("SURGERY", "Surgical Consent"),
|
||||
("ANESTHESIA", "Anesthesia Consent"),
|
||||
("RESEARCH", "Research Consent"),
|
||||
("PRIVACY", "Privacy Consent"),
|
||||
("FINANCIAL", "Financial Consent"),
|
||||
("ADMISSION", "Admission Consent"),
|
||||
("DISCHARGE", "Discharge Consent"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
help_text="Consent category",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -14,6 +14,59 @@ class PatientProfile(models.Model):
|
||||
"""
|
||||
Patient profile with comprehensive demographics and healthcare information.
|
||||
"""
|
||||
GENDER_CHOICES = [
|
||||
('MALE', 'Male'),
|
||||
('FEMALE', 'Female'),
|
||||
('OTHER', 'Other'),
|
||||
('UNKNOWN', 'Unknown'),
|
||||
('PREFER_NOT_TO_SAY', 'Prefer not to say'),
|
||||
]
|
||||
SEX_ASSIGNED_AT_BIRTH_CHOICES = [
|
||||
('MALE', 'Male'),
|
||||
('FEMALE', 'Female'),
|
||||
('INTERSEX', 'Intersex'),
|
||||
('UNKNOWN', 'Unknown'),
|
||||
]
|
||||
RACE_CHOICES = [
|
||||
('AMERICAN_INDIAN', 'American Indian or Alaska Native'),
|
||||
('ASIAN', 'Asian'),
|
||||
('BLACK', 'Black or African American'),
|
||||
('PACIFIC_ISLANDER', 'Native Hawaiian or Other Pacific Islander'),
|
||||
('WHITE', 'White'),
|
||||
('OTHER', 'Other'),
|
||||
('UNKNOWN', 'Unknown'),
|
||||
('DECLINED', 'Patient Declined'),
|
||||
]
|
||||
ETHNICITY_CHOICES = [
|
||||
('HISPANIC', 'Hispanic or Latino'),
|
||||
('NON_HISPANIC', 'Not Hispanic or Latino'),
|
||||
('UNKNOWN', 'Unknown'),
|
||||
('DECLINED', 'Patient Declined'),
|
||||
]
|
||||
MARITAL_STATUS_CHOICES = [
|
||||
('SINGLE', 'Single'),
|
||||
('MARRIED', 'Married'),
|
||||
('DIVORCED', 'Divorced'),
|
||||
('WIDOWED', 'Widowed'),
|
||||
('SEPARATED', 'Separated'),
|
||||
('DOMESTIC_PARTNER', 'Domestic Partner'),
|
||||
('OTHER', 'Other'),
|
||||
('UNKNOWN', 'Unknown'),
|
||||
]
|
||||
COMMUNICATION_PREFERENCE_CHOICES = [
|
||||
('PHONE', 'Phone'),
|
||||
('EMAIL', 'Email'),
|
||||
('SMS', 'SMS'),
|
||||
('MAIL', 'Mail'),
|
||||
('PORTAL', 'Patient Portal'),
|
||||
]
|
||||
ADVANCE_DIRECTIVE_TYPE_CHOICES = [
|
||||
('LIVING_WILL', 'Living Will'),
|
||||
('HEALTHCARE_PROXY', 'Healthcare Proxy'),
|
||||
('DNR', 'Do Not Resuscitate'),
|
||||
('POLST', 'POLST'),
|
||||
('OTHER', 'Other'),
|
||||
]
|
||||
|
||||
# Basic Identifiers
|
||||
patient_id = models.UUIDField(
|
||||
@ -72,23 +125,12 @@ class PatientProfile(models.Model):
|
||||
)
|
||||
gender = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
('MALE', 'Male'),
|
||||
('FEMALE', 'Female'),
|
||||
('OTHER', 'Other'),
|
||||
('UNKNOWN', 'Unknown'),
|
||||
('PREFER_NOT_TO_SAY', 'Prefer not to say'),
|
||||
],
|
||||
choices=GENDER_CHOICES,
|
||||
help_text='Gender'
|
||||
)
|
||||
sex_assigned_at_birth = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
('MALE', 'Male'),
|
||||
('FEMALE', 'Female'),
|
||||
('INTERSEX', 'Intersex'),
|
||||
('UNKNOWN', 'Unknown'),
|
||||
],
|
||||
choices=SEX_ASSIGNED_AT_BIRTH_CHOICES,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Sex assigned at birth'
|
||||
@ -97,28 +139,14 @@ class PatientProfile(models.Model):
|
||||
# Race and Ethnicity
|
||||
race = models.CharField(
|
||||
max_length=50,
|
||||
choices=[
|
||||
('AMERICAN_INDIAN', 'American Indian or Alaska Native'),
|
||||
('ASIAN', 'Asian'),
|
||||
('BLACK', 'Black or African American'),
|
||||
('PACIFIC_ISLANDER', 'Native Hawaiian or Other Pacific Islander'),
|
||||
('WHITE', 'White'),
|
||||
('OTHER', 'Other'),
|
||||
('UNKNOWN', 'Unknown'),
|
||||
('DECLINED', 'Patient Declined'),
|
||||
],
|
||||
choices=RACE_CHOICES,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Race'
|
||||
)
|
||||
ethnicity = models.CharField(
|
||||
max_length=50,
|
||||
choices=[
|
||||
('HISPANIC', 'Hispanic or Latino'),
|
||||
('NON_HISPANIC', 'Not Hispanic or Latino'),
|
||||
('UNKNOWN', 'Unknown'),
|
||||
('DECLINED', 'Patient Declined'),
|
||||
],
|
||||
choices=ETHNICITY_CHOICES,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Ethnicity'
|
||||
@ -215,16 +243,7 @@ class PatientProfile(models.Model):
|
||||
# Marital Status and Family
|
||||
marital_status = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
('SINGLE', 'Single'),
|
||||
('MARRIED', 'Married'),
|
||||
('DIVORCED', 'Divorced'),
|
||||
('WIDOWED', 'Widowed'),
|
||||
('SEPARATED', 'Separated'),
|
||||
('DOMESTIC_PARTNER', 'Domestic Partner'),
|
||||
('OTHER', 'Other'),
|
||||
('UNKNOWN', 'Unknown'),
|
||||
],
|
||||
choices=MARITAL_STATUS_CHOICES,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Marital status'
|
||||
@ -242,13 +261,7 @@ class PatientProfile(models.Model):
|
||||
)
|
||||
communication_preference = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
('PHONE', 'Phone'),
|
||||
('EMAIL', 'Email'),
|
||||
('SMS', 'SMS'),
|
||||
('MAIL', 'Mail'),
|
||||
('PORTAL', 'Patient Portal'),
|
||||
],
|
||||
choices=COMMUNICATION_PREFERENCE_CHOICES,
|
||||
default='PHONE',
|
||||
help_text='Preferred communication method'
|
||||
)
|
||||
@ -300,13 +313,7 @@ class PatientProfile(models.Model):
|
||||
)
|
||||
advance_directive_type = models.CharField(
|
||||
max_length=50,
|
||||
choices=[
|
||||
('LIVING_WILL', 'Living Will'),
|
||||
('HEALTHCARE_PROXY', 'Healthcare Proxy'),
|
||||
('DNR', 'Do Not Resuscitate'),
|
||||
('POLST', 'POLST'),
|
||||
('OTHER', 'Other'),
|
||||
],
|
||||
choices=ADVANCE_DIRECTIVE_TYPE_CHOICES,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Type of advance directive'
|
||||
@ -445,7 +452,21 @@ class EmergencyContact(models.Model):
|
||||
"""
|
||||
Emergency contact information for patients.
|
||||
"""
|
||||
|
||||
RELATIONSHIP_CHOICES = [
|
||||
('SPOUSE', 'Spouse'),
|
||||
('PARENT', 'Parent'),
|
||||
('CHILD', 'Child'),
|
||||
('SIBLING', 'Sibling'),
|
||||
('GRANDPARENT', 'Grandparent'),
|
||||
('GRANDCHILD', 'Grandchild'),
|
||||
('AUNT_UNCLE', 'Aunt/Uncle'),
|
||||
('COUSIN', 'Cousin'),
|
||||
('FRIEND', 'Friend'),
|
||||
('NEIGHBOR', 'Neighbor'),
|
||||
('CAREGIVER', 'Caregiver'),
|
||||
('GUARDIAN', 'Guardian'),
|
||||
('OTHER', 'Other'),
|
||||
]
|
||||
# Patient relationship
|
||||
patient = models.ForeignKey(
|
||||
PatientProfile,
|
||||
@ -464,21 +485,7 @@ class EmergencyContact(models.Model):
|
||||
)
|
||||
relationship = models.CharField(
|
||||
max_length=50,
|
||||
choices=[
|
||||
('SPOUSE', 'Spouse'),
|
||||
('PARENT', 'Parent'),
|
||||
('CHILD', 'Child'),
|
||||
('SIBLING', 'Sibling'),
|
||||
('GRANDPARENT', 'Grandparent'),
|
||||
('GRANDCHILD', 'Grandchild'),
|
||||
('AUNT_UNCLE', 'Aunt/Uncle'),
|
||||
('COUSIN', 'Cousin'),
|
||||
('FRIEND', 'Friend'),
|
||||
('NEIGHBOR', 'Neighbor'),
|
||||
('CAREGIVER', 'Caregiver'),
|
||||
('GUARDIAN', 'Guardian'),
|
||||
('OTHER', 'Other'),
|
||||
],
|
||||
choices=RELATIONSHIP_CHOICES,
|
||||
help_text='Relationship to patient'
|
||||
)
|
||||
|
||||
@ -607,7 +614,36 @@ class InsuranceInfo(models.Model):
|
||||
"""
|
||||
Insurance information for patients.
|
||||
"""
|
||||
|
||||
INSURANCE_TYPE_CHOICES = [
|
||||
('PRIMARY', 'Primary'),
|
||||
('SECONDARY', 'Secondary'),
|
||||
('TERTIARY', 'Tertiary'),
|
||||
]
|
||||
PLAN_TYPE_CHOICES = [
|
||||
('HMO', 'Health Maintenance Organization'),
|
||||
('PPO', 'Preferred Provider Organization'),
|
||||
('EPO', 'Exclusive Provider Organization'),
|
||||
('POS', 'Point of Service'),
|
||||
('HDHP', 'High Deductible Health Plan'),
|
||||
('MEDICARE', 'Medicare'),
|
||||
('MEDICAID', 'Medicaid'),
|
||||
('TRICARE', 'TRICARE'),
|
||||
('WORKERS_COMP', 'Workers Compensation'),
|
||||
('AUTO', 'Auto Insurance'),
|
||||
('OTHER', 'Other'),
|
||||
]
|
||||
SUBSCRIBER_RELATIONSHIP_CHOICES = [
|
||||
('SELF', 'Self'),
|
||||
('SPOUSE', 'Spouse'),
|
||||
('CHILD', 'Child'),
|
||||
('PARENT', 'Parent'),
|
||||
('OTHER', 'Other'),
|
||||
]
|
||||
STATUS_CHOICES = [
|
||||
('PENDING', 'Pending'),
|
||||
('APPROVED', 'Approved'),
|
||||
('DENIED', 'Denied'),
|
||||
]
|
||||
# Patient relationship
|
||||
patient = models.ForeignKey(
|
||||
PatientProfile,
|
||||
@ -618,11 +654,7 @@ class InsuranceInfo(models.Model):
|
||||
# Insurance Details
|
||||
insurance_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
('PRIMARY', 'Primary'),
|
||||
('SECONDARY', 'Secondary'),
|
||||
('TERTIARY', 'Tertiary'),
|
||||
],
|
||||
choices=INSURANCE_TYPE_CHOICES,
|
||||
default='PRIMARY',
|
||||
help_text='Insurance type'
|
||||
)
|
||||
@ -640,24 +672,17 @@ class InsuranceInfo(models.Model):
|
||||
)
|
||||
plan_type = models.CharField(
|
||||
max_length=50,
|
||||
choices=[
|
||||
('HMO', 'Health Maintenance Organization'),
|
||||
('PPO', 'Preferred Provider Organization'),
|
||||
('EPO', 'Exclusive Provider Organization'),
|
||||
('POS', 'Point of Service'),
|
||||
('HDHP', 'High Deductible Health Plan'),
|
||||
('MEDICARE', 'Medicare'),
|
||||
('MEDICAID', 'Medicaid'),
|
||||
('TRICARE', 'TRICARE'),
|
||||
('WORKERS_COMP', 'Workers Compensation'),
|
||||
('AUTO', 'Auto Insurance'),
|
||||
('OTHER', 'Other'),
|
||||
],
|
||||
choices=PLAN_TYPE_CHOICES,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Plan type'
|
||||
)
|
||||
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=STATUS_CHOICES,
|
||||
default='PENDING',
|
||||
help_text='Insurance status'
|
||||
)
|
||||
# Policy Information
|
||||
policy_number = models.CharField(
|
||||
max_length=100,
|
||||
@ -677,13 +702,7 @@ class InsuranceInfo(models.Model):
|
||||
)
|
||||
subscriber_relationship = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
('SELF', 'Self'),
|
||||
('SPOUSE', 'Spouse'),
|
||||
('CHILD', 'Child'),
|
||||
('PARENT', 'Parent'),
|
||||
('OTHER', 'Other'),
|
||||
],
|
||||
choices=SUBSCRIBER_RELATIONSHIP_CHOICES,
|
||||
default='SELF',
|
||||
help_text='Relationship to subscriber'
|
||||
)
|
||||
@ -777,6 +796,10 @@ class InsuranceInfo(models.Model):
|
||||
default=True,
|
||||
help_text='Insurance is active'
|
||||
)
|
||||
is_primary = models.BooleanField(
|
||||
default=False,
|
||||
help_text='Primary insurance'
|
||||
)
|
||||
|
||||
# Notes
|
||||
notes = models.TextField(
|
||||
@ -814,10 +837,497 @@ class InsuranceInfo(models.Model):
|
||||
return today >= self.effective_date and self.is_active
|
||||
|
||||
|
||||
class InsuranceClaim(models.Model):
|
||||
"""
|
||||
Insurance claims for patient services and treatments.
|
||||
Designed for Saudi healthcare system with local insurance providers.
|
||||
"""
|
||||
|
||||
# Claim Status Choices
|
||||
STATUS_CHOICES = [
|
||||
('DRAFT', 'Draft'),
|
||||
('SUBMITTED', 'Submitted'),
|
||||
('UNDER_REVIEW', 'Under Review'),
|
||||
('APPROVED', 'Approved'),
|
||||
('PARTIALLY_APPROVED', 'Partially Approved'),
|
||||
('DENIED', 'Denied'),
|
||||
('PAID', 'Paid'),
|
||||
('CANCELLED', 'Cancelled'),
|
||||
('APPEALED', 'Appealed'),
|
||||
('RESUBMITTED', 'Resubmitted'),
|
||||
]
|
||||
|
||||
# Claim Type Choices
|
||||
CLAIM_TYPE_CHOICES = [
|
||||
('MEDICAL', 'Medical'),
|
||||
('DENTAL', 'Dental'),
|
||||
('VISION', 'Vision'),
|
||||
('PHARMACY', 'Pharmacy'),
|
||||
('EMERGENCY', 'Emergency'),
|
||||
('INPATIENT', 'Inpatient'),
|
||||
('OUTPATIENT', 'Outpatient'),
|
||||
('PREVENTIVE', 'Preventive Care'),
|
||||
('MATERNITY', 'Maternity'),
|
||||
('MENTAL_HEALTH', 'Mental Health'),
|
||||
('REHABILITATION', 'Rehabilitation'),
|
||||
('DIAGNOSTIC', 'Diagnostic'),
|
||||
('SURGICAL', 'Surgical'),
|
||||
('CHRONIC_CARE', 'Chronic Care'),
|
||||
]
|
||||
|
||||
# Priority Choices
|
||||
PRIORITY_CHOICES = [
|
||||
('LOW', 'Low'),
|
||||
('NORMAL', 'Normal'),
|
||||
('HIGH', 'High'),
|
||||
('URGENT', 'Urgent'),
|
||||
('EMERGENCY', 'Emergency'),
|
||||
]
|
||||
|
||||
# Basic Information
|
||||
claim_number = models.CharField(
|
||||
max_length=50,
|
||||
unique=True,
|
||||
help_text='Unique claim number'
|
||||
)
|
||||
|
||||
# Relationships
|
||||
patient = models.ForeignKey(
|
||||
PatientProfile,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='insurance_claims',
|
||||
help_text='Patient associated with this claim'
|
||||
)
|
||||
|
||||
insurance_info = models.ForeignKey(
|
||||
InsuranceInfo,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='claims',
|
||||
help_text='Insurance policy used for this claim'
|
||||
)
|
||||
|
||||
# Claim Details
|
||||
claim_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=CLAIM_TYPE_CHOICES,
|
||||
default='MEDICAL',
|
||||
help_text='Type of claim'
|
||||
)
|
||||
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=STATUS_CHOICES,
|
||||
default='DRAFT',
|
||||
help_text='Current claim status'
|
||||
)
|
||||
|
||||
priority = models.CharField(
|
||||
max_length=10,
|
||||
choices=PRIORITY_CHOICES,
|
||||
default='NORMAL',
|
||||
help_text='Claim priority'
|
||||
)
|
||||
|
||||
# Service Information
|
||||
service_date = models.DateField(
|
||||
help_text='Date when service was provided'
|
||||
)
|
||||
|
||||
service_provider = models.CharField(
|
||||
max_length=200,
|
||||
help_text='Healthcare provider who provided the service'
|
||||
)
|
||||
|
||||
service_provider_license = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Provider license number (Saudi Medical License)'
|
||||
)
|
||||
|
||||
facility_name = models.CharField(
|
||||
max_length=200,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Healthcare facility name'
|
||||
)
|
||||
|
||||
facility_license = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Facility license number (MOH License)'
|
||||
)
|
||||
|
||||
# Medical Codes (Saudi/International Standards)
|
||||
primary_diagnosis_code = models.CharField(
|
||||
max_length=20,
|
||||
help_text='Primary diagnosis code (ICD-10)'
|
||||
)
|
||||
|
||||
primary_diagnosis_description = models.TextField(
|
||||
help_text='Primary diagnosis description'
|
||||
)
|
||||
|
||||
secondary_diagnosis_codes = models.JSONField(
|
||||
default=list,
|
||||
blank=True,
|
||||
help_text='Secondary diagnosis codes and descriptions'
|
||||
)
|
||||
|
||||
procedure_codes = models.JSONField(
|
||||
default=list,
|
||||
blank=True,
|
||||
help_text='Procedure codes (CPT/HCPCS) and descriptions'
|
||||
)
|
||||
|
||||
# Financial Information (Saudi Riyal)
|
||||
billed_amount = models.DecimalField(
|
||||
max_digits=12,
|
||||
decimal_places=2,
|
||||
help_text='Total amount billed (SAR)'
|
||||
)
|
||||
|
||||
approved_amount = models.DecimalField(
|
||||
max_digits=12,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
help_text='Amount approved by insurance (SAR)'
|
||||
)
|
||||
|
||||
paid_amount = models.DecimalField(
|
||||
max_digits=12,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
help_text='Amount actually paid (SAR)'
|
||||
)
|
||||
|
||||
patient_responsibility = models.DecimalField(
|
||||
max_digits=12,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
help_text='Patient copay/deductible amount (SAR)'
|
||||
)
|
||||
|
||||
discount_amount = models.DecimalField(
|
||||
max_digits=12,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
help_text='Discount applied (SAR)'
|
||||
)
|
||||
|
||||
# Claim Processing
|
||||
submitted_date = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Date claim was submitted to insurance'
|
||||
)
|
||||
|
||||
processed_date = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Date claim was processed'
|
||||
)
|
||||
|
||||
payment_date = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Date payment was received'
|
||||
)
|
||||
|
||||
# Saudi-specific fields
|
||||
saudi_id_number = models.CharField(
|
||||
max_length=10,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Saudi National ID or Iqama number'
|
||||
)
|
||||
|
||||
insurance_card_number = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Insurance card number'
|
||||
)
|
||||
|
||||
authorization_number = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Prior authorization number if required'
|
||||
)
|
||||
|
||||
# Denial/Appeal Information
|
||||
denial_reason = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Reason for denial if applicable'
|
||||
)
|
||||
|
||||
denial_code = models.CharField(
|
||||
max_length=20,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Insurance denial code'
|
||||
)
|
||||
|
||||
appeal_date = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Date appeal was filed'
|
||||
)
|
||||
|
||||
appeal_reason = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Reason for appeal'
|
||||
)
|
||||
|
||||
# Additional Information
|
||||
notes = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Additional notes about the claim'
|
||||
)
|
||||
|
||||
attachments = models.JSONField(
|
||||
default=list,
|
||||
blank=True,
|
||||
help_text='List of attached documents'
|
||||
)
|
||||
|
||||
# Tracking
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='created_claims'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = 'patients_insurance_claim'
|
||||
verbose_name = 'Insurance Claim'
|
||||
verbose_name_plural = 'Insurance Claims'
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['claim_number']),
|
||||
models.Index(fields=['patient', 'service_date']),
|
||||
models.Index(fields=['status', 'priority']),
|
||||
models.Index(fields=['submitted_date']),
|
||||
models.Index(fields=['insurance_info']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Claim {self.claim_number} - {self.patient.get_full_name()}"
|
||||
|
||||
@property
|
||||
def is_approved(self):
|
||||
"""Check if claim is approved."""
|
||||
return self.status in ['APPROVED', 'PARTIALLY_APPROVED', 'PAID']
|
||||
|
||||
@property
|
||||
def is_denied(self):
|
||||
"""Check if claim is denied."""
|
||||
return self.status == 'DENIED'
|
||||
|
||||
@property
|
||||
def is_paid(self):
|
||||
"""Check if claim is paid."""
|
||||
return self.status == 'PAID'
|
||||
|
||||
@property
|
||||
def days_since_submission(self):
|
||||
"""Calculate days since submission."""
|
||||
if self.submitted_date:
|
||||
return (timezone.now() - self.submitted_date).days
|
||||
return None
|
||||
|
||||
@property
|
||||
def processing_time_days(self):
|
||||
"""Calculate processing time in days."""
|
||||
if self.submitted_date and self.processed_date:
|
||||
return (self.processed_date - self.submitted_date).days
|
||||
return None
|
||||
|
||||
@property
|
||||
def approval_percentage(self):
|
||||
"""Calculate approval percentage."""
|
||||
if self.billed_amount > 0:
|
||||
return (self.approved_amount / self.billed_amount) * 100
|
||||
return 0
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Generate claim number if not provided
|
||||
if not self.claim_number:
|
||||
import random
|
||||
from datetime import datetime
|
||||
year = datetime.now().year
|
||||
random_num = random.randint(100000, 999999)
|
||||
self.claim_number = f"CLM{year}{random_num}"
|
||||
|
||||
# Auto-set dates based on status changes
|
||||
if self.status == 'SUBMITTED' and not self.submitted_date:
|
||||
self.submitted_date = timezone.now()
|
||||
elif self.status in ['APPROVED', 'PARTIALLY_APPROVED', 'DENIED'] and not self.processed_date:
|
||||
self.processed_date = timezone.now()
|
||||
elif self.status == 'PAID' and not self.payment_date:
|
||||
self.payment_date = timezone.now()
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class ClaimDocument(models.Model):
|
||||
"""
|
||||
Documents attached to insurance claims.
|
||||
"""
|
||||
|
||||
DOCUMENT_TYPE_CHOICES = [
|
||||
('MEDICAL_REPORT', 'Medical Report'),
|
||||
('LAB_RESULT', 'Laboratory Result'),
|
||||
('RADIOLOGY_REPORT', 'Radiology Report'),
|
||||
('PRESCRIPTION', 'Prescription'),
|
||||
('INVOICE', 'Invoice'),
|
||||
('RECEIPT', 'Receipt'),
|
||||
('AUTHORIZATION', 'Prior Authorization'),
|
||||
('REFERRAL', 'Referral Letter'),
|
||||
('DISCHARGE_SUMMARY', 'Discharge Summary'),
|
||||
('OPERATIVE_REPORT', 'Operative Report'),
|
||||
('PATHOLOGY_REPORT', 'Pathology Report'),
|
||||
('INSURANCE_CARD', 'Insurance Card Copy'),
|
||||
('ID_COPY', 'ID Copy'),
|
||||
('OTHER', 'Other'),
|
||||
]
|
||||
|
||||
claim = models.ForeignKey(
|
||||
InsuranceClaim,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='documents'
|
||||
)
|
||||
|
||||
document_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=DOCUMENT_TYPE_CHOICES,
|
||||
help_text='Type of document'
|
||||
)
|
||||
|
||||
title = models.CharField(
|
||||
max_length=200,
|
||||
help_text='Document title'
|
||||
)
|
||||
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Document description'
|
||||
)
|
||||
|
||||
file_path = models.CharField(
|
||||
max_length=500,
|
||||
help_text='Path to the document file'
|
||||
)
|
||||
|
||||
file_size = models.PositiveIntegerField(
|
||||
help_text='File size in bytes'
|
||||
)
|
||||
|
||||
mime_type = models.CharField(
|
||||
max_length=100,
|
||||
help_text='MIME type of the file'
|
||||
)
|
||||
|
||||
uploaded_at = models.DateTimeField(auto_now_add=True)
|
||||
uploaded_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = 'patients_claim_document'
|
||||
verbose_name = 'Claim Document'
|
||||
verbose_name_plural = 'Claim Documents'
|
||||
ordering = ['-uploaded_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} - {self.claim.claim_number}"
|
||||
|
||||
|
||||
class ClaimStatusHistory(models.Model):
|
||||
"""
|
||||
Track status changes for insurance claims.
|
||||
"""
|
||||
|
||||
claim = models.ForeignKey(
|
||||
InsuranceClaim,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='status_history'
|
||||
)
|
||||
|
||||
from_status = models.CharField(
|
||||
max_length=20,
|
||||
choices=InsuranceClaim.STATUS_CHOICES,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Previous status'
|
||||
)
|
||||
|
||||
to_status = models.CharField(
|
||||
max_length=20,
|
||||
choices=InsuranceClaim.STATUS_CHOICES,
|
||||
help_text='New status'
|
||||
)
|
||||
|
||||
reason = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Reason for status change'
|
||||
)
|
||||
|
||||
notes = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Additional notes'
|
||||
)
|
||||
|
||||
changed_at = models.DateTimeField(auto_now_add=True)
|
||||
changed_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = 'patients_claim_status_history'
|
||||
verbose_name = 'Claim Status History'
|
||||
verbose_name_plural = 'Claim Status Histories'
|
||||
ordering = ['-changed_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.claim.claim_number}: {self.from_status} → {self.to_status}"
|
||||
|
||||
|
||||
|
||||
class ConsentTemplate(models.Model):
|
||||
"""
|
||||
Templates for consent forms.
|
||||
"""
|
||||
CATEGORY_CHOICES = [
|
||||
('TREATMENT', 'Treatment Consent'),
|
||||
('PROCEDURE', 'Procedure Consent'),
|
||||
('SURGERY', 'Surgical Consent'),
|
||||
('ANESTHESIA', 'Anesthesia Consent'),
|
||||
('RESEARCH', 'Research Consent'),
|
||||
('PRIVACY', 'Privacy Consent'),
|
||||
('FINANCIAL', 'Financial Consent'),
|
||||
('ADMISSION', 'Admission Consent'),
|
||||
('DISCHARGE', 'Discharge Consent'),
|
||||
('OTHER', 'Other'),
|
||||
]
|
||||
|
||||
# Tenant relationship
|
||||
tenant = models.ForeignKey(
|
||||
@ -838,17 +1348,7 @@ class ConsentTemplate(models.Model):
|
||||
)
|
||||
category = models.CharField(
|
||||
max_length=50,
|
||||
choices=[
|
||||
('TREATMENT', 'Treatment Consent'),
|
||||
('PROCEDURE', 'Procedure Consent'),
|
||||
('SURGERY', 'Surgical Consent'),
|
||||
('ANESTHESIA', 'Anesthesia Consent'),
|
||||
('RESEARCH', 'Research Consent'),
|
||||
('PRIVACY', 'Privacy Consent'),
|
||||
('FINANCIAL', 'Financial Consent'),
|
||||
('DISCHARGE', 'Discharge Consent'),
|
||||
('OTHER', 'Other'),
|
||||
],
|
||||
choices=CATEGORY_CHOICES,
|
||||
help_text='Consent category'
|
||||
)
|
||||
|
||||
@ -921,7 +1421,13 @@ class ConsentForm(models.Model):
|
||||
"""
|
||||
Patient consent forms.
|
||||
"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('PENDING', 'Pending'),
|
||||
('SIGNED', 'Signed'),
|
||||
('DECLINED', 'Declined'),
|
||||
('EXPIRED', 'Expired'),
|
||||
('REVOKED', 'Revoked'),
|
||||
]
|
||||
# Patient relationship
|
||||
patient = models.ForeignKey(
|
||||
PatientProfile,
|
||||
@ -947,13 +1453,7 @@ class ConsentForm(models.Model):
|
||||
# Status
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
('PENDING', 'Pending'),
|
||||
('SIGNED', 'Signed'),
|
||||
('DECLINED', 'Declined'),
|
||||
('EXPIRED', 'Expired'),
|
||||
('REVOKED', 'Revoked'),
|
||||
],
|
||||
choices=STATUS_CHOICES,
|
||||
default='PENDING',
|
||||
help_text='Consent status'
|
||||
)
|
||||
@ -1136,6 +1636,24 @@ class PatientNote(models.Model):
|
||||
"""
|
||||
General notes and comments about patients.
|
||||
"""
|
||||
CATEGORY_CHOICES = [
|
||||
('GENERAL', 'General'),
|
||||
('ADMINISTRATIVE', 'Administrative'),
|
||||
('CLINICAL', 'Clinical'),
|
||||
('BILLING', 'Billing'),
|
||||
('INSURANCE', 'Insurance'),
|
||||
('SOCIAL', 'Social'),
|
||||
('DISCHARGE', 'Discharge Planning'),
|
||||
('FOLLOW_UP', 'Follow-up'),
|
||||
('ALERT', 'Alert'),
|
||||
('OTHER', 'Other'),
|
||||
]
|
||||
PRIORITY_CHOICES = [
|
||||
('LOW', 'Low'),
|
||||
('NORMAL', 'Normal'),
|
||||
('HIGH', 'High'),
|
||||
('URGENT', 'Urgent'),
|
||||
]
|
||||
|
||||
# Patient relationship
|
||||
patient = models.ForeignKey(
|
||||
@ -1164,18 +1682,7 @@ class PatientNote(models.Model):
|
||||
# Category
|
||||
category = models.CharField(
|
||||
max_length=50,
|
||||
choices=[
|
||||
('GENERAL', 'General'),
|
||||
('ADMINISTRATIVE', 'Administrative'),
|
||||
('CLINICAL', 'Clinical'),
|
||||
('BILLING', 'Billing'),
|
||||
('INSURANCE', 'Insurance'),
|
||||
('SOCIAL', 'Social'),
|
||||
('DISCHARGE', 'Discharge Planning'),
|
||||
('FOLLOW_UP', 'Follow-up'),
|
||||
('ALERT', 'Alert'),
|
||||
('OTHER', 'Other'),
|
||||
],
|
||||
choices=CATEGORY_CHOICES,
|
||||
default='GENERAL',
|
||||
help_text='Note category'
|
||||
)
|
||||
@ -1183,12 +1690,7 @@ class PatientNote(models.Model):
|
||||
# Priority
|
||||
priority = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
('LOW', 'Low'),
|
||||
('NORMAL', 'Normal'),
|
||||
('HIGH', 'High'),
|
||||
('URGENT', 'Urgent'),
|
||||
],
|
||||
choices=PRIORITY_CHOICES,
|
||||
default='NORMAL',
|
||||
help_text='Note priority'
|
||||
)
|
||||
|
||||
@ -14,14 +14,14 @@ urlpatterns = [
|
||||
path('register/', views.PatientCreateView.as_view(), name='patient_registration'),
|
||||
path('update/<int:pk>/', views.PatientUpdateView.as_view(), name='patient_update'),
|
||||
path('delete/<int:pk>/', views.PatientDeleteView.as_view(), name='patient_delete'),
|
||||
path('consents/', views.ConsentFormListView.as_view(), name='consent_management'),
|
||||
path('consents/', views.ConsentManagementView.as_view(), name='consent_management'),
|
||||
path('consent/<int:pk>/', views.ConsentFormDetailView.as_view(), name='consent_management_detail'),
|
||||
path('emergency-contacts/', views.EmergencyContactListView.as_view(), name='emergency_contact_management'),
|
||||
path('emergency-contact/<int:pk>/', views.EmergencyContactDetailView.as_view(), name='emergency_contact_management_detail'),
|
||||
path('emergency-contacts/delete/<int:pk>/', views.EmergencyContactDeleteView.as_view(), name='emergency_contact_delete'),
|
||||
path('emergency-contacts/update/<int:pk>/', views.EmergencyContactUpdateView.as_view(), name='emergency_contact_update'),
|
||||
path('emergency-contacts/create/<int:pk>/', views.EmergencyContactCreateView.as_view(), name='emergency_contact_create'),
|
||||
path('insurance-info/<int:pk>/', views.InsuranceInfoListView.as_view(), name='insurance_list'),
|
||||
path('insurance-info/', views.InsuranceInfoListView.as_view(), name='insurance_list'),
|
||||
path('insurance-info/<int:pk>/', views.InsuranceInfoDetailView.as_view(), name='insurance_detail'),
|
||||
path('insurance-info/delete/<int:pk>/', views.InsuranceInfoDeleteView.as_view(), name='insurance_delete'),
|
||||
path('insurance-info/update/<int:pk>/', views.InsuranceInfoUpdateView.as_view(), name='insurance_update'),
|
||||
@ -40,10 +40,33 @@ urlpatterns = [
|
||||
path('emergency-contacts/<int:patient_id>/', views.emergency_contacts_list, name='emergency_contacts_list'),
|
||||
path('insurance-info/<int:patient_id>/', views.insurance_info_list, name='insurance_info_list'),
|
||||
path('consent-forms/<int:patient_id>/', views.consent_forms_list, name='consent_forms_list'),
|
||||
path('consent-forms/detail/<int:pk>/', views.ConsentFormDetailView.as_view(), name='consent_form_detail'),
|
||||
path('consent-forms/update/<int:pk>/', views.ConsentFormUpdateView.as_view(), name='consent_form_update'),
|
||||
|
||||
path('patient-notes/<int:patient_id>/', views.patient_notes_list, name='patient_notes_list'),
|
||||
path('add-patient-note/<int:patient_id>/', views.add_patient_note, name='add_patient_note'),
|
||||
path('sign-consent/<int:pk>/', views.sign_consent_form, name='sign_consent_form'),
|
||||
path('appointments/<int:patient_id>/', views.patient_appointment_list, name='patient_appointments'),
|
||||
path('patient-info/<int:pk>/', views.get_patient_info, name='get_patient_info')
|
||||
path('patient-info/<int:pk>/', views.get_patient_info, name='get_patient_info'),
|
||||
path('verify-insurance/<int:pk>/', views.verify_insurance, name='verify_insurance'),
|
||||
path('check-eligibility/<int:pk>/', views.check_eligibility, name='check_eligibility'),
|
||||
path('renew-insurance/<int:pk>/', views.renew_insurance, name='renew_insurance'),
|
||||
path('bulk-renew-insurance/', views.bulk_renew_insurance, name='bulk_renew_insurance'),
|
||||
path('insurance-claims-history/<int:pk>/', views.insurance_claims_history, name='insurance_claims_history'),
|
||||
path('check-primary-insurance/', views.check_primary_insurance, name='check_primary_insurance'),
|
||||
path('validate-policy-number/', views.validate_policy_number, name='validate_policy_number'),
|
||||
path('save-insurance-draft/', views.save_insurance_draft, name='save_insurance_draft'),
|
||||
path('verify-with-provider/', views.verify_with_provider, name='verify_with_provider'),
|
||||
|
||||
# Insurance Claims URLs
|
||||
path('claims/', views.insurance_claims_list, name='insurance_claims_list'),
|
||||
path('claims/dashboard/', views.claims_dashboard, name='claims_dashboard'),
|
||||
path('claims/new/', views.insurance_claim_form, name='insurance_claim_create'),
|
||||
path('claims/<int:claim_id>/', views.insurance_claim_detail, name='insurance_claim_detail'),
|
||||
path('claims/<int:claim_id>/edit/', views.insurance_claim_form, name='insurance_claim_edit'),
|
||||
path('claims/<int:claim_id>/delete/', views.insurance_claim_delete, name='insurance_claim_delete'),
|
||||
path('claims/<int:claim_id>/update-status/', views.update_claim_status, name='update_claim_status'),
|
||||
path('claims/bulk-actions/', views.bulk_claim_actions, name='bulk_claim_actions'),
|
||||
path('patient/<int:patient_id>/insurance/', views.get_patient_insurance, name='get_patient_insurance'),
|
||||
]
|
||||
|
||||
|
||||
2244
patients/views.py
2244
patients/views.py
File diff suppressed because it is too large
Load Diff
BIN
templates/.DS_Store
vendored
BIN
templates/.DS_Store
vendored
Binary file not shown.
BIN
templates/blood_bank/.DS_Store
vendored
Normal file
BIN
templates/blood_bank/.DS_Store
vendored
Normal file
Binary file not shown.
839
templates/blood_bank/crossmatch/crossmatch_form.html
Normal file
839
templates/blood_bank/crossmatch/crossmatch_form.html
Normal file
@ -0,0 +1,839 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Crossmatch Test - {{ blood_unit.unit_number }} & {{ patient.full_name }}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link href="{% static 'assets/plugins/select2/dist/css/select2.min.css' %}" rel="stylesheet" />
|
||||
<style>
|
||||
.form-section {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
border-left: 4px solid #007bff;
|
||||
}
|
||||
|
||||
.form-section h5 {
|
||||
color: #007bff;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.required-field {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.patient-info {
|
||||
background: #d4edda;
|
||||
border-left: 4px solid #28a745;
|
||||
}
|
||||
|
||||
.unit-info {
|
||||
background: #d1ecf1;
|
||||
border-left: 4px solid #17a2b8;
|
||||
}
|
||||
|
||||
.compatibility-info {
|
||||
background: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
}
|
||||
|
||||
.crossmatch-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.test-panel {
|
||||
background: white;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.test-panel h6 {
|
||||
color: #495057;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.test-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.result-indicator {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.compatible { background-color: #28a745; }
|
||||
.incompatible { background-color: #dc3545; }
|
||||
.pending { background-color: #6c757d; }
|
||||
.weak { background-color: #ffc107; }
|
||||
|
||||
.temperature-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 15px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.temperature-test {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.compatibility-summary {
|
||||
background: #e2e3e5;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.safety-warning {
|
||||
background: #f8d7da;
|
||||
border: 2px solid #dc3545;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-top: 15px;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.8; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content -->
|
||||
<!-- BEGIN breadcrumb -->
|
||||
<ol class="breadcrumb float-xl-end">
|
||||
<li class="breadcrumb-item"><a href="{% url 'core:dashboard' %}">Home</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'blood_bank:dashboard' %}">Blood Bank</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'blood_bank:blood_unit_list' %}">Blood Units</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'blood_bank:blood_unit_detail' blood_unit.id %}">{{ blood_unit.unit_number }}</a></li>
|
||||
<li class="breadcrumb-item active">Crossmatch</li>
|
||||
</ol>
|
||||
<!-- END breadcrumb -->
|
||||
|
||||
<!-- BEGIN page-header -->
|
||||
<h1 class="page-header">Crossmatch Test <small>compatibility verification</small></h1>
|
||||
<!-- END page-header -->
|
||||
|
||||
<!-- BEGIN panel -->
|
||||
<div class="panel panel-inverse">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<i class="fa fa-exchange-alt"></i> Crossmatch Compatibility Test
|
||||
</h4>
|
||||
<div class="panel-heading-btn">
|
||||
<span class="badge bg-info">Unit: {{ blood_unit.unit_number }}</span>
|
||||
<span class="badge bg-success">Patient: {{ patient.patient_id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form method="post" id="crossmatchForm">
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- BEGIN patient information -->
|
||||
<div class="form-section patient-info">
|
||||
<h5><i class="fa fa-user-injured"></i> Patient Information</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<table class="table table-borderless mb-0">
|
||||
<tr>
|
||||
<td class="fw-bold">Name:</td>
|
||||
<td>{{ patient.full_name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Patient ID:</td>
|
||||
<td>{{ patient.patient_id }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Blood Group:</td>
|
||||
<td>
|
||||
<span class="badge bg-primary">{{ patient.blood_group.display_name|default:"Unknown" }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Age:</td>
|
||||
<td>{{ patient.age }} years</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<table class="table table-borderless mb-0">
|
||||
<tr>
|
||||
<td class="fw-bold">Previous Transfusions:</td>
|
||||
<td>{{ patient.transfusion_history.count }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Pregnancies:</td>
|
||||
<td>{{ patient.pregnancy_history|default:"N/A" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Known Antibodies:</td>
|
||||
<td>{{ patient.known_antibodies|default:"None" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Last Crossmatch:</td>
|
||||
<td>{{ patient.last_crossmatch_date|date:"M d, Y"|default:"Never" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END patient information -->
|
||||
|
||||
<!-- BEGIN blood unit information -->
|
||||
<div class="form-section unit-info">
|
||||
<h5><i class="fa fa-tint"></i> Blood Unit Information</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<table class="table table-borderless mb-0">
|
||||
<tr>
|
||||
<td class="fw-bold">Unit Number:</td>
|
||||
<td>{{ blood_unit.unit_number }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Blood Group:</td>
|
||||
<td><span class="badge bg-primary">{{ blood_unit.blood_group.display_name }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Component:</td>
|
||||
<td>{{ blood_unit.component.get_name_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Volume:</td>
|
||||
<td>{{ blood_unit.volume_ml }} ml</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<table class="table table-borderless mb-0">
|
||||
<tr>
|
||||
<td class="fw-bold">Donor:</td>
|
||||
<td>{{ blood_unit.donor.full_name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Collection Date:</td>
|
||||
<td>{{ blood_unit.collection_date|date:"M d, Y" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Expiry Date:</td>
|
||||
<td>{{ blood_unit.expiry_date|date:"M d, Y" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Test Status:</td>
|
||||
<td>
|
||||
{% if blood_unit.all_tests_passed %}
|
||||
<span class="badge bg-success">All Tests Passed</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning">Tests Pending</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END blood unit information -->
|
||||
|
||||
<!-- BEGIN test information -->
|
||||
<div class="form-section">
|
||||
<h5><i class="fa fa-flask"></i> Test Information</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="form-group">
|
||||
<label for="testDate" class="form-label">
|
||||
Test Date <span class="required-field">*</span>
|
||||
</label>
|
||||
<input type="datetime-local" class="form-control" id="testDate" name="test_date" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-group">
|
||||
<label for="testedBy" class="form-label">
|
||||
Tested By <span class="required-field">*</span>
|
||||
</label>
|
||||
<select class="form-select" id="testedBy" name="tested_by" required>
|
||||
<option value="">Select technician...</option>
|
||||
{% for staff in lab_staff %}
|
||||
<option value="{{ staff.id }}">{{ staff.get_full_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-group">
|
||||
<label for="testMethod" class="form-label">
|
||||
Test Method <span class="required-field">*</span>
|
||||
</label>
|
||||
<select class="form-select" id="testMethod" name="test_method" required>
|
||||
<option value="">Select method...</option>
|
||||
<option value="tube">Tube Method</option>
|
||||
<option value="gel">Gel Card</option>
|
||||
<option value="solid_phase">Solid Phase</option>
|
||||
<option value="automated">Automated System</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END test information -->
|
||||
|
||||
<!-- BEGIN ABO/Rh compatibility check -->
|
||||
<div class="form-section compatibility-info">
|
||||
<h5><i class="fa fa-shield-alt"></i> ABO/Rh Compatibility Check</h5>
|
||||
<div class="compatibility-summary">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>Patient Blood Group</h6>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<span class="badge bg-primary fs-5">{{ patient.blood_group.display_name|default:"Unknown" }}</span>
|
||||
{% if patient.blood_group.display_name == "Unknown" %}
|
||||
<span class="text-danger">⚠️ Patient blood group must be confirmed</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>Donor Blood Group</h6>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<span class="badge bg-info fs-5">{{ blood_unit.blood_group.display_name }}</span>
|
||||
<div id="aboCompatibility">
|
||||
<!-- Will be populated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END ABO/Rh compatibility check -->
|
||||
|
||||
<!-- BEGIN crossmatch testing -->
|
||||
<div class="form-section">
|
||||
<h5><i class="fa fa-microscope"></i> Crossmatch Testing</h5>
|
||||
<div class="crossmatch-grid">
|
||||
<!-- Major Crossmatch -->
|
||||
<div class="test-panel">
|
||||
<h6><i class="fa fa-arrow-right"></i> Major Crossmatch</h6>
|
||||
<p class="small text-muted">Patient serum + Donor red cells</p>
|
||||
|
||||
<div class="temperature-grid">
|
||||
<div class="temperature-test">
|
||||
<label class="form-label">Room Temp</label>
|
||||
<div class="test-result">
|
||||
<select class="form-select form-select-sm" name="major_room_temp" required>
|
||||
<option value="">Result</option>
|
||||
<option value="compatible">Compatible</option>
|
||||
<option value="incompatible">Incompatible</option>
|
||||
<option value="weak">Weak Reaction</option>
|
||||
</select>
|
||||
<span class="result-indicator pending" id="major_room_temp_indicator"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="temperature-test">
|
||||
<label class="form-label">37°C</label>
|
||||
<div class="test-result">
|
||||
<select class="form-select form-select-sm" name="major_37c" required>
|
||||
<option value="">Result</option>
|
||||
<option value="compatible">Compatible</option>
|
||||
<option value="incompatible">Incompatible</option>
|
||||
<option value="weak">Weak Reaction</option>
|
||||
</select>
|
||||
<span class="result-indicator pending" id="major_37c_indicator"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="temperature-test">
|
||||
<label class="form-label">AHG Phase</label>
|
||||
<div class="test-result">
|
||||
<select class="form-select form-select-sm" name="major_ahg" required>
|
||||
<option value="">Result</option>
|
||||
<option value="compatible">Compatible</option>
|
||||
<option value="incompatible">Incompatible</option>
|
||||
<option value="weak">Weak Reaction</option>
|
||||
</select>
|
||||
<span class="result-indicator pending" id="major_ahg_indicator"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Minor Crossmatch -->
|
||||
<div class="test-panel">
|
||||
<h6><i class="fa fa-arrow-left"></i> Minor Crossmatch</h6>
|
||||
<p class="small text-muted">Donor serum + Patient red cells</p>
|
||||
|
||||
<div class="temperature-grid">
|
||||
<div class="temperature-test">
|
||||
<label class="form-label">Room Temp</label>
|
||||
<div class="test-result">
|
||||
<select class="form-select form-select-sm" name="minor_room_temp">
|
||||
<option value="">Result</option>
|
||||
<option value="compatible">Compatible</option>
|
||||
<option value="incompatible">Incompatible</option>
|
||||
<option value="weak">Weak Reaction</option>
|
||||
</select>
|
||||
<span class="result-indicator pending" id="minor_room_temp_indicator"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="temperature-test">
|
||||
<label class="form-label">37°C</label>
|
||||
<div class="test-result">
|
||||
<select class="form-select form-select-sm" name="minor_37c">
|
||||
<option value="">Result</option>
|
||||
<option value="compatible">Compatible</option>
|
||||
<option value="incompatible">Incompatible</option>
|
||||
<option value="weak">Weak Reaction</option>
|
||||
</select>
|
||||
<span class="result-indicator pending" id="minor_37c_indicator"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="temperature-test">
|
||||
<label class="form-label">AHG Phase</label>
|
||||
<div class="test-result">
|
||||
<select class="form-select form-select-sm" name="minor_ahg">
|
||||
<option value="">Result</option>
|
||||
<option value="compatible">Compatible</option>
|
||||
<option value="incompatible">Incompatible</option>
|
||||
<option value="weak">Weak Reaction</option>
|
||||
</select>
|
||||
<span class="result-indicator pending" id="minor_ahg_indicator"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Control Tests -->
|
||||
<div class="test-panel">
|
||||
<h6><i class="fa fa-check-circle"></i> Control Tests</h6>
|
||||
<p class="small text-muted">Quality control verification</p>
|
||||
|
||||
<div class="test-result">
|
||||
<label class="form-label">Positive Control</label>
|
||||
<select class="form-select form-select-sm" name="positive_control" required>
|
||||
<option value="">Result</option>
|
||||
<option value="positive">Positive</option>
|
||||
<option value="negative">Negative</option>
|
||||
<option value="weak">Weak</option>
|
||||
</select>
|
||||
<span class="result-indicator pending" id="positive_control_indicator"></span>
|
||||
</div>
|
||||
|
||||
<div class="test-result">
|
||||
<label class="form-label">Negative Control</label>
|
||||
<select class="form-select form-select-sm" name="negative_control" required>
|
||||
<option value="">Result</option>
|
||||
<option value="positive">Positive</option>
|
||||
<option value="negative">Negative</option>
|
||||
<option value="weak">Weak</option>
|
||||
</select>
|
||||
<span class="result-indicator pending" id="negative_control_indicator"></span>
|
||||
</div>
|
||||
|
||||
<div class="test-result">
|
||||
<label class="form-label">Auto Control</label>
|
||||
<select class="form-select form-select-sm" name="auto_control" required>
|
||||
<option value="">Result</option>
|
||||
<option value="positive">Positive</option>
|
||||
<option value="negative">Negative</option>
|
||||
<option value="weak">Weak</option>
|
||||
</select>
|
||||
<span class="result-indicator pending" id="auto_control_indicator"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END crossmatch testing -->
|
||||
|
||||
<!-- BEGIN antibody screening -->
|
||||
<div class="form-section">
|
||||
<h5><i class="fa fa-search"></i> Antibody Screening</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="antibodyScreen" class="form-label">
|
||||
Antibody Screen Result <span class="required-field">*</span>
|
||||
</label>
|
||||
<select class="form-select" id="antibodyScreen" name="antibody_screen" required>
|
||||
<option value="">Select result...</option>
|
||||
<option value="negative">Negative</option>
|
||||
<option value="positive">Positive</option>
|
||||
<option value="indeterminate">Indeterminate</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="antibodyIdentification" class="form-label">
|
||||
Antibody Identification
|
||||
</label>
|
||||
<input type="text" class="form-control" id="antibodyIdentification" name="antibody_identification"
|
||||
placeholder="If positive, specify antibodies identified">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END antibody screening -->
|
||||
|
||||
<!-- BEGIN test notes -->
|
||||
<div class="form-section">
|
||||
<h5><i class="fa fa-clipboard"></i> Test Notes & Observations</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="form-group">
|
||||
<label for="testNotes" class="form-label">Test Notes</label>
|
||||
<textarea class="form-control" id="testNotes" name="test_notes" rows="4"
|
||||
placeholder="Record any observations, reaction strengths, or additional notes..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="reagentLots" class="form-label">Reagent Lot Numbers</label>
|
||||
<input type="text" class="form-control" id="reagentLots" name="reagent_lots"
|
||||
placeholder="AHG, enhancement media lot numbers">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="equipmentUsed" class="form-label">Equipment Used</label>
|
||||
<input type="text" class="form-control" id="equipmentUsed" name="equipment_used"
|
||||
placeholder="Centrifuge, incubator details">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END test notes -->
|
||||
|
||||
<!-- BEGIN final result -->
|
||||
<div class="form-section">
|
||||
<h5><i class="fa fa-clipboard-check"></i> Final Crossmatch Result</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="finalResult" class="form-label">
|
||||
Overall Compatibility <span class="required-field">*</span>
|
||||
</label>
|
||||
<select class="form-select" id="finalResult" name="final_result" required>
|
||||
<option value="">Select final result...</option>
|
||||
<option value="compatible">Compatible</option>
|
||||
<option value="incompatible">Incompatible</option>
|
||||
<option value="conditional">Conditional (with restrictions)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="restrictions" class="form-label">Restrictions/Conditions</label>
|
||||
<input type="text" class="form-control" id="restrictions" name="restrictions"
|
||||
placeholder="Any special conditions or restrictions">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Result summary -->
|
||||
<div id="resultSummary" class="mt-3">
|
||||
<!-- Will be populated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- END final result -->
|
||||
|
||||
<!-- BEGIN form actions -->
|
||||
<div class="d-flex justify-content-between mt-4">
|
||||
<a href="{% url 'blood_bank:blood_unit_detail' blood_unit.id %}" class="btn btn-secondary">
|
||||
<i class="fa fa-arrow-left"></i> Cancel
|
||||
</a>
|
||||
<div>
|
||||
<button type="button" class="btn btn-info" onclick="validateCrossmatch()">
|
||||
<i class="fa fa-check"></i> Validate Results
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" id="submitBtn" disabled>
|
||||
<i class="fa fa-save"></i> Save Crossmatch Results
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END form actions -->
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END panel -->
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{% static 'assets/plugins/select2/dist/js/select2.min.js' %}"></script>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Initialize Select2
|
||||
$('#testedBy, #testMethod').select2({
|
||||
theme: 'bootstrap-5'
|
||||
});
|
||||
|
||||
// Set default test date to now
|
||||
var now = new Date();
|
||||
$('#testDate').val(now.toISOString().slice(0, 16));
|
||||
|
||||
// Check ABO compatibility on load
|
||||
checkABOCompatibility();
|
||||
|
||||
// Update result indicators when test results change
|
||||
$('select[name$="_temp"], select[name$="_37c"], select[name$="_ahg"], select[name$="_control"]').on('change', function() {
|
||||
updateResultIndicator(this);
|
||||
updateResultSummary();
|
||||
validateCrossmatch();
|
||||
});
|
||||
|
||||
$('#finalResult').on('change', function() {
|
||||
updateResultSummary();
|
||||
validateCrossmatch();
|
||||
});
|
||||
|
||||
// Form validation
|
||||
$('#crossmatchForm').on('submit', function(e) {
|
||||
if (!validateCrossmatch()) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function checkABOCompatibility() {
|
||||
var patientBloodGroup = "{{ patient.blood_group.display_name|default:'Unknown' }}";
|
||||
var donorBloodGroup = "{{ blood_unit.blood_group.display_name }}";
|
||||
|
||||
if (patientBloodGroup === "Unknown") {
|
||||
$('#aboCompatibility').html('<span class="text-danger">⚠️ Patient blood group unknown</span>');
|
||||
return;
|
||||
}
|
||||
|
||||
// Simple ABO compatibility check
|
||||
var compatible = false;
|
||||
var message = "";
|
||||
|
||||
if (patientBloodGroup === donorBloodGroup) {
|
||||
compatible = true;
|
||||
message = "✅ Identical blood groups";
|
||||
} else if (donorBloodGroup === "O-") {
|
||||
compatible = true;
|
||||
message = "✅ Universal donor";
|
||||
} else if (patientBloodGroup === "AB+" && donorBloodGroup.includes("AB")) {
|
||||
compatible = true;
|
||||
message = "✅ Compatible";
|
||||
} else {
|
||||
compatible = false;
|
||||
message = "⚠️ Potential incompatibility";
|
||||
}
|
||||
|
||||
var alertClass = compatible ? 'text-success' : 'text-warning';
|
||||
$('#aboCompatibility').html('<span class="' + alertClass + '">' + message + '</span>');
|
||||
}
|
||||
|
||||
function updateResultIndicator(selectElement) {
|
||||
var result = $(selectElement).val();
|
||||
var indicatorId = $(selectElement).attr('name') + '_indicator';
|
||||
var indicator = $('#' + indicatorId);
|
||||
|
||||
indicator.removeClass('compatible incompatible pending weak');
|
||||
|
||||
switch(result) {
|
||||
case 'compatible':
|
||||
case 'negative':
|
||||
indicator.addClass('compatible');
|
||||
break;
|
||||
case 'incompatible':
|
||||
case 'positive':
|
||||
indicator.addClass('incompatible');
|
||||
break;
|
||||
case 'weak':
|
||||
indicator.addClass('weak');
|
||||
break;
|
||||
default:
|
||||
indicator.addClass('pending');
|
||||
}
|
||||
}
|
||||
|
||||
function updateResultSummary() {
|
||||
var majorResults = ['major_room_temp', 'major_37c', 'major_ahg'];
|
||||
var minorResults = ['minor_room_temp', 'minor_37c', 'minor_ahg'];
|
||||
var controlResults = ['positive_control', 'negative_control', 'auto_control'];
|
||||
|
||||
var majorCompatible = majorResults.every(function(name) {
|
||||
var value = $('select[name="' + name + '"]').val();
|
||||
return value === 'compatible';
|
||||
});
|
||||
|
||||
var majorIncompatible = majorResults.some(function(name) {
|
||||
var value = $('select[name="' + name + '"]').val();
|
||||
return value === 'incompatible';
|
||||
});
|
||||
|
||||
var controlsValid =
|
||||
$('select[name="positive_control"]').val() === 'positive' &&
|
||||
$('select[name="negative_control"]').val() === 'negative' &&
|
||||
$('select[name="auto_control"]').val() === 'negative';
|
||||
|
||||
var finalResult = $('#finalResult').val();
|
||||
|
||||
var summaryHtml = '<div class="row">';
|
||||
|
||||
// Major crossmatch summary
|
||||
if (majorCompatible) {
|
||||
summaryHtml += '<div class="col-md-4"><div class="alert alert-success"><strong>Major Crossmatch:</strong> Compatible</div></div>';
|
||||
} else if (majorIncompatible) {
|
||||
summaryHtml += '<div class="col-md-4"><div class="alert alert-danger"><strong>Major Crossmatch:</strong> Incompatible</div></div>';
|
||||
} else {
|
||||
summaryHtml += '<div class="col-md-4"><div class="alert alert-info"><strong>Major Crossmatch:</strong> Pending</div></div>';
|
||||
}
|
||||
|
||||
// Controls summary
|
||||
if (controlsValid) {
|
||||
summaryHtml += '<div class="col-md-4"><div class="alert alert-success"><strong>Controls:</strong> Valid</div></div>';
|
||||
} else {
|
||||
summaryHtml += '<div class="col-md-4"><div class="alert alert-warning"><strong>Controls:</strong> Check Required</div></div>';
|
||||
}
|
||||
|
||||
// Final result
|
||||
if (finalResult === 'compatible') {
|
||||
summaryHtml += '<div class="col-md-4"><div class="alert alert-success"><strong>Final Result:</strong> Compatible</div></div>';
|
||||
} else if (finalResult === 'incompatible') {
|
||||
summaryHtml += '<div class="col-md-4"><div class="alert alert-danger"><strong>Final Result:</strong> Incompatible</div></div>';
|
||||
} else if (finalResult === 'conditional') {
|
||||
summaryHtml += '<div class="col-md-4"><div class="alert alert-warning"><strong>Final Result:</strong> Conditional</div></div>';
|
||||
} else {
|
||||
summaryHtml += '<div class="col-md-4"><div class="alert alert-info"><strong>Final Result:</strong> Pending</div></div>';
|
||||
}
|
||||
|
||||
summaryHtml += '</div>';
|
||||
|
||||
// Add safety warnings
|
||||
if (majorIncompatible || finalResult === 'incompatible') {
|
||||
summaryHtml += '<div class="safety-warning">';
|
||||
summaryHtml += '<h6><i class="fa fa-exclamation-triangle"></i> INCOMPATIBLE CROSSMATCH DETECTED</h6>';
|
||||
summaryHtml += '<p><strong>This blood unit MUST NOT be transfused to this patient.</strong></p>';
|
||||
summaryHtml += '<p>Immediate investigation and alternative blood unit selection required.</p>';
|
||||
summaryHtml += '</div>';
|
||||
}
|
||||
|
||||
$('#resultSummary').html(summaryHtml);
|
||||
}
|
||||
|
||||
function validateCrossmatch() {
|
||||
var errors = [];
|
||||
|
||||
// Check required fields
|
||||
if (!$('#testDate').val()) {
|
||||
errors.push('Please enter test date');
|
||||
}
|
||||
|
||||
if (!$('#testedBy').val()) {
|
||||
errors.push('Please select who performed the test');
|
||||
}
|
||||
|
||||
if (!$('#testMethod').val()) {
|
||||
errors.push('Please select test method');
|
||||
}
|
||||
|
||||
// Check major crossmatch results
|
||||
var majorResults = ['major_room_temp', 'major_37c', 'major_ahg'];
|
||||
var missingMajor = majorResults.filter(function(name) {
|
||||
return !$('select[name="' + name + '"]').val();
|
||||
});
|
||||
|
||||
if (missingMajor.length > 0) {
|
||||
errors.push('Please complete all major crossmatch results');
|
||||
}
|
||||
|
||||
// Check control results
|
||||
var controlResults = ['positive_control', 'negative_control', 'auto_control'];
|
||||
var missingControls = controlResults.filter(function(name) {
|
||||
return !$('select[name="' + name + '"]').val();
|
||||
});
|
||||
|
||||
if (missingControls.length > 0) {
|
||||
errors.push('Please complete all control test results');
|
||||
}
|
||||
|
||||
// Check antibody screen
|
||||
if (!$('#antibodyScreen').val()) {
|
||||
errors.push('Please enter antibody screen result');
|
||||
}
|
||||
|
||||
// Check final result
|
||||
if (!$('#finalResult').val()) {
|
||||
errors.push('Please select final compatibility result');
|
||||
}
|
||||
|
||||
// Validate control results
|
||||
var positiveControl = $('select[name="positive_control"]').val();
|
||||
var negativeControl = $('select[name="negative_control"]').val();
|
||||
var autoControl = $('select[name="auto_control"]').val();
|
||||
|
||||
if (positiveControl === 'negative') {
|
||||
errors.push('Positive control should be positive - check reagents');
|
||||
}
|
||||
|
||||
if (negativeControl === 'positive') {
|
||||
errors.push('Negative control should be negative - check for contamination');
|
||||
}
|
||||
|
||||
if (autoControl === 'positive') {
|
||||
errors.push('Auto control positive - patient may have autoantibodies');
|
||||
}
|
||||
|
||||
// Enable/disable submit button
|
||||
$('#submitBtn').prop('disabled', errors.length > 0);
|
||||
|
||||
if (errors.length > 0) {
|
||||
if (errors.length < 8) { // Only show errors if validation was explicitly requested
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Validation Errors',
|
||||
html: '<ul class="text-start"><li>' + errors.join('</li><li>') + '</li></ul>',
|
||||
confirmButtonText: 'OK'
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for incompatibility
|
||||
var finalResult = $('#finalResult').val();
|
||||
if (finalResult === 'incompatible') {
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Incompatible Crossmatch',
|
||||
html: '<strong>This crossmatch shows incompatibility.</strong><br><br>This blood unit must not be used for this patient. Please select an alternative unit.',
|
||||
confirmButtonText: 'I Understand'
|
||||
});
|
||||
} else if (finalResult === 'compatible') {
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: 'Compatible Crossmatch',
|
||||
text: 'Crossmatch is compatible. Blood unit cleared for transfusion.',
|
||||
timer: 1500,
|
||||
showConfirmButton: false
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
364
templates/blood_bank/dashboard.html
Normal file
364
templates/blood_bank/dashboard.html
Normal file
@ -0,0 +1,364 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Blood Bank Dashboard{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link href="{% static 'assets/plugins/datatables.net-bs5/css/dataTables.bootstrap5.min.css' %}" rel="stylesheet" />
|
||||
<link href="{% static 'assets/plugins/datatables.net-responsive-bs5/css/responsive.bootstrap5.min.css' %}" rel="stylesheet" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- BEGIN breadcrumb -->
|
||||
<ol class="breadcrumb float-xl-end">
|
||||
<li class="breadcrumb-item"><a href="{% url 'core:dashboard' %}">Home</a></li>
|
||||
<li class="breadcrumb-item active">Blood Bank Dashboard</li>
|
||||
</ol>
|
||||
<!-- END breadcrumb -->
|
||||
|
||||
<!-- BEGIN page-header -->
|
||||
<h1 class="page-header">Blood Bank Dashboard <small>overview of blood bank operations</small></h1>
|
||||
<!-- END page-header -->
|
||||
|
||||
<!-- BEGIN row -->
|
||||
<div class="row">
|
||||
<!-- BEGIN col-3 -->
|
||||
<div class="col-xl-3 col-md-6">
|
||||
<div class="widget widget-stats bg-blue">
|
||||
<div class="stats-icon"><i class="fa fa-users"></i></div>
|
||||
<div class="stats-info">
|
||||
<h4>ACTIVE DONORS</h4>
|
||||
<p>{{ total_donors }}</p>
|
||||
</div>
|
||||
<div class="stats-link">
|
||||
<a href="{% url 'blood_bank:donor_list' %}">View Detail <i class="fa fa-arrow-alt-circle-right"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END col-3 -->
|
||||
|
||||
<!-- BEGIN col-3 -->
|
||||
<div class="col-xl-3 col-md-6">
|
||||
<div class="widget widget-stats bg-info">
|
||||
<div class="stats-icon"><i class="fa fa-tint"></i></div>
|
||||
<div class="stats-info">
|
||||
<h4>AVAILABLE UNITS</h4>
|
||||
<p>{{ total_units }}</p>
|
||||
</div>
|
||||
<div class="stats-link">
|
||||
<a href="{% url 'blood_bank:blood_unit_list' %}">View Detail <i class="fa fa-arrow-alt-circle-right"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END col-3 -->
|
||||
|
||||
<!-- BEGIN col-3 -->
|
||||
<div class="col-xl-3 col-md-6">
|
||||
<div class="widget widget-stats bg-orange">
|
||||
<div class="stats-icon"><i class="fa fa-clipboard-list"></i></div>
|
||||
<div class="stats-info">
|
||||
<h4>PENDING REQUESTS</h4>
|
||||
<p>{{ pending_requests }}</p>
|
||||
</div>
|
||||
<div class="stats-link">
|
||||
<a href="{% url 'blood_bank:blood_request_list' %}">View Detail <i class="fa fa-arrow-alt-circle-right"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END col-3 -->
|
||||
|
||||
<!-- BEGIN col-3 -->
|
||||
<div class="col-xl-3 col-md-6">
|
||||
<div class="widget widget-stats bg-red">
|
||||
<div class="stats-icon"><i class="fa fa-exclamation-triangle"></i></div>
|
||||
<div class="stats-info">
|
||||
<h4>EXPIRING SOON</h4>
|
||||
<p>{{ expiring_soon }}</p>
|
||||
</div>
|
||||
<div class="stats-link">
|
||||
<a href="{% url 'blood_bank:inventory_overview' %}">View Detail <i class="fa fa-arrow-alt-circle-right"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END col-3 -->
|
||||
</div>
|
||||
<!-- END row -->
|
||||
|
||||
<!-- BEGIN row -->
|
||||
<div class="row">
|
||||
<!-- BEGIN col-8 -->
|
||||
<div class="col-xl-8">
|
||||
<!-- BEGIN panel -->
|
||||
<div class="panel panel-inverse">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">Blood Group Distribution</h4>
|
||||
<div class="panel-heading-btn">
|
||||
<a href="javascript:;" class="btn btn-xs btn-icon btn-default" data-toggle="panel-expand"><i class="fa fa-expand"></i></a>
|
||||
<a href="javascript:;" class="btn btn-xs btn-icon btn-success" data-toggle="panel-reload"><i class="fa fa-redo"></i></a>
|
||||
<a href="javascript:;" class="btn btn-xs btn-icon btn-warning" data-toggle="panel-collapse"><i class="fa fa-minus"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Blood Group</th>
|
||||
<th>Available Units</th>
|
||||
<th>Percentage</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for stat in blood_group_stats %}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="badge bg-primary">
|
||||
{{ stat.blood_group__abo_type }}{% if stat.blood_group__rh_factor == 'positive' %}+{% else %}-{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ stat.count }}</td>
|
||||
<td>
|
||||
{% widthratio stat.count total_units 100 %}%
|
||||
</td>
|
||||
<td>
|
||||
{% if stat.count >= 10 %}
|
||||
<span class="badge bg-success">Adequate</span>
|
||||
{% elif stat.count >= 5 %}
|
||||
<span class="badge bg-warning">Low</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">Critical</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center">No blood units available</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END panel -->
|
||||
</div>
|
||||
<!-- END col-8 -->
|
||||
|
||||
<!-- BEGIN col-4 -->
|
||||
<div class="col-xl-4">
|
||||
<!-- BEGIN panel -->
|
||||
<div class="panel panel-inverse">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">Quick Stats</h4>
|
||||
<div class="panel-heading-btn">
|
||||
<a href="javascript:;" class="btn btn-xs btn-icon btn-default" data-toggle="panel-expand"><i class="fa fa-expand"></i></a>
|
||||
<a href="javascript:;" class="btn btn-xs btn-icon btn-success" data-toggle="panel-reload"><i class="fa fa-redo"></i></a>
|
||||
<a href="javascript:;" class="btn btn-xs btn-icon btn-warning" data-toggle="panel-collapse"><i class="fa fa-minus"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="list-group">
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i class="fa fa-tint text-info me-2"></i>
|
||||
Recent Donations (7 days)
|
||||
</div>
|
||||
<span class="badge bg-info rounded-pill">{{ recent_donations }}</span>
|
||||
</div>
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i class="fa fa-heartbeat text-success me-2"></i>
|
||||
Active Transfusions
|
||||
</div>
|
||||
<span class="badge bg-success rounded-pill">{{ active_transfusions }}</span>
|
||||
</div>
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i class="fa fa-clock text-warning me-2"></i>
|
||||
Expiring This Week
|
||||
</div>
|
||||
<span class="badge bg-warning rounded-pill">{{ expiring_soon }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END panel -->
|
||||
</div>
|
||||
<!-- END col-4 -->
|
||||
</div>
|
||||
<!-- END row -->
|
||||
|
||||
<!-- BEGIN row -->
|
||||
<div class="row">
|
||||
<!-- BEGIN col-6 -->
|
||||
<div class="col-xl-6">
|
||||
<!-- BEGIN panel -->
|
||||
<div class="panel panel-inverse">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">Recent Blood Units</h4>
|
||||
<div class="panel-heading-btn">
|
||||
<a href="{% url 'blood_bank:blood_unit_list' %}" class="btn btn-xs btn-primary">View All</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Unit Number</th>
|
||||
<th>Donor</th>
|
||||
<th>Component</th>
|
||||
<th>Blood Group</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for unit in recent_units %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{% url 'blood_bank:blood_unit_detail' unit.id %}">
|
||||
{{ unit.unit_number }}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ unit.donor.full_name }}</td>
|
||||
<td>{{ unit.component.get_name_display }}</td>
|
||||
<td>
|
||||
<span class="badge bg-primary">{{ unit.blood_group.display_name }}</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if unit.status == 'available' %}
|
||||
<span class="badge bg-success">{{ unit.get_status_display }}</span>
|
||||
{% elif unit.status == 'expired' %}
|
||||
<span class="badge bg-danger">{{ unit.get_status_display }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-info">{{ unit.get_status_display }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center">No recent blood units</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END panel -->
|
||||
</div>
|
||||
<!-- END col-6 -->
|
||||
|
||||
<!-- BEGIN col-6 -->
|
||||
<div class="col-xl-6">
|
||||
<!-- BEGIN panel -->
|
||||
<div class="panel panel-inverse">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">Urgent Blood Requests</h4>
|
||||
<div class="panel-heading-btn">
|
||||
<a href="{% url 'blood_bank:blood_request_list' %}" class="btn btn-xs btn-primary">View All</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Request #</th>
|
||||
<th>Patient</th>
|
||||
<th>Component</th>
|
||||
<th>Units</th>
|
||||
<th>Urgency</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for request in urgent_requests %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{% url 'blood_bank:blood_request_detail' request.id %}">
|
||||
{{ request.request_number }}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ request.patient.full_name }}</td>
|
||||
<td>{{ request.component_requested.get_name_display }}</td>
|
||||
<td>{{ request.units_requested }}</td>
|
||||
<td>
|
||||
<span class="badge bg-danger">{{ request.get_urgency_display }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center">No urgent requests</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END panel -->
|
||||
</div>
|
||||
<!-- END col-6 -->
|
||||
</div>
|
||||
<!-- END row -->
|
||||
|
||||
<!-- BEGIN row -->
|
||||
<div class="row">
|
||||
<div class="col-xl-12">
|
||||
<!-- BEGIN panel -->
|
||||
<div class="panel panel-inverse">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">Quick Actions</h4>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<a href="{% url 'blood_bank:donor_create' %}" class="btn btn-primary btn-lg w-100">
|
||||
<i class="fa fa-user-plus fa-2x d-block mb-2"></i>
|
||||
Register Donor
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<a href="{% url 'blood_bank:blood_unit_create' %}" class="btn btn-info btn-lg w-100">
|
||||
<i class="fa fa-tint fa-2x d-block mb-2"></i>
|
||||
Register Blood Unit
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<a href="{% url 'blood_bank:blood_request_create' %}" class="btn btn-warning btn-lg w-100">
|
||||
<i class="fa fa-clipboard-list fa-2x d-block mb-2"></i>
|
||||
Create Request
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<a href="{% url 'blood_bank:inventory_overview' %}" class="btn btn-success btn-lg w-100">
|
||||
<i class="fa fa-boxes fa-2x d-block mb-2"></i>
|
||||
View Inventory
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END panel -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- END row -->
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{% static 'assets/plugins/datatables.net/js/jquery.dataTables.min.js' %}"></script>
|
||||
<script src="{% static 'assets/plugins/datatables.net-bs5/js/dataTables.bootstrap5.min.js' %}"></script>
|
||||
<script src="{% static 'assets/plugins/datatables.net-responsive/js/dataTables.responsive.min.js' %}"></script>
|
||||
<script src="{% static 'assets/plugins/datatables.net-responsive-bs5/js/responsive.bootstrap5.min.js' %}"></script>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Auto-refresh dashboard every 5 minutes
|
||||
setInterval(function() {
|
||||
location.reload();
|
||||
}, 300000);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
279
templates/blood_bank/donors/donor_confirm_delete.html
Normal file
279
templates/blood_bank/donors/donor_confirm_delete.html
Normal file
@ -0,0 +1,279 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Delete Donor - {{ donor.full_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- BEGIN breadcrumb -->
|
||||
<ol class="breadcrumb float-xl-end">
|
||||
<li class="breadcrumb-item"><a href="{% url 'core:dashboard' %}">Home</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'blood_bank:dashboard' %}">Blood Bank</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'blood_bank:donor_list' %}">Donors</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'blood_bank:donor_detail' donor.id %}">{{ donor.donor_id }}</a></li>
|
||||
<li class="breadcrumb-item active">Delete</li>
|
||||
</ol>
|
||||
<!-- END breadcrumb -->
|
||||
|
||||
<!-- BEGIN page-header -->
|
||||
<h1 class="page-header">Delete Donor <small>{{ donor.full_name }}</small></h1>
|
||||
<!-- END page-header -->
|
||||
|
||||
<!-- BEGIN panel -->
|
||||
<div class="panel panel-inverse">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<i class="fa fa-exclamation-triangle text-danger"></i> Confirm Donor Deletion
|
||||
</h4>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="alert alert-danger">
|
||||
<h5><i class="fa fa-exclamation-triangle"></i> Warning</h5>
|
||||
<p>You are about to permanently delete this donor record. This action cannot be undone.</p>
|
||||
</div>
|
||||
|
||||
<!-- BEGIN donor info -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Donor Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-borderless">
|
||||
<tr>
|
||||
<td class="fw-bold">Donor ID:</td>
|
||||
<td>{{ donor.donor_id }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Name:</td>
|
||||
<td>{{ donor.full_name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Blood Group:</td>
|
||||
<td>
|
||||
<span class="badge bg-primary">{{ donor.blood_group.display_name }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Phone:</td>
|
||||
<td>{{ donor.phone }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Registration Date:</td>
|
||||
<td>{{ donor.registration_date|date:"M d, Y" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Status:</td>
|
||||
<td>
|
||||
<span class="badge bg-{% if donor.status == 'active' %}success{% else %}secondary{% endif %}">
|
||||
{{ donor.get_status_display }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Impact Assessment</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info">
|
||||
<h6><i class="fa fa-info-circle"></i> Related Records</h6>
|
||||
<ul class="mb-0">
|
||||
<li><strong>{{ donor.total_donations }}</strong> blood unit(s) collected</li>
|
||||
<li><strong>{{ donor.blood_units.count }}</strong> blood unit record(s)</li>
|
||||
{% if donor.blood_units.filter(status='available').exists %}
|
||||
<li class="text-warning">
|
||||
<i class="fa fa-exclamation-triangle"></i>
|
||||
Has active blood units in inventory
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{% if donor.blood_units.filter(status='available').exists %}
|
||||
<div class="alert alert-warning">
|
||||
<h6><i class="fa fa-exclamation-triangle"></i> Active Blood Units</h6>
|
||||
<p class="mb-0">This donor has blood units currently available in inventory.
|
||||
Deleting this donor will affect inventory tracking.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if donor.total_donations > 0 %}
|
||||
<div class="alert alert-info">
|
||||
<h6><i class="fa fa-history"></i> Donation History</h6>
|
||||
<p class="mb-0">This donor has a donation history. Consider marking as
|
||||
"inactive" instead of deleting to preserve historical data.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END donor info -->
|
||||
|
||||
<!-- BEGIN deletion options -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Deletion Options</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Reason for Deletion <span class="text-danger">*</span></label>
|
||||
<select class="form-select" name="deletion_reason" required>
|
||||
<option value="">Select reason...</option>
|
||||
<option value="duplicate_record">Duplicate Record</option>
|
||||
<option value="data_error">Data Entry Error</option>
|
||||
<option value="donor_request">Donor Request</option>
|
||||
<option value="privacy_compliance">Privacy Compliance</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Additional Notes</label>
|
||||
<textarea class="form-control" name="deletion_notes" rows="3"
|
||||
placeholder="Provide additional details about the deletion..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="archive_data" id="archiveData" checked>
|
||||
<label class="form-check-label" for="archiveData">
|
||||
Archive donation history before deletion
|
||||
</label>
|
||||
<div class="form-text">Keep anonymized donation records for statistical purposes</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="confirm_deletion" id="confirmDeletion" required>
|
||||
<label class="form-check-label" for="confirmDeletion">
|
||||
<strong>I understand that this action cannot be undone</strong>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BEGIN form actions -->
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<a href="{% url 'blood_bank:donor_detail' donor.id %}" class="btn btn-secondary">
|
||||
<i class="fa fa-arrow-left"></i> Cancel
|
||||
</a>
|
||||
<a href="{% url 'blood_bank:donor_update' donor.id %}" class="btn btn-warning">
|
||||
<i class="fa fa-pause"></i> Mark as Inactive Instead
|
||||
</a>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-danger" id="deleteBtn" disabled>
|
||||
<i class="fa fa-trash"></i> Delete Donor Permanently
|
||||
</button>
|
||||
</div>
|
||||
<!-- END form actions -->
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END deletion options -->
|
||||
|
||||
<!-- BEGIN alternative actions -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Alternative Actions</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">Consider these alternatives to permanent deletion:</p>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="d-grid">
|
||||
<a href="{% url 'blood_bank:donor_update' donor.id %}" class="btn btn-outline-warning">
|
||||
<i class="fa fa-pause"></i> Mark as Inactive
|
||||
</a>
|
||||
</div>
|
||||
<small class="text-muted">Preserves historical data while preventing new donations</small>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="d-grid">
|
||||
<a href="{% url 'blood_bank:donor_update' donor.id %}" class="btn btn-outline-info">
|
||||
<i class="fa fa-edit"></i> Update Information
|
||||
</a>
|
||||
</div>
|
||||
<small class="text-muted">Correct any data errors without deletion</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END alternative actions -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- END panel -->
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Enable delete button only when confirmation is checked
|
||||
$('#confirmDeletion').on('change', function() {
|
||||
$('#deleteBtn').prop('disabled', !this.checked);
|
||||
});
|
||||
|
||||
// Form submission confirmation
|
||||
$('form').on('submit', function(e) {
|
||||
var reason = $('select[name="deletion_reason"]').val();
|
||||
if (!reason) {
|
||||
e.preventDefault();
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Missing Information',
|
||||
text: 'Please select a reason for deletion.',
|
||||
confirmButtonText: 'OK'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
Swal.fire({
|
||||
title: 'Final Confirmation',
|
||||
html: `
|
||||
<div class="text-start">
|
||||
<p><strong>You are about to permanently delete:</strong></p>
|
||||
<ul>
|
||||
<li>Donor: {{ donor.full_name }} ({{ donor.donor_id }})</li>
|
||||
<li>Reason: ${$('select[name="deletion_reason"] option:selected').text()}</li>
|
||||
</ul>
|
||||
<p class="text-danger"><strong>This action cannot be undone!</strong></p>
|
||||
</div>
|
||||
`,
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: '#d33',
|
||||
cancelButtonColor: '#3085d6',
|
||||
confirmButtonText: 'Yes, Delete Permanently',
|
||||
cancelButtonText: 'Cancel'
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
// Show loading
|
||||
Swal.fire({
|
||||
title: 'Deleting Donor...',
|
||||
text: 'Please wait while we process the deletion.',
|
||||
allowOutsideClick: false,
|
||||
didOpen: () => {
|
||||
Swal.showLoading();
|
||||
}
|
||||
});
|
||||
|
||||
// Submit the form
|
||||
this.submit();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
330
templates/blood_bank/donors/donor_detail.html
Normal file
330
templates/blood_bank/donors/donor_detail.html
Normal file
@ -0,0 +1,330 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Donor Details - {{ donor.full_name }}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link href="{% static 'assets/plugins/datatables.net-bs5/css/dataTables.bootstrap5.min.css' %}" rel="stylesheet" />
|
||||
<link href="{% static 'assets/plugins/datatables.net-responsive-bs5/css/responsive.bootstrap5.min.css' %}" rel="stylesheet" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- BEGIN breadcrumb -->
|
||||
<ol class="breadcrumb float-xl-end">
|
||||
<li class="breadcrumb-item"><a href="{% url 'core:dashboard' %}">Home</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'blood_bank:dashboard' %}">Blood Bank</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'blood_bank:donor_list' %}">Donors</a></li>
|
||||
<li class="breadcrumb-item active">{{ donor.donor_id }}</li>
|
||||
</ol>
|
||||
<!-- END breadcrumb -->
|
||||
|
||||
<!-- BEGIN page-header -->
|
||||
<h1 class="page-header">
|
||||
Donor Details
|
||||
<small>{{ donor.full_name }} ({{ donor.donor_id }})</small>
|
||||
</h1>
|
||||
<!-- END page-header -->
|
||||
|
||||
<!-- BEGIN row -->
|
||||
<div class="row">
|
||||
<!-- BEGIN col-4 -->
|
||||
<div class="col-xl-4">
|
||||
<!-- BEGIN panel -->
|
||||
<div class="panel panel-inverse">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">Donor Information</h4>
|
||||
<div class="panel-heading-btn">
|
||||
<a href="{% url 'blood_bank:donor_update' donor.id %}" class="btn btn-warning btn-sm">
|
||||
<i class="fa fa-edit"></i> Edit
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-12 text-center">
|
||||
<div class="bg-light rounded p-4 mb-3">
|
||||
<i class="fa fa-user fa-4x text-muted mb-2"></i>
|
||||
<h4>{{ donor.full_name }}</h4>
|
||||
<p class="text-muted mb-0">{{ donor.donor_id }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table table-borderless">
|
||||
<tr>
|
||||
<td class="fw-bold">Blood Group:</td>
|
||||
<td>
|
||||
<span class="badge bg-primary fs-6">{{ donor.blood_group.display_name }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Age:</td>
|
||||
<td>{{ donor.age }} years</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Gender:</td>
|
||||
<td>{{ donor.get_gender_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Weight:</td>
|
||||
<td>{{ donor.weight }} kg</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Height:</td>
|
||||
<td>{{ donor.height }} cm</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Status:</td>
|
||||
<td>
|
||||
{% if donor.status == 'active' %}
|
||||
<span class="badge bg-success">{{ donor.get_status_display }}</span>
|
||||
{% elif donor.status == 'deferred' %}
|
||||
<span class="badge bg-warning">{{ donor.get_status_display }}</span>
|
||||
{% elif donor.status == 'permanently_deferred' %}
|
||||
<span class="badge bg-danger">{{ donor.get_status_display }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{{ donor.get_status_display }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Donor Type:</td>
|
||||
<td>{{ donor.get_donor_type_display }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<hr>
|
||||
|
||||
<h6 class="fw-bold">Contact Information</h6>
|
||||
<table class="table table-borderless">
|
||||
<tr>
|
||||
<td class="fw-bold">Phone:</td>
|
||||
<td>{{ donor.phone }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Email:</td>
|
||||
<td>{{ donor.email|default:"Not provided" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Address:</td>
|
||||
<td>{{ donor.address }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<hr>
|
||||
|
||||
<h6 class="fw-bold">Emergency Contact</h6>
|
||||
<table class="table table-borderless">
|
||||
<tr>
|
||||
<td class="fw-bold">Name:</td>
|
||||
<td>{{ donor.emergency_contact_name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Phone:</td>
|
||||
<td>{{ donor.emergency_contact_phone }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{% if donor.notes %}
|
||||
<hr>
|
||||
<h6 class="fw-bold">Notes</h6>
|
||||
<p class="text-muted">{{ donor.notes }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<!-- END panel -->
|
||||
</div>
|
||||
<!-- END col-4 -->
|
||||
|
||||
<!-- BEGIN col-8 -->
|
||||
<div class="col-xl-8">
|
||||
<!-- BEGIN panel -->
|
||||
<div class="panel panel-inverse">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">Donation Statistics</h4>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<div class="widget widget-stats bg-blue">
|
||||
<div class="stats-icon"><i class="fa fa-tint"></i></div>
|
||||
<div class="stats-info">
|
||||
<h4>TOTAL DONATIONS</h4>
|
||||
<p>{{ donor.total_donations }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="widget widget-stats bg-info">
|
||||
<div class="stats-icon"><i class="fa fa-calendar"></i></div>
|
||||
<div class="stats-info">
|
||||
<h4>LAST DONATION</h4>
|
||||
<p>
|
||||
{% if donor.last_donation_date %}
|
||||
{{ donor.last_donation_date|date:"M d, Y" }}
|
||||
{% else %}
|
||||
Never
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="widget widget-stats bg-orange">
|
||||
<div class="stats-icon"><i class="fa fa-clock"></i></div>
|
||||
<div class="stats-info">
|
||||
<h4>NEXT ELIGIBLE</h4>
|
||||
<p>{{ donor.next_eligible_date|date:"M d, Y" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="widget widget-stats {% if donor.is_eligible_for_donation %}bg-green{% else %}bg-red{% endif %}">
|
||||
<div class="stats-icon">
|
||||
<i class="fa {% if donor.is_eligible_for_donation %}fa-check{% else %}fa-times{% endif %}"></i>
|
||||
</div>
|
||||
<div class="stats-info">
|
||||
<h4>ELIGIBILITY</h4>
|
||||
<p>{% if donor.is_eligible_for_donation %}Eligible{% else %}Not Eligible{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END panel -->
|
||||
|
||||
<!-- BEGIN panel -->
|
||||
<div class="panel panel-inverse">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">Donation History</h4>
|
||||
<div class="panel-heading-btn">
|
||||
{% if donor.is_eligible_for_donation %}
|
||||
<a href="{% url 'blood_bank:donor_eligibility' donor.id %}" class="btn btn-success btn-sm">
|
||||
<i class="fa fa-tint"></i> New Donation
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{% if blood_units %}
|
||||
<div class="table-responsive">
|
||||
<table id="donationTable" class="table table-striped table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Unit Number</th>
|
||||
<th>Component</th>
|
||||
<th>Collection Date</th>
|
||||
<th>Volume (ml)</th>
|
||||
<th>Status</th>
|
||||
<th>Expiry Date</th>
|
||||
<th>Location</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for unit in blood_units %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{% url 'blood_bank:blood_unit_detail' unit.id %}">
|
||||
{{ unit.unit_number }}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ unit.component.get_name_display }}</td>
|
||||
<td>{{ unit.collection_date|date:"M d, Y H:i" }}</td>
|
||||
<td>{{ unit.volume_ml }}</td>
|
||||
<td>
|
||||
{% if unit.status == 'available' %}
|
||||
<span class="badge bg-success">{{ unit.get_status_display }}</span>
|
||||
{% elif unit.status == 'expired' %}
|
||||
<span class="badge bg-danger">{{ unit.get_status_display }}</span>
|
||||
{% elif unit.status == 'transfused' %}
|
||||
<span class="badge bg-info">{{ unit.get_status_display }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning">{{ unit.get_status_display }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ unit.expiry_date|date:"M d, Y" }}
|
||||
{% if unit.days_to_expiry <= 3 and unit.status == 'available' %}
|
||||
<span class="badge bg-danger ms-1">Expiring Soon</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ unit.location }}</td>
|
||||
<td>
|
||||
<a href="{% url 'blood_bank:blood_unit_detail' unit.id %}"
|
||||
class="btn btn-outline-primary btn-sm" title="View Details">
|
||||
<i class="fa fa-eye"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="fa fa-tint fa-3x text-muted mb-3"></i>
|
||||
<h5 class="text-muted">No Donation History</h5>
|
||||
<p class="text-muted">This donor has not made any donations yet.</p>
|
||||
{% if donor.is_eligible_for_donation %}
|
||||
<a href="{% url 'blood_bank:donor_eligibility' donor.id %}" class="btn btn-primary">
|
||||
<i class="fa fa-tint"></i> Start First Donation
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<!-- END panel -->
|
||||
</div>
|
||||
<!-- END col-8 -->
|
||||
</div>
|
||||
<!-- END row -->
|
||||
|
||||
<!-- BEGIN action buttons -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{% url 'blood_bank:donor_list' %}" class="btn btn-default">
|
||||
<i class="fa fa-arrow-left"></i> Back to Donors
|
||||
</a>
|
||||
<div>
|
||||
{% if donor.is_eligible_for_donation %}
|
||||
<a href="{% url 'blood_bank:donor_eligibility' donor.id %}" class="btn btn-success">
|
||||
<i class="fa fa-tint"></i> Check Eligibility
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{% url 'blood_bank:donor_update' donor.id %}" class="btn btn-warning">
|
||||
<i class="fa fa-edit"></i> Edit Donor
|
||||
</a>
|
||||
<button type="button" class="btn btn-info" onclick="window.print()">
|
||||
<i class="fa fa-print"></i> Print
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END action buttons -->
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{% static 'assets/plugins/datatables.net/js/jquery.dataTables.min.js' %}"></script>
|
||||
<script src="{% static 'assets/plugins/datatables.net-bs5/js/dataTables.bootstrap5.min.js' %}"></script>
|
||||
<script src="{% static 'assets/plugins/datatables.net-responsive/js/dataTables.responsive.min.js' %}"></script>
|
||||
<script src="{% static 'assets/plugins/datatables.net-responsive-bs5/js/responsive.bootstrap5.min.js' %}"></script>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
$('#donationTable').DataTable({
|
||||
responsive: true,
|
||||
pageLength: 10,
|
||||
order: [[2, 'desc']], // Sort by collection date
|
||||
columnDefs: [
|
||||
{ orderable: false, targets: [7] } // Actions column
|
||||
]
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
449
templates/blood_bank/donors/donor_eligibility.html
Normal file
449
templates/blood_bank/donors/donor_eligibility.html
Normal file
@ -0,0 +1,449 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Donor Eligibility Check - {{ donor.full_name }}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.eligibility-card {
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 10px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.eligibility-card.eligible {
|
||||
border-color: #28a745;
|
||||
background-color: #f8fff9;
|
||||
}
|
||||
|
||||
.eligibility-card.not-eligible {
|
||||
border-color: #dc3545;
|
||||
background-color: #fff8f8;
|
||||
}
|
||||
|
||||
.question-card {
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.question-card:hover {
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.question-card.answered-yes {
|
||||
border-color: #28a745;
|
||||
background-color: #f8fff9;
|
||||
}
|
||||
|
||||
.question-card.answered-no {
|
||||
border-color: #dc3545;
|
||||
background-color: #fff8f8;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- BEGIN breadcrumb -->
|
||||
<ol class="breadcrumb float-xl-end">
|
||||
<li class="breadcrumb-item"><a href="{% url 'core:dashboard' %}">Home</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'blood_bank:dashboard' %}">Blood Bank</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'blood_bank:donor_list' %}">Donors</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'blood_bank:donor_detail' donor.id %}">{{ donor.donor_id }}</a></li>
|
||||
<li class="breadcrumb-item active">Eligibility Check</li>
|
||||
</ol>
|
||||
<!-- END breadcrumb -->
|
||||
|
||||
<!-- BEGIN page-header -->
|
||||
<h1 class="page-header">
|
||||
Donor Eligibility Check
|
||||
<small>{{ donor.full_name }} ({{ donor.donor_id }})</small>
|
||||
</h1>
|
||||
<!-- END page-header -->
|
||||
|
||||
<!-- BEGIN row -->
|
||||
<div class="row">
|
||||
<!-- BEGIN col-4 -->
|
||||
<div class="col-xl-4">
|
||||
<!-- BEGIN donor info panel -->
|
||||
<div class="panel panel-inverse">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">Donor Information</h4>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="text-center mb-3">
|
||||
<i class="fa fa-user fa-3x text-muted mb-2"></i>
|
||||
<h5>{{ donor.full_name }}</h5>
|
||||
<p class="text-muted">{{ donor.donor_id }}</p>
|
||||
</div>
|
||||
|
||||
<table class="table table-borderless">
|
||||
<tr>
|
||||
<td class="fw-bold">Blood Group:</td>
|
||||
<td>
|
||||
<span class="badge bg-primary">{{ donor.blood_group.display_name }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Age:</td>
|
||||
<td>{{ donor.age }} years</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Weight:</td>
|
||||
<td>{{ donor.weight }} kg</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Total Donations:</td>
|
||||
<td>{{ donor.total_donations }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Last Donation:</td>
|
||||
<td>
|
||||
{% if donor.last_donation_date %}
|
||||
{{ donor.last_donation_date|date:"M d, Y" }}
|
||||
{% else %}
|
||||
<span class="text-muted">Never</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END donor info panel -->
|
||||
|
||||
<!-- BEGIN eligibility status -->
|
||||
<div class="eligibility-card p-4 {% if is_eligible %}eligible{% else %}not-eligible{% endif %}">
|
||||
<div class="text-center">
|
||||
<i class="fa {% if is_eligible %}fa-check-circle text-success{% else %}fa-times-circle text-danger{% endif %} fa-3x mb-3"></i>
|
||||
<h4>
|
||||
{% if is_eligible %}
|
||||
Eligible for Donation
|
||||
{% else %}
|
||||
Not Eligible for Donation
|
||||
{% endif %}
|
||||
</h4>
|
||||
<p class="mb-0">
|
||||
{% if is_eligible %}
|
||||
This donor meets the basic eligibility criteria.
|
||||
{% else %}
|
||||
Next eligible date: {{ next_eligible_date|date:"M d, Y" }}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END eligibility status -->
|
||||
</div>
|
||||
<!-- END col-4 -->
|
||||
|
||||
<!-- BEGIN col-8 -->
|
||||
<div class="col-xl-8">
|
||||
<!-- BEGIN eligibility screening panel -->
|
||||
<div class="panel panel-inverse">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">Eligibility Screening Questionnaire</h4>
|
||||
<div class="panel-heading-btn">
|
||||
<span class="badge bg-info">Required before donation</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form method="post" id="eligibilityForm">
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- BEGIN basic health questions -->
|
||||
<div class="mb-4">
|
||||
<h5 class="text-primary mb-3">
|
||||
<i class="fa fa-heartbeat"></i> Basic Health Questions
|
||||
</h5>
|
||||
|
||||
<div class="question-card p-3" data-question="feeling_well">
|
||||
<div class="form-check">
|
||||
{{ form.feeling_well }}
|
||||
<label class="form-check-label fw-bold" for="{{ form.feeling_well.id_for_label }}">
|
||||
{{ form.feeling_well.label }}
|
||||
</label>
|
||||
</div>
|
||||
{% if form.feeling_well.errors %}
|
||||
<div class="text-danger small mt-1">{{ form.feeling_well.errors.0 }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="question-card p-3" data-question="adequate_sleep">
|
||||
<div class="form-check">
|
||||
{{ form.adequate_sleep }}
|
||||
<label class="form-check-label fw-bold" for="{{ form.adequate_sleep.id_for_label }}">
|
||||
{{ form.adequate_sleep.label }}
|
||||
</label>
|
||||
</div>
|
||||
{% if form.adequate_sleep.errors %}
|
||||
<div class="text-danger small mt-1">{{ form.adequate_sleep.errors.0 }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="question-card p-3" data-question="eaten_today">
|
||||
<div class="form-check">
|
||||
{{ form.eaten_today }}
|
||||
<label class="form-check-label fw-bold" for="{{ form.eaten_today.id_for_label }}">
|
||||
{{ form.eaten_today.label }}
|
||||
</label>
|
||||
</div>
|
||||
{% if form.eaten_today.errors %}
|
||||
<div class="text-danger small mt-1">{{ form.eaten_today.errors.0 }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<!-- END basic health questions -->
|
||||
|
||||
<!-- BEGIN medical history -->
|
||||
<div class="mb-4">
|
||||
<h5 class="text-primary mb-3">
|
||||
<i class="fa fa-stethoscope"></i> Medical History
|
||||
</h5>
|
||||
|
||||
<div class="question-card p-3" data-question="recent_illness">
|
||||
<div class="form-check">
|
||||
{{ form.recent_illness }}
|
||||
<label class="form-check-label fw-bold" for="{{ form.recent_illness.id_for_label }}">
|
||||
{{ form.recent_illness.label }}
|
||||
</label>
|
||||
</div>
|
||||
<small class="text-muted">Including cold, flu, fever, or any infection</small>
|
||||
</div>
|
||||
|
||||
<div class="question-card p-3" data-question="medications">
|
||||
<div class="form-check">
|
||||
{{ form.medications }}
|
||||
<label class="form-check-label fw-bold" for="{{ form.medications.id_for_label }}">
|
||||
{{ form.medications.label }}
|
||||
</label>
|
||||
</div>
|
||||
<small class="text-muted">Including prescription and over-the-counter medications</small>
|
||||
</div>
|
||||
|
||||
<div class="question-card p-3" data-question="recent_travel">
|
||||
<div class="form-check">
|
||||
{{ form.recent_travel }}
|
||||
<label class="form-check-label fw-bold" for="{{ form.recent_travel.id_for_label }}">
|
||||
{{ form.recent_travel.label }}
|
||||
</label>
|
||||
</div>
|
||||
<small class="text-muted">Travel to malaria-endemic areas may require deferral</small>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END medical history -->
|
||||
|
||||
<!-- BEGIN risk factors -->
|
||||
<div class="mb-4">
|
||||
<h5 class="text-primary mb-3">
|
||||
<i class="fa fa-exclamation-triangle"></i> Risk Factors
|
||||
</h5>
|
||||
|
||||
<div class="question-card p-3" data-question="recent_tattoo">
|
||||
<div class="form-check">
|
||||
{{ form.recent_tattoo }}
|
||||
<label class="form-check-label fw-bold" for="{{ form.recent_tattoo.id_for_label }}">
|
||||
{{ form.recent_tattoo.label }}
|
||||
</label>
|
||||
</div>
|
||||
<small class="text-muted">Including permanent makeup and body piercing</small>
|
||||
</div>
|
||||
|
||||
<div class="question-card p-3" data-question="recent_surgery">
|
||||
<div class="form-check">
|
||||
{{ form.recent_surgery }}
|
||||
<label class="form-check-label fw-bold" for="{{ form.recent_surgery.id_for_label }}">
|
||||
{{ form.recent_surgery.label }}
|
||||
</label>
|
||||
</div>
|
||||
<small class="text-muted">Any surgical procedure or dental work</small>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END risk factors -->
|
||||
|
||||
<!-- BEGIN additional notes -->
|
||||
<div class="mb-4">
|
||||
<h5 class="text-primary mb-3">
|
||||
<i class="fa fa-notes-medical"></i> Additional Information
|
||||
</h5>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="{{ form.notes.id_for_label }}">
|
||||
{{ form.notes.label }}
|
||||
</label>
|
||||
{{ form.notes }}
|
||||
{% if form.notes.errors %}
|
||||
<div class="text-danger small">{{ form.notes.errors.0 }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<!-- END additional notes -->
|
||||
|
||||
<!-- BEGIN form actions -->
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{% url 'blood_bank:donor_detail' donor.id %}" class="btn btn-default">
|
||||
<i class="fa fa-arrow-left"></i> Back to Donor
|
||||
</a>
|
||||
<div>
|
||||
<button type="button" class="btn btn-info" onclick="reviewAnswers()">
|
||||
<i class="fa fa-eye"></i> Review Answers
|
||||
</button>
|
||||
<button type="submit" class="btn btn-success" id="proceedBtn" disabled>
|
||||
<i class="fa fa-arrow-right"></i> Proceed to Donation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END form actions -->
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END eligibility screening panel -->
|
||||
</div>
|
||||
<!-- END col-8 -->
|
||||
</div>
|
||||
<!-- END row -->
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Check form validity on load and when inputs change
|
||||
checkFormValidity();
|
||||
|
||||
$('input[type="checkbox"]').on('change', function() {
|
||||
updateQuestionCard(this);
|
||||
checkFormValidity();
|
||||
});
|
||||
|
||||
// Initialize question cards based on current values
|
||||
$('input[type="checkbox"]').each(function() {
|
||||
updateQuestionCard(this);
|
||||
});
|
||||
});
|
||||
|
||||
function updateQuestionCard(checkbox) {
|
||||
var $card = $(checkbox).closest('.question-card');
|
||||
var questionName = $card.data('question');
|
||||
|
||||
$card.removeClass('answered-yes answered-no');
|
||||
|
||||
if ($(checkbox).is(':checked')) {
|
||||
if (['feeling_well', 'adequate_sleep', 'eaten_today'].includes(questionName)) {
|
||||
$card.addClass('answered-yes');
|
||||
} else {
|
||||
$card.addClass('answered-no');
|
||||
}
|
||||
} else {
|
||||
if (['feeling_well', 'adequate_sleep', 'eaten_today'].includes(questionName)) {
|
||||
$card.addClass('answered-no');
|
||||
} else {
|
||||
$card.addClass('answered-yes');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkFormValidity() {
|
||||
var isValid = true;
|
||||
var requiredQuestions = ['feeling_well', 'adequate_sleep', 'eaten_today'];
|
||||
|
||||
// Check required questions
|
||||
requiredQuestions.forEach(function(question) {
|
||||
var checkbox = $('input[data-question="' + question + '"]');
|
||||
if (!checkbox.is(':checked')) {
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Check if any disqualifying answers
|
||||
var disqualifyingQuestions = ['recent_illness', 'recent_tattoo', 'recent_surgery'];
|
||||
disqualifyingQuestions.forEach(function(question) {
|
||||
var checkbox = $('input[name="' + question + '"]');
|
||||
if (checkbox.is(':checked')) {
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Enable/disable proceed button
|
||||
$('#proceedBtn').prop('disabled', !isValid);
|
||||
|
||||
if (isValid) {
|
||||
$('#proceedBtn').removeClass('btn-secondary').addClass('btn-success');
|
||||
} else {
|
||||
$('#proceedBtn').removeClass('btn-success').addClass('btn-secondary');
|
||||
}
|
||||
}
|
||||
|
||||
function reviewAnswers() {
|
||||
var answers = [];
|
||||
|
||||
$('input[type="checkbox"]').each(function() {
|
||||
var label = $(this).next('label').text();
|
||||
var answer = $(this).is(':checked') ? 'Yes' : 'No';
|
||||
answers.push('<tr><td>' + label + '</td><td><strong>' + answer + '</strong></td></tr>');
|
||||
});
|
||||
|
||||
var notesValue = $('textarea[name="notes"]').val();
|
||||
if (notesValue) {
|
||||
answers.push('<tr><td>Additional Notes</td><td>' + notesValue + '</td></tr>');
|
||||
}
|
||||
|
||||
Swal.fire({
|
||||
title: 'Review Screening Answers',
|
||||
html: '<table class="table table-sm">' + answers.join('') + '</table>',
|
||||
width: '600px',
|
||||
confirmButtonText: 'Looks Good',
|
||||
showCancelButton: true,
|
||||
cancelButtonText: 'Make Changes'
|
||||
});
|
||||
}
|
||||
|
||||
// Form submission handling
|
||||
$('#eligibilityForm').on('submit', function(e) {
|
||||
var requiredAnswered = $('#{{ form.feeling_well.id_for_label }}').is(':checked') &&
|
||||
$('#{{ form.adequate_sleep.id_for_label }}').is(':checked') &&
|
||||
$('#{{ form.eaten_today.id_for_label }}').is(':checked');
|
||||
|
||||
var hasDisqualifyingFactors = $('#{{ form.recent_illness.id_for_label }}').is(':checked') ||
|
||||
$('#{{ form.recent_tattoo.id_for_label }}').is(':checked') ||
|
||||
$('#{{ form.recent_surgery.id_for_label }}').is(':checked');
|
||||
|
||||
if (!requiredAnswered) {
|
||||
e.preventDefault();
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Incomplete Screening',
|
||||
text: 'Please answer all required health questions.',
|
||||
confirmButtonText: 'OK'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasDisqualifyingFactors) {
|
||||
e.preventDefault();
|
||||
Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'Temporary Deferral',
|
||||
text: 'Based on the answers provided, this donor should be temporarily deferred.',
|
||||
confirmButtonText: 'Understood'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Show confirmation before proceeding
|
||||
e.preventDefault();
|
||||
Swal.fire({
|
||||
title: 'Proceed to Donation?',
|
||||
text: 'The donor has passed the eligibility screening. Proceed to blood collection?',
|
||||
icon: 'question',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Yes, Proceed',
|
||||
cancelButtonText: 'Cancel'
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
// Submit the form
|
||||
$('#eligibilityForm')[0].submit();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
433
templates/blood_bank/donors/donor_form.html
Normal file
433
templates/blood_bank/donors/donor_form.html
Normal file
@ -0,0 +1,433 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link href="{% static 'assets/plugins/bootstrap-datepicker/dist/css/bootstrap-datepicker.min.css' %}" rel="stylesheet" />
|
||||
<link href="{% static 'assets/plugins/select2/dist/css/select2.min.css' %}" rel="stylesheet" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- BEGIN breadcrumb -->
|
||||
<ol class="breadcrumb float-xl-end">
|
||||
<li class="breadcrumb-item"><a href="{% url 'core:dashboard' %}">Home</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'blood_bank:dashboard' %}">Blood Bank</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'blood_bank:donor_list' %}">Donors</a></li>
|
||||
<li class="breadcrumb-item active">{{ title }}</li>
|
||||
</ol>
|
||||
<!-- END breadcrumb -->
|
||||
|
||||
<!-- BEGIN page-header -->
|
||||
<h1 class="page-header">{{ title }} <small>donor information management</small></h1>
|
||||
<!-- END page-header -->
|
||||
|
||||
<!-- BEGIN panel -->
|
||||
<div class="panel panel-inverse">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">{{ title }}</h4>
|
||||
<div class="panel-heading-btn">
|
||||
<a href="{% url 'blood_bank:donor_list' %}" class="btn btn-default btn-sm">
|
||||
<i class="fa fa-arrow-left"></i> Back to List
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form method="post" class="form-horizontal" id="donorForm">
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- BEGIN personal information -->
|
||||
<fieldset>
|
||||
<legend class="mb-3">
|
||||
<i class="fa fa-user text-primary"></i> Personal Information
|
||||
</legend>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="{{ form.donor_id.id_for_label }}">
|
||||
Donor ID <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.donor_id }}
|
||||
{% if form.donor_id.errors %}
|
||||
<div class="text-danger small">{{ form.donor_id.errors.0 }}</div>
|
||||
{% endif %}
|
||||
<div class="form-text">Unique identifier for the donor</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="{{ form.blood_group.id_for_label }}">
|
||||
Blood Group <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.blood_group }}
|
||||
{% if form.blood_group.errors %}
|
||||
<div class="text-danger small">{{ form.blood_group.errors.0 }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="{{ form.first_name.id_for_label }}">
|
||||
First Name <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.first_name }}
|
||||
{% if form.first_name.errors %}
|
||||
<div class="text-danger small">{{ form.first_name.errors.0 }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="{{ form.last_name.id_for_label }}">
|
||||
Last Name <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.last_name }}
|
||||
{% if form.last_name.errors %}
|
||||
<div class="text-danger small">{{ form.last_name.errors.0 }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="{{ form.date_of_birth.id_for_label }}">
|
||||
Date of Birth <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.date_of_birth }}
|
||||
{% if form.date_of_birth.errors %}
|
||||
<div class="text-danger small">{{ form.date_of_birth.errors.0 }}</div>
|
||||
{% endif %}
|
||||
<div class="form-text">Must be 18-65 years old</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="{{ form.gender.id_for_label }}">
|
||||
Gender <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.gender }}
|
||||
{% if form.gender.errors %}
|
||||
<div class="text-danger small">{{ form.gender.errors.0 }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div id="ageDisplay" class="mt-4">
|
||||
<span class="badge bg-info">Age will be calculated</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="{{ form.weight.id_for_label }}">
|
||||
Weight (kg) <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.weight }}
|
||||
{% if form.weight.errors %}
|
||||
<div class="text-danger small">{{ form.weight.errors.0 }}</div>
|
||||
{% endif %}
|
||||
<div class="form-text">Minimum 45 kg required for donation</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="{{ form.height.id_for_label }}">
|
||||
Height (cm) <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.height }}
|
||||
{% if form.height.errors %}
|
||||
<div class="text-danger small">{{ form.height.errors.0 }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<!-- END personal information -->
|
||||
|
||||
<!-- BEGIN contact information -->
|
||||
<fieldset>
|
||||
<legend class="mb-3">
|
||||
<i class="fa fa-phone text-primary"></i> Contact Information
|
||||
</legend>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="{{ form.phone.id_for_label }}">
|
||||
Phone Number <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.phone }}
|
||||
{% if form.phone.errors %}
|
||||
<div class="text-danger small">{{ form.phone.errors.0 }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="{{ form.email.id_for_label }}">
|
||||
Email Address
|
||||
</label>
|
||||
{{ form.email }}
|
||||
{% if form.email.errors %}
|
||||
<div class="text-danger small">{{ form.email.errors.0 }}</div>
|
||||
{% endif %}
|
||||
<div class="form-text">Optional but recommended</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="{{ form.address.id_for_label }}">
|
||||
Address <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.address }}
|
||||
{% if form.address.errors %}
|
||||
<div class="text-danger small">{{ form.address.errors.0 }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<!-- END contact information -->
|
||||
|
||||
<!-- BEGIN emergency contact -->
|
||||
<fieldset>
|
||||
<legend class="mb-3">
|
||||
<i class="fa fa-exclamation-triangle text-primary"></i> Emergency Contact
|
||||
</legend>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="{{ form.emergency_contact_name.id_for_label }}">
|
||||
Emergency Contact Name <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.emergency_contact_name }}
|
||||
{% if form.emergency_contact_name.errors %}
|
||||
<div class="text-danger small">{{ form.emergency_contact_name.errors.0 }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="{{ form.emergency_contact_phone.id_for_label }}">
|
||||
Emergency Contact Phone <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.emergency_contact_phone }}
|
||||
{% if form.emergency_contact_phone.errors %}
|
||||
<div class="text-danger small">{{ form.emergency_contact_phone.errors.0 }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<!-- END emergency contact -->
|
||||
|
||||
<!-- BEGIN donor status -->
|
||||
<fieldset>
|
||||
<legend class="mb-3">
|
||||
<i class="fa fa-cog text-primary"></i> Donor Status
|
||||
</legend>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="{{ form.donor_type.id_for_label }}">
|
||||
Donor Type <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.donor_type }}
|
||||
{% if form.donor_type.errors %}
|
||||
<div class="text-danger small">{{ form.donor_type.errors.0 }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="{{ form.status.id_for_label }}">
|
||||
Status <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.status }}
|
||||
{% if form.status.errors %}
|
||||
<div class="text-danger small">{{ form.status.errors.0 }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="{{ form.notes.id_for_label }}">
|
||||
Notes
|
||||
</label>
|
||||
{{ form.notes }}
|
||||
{% if form.notes.errors %}
|
||||
<div class="text-danger small">{{ form.notes.errors.0 }}</div>
|
||||
{% endif %}
|
||||
<div class="form-text">Any additional information about the donor</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<!-- END donor status -->
|
||||
|
||||
<!-- BEGIN form actions -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<hr>
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{% url 'blood_bank:donor_list' %}" class="btn btn-default">
|
||||
<i class="fa fa-times"></i> Cancel
|
||||
</a>
|
||||
<div>
|
||||
<button type="button" class="btn btn-info" onclick="validateForm()">
|
||||
<i class="fa fa-check-circle"></i> Validate
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fa fa-save"></i> Save Donor
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END form actions -->
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END panel -->
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{% static 'assets/plugins/bootstrap-datepicker/dist/js/bootstrap-datepicker.min.js' %}"></script>
|
||||
<script src="{% static 'assets/plugins/select2/dist/js/select2.min.js' %}"></script>
|
||||
<script src="{% static 'assets/plugins/jquery-mask/dist/jquery.mask.min.js' %}"></script>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Initialize Select2 for dropdowns
|
||||
$('.form-select').select2({
|
||||
theme: 'bootstrap-5',
|
||||
width: '100%'
|
||||
});
|
||||
|
||||
// Initialize date picker
|
||||
$('#{{ form.date_of_birth.id_for_label }}').datepicker({
|
||||
format: 'yyyy-mm-dd',
|
||||
autoclose: true,
|
||||
todayHighlight: true,
|
||||
endDate: '-18y',
|
||||
startDate: '-65y'
|
||||
});
|
||||
|
||||
// Phone number masking
|
||||
$('#{{ form.phone.id_for_label }}').mask('(000) 000-0000');
|
||||
$('#{{ form.emergency_contact_phone.id_for_label }}').mask('(000) 000-0000');
|
||||
|
||||
// Calculate age when date of birth changes
|
||||
$('#{{ form.date_of_birth.id_for_label }}').on('change', function() {
|
||||
var dob = new Date($(this).val());
|
||||
var today = new Date();
|
||||
var age = Math.floor((today - dob) / (365.25 * 24 * 60 * 60 * 1000));
|
||||
|
||||
if (age >= 18 && age <= 65) {
|
||||
$('#ageDisplay').html('<span class="badge bg-success">Age: ' + age + ' years (Eligible)</span>');
|
||||
} else if (age < 18) {
|
||||
$('#ageDisplay').html('<span class="badge bg-danger">Age: ' + age + ' years (Too Young)</span>');
|
||||
} else {
|
||||
$('#ageDisplay').html('<span class="badge bg-danger">Age: ' + age + ' years (Too Old)</span>');
|
||||
}
|
||||
});
|
||||
|
||||
// Weight validation
|
||||
$('#{{ form.weight.id_for_label }}').on('input', function() {
|
||||
var weight = parseFloat($(this).val());
|
||||
var feedback = $(this).siblings('.form-text');
|
||||
|
||||
if (weight < 45) {
|
||||
feedback.html('<span class="text-danger">Weight too low for donation (minimum 45 kg)</span>');
|
||||
} else {
|
||||
feedback.html('<span class="text-success">Weight acceptable for donation</span>');
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-generate donor ID if empty
|
||||
$('#{{ form.first_name.id_for_label }}, #{{ form.last_name.id_for_label }}').on('blur', function() {
|
||||
var donorId = $('#{{ form.donor_id.id_for_label }}').val();
|
||||
if (!donorId) {
|
||||
var firstName = $('#{{ form.first_name.id_for_label }}').val();
|
||||
var lastName = $('#{{ form.last_name.id_for_label }}').val();
|
||||
|
||||
if (firstName && lastName) {
|
||||
var timestamp = new Date().getTime().toString().slice(-6);
|
||||
var generatedId = 'D' + firstName.charAt(0).toUpperCase() +
|
||||
lastName.charAt(0).toUpperCase() + timestamp;
|
||||
$('#{{ form.donor_id.id_for_label }}').val(generatedId);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function validateForm() {
|
||||
var isValid = true;
|
||||
var errors = [];
|
||||
|
||||
// Check required fields
|
||||
var requiredFields = [
|
||||
'{{ form.donor_id.id_for_label }}',
|
||||
'{{ form.first_name.id_for_label }}',
|
||||
'{{ form.last_name.id_for_label }}',
|
||||
'{{ form.date_of_birth.id_for_label }}',
|
||||
'{{ form.gender.id_for_label }}',
|
||||
'{{ form.blood_group.id_for_label }}',
|
||||
'{{ form.weight.id_for_label }}',
|
||||
'{{ form.height.id_for_label }}',
|
||||
'{{ form.phone.id_for_label }}',
|
||||
'{{ form.address.id_for_label }}',
|
||||
'{{ form.emergency_contact_name.id_for_label }}',
|
||||
'{{ form.emergency_contact_phone.id_for_label }}'
|
||||
];
|
||||
|
||||
requiredFields.forEach(function(fieldId) {
|
||||
var field = $('#' + fieldId);
|
||||
if (!field.val()) {
|
||||
isValid = false;
|
||||
errors.push(field.prev('label').text().replace(' *', '') + ' is required');
|
||||
field.addClass('is-invalid');
|
||||
} else {
|
||||
field.removeClass('is-invalid');
|
||||
}
|
||||
});
|
||||
|
||||
// Validate age
|
||||
var dob = new Date($('#{{ form.date_of_birth.id_for_label }}').val());
|
||||
var today = new Date();
|
||||
var age = Math.floor((today - dob) / (365.25 * 24 * 60 * 60 * 1000));
|
||||
|
||||
if (age < 18 || age > 65) {
|
||||
isValid = false;
|
||||
errors.push('Donor must be between 18 and 65 years old');
|
||||
}
|
||||
|
||||
// Validate weight
|
||||
var weight = parseFloat($('#{{ form.weight.id_for_label }}').val());
|
||||
if (weight < 45) {
|
||||
isValid = false;
|
||||
errors.push('Minimum weight for donation is 45 kg');
|
||||
}
|
||||
|
||||
// Show validation results
|
||||
if (isValid) {
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: 'Validation Successful',
|
||||
text: 'All fields are valid. You can now save the donor.',
|
||||
confirmButtonText: 'OK'
|
||||
});
|
||||
} else {
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Validation Failed',
|
||||
html: '<ul class="text-start"><li>' + errors.join('</li><li>') + '</li></ul>',
|
||||
confirmButtonText: 'Fix Errors'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Form submission validation
|
||||
$('#donorForm').on('submit', function(e) {
|
||||
var weight = parseFloat($('#{{ form.weight.id_for_label }}').val());
|
||||
var dob = new Date($('#{{ form.date_of_birth.id_for_label }}').val());
|
||||
var today = new Date();
|
||||
var age = Math.floor((today - dob) / (365.25 * 24 * 60 * 60 * 1000));
|
||||
|
||||
if (weight < 45 || age < 18 || age > 65) {
|
||||
e.preventDefault();
|
||||
Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'Invalid Data',
|
||||
text: 'Please check the donor eligibility criteria before saving.',
|
||||
confirmButtonText: 'OK'
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
298
templates/blood_bank/donors/donor_list.html
Normal file
298
templates/blood_bank/donors/donor_list.html
Normal file
@ -0,0 +1,298 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Donor Management{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link href="{% static 'assets/plugins/datatables.net-bs5/css/dataTables.bootstrap5.min.css' %}" rel="stylesheet" />
|
||||
<link href="{% static 'assets/plugins/datatables.net-responsive-bs5/css/responsive.bootstrap5.min.css' %}" rel="stylesheet" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- BEGIN breadcrumb -->
|
||||
<ol class="breadcrumb float-xl-end">
|
||||
<li class="breadcrumb-item"><a href="{% url 'core:dashboard' %}">Home</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'blood_bank:dashboard' %}">Blood Bank</a></li>
|
||||
<li class="breadcrumb-item active">Donors</li>
|
||||
</ol>
|
||||
<!-- END breadcrumb -->
|
||||
|
||||
<!-- BEGIN page-header -->
|
||||
<h1 class="page-header">Donor Management <small>manage blood donors</small></h1>
|
||||
<!-- END page-header -->
|
||||
|
||||
<!-- BEGIN panel -->
|
||||
<div class="panel panel-inverse">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">Blood Donors</h4>
|
||||
<div class="panel-heading-btn">
|
||||
<a href="{% url 'blood_bank:donor_create' %}" class="btn btn-primary btn-sm">
|
||||
<i class="fa fa-plus"></i> Add New Donor
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<!-- BEGIN search and filter form -->
|
||||
<form method="get" class="mb-3">
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<div class="form-group">
|
||||
<label for="search">Search</label>
|
||||
<input type="text" class="form-control" id="search" name="search"
|
||||
value="{{ search_query }}" placeholder="Donor ID, Name, Phone">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-group">
|
||||
<label for="status">Status</label>
|
||||
<select class="form-select" id="status" name="status">
|
||||
<option value="">All Statuses</option>
|
||||
{% for value, label in status_choices %}
|
||||
<option value="{{ value }}" {% if status_filter == value %}selected{% endif %}>
|
||||
{{ label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-group">
|
||||
<label for="blood_group">Blood Group</label>
|
||||
<select class="form-select" id="blood_group" name="blood_group">
|
||||
<option value="">All Blood Groups</option>
|
||||
{% for group in blood_groups %}
|
||||
<option value="{{ group.id }}" {% if blood_group_filter == group.id|stringformat:"s" %}selected{% endif %}>
|
||||
{{ group.display_name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-group">
|
||||
<label> </label>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fa fa-search"></i> Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<!-- END search and filter form -->
|
||||
|
||||
<!-- BEGIN table -->
|
||||
<div class="table-responsive">
|
||||
<table id="donorTable" class="table table-striped table-bordered align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Donor ID</th>
|
||||
<th>Name</th>
|
||||
<th>Blood Group</th>
|
||||
<th>Age</th>
|
||||
<th>Phone</th>
|
||||
<th>Status</th>
|
||||
<th>Total Donations</th>
|
||||
<th>Last Donation</th>
|
||||
<th>Eligible</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for donor in page_obj %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{% url 'blood_bank:donor_detail' donor.id %}" class="text-decoration-none">
|
||||
<strong>{{ donor.donor_id }}</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ donor.full_name }}</td>
|
||||
<td>
|
||||
<span class="badge bg-primary">{{ donor.blood_group.display_name }}</span>
|
||||
</td>
|
||||
<td>{{ donor.age }}</td>
|
||||
<td>{{ donor.phone }}</td>
|
||||
<td>
|
||||
{% if donor.status == 'active' %}
|
||||
<span class="badge bg-success">{{ donor.get_status_display }}</span>
|
||||
{% elif donor.status == 'deferred' %}
|
||||
<span class="badge bg-warning">{{ donor.get_status_display }}</span>
|
||||
{% elif donor.status == 'permanently_deferred' %}
|
||||
<span class="badge bg-danger">{{ donor.get_status_display }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{{ donor.get_status_display }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ donor.total_donations }}</td>
|
||||
<td>
|
||||
{% if donor.last_donation_date %}
|
||||
{{ donor.last_donation_date|date:"M d, Y" }}
|
||||
{% else %}
|
||||
<span class="text-muted">Never</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if donor.is_eligible_for_donation %}
|
||||
<span class="badge bg-success">
|
||||
<i class="fa fa-check"></i> Eligible
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">
|
||||
<i class="fa fa-times"></i> Not Eligible
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{% url 'blood_bank:donor_detail' donor.id %}"
|
||||
class="btn btn-outline-primary btn-sm" title="View Details">
|
||||
<i class="fa fa-eye"></i>
|
||||
</a>
|
||||
<a href="{% url 'blood_bank:donor_update' donor.id %}"
|
||||
class="btn btn-outline-warning btn-sm" title="Edit">
|
||||
<i class="fa fa-edit"></i>
|
||||
</a>
|
||||
{% if donor.is_eligible_for_donation %}
|
||||
<a href="{% url 'blood_bank:donor_eligibility' donor.id %}"
|
||||
class="btn btn-outline-success btn-sm" title="Donation Check">
|
||||
<i class="fa fa-tint"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="10" class="text-center">
|
||||
<div class="py-4">
|
||||
<i class="fa fa-users fa-3x text-muted mb-3"></i>
|
||||
<h5 class="text-muted">No donors found</h5>
|
||||
<p class="text-muted">Try adjusting your search criteria or add a new donor.</p>
|
||||
<a href="{% url 'blood_bank:donor_create' %}" class="btn btn-primary">
|
||||
<i class="fa fa-plus"></i> Add New Donor
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- END table -->
|
||||
|
||||
<!-- BEGIN pagination -->
|
||||
{% if page_obj.has_other_pages %}
|
||||
<nav aria-label="Donor pagination">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page=1{% if search_query %}&search={{ search_query }}{% endif %}{% if status_filter %}&status={{ status_filter }}{% endif %}{% if blood_group_filter %}&blood_group={{ blood_group_filter }}{% endif %}">
|
||||
<i class="fa fa-angle-double-left"></i>
|
||||
</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if search_query %}&search={{ search_query }}{% endif %}{% if status_filter %}&status={{ status_filter }}{% endif %}{% if blood_group_filter %}&blood_group={{ blood_group_filter }}{% endif %}">
|
||||
<i class="fa fa-angle-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 }}{% if search_query %}&search={{ search_query }}{% endif %}{% if status_filter %}&status={{ status_filter }}{% endif %}{% if blood_group_filter %}&blood_group={{ blood_group_filter }}{% endif %}">{{ 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 }}{% if search_query %}&search={{ search_query }}{% endif %}{% if status_filter %}&status={{ status_filter }}{% endif %}{% if blood_group_filter %}&blood_group={{ blood_group_filter }}{% endif %}">
|
||||
<i class="fa fa-angle-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if search_query %}&search={{ search_query }}{% endif %}{% if status_filter %}&status={{ status_filter }}{% endif %}{% if blood_group_filter %}&blood_group={{ blood_group_filter }}{% endif %}">
|
||||
<i class="fa fa-angle-double-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
<!-- END pagination -->
|
||||
|
||||
<!-- BEGIN summary -->
|
||||
<div class="row mt-3">
|
||||
<div class="col-md-6">
|
||||
<p class="text-muted">
|
||||
Showing {{ page_obj.start_index }} to {{ page_obj.end_index }} of {{ page_obj.paginator.count }} donors
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-md-6 text-end">
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="window.print()">
|
||||
<i class="fa fa-print"></i> Print
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="exportToCSV()">
|
||||
<i class="fa fa-download"></i> Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END summary -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- END panel -->
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{% static 'assets/plugins/datatables.net/js/jquery.dataTables.min.js' %}"></script>
|
||||
<script src="{% static 'assets/plugins/datatables.net-bs5/js/dataTables.bootstrap5.min.js' %}"></script>
|
||||
<script src="{% static 'assets/plugins/datatables.net-responsive/js/dataTables.responsive.min.js' %}"></script>
|
||||
<script src="{% static 'assets/plugins/datatables.net-responsive-bs5/js/responsive.bootstrap5.min.js' %}"></script>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Initialize DataTable for enhanced functionality
|
||||
$('#donorTable').DataTable({
|
||||
responsive: true,
|
||||
pageLength: 25,
|
||||
order: [[0, 'desc']],
|
||||
columnDefs: [
|
||||
{ orderable: false, targets: [9] } // Actions column
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
function exportToCSV() {
|
||||
// Simple CSV export functionality
|
||||
var csv = [];
|
||||
var rows = document.querySelectorAll("#donorTable tr");
|
||||
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
var row = [], cols = rows[i].querySelectorAll("td, th");
|
||||
|
||||
for (var j = 0; j < cols.length - 1; j++) { // Exclude actions column
|
||||
row.push(cols[j].innerText);
|
||||
}
|
||||
|
||||
csv.push(row.join(","));
|
||||
}
|
||||
|
||||
var csvFile = new Blob([csv.join("\n")], {type: "text/csv"});
|
||||
var downloadLink = document.createElement("a");
|
||||
downloadLink.download = "donors.csv";
|
||||
downloadLink.href = window.URL.createObjectURL(csvFile);
|
||||
downloadLink.style.display = "none";
|
||||
document.body.appendChild(downloadLink);
|
||||
downloadLink.click();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
604
templates/blood_bank/inventory/inventory_dashboard.html
Normal file
604
templates/blood_bank/inventory/inventory_dashboard.html
Normal file
@ -0,0 +1,604 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Blood Bank Inventory Dashboard{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link href="{% static 'assets/plugins/chart.js/dist/Chart.min.css' %}" rel="stylesheet" />
|
||||
<style>
|
||||
.inventory-card {
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.inventory-card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.blood-group-card {
|
||||
border-left: 4px solid;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.blood-group-o { border-left-color: #dc3545; }
|
||||
.blood-group-a { border-left-color: #007bff; }
|
||||
.blood-group-b { border-left-color: #28a745; }
|
||||
.blood-group-ab { border-left-color: #ffc107; }
|
||||
|
||||
.expiry-alert {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.temperature-normal { color: #28a745; }
|
||||
.temperature-warning { color: #ffc107; }
|
||||
.temperature-critical { color: #dc3545; }
|
||||
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 300px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- BEGIN breadcrumb -->
|
||||
<ol class="breadcrumb float-xl-end">
|
||||
<li class="breadcrumb-item"><a href="{% url 'core:dashboard' %}">Home</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'blood_bank:dashboard' %}">Blood Bank</a></li>
|
||||
<li class="breadcrumb-item active">Inventory Dashboard</li>
|
||||
</ol>
|
||||
<!-- END breadcrumb -->
|
||||
|
||||
<!-- BEGIN page-header -->
|
||||
<h1 class="page-header">Blood Bank Inventory <small>real-time inventory monitoring</small></h1>
|
||||
<!-- END page-header -->
|
||||
|
||||
<!-- BEGIN summary cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-xl-3 col-md-6">
|
||||
<div class="card inventory-card bg-primary text-white">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<h6 class="card-title">Total Units</h6>
|
||||
<h2 class="mb-0">{{ total_units }}</h2>
|
||||
<small>Available for transfusion</small>
|
||||
</div>
|
||||
<div class="align-self-center">
|
||||
<i class="fa fa-tint fa-3x opacity-75"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-3 col-md-6">
|
||||
<div class="card inventory-card bg-success text-white">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<h6 class="card-title">Fresh Units</h6>
|
||||
<h2 class="mb-0">{{ fresh_units }}</h2>
|
||||
<small>Less than 7 days old</small>
|
||||
</div>
|
||||
<div class="align-self-center">
|
||||
<i class="fa fa-check-circle fa-3x opacity-75"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-3 col-md-6">
|
||||
<div class="card inventory-card bg-warning text-white expiry-alert">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<h6 class="card-title">Expiring Soon</h6>
|
||||
<h2 class="mb-0">{{ expiring_units }}</h2>
|
||||
<small>Within 3 days</small>
|
||||
</div>
|
||||
<div class="align-self-center">
|
||||
<i class="fa fa-exclamation-triangle fa-3x opacity-75"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-3 col-md-6">
|
||||
<div class="card inventory-card bg-danger text-white">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<h6 class="card-title">Critical Low</h6>
|
||||
<h2 class="mb-0">{{ critical_low_count }}</h2>
|
||||
<small>Below minimum levels</small>
|
||||
</div>
|
||||
<div class="align-self-center">
|
||||
<i class="fa fa-exclamation-circle fa-3x opacity-75"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END summary cards -->
|
||||
|
||||
<!-- BEGIN row -->
|
||||
<div class="row">
|
||||
<!-- BEGIN col-8 -->
|
||||
<div class="col-xl-8">
|
||||
<!-- BEGIN blood group inventory -->
|
||||
<div class="panel panel-inverse">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">Blood Group Inventory</h4>
|
||||
<div class="panel-heading-btn">
|
||||
<button type="button" class="btn btn-info btn-sm" onclick="refreshInventory()">
|
||||
<i class="fa fa-refresh"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="row">
|
||||
{% for group in blood_groups %}
|
||||
<div class="col-md-6">
|
||||
<div class="blood-group-card card blood-group-{{ group.abo_type|lower }}">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h5 class="card-title mb-1">{{ group.display_name }}</h5>
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<small class="text-muted">Whole Blood</small>
|
||||
<h6 class="mb-0">{{ group.whole_blood_count }}</h6>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<small class="text-muted">RBC</small>
|
||||
<h6 class="mb-0">{{ group.rbc_count }}</h6>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<small class="text-muted">Plasma</small>
|
||||
<h6 class="mb-0">{{ group.plasma_count }}</h6>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<small class="text-muted">Platelets</small>
|
||||
<h6 class="mb-0">{{ group.platelet_count }}</h6>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<h3 class="mb-0">{{ group.total_units }}</h3>
|
||||
<small class="text-muted">Total Units</small>
|
||||
{% if group.is_critical_low %}
|
||||
<br><span class="badge bg-danger">Critical</span>
|
||||
{% elif group.is_low %}
|
||||
<br><span class="badge bg-warning">Low</span>
|
||||
{% else %}
|
||||
<br><span class="badge bg-success">Good</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END blood group inventory -->
|
||||
|
||||
<!-- BEGIN inventory trends -->
|
||||
<div class="panel panel-inverse">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">Inventory Trends</h4>
|
||||
<div class="panel-heading-btn">
|
||||
<select class="form-select form-select-sm" id="trendPeriod" onchange="updateTrendChart()">
|
||||
<option value="7">Last 7 Days</option>
|
||||
<option value="30">Last 30 Days</option>
|
||||
<option value="90">Last 3 Months</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="chart-container">
|
||||
<canvas id="inventoryTrendChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END inventory trends -->
|
||||
|
||||
<!-- BEGIN component distribution -->
|
||||
<div class="panel panel-inverse">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">Component Distribution</h4>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="chart-container">
|
||||
<canvas id="componentChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="chart-container">
|
||||
<canvas id="bloodGroupChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END component distribution -->
|
||||
</div>
|
||||
<!-- END col-8 -->
|
||||
|
||||
<!-- BEGIN col-4 -->
|
||||
<div class="col-xl-4">
|
||||
<!-- BEGIN storage locations -->
|
||||
<div class="panel panel-inverse">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">Storage Locations</h4>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{% for location in storage_locations %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="card-title mb-1">{{ location.name }}</h6>
|
||||
<small class="text-muted">{{ location.location_type }}</small>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<h5 class="mb-0">{{ location.unit_count }}</h5>
|
||||
<small class="text-muted">Units</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<div class="d-flex justify-content-between">
|
||||
<small>Temperature:</small>
|
||||
<small class="{% if location.temperature_status == 'normal' %}temperature-normal{% elif location.temperature_status == 'warning' %}temperature-warning{% else %}temperature-critical{% endif %}">
|
||||
{{ location.current_temperature }}°C
|
||||
<i class="fa fa-thermometer-half"></i>
|
||||
</small>
|
||||
</div>
|
||||
<div class="progress mt-1" style="height: 5px;">
|
||||
<div class="progress-bar {% if location.capacity_percentage > 90 %}bg-danger{% elif location.capacity_percentage > 75 %}bg-warning{% else %}bg-success{% endif %}"
|
||||
style="width: {{ location.capacity_percentage }}%"></div>
|
||||
</div>
|
||||
<small class="text-muted">{{ location.capacity_percentage }}% capacity</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<!-- END storage locations -->
|
||||
|
||||
<!-- BEGIN expiry alerts -->
|
||||
<div class="panel panel-inverse">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">Expiry Alerts</h4>
|
||||
<div class="panel-heading-btn">
|
||||
<span class="badge bg-warning">{{ expiring_units }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{% for unit in expiring_units_list %}
|
||||
<div class="alert alert-warning d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong>{{ unit.unit_number }}</strong><br>
|
||||
<small>{{ unit.blood_group.display_name }} - {{ unit.component.get_name_display }}</small>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<span class="badge bg-danger">{{ unit.days_to_expiry }} days</span><br>
|
||||
<small class="text-muted">{{ unit.expiry_date|date:"M d" }}</small>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="text-center py-3">
|
||||
<i class="fa fa-check-circle fa-2x text-success mb-2"></i>
|
||||
<p class="text-muted mb-0">No units expiring soon</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<!-- END expiry alerts -->
|
||||
|
||||
<!-- BEGIN recent activity -->
|
||||
<div class="panel panel-inverse">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">Recent Activity</h4>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="timeline">
|
||||
{% for activity in recent_activities %}
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-time">{{ activity.timestamp|date:"H:i" }}</div>
|
||||
<div class="timeline-body">
|
||||
<div class="timeline-content">
|
||||
<i class="fa {{ activity.icon }} text-{{ activity.color }}"></i>
|
||||
{{ activity.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="text-center py-3">
|
||||
<i class="fa fa-clock fa-2x text-muted mb-2"></i>
|
||||
<p class="text-muted mb-0">No recent activity</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END recent activity -->
|
||||
|
||||
<!-- BEGIN quick actions -->
|
||||
<div class="panel panel-inverse">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">Quick Actions</h4>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="d-grid gap-2">
|
||||
<a href="{% url 'blood_bank:blood_unit_create' %}" class="btn btn-primary">
|
||||
<i class="fa fa-plus"></i> Register Blood Unit
|
||||
</a>
|
||||
<a href="{% url 'blood_bank:blood_request_list' %}" class="btn btn-info">
|
||||
<i class="fa fa-clipboard-list"></i> View Requests
|
||||
</a>
|
||||
<a href="{% url 'blood_bank:donor_list' %}" class="btn btn-success">
|
||||
<i class="fa fa-users"></i> Manage Donors
|
||||
</a>
|
||||
<button type="button" class="btn btn-warning" onclick="generateInventoryReport()">
|
||||
<i class="fa fa-file-pdf"></i> Generate Report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END quick actions -->
|
||||
</div>
|
||||
<!-- END col-4 -->
|
||||
</div>
|
||||
<!-- END row -->
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{% static 'assets/plugins/chart.js/dist/Chart.min.js' %}"></script>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
initializeCharts();
|
||||
|
||||
// Auto-refresh every 5 minutes
|
||||
setInterval(function() {
|
||||
if (document.visibilityState === 'visible') {
|
||||
refreshInventory();
|
||||
}
|
||||
}, 300000);
|
||||
});
|
||||
|
||||
function initializeCharts() {
|
||||
// Inventory Trend Chart
|
||||
var trendCtx = document.getElementById('inventoryTrendChart').getContext('2d');
|
||||
var trendChart = new Chart(trendCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: {{ trend_labels|safe }},
|
||||
datasets: [{
|
||||
label: 'Total Units',
|
||||
data: {{ trend_data|safe }},
|
||||
borderColor: '#007bff',
|
||||
backgroundColor: 'rgba(0, 123, 255, 0.1)',
|
||||
tension: 0.4
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Component Distribution Chart
|
||||
var componentCtx = document.getElementById('componentChart').getContext('2d');
|
||||
var componentChart = new Chart(componentCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: {{ component_labels|safe }},
|
||||
datasets: [{
|
||||
data: {{ component_data|safe }},
|
||||
backgroundColor: [
|
||||
'#dc3545',
|
||||
'#007bff',
|
||||
'#28a745',
|
||||
'#ffc107',
|
||||
'#6f42c1'
|
||||
]
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'By Component'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Blood Group Distribution Chart
|
||||
var bloodGroupCtx = document.getElementById('bloodGroupChart').getContext('2d');
|
||||
var bloodGroupChart = new Chart(bloodGroupCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: {{ blood_group_labels|safe }},
|
||||
datasets: [{
|
||||
data: {{ blood_group_data|safe }},
|
||||
backgroundColor: [
|
||||
'#dc3545',
|
||||
'#007bff',
|
||||
'#28a745',
|
||||
'#ffc107'
|
||||
]
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'By Blood Group'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function refreshInventory() {
|
||||
// Show loading indicator
|
||||
Swal.fire({
|
||||
title: 'Refreshing Inventory...',
|
||||
allowOutsideClick: false,
|
||||
didOpen: () => {
|
||||
Swal.showLoading();
|
||||
}
|
||||
});
|
||||
|
||||
// Simulate refresh (in real implementation, this would be an AJAX call)
|
||||
setTimeout(function() {
|
||||
Swal.close();
|
||||
location.reload();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function updateTrendChart() {
|
||||
var period = document.getElementById('trendPeriod').value;
|
||||
|
||||
// Here you would make an AJAX call to get new data
|
||||
// For now, we'll just show a message
|
||||
Swal.fire({
|
||||
icon: 'info',
|
||||
title: 'Updating Chart',
|
||||
text: `Loading data for last ${period} days...`,
|
||||
timer: 1500,
|
||||
showConfirmButton: false
|
||||
});
|
||||
}
|
||||
|
||||
function generateInventoryReport() {
|
||||
Swal.fire({
|
||||
title: 'Generate Inventory Report',
|
||||
html: `
|
||||
<div class="text-start">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Report Type</label>
|
||||
<select class="form-select" id="reportType">
|
||||
<option value="summary">Inventory Summary</option>
|
||||
<option value="detailed">Detailed Inventory</option>
|
||||
<option value="expiry">Expiry Report</option>
|
||||
<option value="usage">Usage Analysis</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Date Range</label>
|
||||
<select class="form-select" id="dateRange">
|
||||
<option value="current">Current Inventory</option>
|
||||
<option value="week">Last Week</option>
|
||||
<option value="month">Last Month</option>
|
||||
<option value="quarter">Last Quarter</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Format</label>
|
||||
<select class="form-select" id="reportFormat">
|
||||
<option value="pdf">PDF</option>
|
||||
<option value="excel">Excel</option>
|
||||
<option value="csv">CSV</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Generate Report',
|
||||
cancelButtonText: 'Cancel',
|
||||
preConfirm: () => {
|
||||
const reportType = document.getElementById('reportType').value;
|
||||
const dateRange = document.getElementById('dateRange').value;
|
||||
const format = document.getElementById('reportFormat').value;
|
||||
|
||||
return { reportType, dateRange, format };
|
||||
}
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: 'Report Generated',
|
||||
text: `${result.value.reportType} report in ${result.value.format} format is being prepared.`,
|
||||
confirmButtonText: 'OK'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Timeline styles
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.timeline {
|
||||
position: relative;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.timeline-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -15px;
|
||||
top: 5px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #007bff;
|
||||
}
|
||||
|
||||
.timeline-time {
|
||||
font-size: 0.8em;
|
||||
color: #6c757d;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
743
templates/blood_bank/issues/blood_issue_form.html
Normal file
743
templates/blood_bank/issues/blood_issue_form.html
Normal file
@ -0,0 +1,743 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Issue Blood Unit - Request #{{ blood_request.request_number }}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link href="{% static 'assets/plugins/select2/dist/css/select2.min.css' %}" rel="stylesheet" />
|
||||
<style>
|
||||
.form-section {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
border-left: 4px solid #007bff;
|
||||
}
|
||||
|
||||
.form-section h5 {
|
||||
color: #007bff;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.required-field {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.request-info {
|
||||
background: #d4edda;
|
||||
border-left: 4px solid #28a745;
|
||||
}
|
||||
|
||||
.unit-selection {
|
||||
background: #d1ecf1;
|
||||
border-left: 4px solid #17a2b8;
|
||||
}
|
||||
|
||||
.safety-checks {
|
||||
background: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
}
|
||||
|
||||
.emergency-section {
|
||||
background: #f8d7da;
|
||||
border-left: 4px solid #dc3545;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.8; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.unit-card {
|
||||
background: white;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.unit-card:hover {
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 2px 8px rgba(0,123,255,0.2);
|
||||
}
|
||||
|
||||
.unit-card.selected {
|
||||
border-color: #28a745;
|
||||
background-color: #d4edda;
|
||||
}
|
||||
|
||||
.unit-card.incompatible {
|
||||
border-color: #dc3545;
|
||||
background-color: #f8d7da;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.compatibility-badge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
.issue-preview {
|
||||
background: #e2e3e5;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.verification-checklist {
|
||||
background: #f8f9fa;
|
||||
border: 2px solid #28a745;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.transport-info {
|
||||
background: #e9ecef;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- BEGIN breadcrumb -->
|
||||
<ol class="breadcrumb float-xl-end">
|
||||
<li class="breadcrumb-item"><a href="{% url 'core:dashboard' %}">Home</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'blood_bank:dashboard' %}">Blood Bank</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'blood_bank:blood_request_list' %}">Blood Requests</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'blood_bank:blood_request_detail' blood_request.id %}">{{ blood_request.request_number }}</a></li>
|
||||
<li class="breadcrumb-item active">Issue Blood</li>
|
||||
</ol>
|
||||
<!-- END breadcrumb -->
|
||||
|
||||
<!-- BEGIN page-header -->
|
||||
<h1 class="page-header">
|
||||
Issue Blood Unit
|
||||
<small>Request #{{ blood_request.request_number }}</small>
|
||||
{% if blood_request.urgency == 'emergency' %}
|
||||
<span class="badge bg-danger ms-2">EMERGENCY</span>
|
||||
{% endif %}
|
||||
</h1>
|
||||
<!-- END page-header -->
|
||||
|
||||
<!-- BEGIN panel -->
|
||||
<div class="panel panel-inverse">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<i class="fa fa-shipping-fast"></i> Blood Unit Issue
|
||||
</h4>
|
||||
<div class="panel-heading-btn">
|
||||
<span class="badge bg-info">Request: {{ blood_request.request_number }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form method="post" id="bloodIssueForm">
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- BEGIN request information -->
|
||||
<div class="form-section {% if blood_request.urgency == 'emergency' %}emergency-section{% else %}request-info{% endif %}">
|
||||
<h5><i class="fa fa-file-medical"></i> Blood Request Information</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<table class="table table-borderless mb-0">
|
||||
<tr>
|
||||
<td class="fw-bold">Request Number:</td>
|
||||
<td>{{ blood_request.request_number }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Patient:</td>
|
||||
<td>{{ blood_request.patient.full_name }} ({{ blood_request.patient.patient_id }})</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Blood Group:</td>
|
||||
<td><span class="badge bg-primary">{{ blood_request.patient.blood_group.display_name }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Component Requested:</td>
|
||||
<td>{{ blood_request.component.get_name_display }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<table class="table table-borderless mb-0">
|
||||
<tr>
|
||||
<td class="fw-bold">Quantity:</td>
|
||||
<td>{{ blood_request.quantity_requested }} units</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Urgency:</td>
|
||||
<td>
|
||||
<span class="badge bg-{% if blood_request.urgency == 'emergency' %}danger{% elif blood_request.urgency == 'urgent' %}warning{% else %}info{% endif %}">
|
||||
{{ blood_request.get_urgency_display }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Requested By:</td>
|
||||
<td>{{ blood_request.requested_by.get_full_name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Department:</td>
|
||||
<td>{{ blood_request.department.name }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if blood_request.special_requirements %}
|
||||
<div class="mt-3">
|
||||
<h6>Special Requirements:</h6>
|
||||
<div class="alert alert-info">{{ blood_request.special_requirements }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<!-- END request information -->
|
||||
|
||||
<!-- BEGIN unit selection -->
|
||||
<div class="form-section unit-selection">
|
||||
<h5><i class="fa fa-tint"></i> Available Blood Units</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<p class="text-muted">Select compatible blood units for this request. Units are filtered by blood group compatibility and availability.</p>
|
||||
|
||||
<div id="availableUnits">
|
||||
{% for unit in available_units %}
|
||||
<div class="unit-card position-relative" data-unit-id="{{ unit.id }}" onclick="selectUnit(this)">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<h6>{{ unit.unit_number }}</h6>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<small class="text-muted">Blood Group:</small><br>
|
||||
<span class="badge bg-primary">{{ unit.blood_group.display_name }}</span>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<small class="text-muted">Component:</small><br>
|
||||
{{ unit.component.get_name_display }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-2">
|
||||
<div class="col-md-6">
|
||||
<small class="text-muted">Volume:</small><br>
|
||||
{{ unit.volume_ml }} ml
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<small class="text-muted">Expiry:</small><br>
|
||||
{{ unit.expiry_date|date:"M d, Y" }}
|
||||
{% if unit.days_to_expiry <= 3 %}
|
||||
<span class="badge bg-warning">Expires Soon</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-end">
|
||||
<small class="text-muted">Donor:</small><br>
|
||||
{{ unit.donor.full_name }}<br>
|
||||
<small class="text-muted">Collection:</small><br>
|
||||
{{ unit.collection_date|date:"M d, Y" }}<br>
|
||||
<small class="text-muted">Location:</small><br>
|
||||
{{ unit.location.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compatibility badge -->
|
||||
<div class="compatibility-badge">
|
||||
{% if unit.is_compatible %}
|
||||
<span class="badge bg-success">Compatible</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">Check Required</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Crossmatch status -->
|
||||
{% if unit.crossmatch_status %}
|
||||
<div class="mt-2">
|
||||
<small class="text-muted">Crossmatch:</small>
|
||||
<span class="badge bg-{% if unit.crossmatch_status == 'compatible' %}success{% elif unit.crossmatch_status == 'incompatible' %}danger{% else %}warning{% endif %}">
|
||||
{{ unit.crossmatch_status|title }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="alert alert-warning">
|
||||
<i class="fa fa-exclamation-triangle"></i> No compatible blood units available for this request.
|
||||
<br>Please check inventory or contact blood bank supervisor.
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="selected_units" id="selectedUnits" value="">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END unit selection -->
|
||||
|
||||
<!-- BEGIN issue details -->
|
||||
<div class="form-section">
|
||||
<h5><i class="fa fa-info-circle"></i> Issue Details</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="form-group">
|
||||
<label for="issueDate" class="form-label">
|
||||
Issue Date & Time <span class="required-field">*</span>
|
||||
</label>
|
||||
<input type="datetime-local" class="form-control" id="issueDate" name="issue_date" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-group">
|
||||
<label for="issuedBy" class="form-label">
|
||||
Issued By <span class="required-field">*</span>
|
||||
</label>
|
||||
<select class="form-select" id="issuedBy" name="issued_by" required>
|
||||
<option value="">Select staff member...</option>
|
||||
{% for staff in blood_bank_staff %}
|
||||
<option value="{{ staff.id }}">{{ staff.get_full_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-group">
|
||||
<label for="receivedBy" class="form-label">
|
||||
Received By <span class="required-field">*</span>
|
||||
</label>
|
||||
<select class="form-select" id="receivedBy" name="received_by" required>
|
||||
<option value="">Select receiving staff...</option>
|
||||
{% for staff in clinical_staff %}
|
||||
<option value="{{ staff.id }}">{{ staff.get_full_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END issue details -->
|
||||
|
||||
<!-- BEGIN transport information -->
|
||||
<div class="form-section">
|
||||
<h5><i class="fa fa-truck"></i> Transport Information</h5>
|
||||
<div class="transport-info">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="transportMethod" class="form-label">
|
||||
Transport Method <span class="required-field">*</span>
|
||||
</label>
|
||||
<select class="form-select" id="transportMethod" name="transport_method" required>
|
||||
<option value="">Select method...</option>
|
||||
<option value="hand_carry">Hand Carry</option>
|
||||
<option value="pneumatic_tube">Pneumatic Tube</option>
|
||||
<option value="courier">Courier Service</option>
|
||||
<option value="emergency_transport">Emergency Transport</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="transportContainer" class="form-label">
|
||||
Transport Container <span class="required-field">*</span>
|
||||
</label>
|
||||
<select class="form-select" id="transportContainer" name="transport_container" required>
|
||||
<option value="">Select container...</option>
|
||||
<option value="insulated_bag">Insulated Transport Bag</option>
|
||||
<option value="cooler_box">Cooler Box</option>
|
||||
<option value="temperature_controlled">Temperature Controlled Container</option>
|
||||
<option value="emergency_kit">Emergency Transport Kit</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="transportTemperature" class="form-label">
|
||||
Transport Temperature (°C)
|
||||
</label>
|
||||
<input type="number" step="0.1" class="form-control" id="transportTemperature"
|
||||
name="transport_temperature" placeholder="2-6°C" min="1" max="10">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="estimatedDelivery" class="form-label">
|
||||
Estimated Delivery Time
|
||||
</label>
|
||||
<input type="datetime-local" class="form-control" id="estimatedDelivery" name="estimated_delivery">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END transport information -->
|
||||
|
||||
<!-- BEGIN verification checklist -->
|
||||
<div class="form-section safety-checks">
|
||||
<h5><i class="fa fa-shield-alt"></i> Pre-Issue Verification Checklist</h5>
|
||||
<div class="verification-checklist">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>Unit Verification</h6>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="unitNumberVerified" required>
|
||||
<label class="form-check-label" for="unitNumberVerified">
|
||||
Blood unit number verified against request
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="bloodGroupVerified" required>
|
||||
<label class="form-check-label" for="bloodGroupVerified">
|
||||
Blood group compatibility verified
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="expiryDateChecked" required>
|
||||
<label class="form-check-label" for="expiryDateChecked">
|
||||
Expiry date checked and valid
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="visualInspection" required>
|
||||
<label class="form-check-label" for="visualInspection">
|
||||
Visual inspection completed (no clots, discoloration)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>Documentation & Testing</h6>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="crossmatchCompleted" required>
|
||||
<label class="form-check-label" for="crossmatchCompleted">
|
||||
Crossmatch completed and compatible
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="testingCompleted" required>
|
||||
<label class="form-check-label" for="testingCompleted">
|
||||
All required testing completed and negative
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="patientIdentityVerified" required>
|
||||
<label class="form-check-label" for="patientIdentityVerified">
|
||||
Patient identity verified (2 identifiers)
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="requestAuthorized" required>
|
||||
<label class="form-check-label" for="requestAuthorized">
|
||||
Request properly authorized by physician
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-3">
|
||||
<div class="col-md-12">
|
||||
<h6>Emergency Procedures (if applicable)</h6>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="emergencyProtocol">
|
||||
<label class="form-check-label" for="emergencyProtocol">
|
||||
Emergency release protocol followed (if applicable)
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="emergencyConsent">
|
||||
<label class="form-check-label" for="emergencyConsent">
|
||||
Emergency consent documented (if applicable)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END verification checklist -->
|
||||
|
||||
<!-- BEGIN special instructions -->
|
||||
<div class="form-section">
|
||||
<h5><i class="fa fa-clipboard-list"></i> Special Instructions & Notes</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="form-group">
|
||||
<label for="issueNotes" class="form-label">Issue Notes</label>
|
||||
<textarea class="form-control" id="issueNotes" name="issue_notes" rows="3"
|
||||
placeholder="Any special handling instructions, observations, or notes..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="handlingInstructions" class="form-label">Handling Instructions</label>
|
||||
<select class="form-select" id="handlingInstructions" name="handling_instructions">
|
||||
<option value="">Select if applicable...</option>
|
||||
<option value="keep_refrigerated">Keep Refrigerated (2-6°C)</option>
|
||||
<option value="room_temperature">Room Temperature (20-24°C)</option>
|
||||
<option value="immediate_use">For Immediate Use</option>
|
||||
<option value="irradiated">Irradiated Product</option>
|
||||
<option value="cmv_negative">CMV Negative</option>
|
||||
<option value="leukoreduced">Leukoreduced</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="returnTime" class="form-label">Return Time Limit</label>
|
||||
<input type="datetime-local" class="form-control" id="returnTime" name="return_time_limit"
|
||||
placeholder="If not used, return by...">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END special instructions -->
|
||||
|
||||
<!-- BEGIN issue preview -->
|
||||
<div class="issue-preview">
|
||||
<h6><i class="fa fa-eye"></i> Issue Summary</h6>
|
||||
<div id="issueSummary">
|
||||
<p class="text-muted">Select blood units and complete the form to preview issue details</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END issue preview -->
|
||||
|
||||
<!-- BEGIN form actions -->
|
||||
<div class="d-flex justify-content-between mt-4">
|
||||
<a href="{% url 'blood_bank:blood_request_detail' blood_request.id %}" class="btn btn-secondary">
|
||||
<i class="fa fa-arrow-left"></i> Cancel
|
||||
</a>
|
||||
<div>
|
||||
<button type="button" class="btn btn-info" onclick="validateIssue()">
|
||||
<i class="fa fa-check"></i> Validate Issue
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" id="submitBtn" disabled>
|
||||
<i class="fa fa-shipping-fast"></i> Issue Blood Units
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END form actions -->
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END panel -->
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{% static 'assets/plugins/select2/dist/js/select2.min.js' %}"></script>
|
||||
|
||||
<script>
|
||||
var selectedUnits = [];
|
||||
|
||||
$(document).ready(function() {
|
||||
// Initialize Select2
|
||||
$('#issuedBy, #receivedBy, #transportMethod, #transportContainer, #handlingInstructions').select2({
|
||||
theme: 'bootstrap-5'
|
||||
});
|
||||
|
||||
// Set default issue date to now
|
||||
var now = new Date();
|
||||
$('#issueDate').val(now.toISOString().slice(0, 16));
|
||||
|
||||
// Set estimated delivery time (30 minutes from now)
|
||||
var deliveryTime = new Date(now.getTime() + 30 * 60000);
|
||||
$('#estimatedDelivery').val(deliveryTime.toISOString().slice(0, 16));
|
||||
|
||||
// Set return time limit (4 hours from now for RBC, 6 hours for platelets)
|
||||
var returnTime = new Date(now.getTime() + 4 * 60 * 60000);
|
||||
$('#returnTime').val(returnTime.toISOString().slice(0, 16));
|
||||
|
||||
// Update displays when form changes
|
||||
updateIssueSummary();
|
||||
|
||||
// Event listeners
|
||||
$('.verification-checklist input[type="checkbox"]').on('change', function() {
|
||||
validateVerificationChecklist();
|
||||
});
|
||||
|
||||
$('#transportMethod, #transportContainer').on('change', function() {
|
||||
updateIssueSummary();
|
||||
});
|
||||
|
||||
// Form validation
|
||||
$('#bloodIssueForm').on('submit', function(e) {
|
||||
if (!validateIssue()) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function selectUnit(unitCard) {
|
||||
var unitId = $(unitCard).data('unit-id');
|
||||
|
||||
if ($(unitCard).hasClass('incompatible')) {
|
||||
Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'Incompatible Unit',
|
||||
text: 'This unit may not be compatible. Please verify crossmatch results.',
|
||||
confirmButtonText: 'OK'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if ($(unitCard).hasClass('selected')) {
|
||||
// Deselect unit
|
||||
$(unitCard).removeClass('selected');
|
||||
selectedUnits = selectedUnits.filter(id => id !== unitId);
|
||||
} else {
|
||||
// Select unit
|
||||
$(unitCard).addClass('selected');
|
||||
selectedUnits.push(unitId);
|
||||
}
|
||||
|
||||
$('#selectedUnits').val(selectedUnits.join(','));
|
||||
updateIssueSummary();
|
||||
validateIssue();
|
||||
}
|
||||
|
||||
function updateIssueSummary() {
|
||||
if (selectedUnits.length === 0) {
|
||||
$('#issueSummary').html('<p class="text-muted">Select blood units and complete the form to preview issue details</p>');
|
||||
return;
|
||||
}
|
||||
|
||||
var summaryHtml = '<div class="row">';
|
||||
summaryHtml += '<div class="col-md-6">';
|
||||
summaryHtml += '<h6>Selected Units (' + selectedUnits.length + ')</h6>';
|
||||
|
||||
selectedUnits.forEach(function(unitId) {
|
||||
var unitCard = $('.unit-card[data-unit-id="' + unitId + '"]');
|
||||
var unitNumber = unitCard.find('h6').text();
|
||||
summaryHtml += '<div class="badge bg-success me-1 mb-1">' + unitNumber + '</div>';
|
||||
});
|
||||
|
||||
summaryHtml += '</div>';
|
||||
summaryHtml += '<div class="col-md-6">';
|
||||
summaryHtml += '<h6>Issue Details</h6>';
|
||||
|
||||
var issuedBy = $('#issuedBy option:selected').text();
|
||||
var receivedBy = $('#receivedBy option:selected').text();
|
||||
var transportMethod = $('#transportMethod option:selected').text();
|
||||
var issueDate = $('#issueDate').val();
|
||||
|
||||
if (issuedBy && issuedBy !== 'Select staff member...') {
|
||||
summaryHtml += '<p><strong>Issued By:</strong> ' + issuedBy + '</p>';
|
||||
}
|
||||
if (receivedBy && receivedBy !== 'Select receiving staff...') {
|
||||
summaryHtml += '<p><strong>Received By:</strong> ' + receivedBy + '</p>';
|
||||
}
|
||||
if (transportMethod && transportMethod !== 'Select method...') {
|
||||
summaryHtml += '<p><strong>Transport:</strong> ' + transportMethod + '</p>';
|
||||
}
|
||||
if (issueDate) {
|
||||
summaryHtml += '<p><strong>Issue Time:</strong> ' + new Date(issueDate).toLocaleString() + '</p>';
|
||||
}
|
||||
|
||||
summaryHtml += '</div>';
|
||||
summaryHtml += '</div>';
|
||||
|
||||
// Add emergency warning if applicable
|
||||
{% if blood_request.urgency == 'emergency' %}
|
||||
summaryHtml += '<div class="alert alert-danger mt-2">';
|
||||
summaryHtml += '<strong>⚠️ EMERGENCY ISSUE:</strong> Expedited processing and transport required.';
|
||||
summaryHtml += '</div>';
|
||||
{% endif %}
|
||||
|
||||
$('#issueSummary').html(summaryHtml);
|
||||
}
|
||||
|
||||
function validateVerificationChecklist() {
|
||||
var checkboxes = $('.verification-checklist input[type="checkbox"]:not(#emergencyProtocol):not(#emergencyConsent)');
|
||||
var checkedCount = checkboxes.filter(':checked').length;
|
||||
var totalCount = checkboxes.length;
|
||||
|
||||
$('#submitBtn').prop('disabled', checkedCount < totalCount || selectedUnits.length === 0);
|
||||
|
||||
if (checkedCount === totalCount) {
|
||||
$('.verification-checklist').removeClass('border-warning').addClass('border-success');
|
||||
} else {
|
||||
$('.verification-checklist').removeClass('border-success').addClass('border-warning');
|
||||
}
|
||||
}
|
||||
|
||||
function validateIssue() {
|
||||
var errors = [];
|
||||
|
||||
// Check selected units
|
||||
if (selectedUnits.length === 0) {
|
||||
errors.push('Please select at least one blood unit');
|
||||
}
|
||||
|
||||
// Check required fields
|
||||
if (!$('#issueDate').val()) {
|
||||
errors.push('Please enter issue date and time');
|
||||
}
|
||||
|
||||
if (!$('#issuedBy').val()) {
|
||||
errors.push('Please select who is issuing the blood');
|
||||
}
|
||||
|
||||
if (!$('#receivedBy').val()) {
|
||||
errors.push('Please select who is receiving the blood');
|
||||
}
|
||||
|
||||
if (!$('#transportMethod').val()) {
|
||||
errors.push('Please select transport method');
|
||||
}
|
||||
|
||||
if (!$('#transportContainer').val()) {
|
||||
errors.push('Please select transport container');
|
||||
}
|
||||
|
||||
// Check verification checklist
|
||||
var requiredCheckboxes = $('.verification-checklist input[type="checkbox"]:not(#emergencyProtocol):not(#emergencyConsent)');
|
||||
var checkedCount = requiredCheckboxes.filter(':checked').length;
|
||||
|
||||
if (checkedCount < requiredCheckboxes.length) {
|
||||
errors.push('Please complete all verification checklist items');
|
||||
}
|
||||
|
||||
// Check quantity match
|
||||
var requestedQuantity = {{ blood_request.quantity_requested }};
|
||||
if (selectedUnits.length !== requestedQuantity) {
|
||||
errors.push('Selected units (' + selectedUnits.length + ') do not match requested quantity (' + requestedQuantity + ')');
|
||||
}
|
||||
|
||||
// Enable/disable submit button
|
||||
$('#submitBtn').prop('disabled', errors.length > 0);
|
||||
|
||||
if (errors.length > 0) {
|
||||
if (errors.length < 8) { // Only show errors if validation was explicitly requested
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Validation Errors',
|
||||
html: '<ul class="text-start"><li>' + errors.join('</li><li>') + '</li></ul>',
|
||||
confirmButtonText: 'OK'
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: 'Validation Passed',
|
||||
text: 'All requirements met. Ready to issue blood units.',
|
||||
timer: 1500,
|
||||
showConfirmButton: false
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -0,0 +1,542 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Delete QC Record - {{ qc_record.test_name }}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.delete-warning {
|
||||
background: #f8d7da;
|
||||
border: 2px solid #dc3545;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.8; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.qc-info {
|
||||
background: #d1ecf1;
|
||||
border-left: 4px solid #17a2b8;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.impact-assessment {
|
||||
background: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.compliance-warning {
|
||||
background: #f8d7da;
|
||||
border: 2px solid #dc3545;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.deletion-options {
|
||||
background: #d4edda;
|
||||
border-left: 4px solid #28a745;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.required-field {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.regulatory-alert {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- BEGIN breadcrumb -->
|
||||
<ol class="breadcrumb float-xl-end">
|
||||
<li class="breadcrumb-item"><a href="{% url 'core:dashboard' %}">Home</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'blood_bank:dashboard' %}">Blood Bank</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'blood_bank:quality_control_list' %}">Quality Control</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'blood_bank:quality_control_detail' qc_record.id %}">{{ qc_record.test_name }}</a></li>
|
||||
<li class="breadcrumb-item active">Delete Record</li>
|
||||
</ol>
|
||||
<!-- END breadcrumb -->
|
||||
|
||||
<!-- BEGIN page-header -->
|
||||
<h1 class="page-header">
|
||||
Delete QC Record
|
||||
<small>{{ qc_record.test_name }}</small>
|
||||
{% if qc_record.result == 'failed' %}
|
||||
<span class="badge bg-danger ms-2">FAILED TEST</span>
|
||||
{% endif %}
|
||||
</h1>
|
||||
<!-- END page-header -->
|
||||
|
||||
<!-- BEGIN regulatory alert -->
|
||||
<div class="regulatory-alert">
|
||||
<i class="fa fa-exclamation-triangle fa-2x"></i><br>
|
||||
WARNING: Deleting QC records may violate regulatory compliance requirements.<br>
|
||||
Consider archiving instead of permanent deletion.
|
||||
</div>
|
||||
<!-- END regulatory alert -->
|
||||
|
||||
<!-- BEGIN panel -->
|
||||
<div class="panel panel-inverse">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<i class="fa fa-trash"></i> Delete Quality Control Record
|
||||
</h4>
|
||||
<div class="panel-heading-btn">
|
||||
<span class="badge bg-danger">CRITICAL ACTION</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<!-- BEGIN delete warning -->
|
||||
<div class="delete-warning">
|
||||
<h5><i class="fa fa-exclamation-triangle"></i> CRITICAL ACTION REQUIRED</h5>
|
||||
<p class="mb-2"><strong>You are about to permanently delete a quality control record.</strong></p>
|
||||
<p class="mb-0">This action will:</p>
|
||||
<ul class="mb-0">
|
||||
<li>Permanently remove the QC test record from the system</li>
|
||||
<li>Delete all associated test results and data</li>
|
||||
<li>Remove compliance documentation</li>
|
||||
<li>Impact regulatory audit trails</li>
|
||||
<li>Potentially violate FDA, AABB, and CAP requirements</li>
|
||||
{% if qc_record.result == 'failed' %}
|
||||
<li><strong>Delete critical failure documentation</strong></li>
|
||||
{% endif %}
|
||||
{% if qc_record.capa_initiated %}
|
||||
<li><strong>Affect active CAPA investigations</strong></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
<!-- END delete warning -->
|
||||
|
||||
<!-- BEGIN QC record information -->
|
||||
<div class="qc-info">
|
||||
<h5><i class="fa fa-clipboard-check"></i> QC Record Information</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<table class="table table-borderless mb-0">
|
||||
<tr>
|
||||
<td class="fw-bold">Test Name:</td>
|
||||
<td>{{ qc_record.test_name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Test Type:</td>
|
||||
<td>{{ qc_record.get_test_type_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Test Date:</td>
|
||||
<td>{{ qc_record.test_date|date:"M d, Y H:i" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Tested By:</td>
|
||||
<td>{{ qc_record.tested_by.get_full_name }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<table class="table table-borderless mb-0">
|
||||
<tr>
|
||||
<td class="fw-bold">Result:</td>
|
||||
<td>
|
||||
<span class="badge bg-{% if qc_record.result == 'passed' %}success{% elif qc_record.result == 'failed' %}danger{% else %}warning{% endif %}">
|
||||
{{ qc_record.get_result_display }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Sample ID:</td>
|
||||
<td>{{ qc_record.sample_id|default:"Not specified" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Equipment:</td>
|
||||
<td>{{ qc_record.equipment_used|default:"Not specified" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">Reviewed:</td>
|
||||
<td>
|
||||
{% if qc_record.reviewed_by %}
|
||||
<span class="text-success">Yes - {{ qc_record.reviewed_by.get_full_name }}</span>
|
||||
{% else %}
|
||||
<span class="text-warning">Pending Review</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if qc_record.test_notes %}
|
||||
<div class="mt-3">
|
||||
<h6>Test Notes:</h6>
|
||||
<div class="alert alert-info">{{ qc_record.test_notes }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<!-- END QC record information -->
|
||||
|
||||
<!-- BEGIN compliance warning -->
|
||||
<div class="compliance-warning">
|
||||
<h5><i class="fa fa-shield-alt"></i> Regulatory Compliance Impact</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>Affected Standards</h6>
|
||||
<ul>
|
||||
<li><strong>FDA 21 CFR Part 606:</strong> QC record retention requirements</li>
|
||||
<li><strong>AABB Standards:</strong> Quality documentation requirements</li>
|
||||
<li><strong>ISO 15189:</strong> Quality management system records</li>
|
||||
<li><strong>CAP Requirements:</strong> Laboratory quality assurance</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>Compliance Risks</h6>
|
||||
<ul>
|
||||
<li>Loss of audit trail integrity</li>
|
||||
<li>Regulatory inspection findings</li>
|
||||
<li>Accreditation issues</li>
|
||||
<li>Legal liability concerns</li>
|
||||
{% if qc_record.result == 'failed' %}
|
||||
<li><strong>Loss of failure investigation records</strong></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if qc_record.result == 'failed' %}
|
||||
<div class="alert alert-danger mt-3">
|
||||
<strong><i class="fa fa-exclamation-triangle"></i> CRITICAL:</strong>
|
||||
This is a failed QC test. Deleting this record may violate regulatory requirements for failure investigation documentation.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if qc_record.capa_initiated %}
|
||||
<div class="alert alert-warning mt-3">
|
||||
<strong><i class="fa fa-exclamation-circle"></i> ACTIVE CAPA:</strong>
|
||||
This record is associated with an active CAPA ({{ qc_record.capa_number }}). Deletion may impact the investigation.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<!-- END compliance warning -->
|
||||
|
||||
<!-- BEGIN impact assessment -->
|
||||
<div class="impact-assessment">
|
||||
<h5><i class="fa fa-exclamation-circle"></i> Impact Assessment</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>Related Records</h6>
|
||||
<ul>
|
||||
{% if qc_record.blood_unit %}
|
||||
<li><strong>Blood Unit:</strong> {{ qc_record.blood_unit.unit_number }}</li>
|
||||
{% endif %}
|
||||
{% if qc_record.equipment_used %}
|
||||
<li><strong>Equipment:</strong> {{ qc_record.equipment_used }}</li>
|
||||
{% endif %}
|
||||
<li><strong>Test Results:</strong> All parameter data will be lost</li>
|
||||
<li><strong>Trend Data:</strong> Historical trending will be affected</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>System Impact</h6>
|
||||
<ul>
|
||||
<li>QC statistics and reports will be affected</li>
|
||||
<li>Trend analysis data will be incomplete</li>
|
||||
<li>Audit trail will show deletion event</li>
|
||||
<li>Compliance reports may be impacted</li>
|
||||
{% if qc_record.result == 'failed' %}
|
||||
<li><strong>Failure rate calculations will change</strong></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END impact assessment -->
|
||||
|
||||
<!-- BEGIN deletion form -->
|
||||
<form method="post" id="deletionForm">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="deletion-options">
|
||||
<h5><i class="fa fa-clipboard-list"></i> Deletion Authorization</h5>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="deletionReason" class="form-label">
|
||||
Deletion Reason <span class="required-field">*</span>
|
||||
</label>
|
||||
<select class="form-select" id="deletionReason" name="deletion_reason" required>
|
||||
<option value="">Select reason...</option>
|
||||
<option value="data_entry_error">Data entry error</option>
|
||||
<option value="duplicate_record">Duplicate record</option>
|
||||
<option value="test_invalidated">Test invalidated</option>
|
||||
<option value="equipment_malfunction">Equipment malfunction</option>
|
||||
<option value="sample_contamination">Sample contamination</option>
|
||||
<option value="procedural_error">Procedural error</option>
|
||||
<option value="regulatory_approval">Regulatory approval obtained</option>
|
||||
<option value="other">Other (specify below)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="authorizedBy" class="form-label">
|
||||
Authorized By <span class="required-field">*</span>
|
||||
</label>
|
||||
<select class="form-select" id="authorizedBy" name="authorized_by" required>
|
||||
<option value="">Select authorizing person...</option>
|
||||
{% for supervisor in supervisors %}
|
||||
<option value="{{ supervisor.id }}">{{ supervisor.get_full_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="authorizationCode" class="form-label">
|
||||
Authorization Code <span class="required-field">*</span>
|
||||
</label>
|
||||
<input type="text" class="form-control" id="authorizationCode" name="authorization_code"
|
||||
placeholder="Enter supervisor authorization code" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="regulatoryApproval" class="form-label">
|
||||
Regulatory Approval
|
||||
</label>
|
||||
<input type="text" class="form-control" id="regulatoryApproval" name="regulatory_approval"
|
||||
placeholder="Reference number (if applicable)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="form-group">
|
||||
<label for="deletionJustification" class="form-label">
|
||||
Detailed Justification <span class="required-field">*</span>
|
||||
</label>
|
||||
<textarea class="form-control" id="deletionJustification" name="deletion_justification"
|
||||
rows="4" required placeholder="Provide detailed justification for deletion, including regulatory compliance considerations..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="alternativeAction" class="form-label">Alternative Action Considered</label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="archiveConsidered" name="archive_considered">
|
||||
<label class="form-check-label" for="archiveConsidered">
|
||||
Archive record instead of deletion
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="correctionConsidered" name="correction_considered">
|
||||
<label class="form-check-label" for="correctionConsidered">
|
||||
Correct data instead of deletion
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="mitigationMeasures" class="form-label">Mitigation Measures</label>
|
||||
<textarea class="form-control" id="mitigationMeasures" name="mitigation_measures"
|
||||
rows="3" placeholder="Describe measures to prevent similar issues..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BEGIN confirmation checklist -->
|
||||
<div class="alert alert-danger">
|
||||
<h6><i class="fa fa-check-square"></i> Critical Confirmation Checklist</h6>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="confirmRegulatory" required>
|
||||
<label class="form-check-label" for="confirmRegulatory">
|
||||
I confirm that this deletion complies with all regulatory requirements
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="confirmAuditTrail" required>
|
||||
<label class="form-check-label" for="confirmAuditTrail">
|
||||
I understand that this action will be permanently recorded in the audit trail
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="confirmImpact" required>
|
||||
<label class="form-check-label" for="confirmImpact">
|
||||
I acknowledge the impact on compliance and quality documentation
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="confirmAuthorization" required>
|
||||
<label class="form-check-label" for="confirmAuthorization">
|
||||
I have proper authorization to perform this critical action
|
||||
</label>
|
||||
</div>
|
||||
{% if qc_record.result == 'failed' %}
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="confirmFailureRecord" required>
|
||||
<label class="form-check-label" for="confirmFailureRecord">
|
||||
I understand that deleting a failed QC record may violate regulatory requirements
|
||||
</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if qc_record.capa_initiated %}
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="confirmCapa" required>
|
||||
<label class="form-check-label" for="confirmCapa">
|
||||
I acknowledge that this record is associated with an active CAPA investigation
|
||||
</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<!-- END confirmation checklist -->
|
||||
|
||||
<!-- BEGIN form actions -->
|
||||
<div class="d-flex justify-content-between mt-4">
|
||||
<a href="{% url 'blood_bank:quality_control_detail' qc_record.id %}" class="btn btn-secondary">
|
||||
<i class="fa fa-arrow-left"></i> Cancel Deletion
|
||||
</a>
|
||||
<div>
|
||||
<button type="button" class="btn btn-warning" onclick="suggestArchive()">
|
||||
<i class="fa fa-archive"></i> Archive Instead
|
||||
</button>
|
||||
<button type="button" class="btn btn-info" onclick="validateDeletion()">
|
||||
<i class="fa fa-check"></i> Validate Deletion
|
||||
</button>
|
||||
<button type="submit" class="btn btn-danger" id="submitBtn" disabled>
|
||||
<i class="fa fa-trash"></i> Permanently Delete Record
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END form actions -->
|
||||
</form>
|
||||
<!-- END deletion form -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- END panel -->
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Form validation
|
||||
$('.form-check-input, #deletionReason, #authorizedBy, #authorizationCode, #deletionJustification').on('change input', function() {
|
||||
validateDeletion();
|
||||
});
|
||||
|
||||
$('#deletionForm').on('submit', function(e) {
|
||||
if (!validateDeletion()) {
|
||||
e.preventDefault();
|
||||
} else {
|
||||
// Final confirmation
|
||||
if (!confirm('Are you absolutely certain you want to permanently delete this QC record? This action cannot be undone and may violate regulatory requirements.')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function validateDeletion() {
|
||||
var errors = [];
|
||||
|
||||
// Check required fields
|
||||
if (!$('#deletionReason').val()) {
|
||||
errors.push('Please select a deletion reason');
|
||||
}
|
||||
|
||||
if (!$('#authorizedBy').val()) {
|
||||
errors.push('Please select the authorizing person');
|
||||
}
|
||||
|
||||
if (!$('#authorizationCode').val().trim()) {
|
||||
errors.push('Please enter the authorization code');
|
||||
}
|
||||
|
||||
if (!$('#deletionJustification').val().trim()) {
|
||||
errors.push('Please provide detailed justification');
|
||||
}
|
||||
|
||||
// Check confirmation checkboxes
|
||||
var requiredCheckboxes = ['confirmRegulatory', 'confirmAuditTrail', 'confirmImpact', 'confirmAuthorization'];
|
||||
{% if qc_record.result == 'failed' %}
|
||||
requiredCheckboxes.push('confirmFailureRecord');
|
||||
{% endif %}
|
||||
{% if qc_record.capa_initiated %}
|
||||
requiredCheckboxes.push('confirmCapa');
|
||||
{% endif %}
|
||||
|
||||
var uncheckedBoxes = requiredCheckboxes.filter(function(id) {
|
||||
return !document.getElementById(id).checked;
|
||||
});
|
||||
|
||||
if (uncheckedBoxes.length > 0) {
|
||||
errors.push('Please confirm all checklist items');
|
||||
}
|
||||
|
||||
// Enable/disable submit button
|
||||
$('#submitBtn').prop('disabled', errors.length > 0);
|
||||
|
||||
if (errors.length > 0 && errors.length < 6) {
|
||||
// Only show errors if there are just a few (user is actively trying to submit)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function suggestArchive() {
|
||||
Swal.fire({
|
||||
title: 'Consider Archiving',
|
||||
html: `
|
||||
<div class="text-start">
|
||||
<p><strong>Archiving is often a better alternative to deletion:</strong></p>
|
||||
<ul>
|
||||
<li>Maintains regulatory compliance</li>
|
||||
<li>Preserves audit trail integrity</li>
|
||||
<li>Allows future reference if needed</li>
|
||||
<li>Meets retention requirements</li>
|
||||
</ul>
|
||||
<p>Would you like to archive this record instead?</p>
|
||||
</div>
|
||||
`,
|
||||
icon: 'info',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Yes, Archive Instead',
|
||||
cancelButtonText: 'Continue with Deletion'
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
// In real implementation, this would redirect to archive function
|
||||
window.location.href = '{% url "blood_bank:quality_control_detail" qc_record.id %}';
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user