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.http import JsonResponse
|
||||||
from django.contrib import messages
|
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.utils import timezone
|
||||||
from django.urls import reverse_lazy, reverse
|
from django.urls import reverse_lazy, reverse
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
@ -29,6 +30,7 @@ from accounts.models import User
|
|||||||
from core.utils import AuditLogger
|
from core.utils import AuditLogger
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# DASHBOARD VIEW
|
# DASHBOARD VIEW
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@ -1334,6 +1336,7 @@ def available_slots(request):
|
|||||||
def queue_status(request, queue_id):
|
def queue_status(request, queue_id):
|
||||||
"""
|
"""
|
||||||
HTMX view for queue status.
|
HTMX view for queue status.
|
||||||
|
Shows queue entries plus aggregated stats with DB-side wait calculations.
|
||||||
"""
|
"""
|
||||||
tenant = getattr(request, 'tenant', None)
|
tenant = getattr(request, 'tenant', None)
|
||||||
if not tenant:
|
if not tenant:
|
||||||
@ -1341,25 +1344,66 @@ def queue_status(request, queue_id):
|
|||||||
|
|
||||||
queue = get_object_or_404(WaitingQueue, id=queue_id, tenant=tenant)
|
queue = get_object_or_404(WaitingQueue, id=queue_id, tenant=tenant)
|
||||||
|
|
||||||
# Get queue entries
|
queue_entries = (
|
||||||
queue_entries = QueueEntry.objects.filter(
|
QueueEntry.objects
|
||||||
queue=queue
|
.filter(queue=queue)
|
||||||
).order_by('position', 'created_at')
|
.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 = {
|
stats = {
|
||||||
'total_entries': queue_entries.count(),
|
'total_entries': total_entries,
|
||||||
'waiting_entries': queue_entries.filter(status='WAITING').count(),
|
'waiting_entries': waiting_entries,
|
||||||
'in_progress_entries': queue_entries.filter(status='IN_PROGRESS').count(),
|
'called_entries': called_entries,
|
||||||
'average_wait_time': queue_entries.filter(
|
'in_service_entries': in_service_entries,
|
||||||
status='COMPLETED'
|
'completed_entries': completed_entries,
|
||||||
).aggregate(avg_wait=Avg('actual_wait_time_minutes'))['avg_wait'] or 0,
|
# 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', {
|
return render(request, 'appointments/partials/queue_status.html', {
|
||||||
'queue': queue,
|
'queue': queue,
|
||||||
'queue_entries': queue_entries,
|
'queue_entries': queue_entries, # each has .wait_minutes
|
||||||
'stats': stats
|
'stats': stats,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@ -1536,7 +1580,7 @@ def next_in_queue(request, queue_id):
|
|||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'patient': str(next_entry.patient),
|
'patient': str(next_entry.patient),
|
||||||
'position': next_entry.position
|
'position': next_entry.queue_position
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
return JsonResponse({'status': 'no_patients'})
|
return JsonResponse({'status': 'no_patients'})
|
||||||
@ -1708,12 +1752,19 @@ class SchedulingCalendarView(LoginRequiredMixin, TemplateView):
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
class QueueManagementView(LoginRequiredMixin, TemplateView):
|
class QueueManagementView(LoginRequiredMixin, ListView):
|
||||||
"""
|
"""
|
||||||
Queue management view for appointments.
|
Queue management view for appointments.
|
||||||
"""
|
"""
|
||||||
|
model = QueueEntry
|
||||||
template_name = 'appointments/queue_management.html'
|
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):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**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
|
from decimal import Decimal
|
||||||
import operator
|
import operator
|
||||||
from functools import reduce
|
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()
|
register = template.Library()
|
||||||
@ -320,3 +325,112 @@ def calculate_stock_value(inventory_items):
|
|||||||
return Decimal('0.00')
|
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 = [
|
LOCAL_APPS = [
|
||||||
'core',
|
'core',
|
||||||
'accounts',
|
'accounts',
|
||||||
|
'blood_bank',
|
||||||
'patients',
|
'patients',
|
||||||
'appointments',
|
'appointments',
|
||||||
'inpatients',
|
'inpatients',
|
||||||
|
|||||||
@ -37,6 +37,7 @@ urlpatterns += i18n_patterns(
|
|||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
path('', include('core.urls')),
|
path('', include('core.urls')),
|
||||||
path('accounts/', include('accounts.urls')),
|
path('accounts/', include('accounts.urls')),
|
||||||
|
path('blood-bank/', include('blood_bank.urls')),
|
||||||
path('patients/', include('patients.urls')),
|
path('patients/', include('patients.urls')),
|
||||||
path('appointments/', include('appointments.urls')),
|
path('appointments/', include('appointments.urls')),
|
||||||
path('inpatients/', include('inpatients.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',
|
default='STANDARD',
|
||||||
help_text='Type of bed'
|
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(
|
room_type = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
choices=ROOM_TYPE_CHOICES,
|
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.core.exceptions import ValidationError
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from datetime import datetime, time, timedelta
|
from datetime import datetime, time, timedelta
|
||||||
|
import json, re
|
||||||
from accounts.models import User
|
from accounts.models import User
|
||||||
from patients.models import PatientProfile
|
from patients.models import PatientProfile
|
||||||
from .models import (
|
from .models import (
|
||||||
@ -20,92 +20,157 @@ from .models import (
|
|||||||
# OPERATING ROOM FORMS
|
# 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):
|
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:
|
class Meta:
|
||||||
model = OperatingRoom
|
model = OperatingRoom
|
||||||
fields = [
|
fields = [
|
||||||
'room_number', 'room_name', 'room_type', 'floor_number', 'building',
|
# Basic
|
||||||
'room_size', 'equipment_list', 'special_features',
|
'room_number', 'room_name', 'room_type', 'status',
|
||||||
'status', 'is_active', 'wing'
|
# 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 = {
|
widgets = {
|
||||||
'room_number': forms.TextInput(attrs={
|
# Basic
|
||||||
'class': 'form-control',
|
'room_number': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g., OR-01', 'required': True}),
|
||||||
'placeholder': 'e.g., OR-01'
|
'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}),
|
||||||
'room_name': forms.TextInput(attrs={
|
'status': forms.Select(attrs={'class': 'form-control', 'required': True}),
|
||||||
'class': 'form-control',
|
# Physical
|
||||||
'placeholder': 'e.g., Main Operating Room 1'
|
'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'}),
|
||||||
'room_type': forms.Select(attrs={'class': 'form-control'}),
|
'wing': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g., East Wing'}),
|
||||||
'floor_number': forms.NumberInput(attrs={
|
'room_size': forms.NumberInput(attrs={'class': 'form-control', 'min': 1, 'max': 2000}),
|
||||||
'class': 'form-control',
|
'ceiling_height': forms.NumberInput(attrs={'class': 'form-control', 'min': 1, 'max': 20}),
|
||||||
'min': 1,
|
# Environment
|
||||||
'max': 50
|
'temperature_min': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.1'}),
|
||||||
}),
|
'temperature_max': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.1'}),
|
||||||
'building': forms.TextInput(attrs={
|
'humidity_min': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.1'}),
|
||||||
'class': 'form-control',
|
'humidity_max': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.1'}),
|
||||||
'placeholder': 'e.g., Main Building'
|
'air_changes_per_hour':forms.NumberInput(attrs={'class': 'form-control', 'min': 0, 'max': 120}),
|
||||||
}),
|
'positive_pressure': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||||
'room_size': forms.NumberInput(attrs={
|
# Capabilities & imaging
|
||||||
'class': 'form-control',
|
'supports_robotic': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||||
'min': 1,
|
'supports_laparoscopic':forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||||
'max': 1000
|
'supports_microscopy': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||||
}),
|
'supports_laser': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||||
'equipment_list': forms.Textarea(attrs={
|
'has_c_arm': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||||
'class': 'form-control',
|
'has_ct': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||||
'rows': 4,
|
'has_mri': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||||
'placeholder': 'List available equipment...'
|
'has_ultrasound': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||||
}),
|
'has_neuromonitoring': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||||
'special_features': forms.Textarea(attrs={
|
'equipment_list': forms.Textarea(attrs={'class': 'form-control', 'rows': 4, 'placeholder': 'One per line, or comma-separated…'}),
|
||||||
'class': 'form-control',
|
'special_features': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Hybrid OR, laminar flow, etc.'}),
|
||||||
'rows': 3,
|
# Scheduling / staffing
|
||||||
'placeholder': 'Special capabilities and features...'
|
'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}),
|
||||||
'status': forms.Select(attrs={'class': 'form-control'}),
|
'cleaning_time': forms.NumberInput(attrs={'class': 'form-control', 'min': 0, 'max': 240}),
|
||||||
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
'required_nurses': forms.NumberInput(attrs={'class': 'form-control', 'min': 0, 'max': 20}),
|
||||||
'wing': forms.TextInput(attrs={
|
'required_techs': forms.NumberInput(attrs={'class': 'form-control', 'min': 0, 'max': 20}),
|
||||||
'class': 'form-control',
|
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||||
'placeholder': 'e.g., East Wing'
|
'accepts_emergency':forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
help_texts = {
|
help_texts = {
|
||||||
'room_number': 'Unique identifier for the operating room',
|
'room_number': 'Unique identifier (per tenant). Letters, numbers, dashes.',
|
||||||
'room_type': 'Type of procedures this room is designed for',
|
'room_size': 'Square meters.',
|
||||||
'room_size': 'Size of the room in square meters',
|
'temperature_min': 'Typical ORs: 18–26°C.',
|
||||||
'equipment_list': 'List of permanently installed equipment',
|
'humidity_min': 'Typical ORs: 30–60%.',
|
||||||
'special_features': 'Special features like imaging, robotics, etc.',
|
'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):
|
def clean_room_number(self):
|
||||||
room_number = self.cleaned_data['room_number']
|
room_number = (self.cleaned_data.get('room_number') or '').strip()
|
||||||
|
if not room_number:
|
||||||
# Check for uniqueness within tenant
|
return room_number
|
||||||
queryset = OperatingRoom.objects.filter(room_number=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:
|
if self.instance.pk:
|
||||||
queryset = queryset.exclude(pk=self.instance.pk)
|
qs = qs.exclude(pk=self.instance.pk)
|
||||||
|
if qs.exists():
|
||||||
if queryset.exists():
|
raise ValidationError('Room number must be unique within the tenant.')
|
||||||
raise ValidationError('Room number must be unique.')
|
|
||||||
|
|
||||||
return room_number
|
return room_number
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
cleaned_data = super().clean()
|
cleaned = super().clean()
|
||||||
status = cleaned_data.get('status')
|
# Temperature/humidity ranges
|
||||||
is_active = cleaned_data.get('is_active')
|
tmin, tmax = cleaned.get('temperature_min'), cleaned.get('temperature_max')
|
||||||
|
hmin, hmax = cleaned.get('humidity_min'), cleaned.get('humidity_max')
|
||||||
# Validate status and active state consistency
|
if tmin is not None and tmax is not None and tmin >= tmax:
|
||||||
if not is_active and status not in ['OUT_OF_SERVICE', 'MAINTENANCE']:
|
self.add_error('temperature_max', 'Maximum temperature must be greater than minimum temperature.')
|
||||||
raise ValidationError(
|
if hmin is not None and hmax is not None and hmin >= hmax:
|
||||||
'Inactive rooms must have status "Out of Service" or "Maintenance".'
|
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)]:
|
||||||
return cleaned_data
|
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
|
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
|
@property
|
||||||
def current_case(self):
|
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').order_by('-scheduled_start').first()
|
||||||
return self.surgical_cases.filter(
|
|
||||||
status='IN_PROGRESS'
|
|
||||||
).first()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class ORBlock(models.Model):
|
class ORBlock(models.Model):
|
||||||
@ -812,7 +821,7 @@ class SurgicalNote(models.Model):
|
|||||||
surgical_case = models.OneToOneField(
|
surgical_case = models.OneToOneField(
|
||||||
SurgicalCase,
|
SurgicalCase,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name='surgical_note',
|
related_name='surgical_notes',
|
||||||
help_text='Related surgical case'
|
help_text='Related surgical case'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -828,7 +837,7 @@ class SurgicalNote(models.Model):
|
|||||||
surgeon = models.ForeignKey(
|
surgeon = models.ForeignKey(
|
||||||
settings.AUTH_USER_MODEL,
|
settings.AUTH_USER_MODEL,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name='surgical_notes',
|
related_name='surgeon_surgical_notes',
|
||||||
help_text='Operating surgeon'
|
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>/', 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>/update/', views.OperatingRoomUpdateView.as_view(), name='operating_room_update'),
|
||||||
path('rooms/<int:pk>/delete/', views.OperatingRoomDeleteView.as_view(), name='operating_room_delete'),
|
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)
|
# 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/', views.SurgicalNoteListView.as_view(), name='surgical_note_list'),
|
||||||
path('notes/create/', views.SurgicalNoteCreateView.as_view(), name='surgical_note_create'),
|
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>/', 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
|
# Note: No update/delete views for surgical notes - append-only for clinical records
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@ -75,8 +78,10 @@ urlpatterns = [
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
# ACTION URLS FOR WORKFLOW OPERATIONS
|
# ACTION URLS FOR WORKFLOW OPERATIONS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
path('cases/<int:case_id>/start/', views.start_case, name='start_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: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('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'),
|
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.contrib import admin
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
from .models import (
|
from .models import *
|
||||||
PatientProfile, EmergencyContact, InsuranceInfo,
|
|
||||||
ConsentTemplate, ConsentForm, PatientNote
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class EmergencyContactInline(admin.TabularInline):
|
class EmergencyContactInline(admin.TabularInline):
|
||||||
@ -425,3 +422,136 @@ class PatientNoteAdmin(admin.ModelAdmin):
|
|||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
return super().get_queryset(request).select_related('patient', 'created_by')
|
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 = [
|
fields = [
|
||||||
'name', 'description', 'category', 'content', 'version',
|
'name', 'description', 'category', 'content', 'version',
|
||||||
'is_active', 'requires_signature', 'requires_witness', 'requires_guardian',
|
'is_active', 'requires_signature', 'requires_witness', 'requires_guardian',
|
||||||
'effective_date', 'expiry_date'
|
'effective_date', 'expiry_date',
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
'name': forms.TextInput(attrs={'class': 'form-control'}),
|
'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.
|
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
|
# Basic Identifiers
|
||||||
patient_id = models.UUIDField(
|
patient_id = models.UUIDField(
|
||||||
@ -72,23 +125,12 @@ class PatientProfile(models.Model):
|
|||||||
)
|
)
|
||||||
gender = models.CharField(
|
gender = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
choices=[
|
choices=GENDER_CHOICES,
|
||||||
('MALE', 'Male'),
|
|
||||||
('FEMALE', 'Female'),
|
|
||||||
('OTHER', 'Other'),
|
|
||||||
('UNKNOWN', 'Unknown'),
|
|
||||||
('PREFER_NOT_TO_SAY', 'Prefer not to say'),
|
|
||||||
],
|
|
||||||
help_text='Gender'
|
help_text='Gender'
|
||||||
)
|
)
|
||||||
sex_assigned_at_birth = models.CharField(
|
sex_assigned_at_birth = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
choices=[
|
choices=SEX_ASSIGNED_AT_BIRTH_CHOICES,
|
||||||
('MALE', 'Male'),
|
|
||||||
('FEMALE', 'Female'),
|
|
||||||
('INTERSEX', 'Intersex'),
|
|
||||||
('UNKNOWN', 'Unknown'),
|
|
||||||
],
|
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
help_text='Sex assigned at birth'
|
help_text='Sex assigned at birth'
|
||||||
@ -97,28 +139,14 @@ class PatientProfile(models.Model):
|
|||||||
# Race and Ethnicity
|
# Race and Ethnicity
|
||||||
race = models.CharField(
|
race = models.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
choices=[
|
choices=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'),
|
|
||||||
],
|
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
help_text='Race'
|
help_text='Race'
|
||||||
)
|
)
|
||||||
ethnicity = models.CharField(
|
ethnicity = models.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
choices=[
|
choices=ETHNICITY_CHOICES,
|
||||||
('HISPANIC', 'Hispanic or Latino'),
|
|
||||||
('NON_HISPANIC', 'Not Hispanic or Latino'),
|
|
||||||
('UNKNOWN', 'Unknown'),
|
|
||||||
('DECLINED', 'Patient Declined'),
|
|
||||||
],
|
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
help_text='Ethnicity'
|
help_text='Ethnicity'
|
||||||
@ -215,16 +243,7 @@ class PatientProfile(models.Model):
|
|||||||
# Marital Status and Family
|
# Marital Status and Family
|
||||||
marital_status = models.CharField(
|
marital_status = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
choices=[
|
choices=MARITAL_STATUS_CHOICES,
|
||||||
('SINGLE', 'Single'),
|
|
||||||
('MARRIED', 'Married'),
|
|
||||||
('DIVORCED', 'Divorced'),
|
|
||||||
('WIDOWED', 'Widowed'),
|
|
||||||
('SEPARATED', 'Separated'),
|
|
||||||
('DOMESTIC_PARTNER', 'Domestic Partner'),
|
|
||||||
('OTHER', 'Other'),
|
|
||||||
('UNKNOWN', 'Unknown'),
|
|
||||||
],
|
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
help_text='Marital status'
|
help_text='Marital status'
|
||||||
@ -242,13 +261,7 @@ class PatientProfile(models.Model):
|
|||||||
)
|
)
|
||||||
communication_preference = models.CharField(
|
communication_preference = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
choices=[
|
choices=COMMUNICATION_PREFERENCE_CHOICES,
|
||||||
('PHONE', 'Phone'),
|
|
||||||
('EMAIL', 'Email'),
|
|
||||||
('SMS', 'SMS'),
|
|
||||||
('MAIL', 'Mail'),
|
|
||||||
('PORTAL', 'Patient Portal'),
|
|
||||||
],
|
|
||||||
default='PHONE',
|
default='PHONE',
|
||||||
help_text='Preferred communication method'
|
help_text='Preferred communication method'
|
||||||
)
|
)
|
||||||
@ -300,13 +313,7 @@ class PatientProfile(models.Model):
|
|||||||
)
|
)
|
||||||
advance_directive_type = models.CharField(
|
advance_directive_type = models.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
choices=[
|
choices=ADVANCE_DIRECTIVE_TYPE_CHOICES,
|
||||||
('LIVING_WILL', 'Living Will'),
|
|
||||||
('HEALTHCARE_PROXY', 'Healthcare Proxy'),
|
|
||||||
('DNR', 'Do Not Resuscitate'),
|
|
||||||
('POLST', 'POLST'),
|
|
||||||
('OTHER', 'Other'),
|
|
||||||
],
|
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
help_text='Type of advance directive'
|
help_text='Type of advance directive'
|
||||||
@ -445,7 +452,21 @@ class EmergencyContact(models.Model):
|
|||||||
"""
|
"""
|
||||||
Emergency contact information for patients.
|
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 relationship
|
||||||
patient = models.ForeignKey(
|
patient = models.ForeignKey(
|
||||||
PatientProfile,
|
PatientProfile,
|
||||||
@ -464,21 +485,7 @@ class EmergencyContact(models.Model):
|
|||||||
)
|
)
|
||||||
relationship = models.CharField(
|
relationship = models.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
choices=[
|
choices=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'),
|
|
||||||
],
|
|
||||||
help_text='Relationship to patient'
|
help_text='Relationship to patient'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -607,7 +614,36 @@ class InsuranceInfo(models.Model):
|
|||||||
"""
|
"""
|
||||||
Insurance information for patients.
|
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 relationship
|
||||||
patient = models.ForeignKey(
|
patient = models.ForeignKey(
|
||||||
PatientProfile,
|
PatientProfile,
|
||||||
@ -618,11 +654,7 @@ class InsuranceInfo(models.Model):
|
|||||||
# Insurance Details
|
# Insurance Details
|
||||||
insurance_type = models.CharField(
|
insurance_type = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
choices=[
|
choices=INSURANCE_TYPE_CHOICES,
|
||||||
('PRIMARY', 'Primary'),
|
|
||||||
('SECONDARY', 'Secondary'),
|
|
||||||
('TERTIARY', 'Tertiary'),
|
|
||||||
],
|
|
||||||
default='PRIMARY',
|
default='PRIMARY',
|
||||||
help_text='Insurance type'
|
help_text='Insurance type'
|
||||||
)
|
)
|
||||||
@ -640,24 +672,17 @@ class InsuranceInfo(models.Model):
|
|||||||
)
|
)
|
||||||
plan_type = models.CharField(
|
plan_type = models.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
choices=[
|
choices=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'),
|
|
||||||
],
|
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
help_text='Plan type'
|
help_text='Plan type'
|
||||||
)
|
)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=STATUS_CHOICES,
|
||||||
|
default='PENDING',
|
||||||
|
help_text='Insurance status'
|
||||||
|
)
|
||||||
# Policy Information
|
# Policy Information
|
||||||
policy_number = models.CharField(
|
policy_number = models.CharField(
|
||||||
max_length=100,
|
max_length=100,
|
||||||
@ -677,13 +702,7 @@ class InsuranceInfo(models.Model):
|
|||||||
)
|
)
|
||||||
subscriber_relationship = models.CharField(
|
subscriber_relationship = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
choices=[
|
choices=SUBSCRIBER_RELATIONSHIP_CHOICES,
|
||||||
('SELF', 'Self'),
|
|
||||||
('SPOUSE', 'Spouse'),
|
|
||||||
('CHILD', 'Child'),
|
|
||||||
('PARENT', 'Parent'),
|
|
||||||
('OTHER', 'Other'),
|
|
||||||
],
|
|
||||||
default='SELF',
|
default='SELF',
|
||||||
help_text='Relationship to subscriber'
|
help_text='Relationship to subscriber'
|
||||||
)
|
)
|
||||||
@ -777,6 +796,10 @@ class InsuranceInfo(models.Model):
|
|||||||
default=True,
|
default=True,
|
||||||
help_text='Insurance is active'
|
help_text='Insurance is active'
|
||||||
)
|
)
|
||||||
|
is_primary = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text='Primary insurance'
|
||||||
|
)
|
||||||
|
|
||||||
# Notes
|
# Notes
|
||||||
notes = models.TextField(
|
notes = models.TextField(
|
||||||
@ -814,10 +837,497 @@ class InsuranceInfo(models.Model):
|
|||||||
return today >= self.effective_date and self.is_active
|
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):
|
class ConsentTemplate(models.Model):
|
||||||
"""
|
"""
|
||||||
Templates for consent forms.
|
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 relationship
|
||||||
tenant = models.ForeignKey(
|
tenant = models.ForeignKey(
|
||||||
@ -838,17 +1348,7 @@ class ConsentTemplate(models.Model):
|
|||||||
)
|
)
|
||||||
category = models.CharField(
|
category = models.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
choices=[
|
choices=CATEGORY_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'),
|
|
||||||
],
|
|
||||||
help_text='Consent category'
|
help_text='Consent category'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -921,7 +1421,13 @@ class ConsentForm(models.Model):
|
|||||||
"""
|
"""
|
||||||
Patient consent forms.
|
Patient consent forms.
|
||||||
"""
|
"""
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('PENDING', 'Pending'),
|
||||||
|
('SIGNED', 'Signed'),
|
||||||
|
('DECLINED', 'Declined'),
|
||||||
|
('EXPIRED', 'Expired'),
|
||||||
|
('REVOKED', 'Revoked'),
|
||||||
|
]
|
||||||
# Patient relationship
|
# Patient relationship
|
||||||
patient = models.ForeignKey(
|
patient = models.ForeignKey(
|
||||||
PatientProfile,
|
PatientProfile,
|
||||||
@ -947,13 +1453,7 @@ class ConsentForm(models.Model):
|
|||||||
# Status
|
# Status
|
||||||
status = models.CharField(
|
status = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
choices=[
|
choices=STATUS_CHOICES,
|
||||||
('PENDING', 'Pending'),
|
|
||||||
('SIGNED', 'Signed'),
|
|
||||||
('DECLINED', 'Declined'),
|
|
||||||
('EXPIRED', 'Expired'),
|
|
||||||
('REVOKED', 'Revoked'),
|
|
||||||
],
|
|
||||||
default='PENDING',
|
default='PENDING',
|
||||||
help_text='Consent status'
|
help_text='Consent status'
|
||||||
)
|
)
|
||||||
@ -1136,6 +1636,24 @@ class PatientNote(models.Model):
|
|||||||
"""
|
"""
|
||||||
General notes and comments about patients.
|
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 relationship
|
||||||
patient = models.ForeignKey(
|
patient = models.ForeignKey(
|
||||||
@ -1164,18 +1682,7 @@ class PatientNote(models.Model):
|
|||||||
# Category
|
# Category
|
||||||
category = models.CharField(
|
category = models.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
choices=[
|
choices=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'),
|
|
||||||
],
|
|
||||||
default='GENERAL',
|
default='GENERAL',
|
||||||
help_text='Note category'
|
help_text='Note category'
|
||||||
)
|
)
|
||||||
@ -1183,12 +1690,7 @@ class PatientNote(models.Model):
|
|||||||
# Priority
|
# Priority
|
||||||
priority = models.CharField(
|
priority = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
choices=[
|
choices=PRIORITY_CHOICES,
|
||||||
('LOW', 'Low'),
|
|
||||||
('NORMAL', 'Normal'),
|
|
||||||
('HIGH', 'High'),
|
|
||||||
('URGENT', 'Urgent'),
|
|
||||||
],
|
|
||||||
default='NORMAL',
|
default='NORMAL',
|
||||||
help_text='Note priority'
|
help_text='Note priority'
|
||||||
)
|
)
|
||||||
|
|||||||
@ -14,14 +14,14 @@ urlpatterns = [
|
|||||||
path('register/', views.PatientCreateView.as_view(), name='patient_registration'),
|
path('register/', views.PatientCreateView.as_view(), name='patient_registration'),
|
||||||
path('update/<int:pk>/', views.PatientUpdateView.as_view(), name='patient_update'),
|
path('update/<int:pk>/', views.PatientUpdateView.as_view(), name='patient_update'),
|
||||||
path('delete/<int:pk>/', views.PatientDeleteView.as_view(), name='patient_delete'),
|
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('consent/<int:pk>/', views.ConsentFormDetailView.as_view(), name='consent_management_detail'),
|
||||||
path('emergency-contacts/', views.EmergencyContactListView.as_view(), name='emergency_contact_management'),
|
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-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/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/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('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/<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/delete/<int:pk>/', views.InsuranceInfoDeleteView.as_view(), name='insurance_delete'),
|
||||||
path('insurance-info/update/<int:pk>/', views.InsuranceInfoUpdateView.as_view(), name='insurance_update'),
|
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('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('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/<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('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('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('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('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