update
This commit is contained in:
parent
beba30e532
commit
2780a2dc7c
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
accounts/__pycache__/signals.cpython-312.pyc
Normal file
BIN
accounts/__pycache__/signals.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
@ -3,75 +3,45 @@ Admin configuration for accounts app.
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
|
||||
|
||||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||
from django.utils.html import format_html
|
||||
from .models import User, TwoFactorDevice, SocialAccount, UserSession, PasswordHistory
|
||||
from hr.models import Employee
|
||||
|
||||
class EmployeeInline(admin.StackedInline):
|
||||
model = Employee
|
||||
can_delete = False
|
||||
fk_name = 'user'
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
class UserAdmin(BaseUserAdmin):
|
||||
"""
|
||||
Admin configuration for User model.
|
||||
"""
|
||||
list_display = [
|
||||
'username', 'email', 'get_full_name', 'role', 'tenant',
|
||||
'is_active', 'is_verified', 'is_approved', 'last_login'
|
||||
]
|
||||
list_filter = [
|
||||
'role', 'tenant', 'is_active', 'is_verified', 'is_approved',
|
||||
'two_factor_enabled', 'is_staff', 'is_superuser'
|
||||
]
|
||||
search_fields = [
|
||||
'username', 'email', 'first_name', 'last_name',
|
||||
'employee_id', 'license_number', 'npi_number'
|
||||
]
|
||||
ordering = ['last_name', 'first_name']
|
||||
|
||||
fieldsets = BaseUserAdmin.fieldsets + (
|
||||
('Tenant Information', {
|
||||
'fields': ('tenant', 'user_id')
|
||||
}),
|
||||
('Personal Information', {
|
||||
'fields': (
|
||||
'middle_name', 'preferred_name', 'phone_number', 'mobile_number',
|
||||
'profile_picture', 'bio'
|
||||
)
|
||||
}),
|
||||
('Professional Information', {
|
||||
'fields': (
|
||||
'employee_id', 'department', 'job_title', 'role',
|
||||
'license_number', 'license_state', 'license_expiry',
|
||||
'dea_number', 'npi_number'
|
||||
)
|
||||
}),
|
||||
('Security Settings', {
|
||||
'fields': (
|
||||
'force_password_change', 'password_expires_at',
|
||||
class UserAdmin(DjangoUserAdmin):
|
||||
inlines = [EmployeeInline]
|
||||
list_display = ('username', 'email', 'tenant', 'is_active', 'is_staff', 'two_factor_enabled', 'locked_until')
|
||||
list_filter = ('tenant', 'is_active', 'is_staff', 'is_superuser', 'two_factor_enabled')
|
||||
search_fields = ('username', 'email', 'first_name', 'last_name')
|
||||
readonly_fields = ('last_login', 'date_joined', 'last_password_change')
|
||||
fieldsets = (
|
||||
(None, {'fields': ('tenant', 'username', 'password')}),
|
||||
('Personal info', {'fields': ('first_name', 'last_name', 'email')}),
|
||||
('Security', {'fields': (
|
||||
'force_password_change', 'password_expires_at', 'last_password_change',
|
||||
'failed_login_attempts', 'locked_until', 'two_factor_enabled',
|
||||
'max_concurrent_sessions', 'session_timeout_minutes'
|
||||
'max_concurrent_sessions', 'session_timeout_minutes',
|
||||
)}),
|
||||
('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}),
|
||||
('Important dates', {'fields': ('last_login', 'date_joined')}),
|
||||
)
|
||||
}),
|
||||
('Preferences', {
|
||||
'fields': ( 'language', 'theme')
|
||||
}),
|
||||
('Status', {
|
||||
'fields': (
|
||||
'is_verified', 'is_approved', 'approval_date', 'approved_by'
|
||||
)
|
||||
}),
|
||||
('Metadata', {
|
||||
'fields': ('created_at', 'updated_at', 'last_password_change'),
|
||||
'classes': ('collapse',)
|
||||
add_fieldsets = (
|
||||
(None, {
|
||||
'classes': ('wide',),
|
||||
'fields': ('tenant', 'username', 'email', 'password1', 'password2'),
|
||||
}),
|
||||
)
|
||||
|
||||
readonly_fields = [
|
||||
'user_id', 'created_at', 'updated_at', 'last_password_change'
|
||||
]
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).select_related('tenant', 'approved_by')
|
||||
|
||||
|
||||
@admin.register(TwoFactorDevice)
|
||||
class TwoFactorDeviceAdmin(admin.ModelAdmin):
|
||||
|
||||
@ -4,3 +4,6 @@ from django.apps import AppConfig
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'accounts'
|
||||
|
||||
def ready(self):
|
||||
from . import signals
|
||||
|
||||
@ -15,25 +15,23 @@ class UserForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
'first_name', 'last_name', 'email', 'phone_number', 'mobile_number',
|
||||
'employee_id', 'role', 'department', 'bio', 'user_timezone', 'language',
|
||||
'theme', 'is_active', 'is_approved'
|
||||
'email',
|
||||
]
|
||||
widgets = {
|
||||
'first_name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'last_name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
# 'first_name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
# 'last_name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'email': forms.EmailInput(attrs={'class': 'form-control'}),
|
||||
'phone_number': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'mobile_number': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'employee_id': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'role': forms.Select(attrs={'class': 'form-select'}),
|
||||
'department': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'bio': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
'user_timezone': forms.Select(attrs={'class': 'form-select'}),
|
||||
'language': forms.Select(attrs={'class': 'form-select'}),
|
||||
'theme': forms.Select(attrs={'class': 'form-select'}),
|
||||
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'is_approved': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
# 'phone_number': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
# 'mobile_number': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
# 'employee_id': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
# 'role': forms.Select(attrs={'class': 'form-select'}),
|
||||
# 'department': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
# 'bio': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
# 'user_timezone': forms.Select(attrs={'class': 'form-select'}),
|
||||
# 'language': forms.Select(attrs={'class': 'form-select'}),
|
||||
# 'theme': forms.Select(attrs={'class': 'form-select'}),
|
||||
# 'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
# 'is_approved': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
}
|
||||
|
||||
|
||||
@ -45,23 +43,22 @@ class UserCreateForm(UserCreationForm):
|
||||
last_name = forms.CharField(max_length=150, required=True)
|
||||
email = forms.EmailField(required=True)
|
||||
employee_id = forms.CharField(max_length=50, required=False)
|
||||
role = forms.ChoiceField(choices=User._meta.get_field('role').choices, required=True)
|
||||
# role = forms.ChoiceField(choices=User._meta.get_field('role').choices, required=True)
|
||||
department = forms.CharField(max_length=100, required=False)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
'username', 'first_name', 'last_name', 'email', 'employee_id',
|
||||
'role', 'department', 'password1', 'password2'
|
||||
'username', 'first_name', 'last_name', 'email', 'password1', 'password2'
|
||||
]
|
||||
widgets = {
|
||||
'username': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'first_name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'last_name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'email': forms.EmailInput(attrs={'class': 'form-control'}),
|
||||
'employee_id': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'role': forms.Select(attrs={'class': 'form-select'}),
|
||||
'department': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
# 'employee_id': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
# 'role': forms.Select(attrs={'class': 'form-select'}),
|
||||
# 'department': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@ -148,11 +145,11 @@ class AccountsSearchForm(forms.Form):
|
||||
'placeholder': 'Search users, sessions, devices...'
|
||||
})
|
||||
)
|
||||
role = forms.ChoiceField(
|
||||
choices=[('', 'All Roles')] + list(User._meta.get_field('role').choices),
|
||||
required=False,
|
||||
widget=forms.Select(attrs={'class': 'form-select'})
|
||||
)
|
||||
# role = forms.ChoiceField(
|
||||
# choices=[('', 'All Roles')] + list(User._meta.get_field('role').choices),
|
||||
# required=False,
|
||||
# widget=forms.Select(attrs={'class': 'form-select'})
|
||||
# )
|
||||
status = forms.ChoiceField(
|
||||
choices=[
|
||||
('', 'All Status'),
|
||||
|
||||
@ -1,12 +1,8 @@
|
||||
# Generated by Django 5.2.6 on 2025-09-08 07:28
|
||||
# Generated by Django 5.2.6 on 2025-09-15 14:05
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@ -390,21 +386,6 @@ class Migration(migrations.Migration):
|
||||
verbose_name="superuser status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"username",
|
||||
models.CharField(
|
||||
error_messages={
|
||||
"unique": "A user with that username already exists."
|
||||
},
|
||||
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
|
||||
max_length=150,
|
||||
unique=True,
|
||||
validators=[
|
||||
django.contrib.auth.validators.UnicodeUsernameValidator()
|
||||
],
|
||||
verbose_name="username",
|
||||
),
|
||||
),
|
||||
(
|
||||
"first_name",
|
||||
models.CharField(
|
||||
@ -417,12 +398,6 @@ class Migration(migrations.Migration):
|
||||
blank=True, max_length=150, verbose_name="last name"
|
||||
),
|
||||
),
|
||||
(
|
||||
"email",
|
||||
models.EmailField(
|
||||
blank=True, max_length=254, verbose_name="email address"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_staff",
|
||||
models.BooleanField(
|
||||
@ -447,157 +422,17 @@ class Migration(migrations.Migration):
|
||||
),
|
||||
(
|
||||
"user_id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
help_text="Unique user identifier",
|
||||
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
|
||||
),
|
||||
(
|
||||
"username",
|
||||
models.CharField(
|
||||
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
|
||||
max_length=150,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"middle_name",
|
||||
models.CharField(
|
||||
blank=True, help_text="Middle name", max_length=150, null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"preferred_name",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Preferred name",
|
||||
max_length=150,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"phone_number",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Primary phone number",
|
||||
max_length=20,
|
||||
null=True,
|
||||
validators=[
|
||||
django.core.validators.RegexValidator(
|
||||
message='Phone number must be entered in the format: "+999999999". Up to 15 digits allowed.',
|
||||
regex="^\\+?1?\\d{9,15}$",
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
(
|
||||
"mobile_number",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Mobile phone number",
|
||||
max_length=20,
|
||||
null=True,
|
||||
validators=[
|
||||
django.core.validators.RegexValidator(
|
||||
message='Phone number must be entered in the format: "+999999999". Up to 15 digits allowed.',
|
||||
regex="^\\+?1?\\d{9,15}$",
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
(
|
||||
"employee_id",
|
||||
models.CharField(
|
||||
blank=True, help_text="Employee ID", max_length=50, null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"department",
|
||||
models.CharField(
|
||||
blank=True, help_text="Department", max_length=100, null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"job_title",
|
||||
models.CharField(
|
||||
blank=True, help_text="Job title", max_length=100, null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"role",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("SUPER_ADMIN", "Super Administrator"),
|
||||
("ADMIN", "Administrator"),
|
||||
("PHYSICIAN", "Physician"),
|
||||
("NURSE", "Nurse"),
|
||||
("NURSE_PRACTITIONER", "Nurse Practitioner"),
|
||||
("PHYSICIAN_ASSISTANT", "Physician Assistant"),
|
||||
("PHARMACIST", "Pharmacist"),
|
||||
("PHARMACY_TECH", "Pharmacy Technician"),
|
||||
("LAB_TECH", "Laboratory Technician"),
|
||||
("RADIOLOGIST", "Radiologist"),
|
||||
("RAD_TECH", "Radiology Technician"),
|
||||
("THERAPIST", "Therapist"),
|
||||
("SOCIAL_WORKER", "Social Worker"),
|
||||
("CASE_MANAGER", "Case Manager"),
|
||||
("BILLING_SPECIALIST", "Billing Specialist"),
|
||||
("REGISTRATION", "Registration Staff"),
|
||||
("SCHEDULER", "Scheduler"),
|
||||
("MEDICAL_ASSISTANT", "Medical Assistant"),
|
||||
("CLERICAL", "Clerical Staff"),
|
||||
("IT_SUPPORT", "IT Support"),
|
||||
("QUALITY_ASSURANCE", "Quality Assurance"),
|
||||
("COMPLIANCE", "Compliance Officer"),
|
||||
("SECURITY", "Security"),
|
||||
("MAINTENANCE", "Maintenance"),
|
||||
("VOLUNTEER", "Volunteer"),
|
||||
("STUDENT", "Student"),
|
||||
("RESEARCHER", "Researcher"),
|
||||
("CONSULTANT", "Consultant"),
|
||||
("VENDOR", "Vendor"),
|
||||
("GUEST", "Guest"),
|
||||
],
|
||||
default="CLERICAL",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
(
|
||||
"license_number",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Professional license number",
|
||||
max_length=100,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"license_state",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="License issuing state",
|
||||
max_length=50,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"license_expiry",
|
||||
models.DateField(
|
||||
blank=True, help_text="License expiry date", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"dea_number",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="DEA number for prescribing",
|
||||
max_length=20,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"npi_number",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="National Provider Identifier",
|
||||
max_length=10,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
("email", models.EmailField(blank=True, max_length=254, null=True)),
|
||||
(
|
||||
"force_password_change",
|
||||
models.BooleanField(
|
||||
@ -643,65 +478,6 @@ class Migration(migrations.Migration):
|
||||
default=30, help_text="Session timeout in minutes"
|
||||
),
|
||||
),
|
||||
(
|
||||
"user_timezone",
|
||||
models.CharField(
|
||||
default="UTC", help_text="User timezone", max_length=50
|
||||
),
|
||||
),
|
||||
(
|
||||
"language",
|
||||
models.CharField(
|
||||
default="en", help_text="Preferred language", max_length=10
|
||||
),
|
||||
),
|
||||
(
|
||||
"theme",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("LIGHT", "Light"),
|
||||
("DARK", "Dark"),
|
||||
("AUTO", "Auto"),
|
||||
],
|
||||
default="LIGHT",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"profile_picture",
|
||||
models.ImageField(
|
||||
blank=True,
|
||||
help_text="Profile picture",
|
||||
null=True,
|
||||
upload_to="profile_pictures/",
|
||||
),
|
||||
),
|
||||
(
|
||||
"bio",
|
||||
models.TextField(
|
||||
blank=True, help_text="Professional bio", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_verified",
|
||||
models.BooleanField(
|
||||
default=False, help_text="User account is verified"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_approved",
|
||||
models.BooleanField(
|
||||
default=False, help_text="User account is approved"
|
||||
),
|
||||
),
|
||||
(
|
||||
"approval_date",
|
||||
models.DateTimeField(
|
||||
blank=True, help_text="Account approval date", null=True
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"last_password_change",
|
||||
models.DateTimeField(
|
||||
@ -709,17 +485,6 @@ class Migration(migrations.Migration):
|
||||
help_text="Last password change date",
|
||||
),
|
||||
),
|
||||
(
|
||||
"approved_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="User who approved this account",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="approved_users",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"groups",
|
||||
models.ManyToManyField(
|
||||
@ -733,8 +498,6 @@ class Migration(migrations.Migration):
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "User",
|
||||
"verbose_name_plural": "Users",
|
||||
"db_table": "accounts_user",
|
||||
"ordering": ["last_name", "first_name"],
|
||||
},
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.6 on 2025-09-08 07:28
|
||||
# Generated by Django 5.2.6 on 2025-09-15 14:05
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
@ -21,7 +21,7 @@ class Migration(migrations.Migration):
|
||||
name="tenant",
|
||||
field=models.ForeignKey(
|
||||
help_text="Organization tenant",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="users",
|
||||
to="core.tenant",
|
||||
),
|
||||
@ -77,25 +77,25 @@ class Migration(migrations.Migration):
|
||||
migrations.AddIndex(
|
||||
model_name="user",
|
||||
index=models.Index(
|
||||
fields=["tenant", "role"], name="accounts_us_tenant__731b87_idx"
|
||||
fields=["tenant", "email"], name="accounts_us_tenant__162cd2_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="user",
|
||||
index=models.Index(
|
||||
fields=["employee_id"], name="accounts_us_employe_0cbd94_idx"
|
||||
fields=["tenant", "username"], name="accounts_us_tenant__d92906_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="user",
|
||||
index=models.Index(
|
||||
fields=["license_number"], name="accounts_us_license_02eb85_idx"
|
||||
fields=["tenant", "user_id"], name="accounts_us_tenant__bd3758_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="user",
|
||||
index=models.Index(
|
||||
fields=["npi_number"], name="accounts_us_npi_num_800ef1_idx"
|
||||
fields=["tenant", "is_active"], name="accounts_us_tenant__78e6c9_idx"
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
|
||||
18
accounts/migrations/0003_alter_user_is_active.py
Normal file
18
accounts/migrations/0003_alter_user_is_active.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.6 on 2025-09-15 14:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0002_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="is_active",
|
||||
field=models.BooleanField(default=True, help_text="User account is active"),
|
||||
),
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -4,6 +4,8 @@ Provides user management, authentication, and authorization functionality.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
from django.core.validators import RegexValidator
|
||||
@ -11,158 +13,379 @@ from django.utils import timezone
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
# class User(AbstractUser):
|
||||
# """
|
||||
# Extended user model for hospital management system.
|
||||
# """
|
||||
# ROLE_CHOICES = [
|
||||
# ('SUPER_ADMIN', 'Super Administrator'),
|
||||
# ('ADMIN', 'Administrator'),
|
||||
# ('PHYSICIAN', 'Physician'),
|
||||
# ('NURSE', 'Nurse'),
|
||||
# ('NURSE_PRACTITIONER', 'Nurse Practitioner'),
|
||||
# ('PHYSICIAN_ASSISTANT', 'Physician Assistant'),
|
||||
# ('PHARMACIST', 'Pharmacist'),
|
||||
# ('PHARMACY_TECH', 'Pharmacy Technician'),
|
||||
# ('LAB_TECH', 'Laboratory Technician'),
|
||||
# ('RADIOLOGIST', 'Radiologist'),
|
||||
# ('RAD_TECH', 'Radiology Technician'),
|
||||
# ('THERAPIST', 'Therapist'),
|
||||
# ('SOCIAL_WORKER', 'Social Worker'),
|
||||
# ('CASE_MANAGER', 'Case Manager'),
|
||||
# ('BILLING_SPECIALIST', 'Billing Specialist'),
|
||||
# ('REGISTRATION', 'Registration Staff'),
|
||||
# ('SCHEDULER', 'Scheduler'),
|
||||
# ('MEDICAL_ASSISTANT', 'Medical Assistant'),
|
||||
# ('CLERICAL', 'Clerical Staff'),
|
||||
# ('IT_SUPPORT', 'IT Support'),
|
||||
# ('QUALITY_ASSURANCE', 'Quality Assurance'),
|
||||
# ('COMPLIANCE', 'Compliance Officer'),
|
||||
# ('SECURITY', 'Security'),
|
||||
# ('MAINTENANCE', 'Maintenance'),
|
||||
# ('VOLUNTEER', 'Volunteer'),
|
||||
# ('STUDENT', 'Student'),
|
||||
# ('RESEARCHER', 'Researcher'),
|
||||
# ('CONSULTANT', 'Consultant'),
|
||||
# ('VENDOR', 'Vendor'),
|
||||
# ('GUEST', 'Guest'),
|
||||
# ]
|
||||
# THEME_CHOICES = [
|
||||
# ('LIGHT', 'Light'),
|
||||
# ('DARK', 'Dark'),
|
||||
# ('AUTO', 'Auto'),
|
||||
# ]
|
||||
# # Basic Information
|
||||
# user_id = models.UUIDField(
|
||||
# default=uuid.uuid4,
|
||||
# unique=True,
|
||||
# editable=False,
|
||||
# help_text='Unique user identifier'
|
||||
# )
|
||||
#
|
||||
# # Tenant relationship
|
||||
# tenant = models.ForeignKey(
|
||||
# 'core.Tenant',
|
||||
# on_delete=models.CASCADE,
|
||||
# related_name='users',
|
||||
# help_text='Organization tenant'
|
||||
# )
|
||||
#
|
||||
# # Personal Information
|
||||
# middle_name = models.CharField(
|
||||
# max_length=150,
|
||||
# blank=True,
|
||||
# null=True,
|
||||
# help_text='Middle name'
|
||||
# )
|
||||
# preferred_name = models.CharField(
|
||||
# max_length=150,
|
||||
# blank=True,
|
||||
# null=True,
|
||||
# help_text='Preferred name'
|
||||
# )
|
||||
#
|
||||
# # Contact Information
|
||||
# phone_number = models.CharField(
|
||||
# max_length=20,
|
||||
# blank=True,
|
||||
# null=True,
|
||||
# validators=[RegexValidator(
|
||||
# regex=r'^\+?1?\d{9,15}$',
|
||||
# message='Phone number must be entered in the format: "+999999999". Up to 15 digits allowed.'
|
||||
# )],
|
||||
# help_text='Primary phone number'
|
||||
# )
|
||||
# mobile_number = models.CharField(
|
||||
# max_length=20,
|
||||
# blank=True,
|
||||
# null=True,
|
||||
# validators=[RegexValidator(
|
||||
# regex=r'^\+?1?\d{9,15}$',
|
||||
# message='Phone number must be entered in the format: "+999999999". Up to 15 digits allowed.'
|
||||
# )],
|
||||
# help_text='Mobile phone number'
|
||||
# )
|
||||
#
|
||||
# # Professional Information
|
||||
# employee_id = models.CharField(
|
||||
# max_length=50,
|
||||
# blank=True,
|
||||
# null=True,
|
||||
# help_text='Employee ID'
|
||||
# )
|
||||
# department = models.ForeignKey(
|
||||
# 'hr.Department',
|
||||
# on_delete=models.SET_NULL,
|
||||
# null=True,
|
||||
# blank=True,
|
||||
# related_name='users',
|
||||
# help_text='Department'
|
||||
# )
|
||||
# job_title = models.CharField(
|
||||
# max_length=100,
|
||||
# blank=True,
|
||||
# null=True,
|
||||
# help_text='Job title'
|
||||
# )
|
||||
#
|
||||
# # Role and Permissions
|
||||
# role = models.CharField(
|
||||
# max_length=50,
|
||||
# choices=ROLE_CHOICES,
|
||||
# default='CLERICAL'
|
||||
# )
|
||||
#
|
||||
# # License and Certification
|
||||
# license_number = models.CharField(
|
||||
# max_length=100,
|
||||
# blank=True,
|
||||
# null=True,
|
||||
# help_text='Professional license number'
|
||||
# )
|
||||
# license_state = models.CharField(
|
||||
# max_length=50,
|
||||
# blank=True,
|
||||
# null=True,
|
||||
# help_text='License issuing state'
|
||||
# )
|
||||
# license_expiry = models.DateField(
|
||||
# blank=True,
|
||||
# null=True,
|
||||
# help_text='License expiry date'
|
||||
# )
|
||||
# dea_number = models.CharField(
|
||||
# max_length=20,
|
||||
# blank=True,
|
||||
# null=True,
|
||||
# help_text='DEA number for prescribing'
|
||||
# )
|
||||
# npi_number = models.CharField(
|
||||
# max_length=10,
|
||||
# blank=True,
|
||||
# null=True,
|
||||
# help_text='National Provider Identifier'
|
||||
# )
|
||||
#
|
||||
# # Security Settings
|
||||
# force_password_change = models.BooleanField(
|
||||
# default=False,
|
||||
# help_text='User must change password on next login'
|
||||
# )
|
||||
# password_expires_at = models.DateTimeField(
|
||||
# blank=True,
|
||||
# null=True,
|
||||
# help_text='Password expiration date'
|
||||
# )
|
||||
# failed_login_attempts = models.PositiveIntegerField(
|
||||
# default=0,
|
||||
# help_text='Number of failed login attempts'
|
||||
# )
|
||||
# locked_until = models.DateTimeField(
|
||||
# blank=True,
|
||||
# null=True,
|
||||
# help_text='Account locked until this time'
|
||||
# )
|
||||
# two_factor_enabled = models.BooleanField(
|
||||
# default=False,
|
||||
# help_text='Two-factor authentication enabled'
|
||||
# )
|
||||
#
|
||||
# # Session Management
|
||||
# max_concurrent_sessions = models.PositiveIntegerField(
|
||||
# default=3,
|
||||
# help_text='Maximum concurrent sessions allowed'
|
||||
# )
|
||||
# session_timeout_minutes = models.PositiveIntegerField(
|
||||
# default=30,
|
||||
# help_text='Session timeout in minutes'
|
||||
# )
|
||||
#
|
||||
# # Preferences
|
||||
# user_timezone = models.CharField(
|
||||
# max_length=50,
|
||||
# default='Asia/Riyadh',
|
||||
# help_text='User timezone'
|
||||
# )
|
||||
# language = models.CharField(
|
||||
# max_length=10,
|
||||
# default='en',
|
||||
# help_text='Preferred language'
|
||||
# )
|
||||
# theme = models.CharField(
|
||||
# max_length=20,
|
||||
# choices=THEME_CHOICES,
|
||||
# default='LIGHT'
|
||||
# )
|
||||
#
|
||||
# # Profile Information
|
||||
# profile_picture = models.ImageField(
|
||||
# upload_to='profile_pictures/',
|
||||
# blank=True,
|
||||
# null=True,
|
||||
# help_text='Profile picture'
|
||||
# )
|
||||
# bio = models.TextField(
|
||||
# blank=True,
|
||||
# null=True,
|
||||
# help_text='Professional bio'
|
||||
# )
|
||||
#
|
||||
# # Status
|
||||
# is_verified = models.BooleanField(
|
||||
# default=False,
|
||||
# help_text='User account is verified'
|
||||
# )
|
||||
# is_approved = models.BooleanField(
|
||||
# default=False,
|
||||
# help_text='User account is approved'
|
||||
# )
|
||||
# approval_date = models.DateTimeField(
|
||||
# blank=True,
|
||||
# null=True,
|
||||
# help_text='Account approval date'
|
||||
# )
|
||||
# approved_by = models.ForeignKey(
|
||||
# 'self',
|
||||
# on_delete=models.SET_NULL,
|
||||
# null=True,
|
||||
# blank=True,
|
||||
# related_name='approved_users',
|
||||
# help_text='User who approved this account'
|
||||
# )
|
||||
#
|
||||
# # Metadata
|
||||
# created_at = models.DateTimeField(auto_now_add=True)
|
||||
# updated_at = models.DateTimeField(auto_now=True)
|
||||
# last_password_change = models.DateTimeField(
|
||||
# default=timezone.now,
|
||||
# help_text='Last password change date'
|
||||
# )
|
||||
#
|
||||
# class Meta:
|
||||
# db_table = 'accounts_user'
|
||||
# verbose_name = 'User'
|
||||
# verbose_name_plural = 'Users'
|
||||
# ordering = ['last_name', 'first_name']
|
||||
# indexes = [
|
||||
# models.Index(fields=['tenant', 'role']),
|
||||
# models.Index(fields=['employee_id']),
|
||||
# models.Index(fields=['license_number']),
|
||||
# models.Index(fields=['npi_number']),
|
||||
# ]
|
||||
#
|
||||
# def __str__(self):
|
||||
# return f"{self.get_full_name()} ({self.username})"
|
||||
#
|
||||
# def get_full_name(self):
|
||||
# """
|
||||
# Return the full name for the user.
|
||||
# """
|
||||
# if self.preferred_name:
|
||||
# return f"{self.preferred_name} {self.last_name}"
|
||||
# return super().get_full_name()
|
||||
#
|
||||
# def get_display_name(self):
|
||||
# """
|
||||
# Return the display name for the user.
|
||||
# """
|
||||
# full_name = self.get_full_name()
|
||||
# if full_name.strip():
|
||||
# return full_name
|
||||
# return self.username
|
||||
#
|
||||
# @property
|
||||
# def is_account_locked(self):
|
||||
# """
|
||||
# Check if account is currently locked.
|
||||
# """
|
||||
# if self.locked_until:
|
||||
# return timezone.now() < self.locked_until
|
||||
# return False
|
||||
#
|
||||
# @property
|
||||
# def is_password_expired(self):
|
||||
# """
|
||||
# Check if password has expired.
|
||||
# """
|
||||
# if self.password_expires_at:
|
||||
# return timezone.now() > self.password_expires_at
|
||||
# return False
|
||||
#
|
||||
# @property
|
||||
# def is_license_expired(self):
|
||||
# """
|
||||
# Check if professional license has expired.
|
||||
# """
|
||||
# if self.license_expiry:
|
||||
# return timezone.now().date() > self.license_expiry
|
||||
# return False
|
||||
#
|
||||
# def lock_account(self, duration_minutes=15):
|
||||
# """
|
||||
# Lock the user account for specified duration.
|
||||
# """
|
||||
# self.locked_until = timezone.now() + timezone.timedelta(minutes=duration_minutes)
|
||||
# self.save(update_fields=['locked_until'])
|
||||
#
|
||||
# def unlock_account(self):
|
||||
# """
|
||||
# Unlock the user account.
|
||||
# """
|
||||
# self.locked_until = None
|
||||
# self.failed_login_attempts = 0
|
||||
# self.save(update_fields=['locked_until', 'failed_login_attempts'])
|
||||
#
|
||||
# def increment_failed_login(self):
|
||||
# """
|
||||
# Increment failed login attempts and lock if threshold reached.
|
||||
# """
|
||||
# self.failed_login_attempts += 1
|
||||
#
|
||||
# # Lock account after 5 failed attempts
|
||||
# max_attempts = getattr(settings, 'HOSPITAL_SETTINGS', {}).get('MAX_LOGIN_ATTEMPTS', 5)
|
||||
# if self.failed_login_attempts >= max_attempts:
|
||||
# lockout_duration = getattr(settings, 'HOSPITAL_SETTINGS', {}).get('LOCKOUT_DURATION_MINUTES', 15)
|
||||
# self.lock_account(lockout_duration)
|
||||
#
|
||||
# self.save(update_fields=['failed_login_attempts'])
|
||||
#
|
||||
# def reset_failed_login(self):
|
||||
# """
|
||||
# Reset failed login attempts.
|
||||
# """
|
||||
# self.failed_login_attempts = 0
|
||||
# self.save(update_fields=['failed_login_attempts'])
|
||||
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
"""
|
||||
Extended user model for hospital management system.
|
||||
Minimal auth user for a multi-tenant app:
|
||||
- Authentication core (from AbstractUser)
|
||||
- Tenant link
|
||||
- Security/session controls (lockout, password expiry, 2FA flag, session caps)
|
||||
Everything else lives on hr.Employee.
|
||||
"""
|
||||
ROLE_CHOICES = [
|
||||
('SUPER_ADMIN', 'Super Administrator'),
|
||||
('ADMIN', 'Administrator'),
|
||||
('PHYSICIAN', 'Physician'),
|
||||
('NURSE', 'Nurse'),
|
||||
('NURSE_PRACTITIONER', 'Nurse Practitioner'),
|
||||
('PHYSICIAN_ASSISTANT', 'Physician Assistant'),
|
||||
('PHARMACIST', 'Pharmacist'),
|
||||
('PHARMACY_TECH', 'Pharmacy Technician'),
|
||||
('LAB_TECH', 'Laboratory Technician'),
|
||||
('RADIOLOGIST', 'Radiologist'),
|
||||
('RAD_TECH', 'Radiology Technician'),
|
||||
('THERAPIST', 'Therapist'),
|
||||
('SOCIAL_WORKER', 'Social Worker'),
|
||||
('CASE_MANAGER', 'Case Manager'),
|
||||
('BILLING_SPECIALIST', 'Billing Specialist'),
|
||||
('REGISTRATION', 'Registration Staff'),
|
||||
('SCHEDULER', 'Scheduler'),
|
||||
('MEDICAL_ASSISTANT', 'Medical Assistant'),
|
||||
('CLERICAL', 'Clerical Staff'),
|
||||
('IT_SUPPORT', 'IT Support'),
|
||||
('QUALITY_ASSURANCE', 'Quality Assurance'),
|
||||
('COMPLIANCE', 'Compliance Officer'),
|
||||
('SECURITY', 'Security'),
|
||||
('MAINTENANCE', 'Maintenance'),
|
||||
('VOLUNTEER', 'Volunteer'),
|
||||
('STUDENT', 'Student'),
|
||||
('RESEARCHER', 'Researcher'),
|
||||
('CONSULTANT', 'Consultant'),
|
||||
('VENDOR', 'Vendor'),
|
||||
('GUEST', 'Guest'),
|
||||
]
|
||||
THEME_CHOICES = [
|
||||
('LIGHT', 'Light'),
|
||||
('DARK', 'Dark'),
|
||||
('AUTO', 'Auto'),
|
||||
]
|
||||
# Basic Information
|
||||
user_id = models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
unique=True,
|
||||
editable=False,
|
||||
help_text='Unique user identifier'
|
||||
)
|
||||
|
||||
# Tenant relationship
|
||||
# Stable internal UUID
|
||||
user_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||
|
||||
# Tenant (PROTECT = safer than cascading deletion)
|
||||
tenant = models.ForeignKey(
|
||||
'core.Tenant',
|
||||
on_delete=models.CASCADE,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='users',
|
||||
help_text='Organization tenant'
|
||||
help_text='Organization tenant',
|
||||
)
|
||||
|
||||
# Personal Information
|
||||
middle_name = models.CharField(
|
||||
username = models.CharField(
|
||||
max_length=150,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Middle name'
|
||||
)
|
||||
preferred_name = models.CharField(
|
||||
max_length=150,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Preferred name'
|
||||
unique=True,
|
||||
help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.'
|
||||
)
|
||||
email = models.EmailField(blank=True, null=True)
|
||||
|
||||
# Contact Information
|
||||
phone_number = models.CharField(
|
||||
max_length=20,
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[RegexValidator(
|
||||
regex=r'^\+?1?\d{9,15}$',
|
||||
message='Phone number must be entered in the format: "+999999999". Up to 15 digits allowed.'
|
||||
)],
|
||||
help_text='Primary phone number'
|
||||
)
|
||||
mobile_number = models.CharField(
|
||||
max_length=20,
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[RegexValidator(
|
||||
regex=r'^\+?1?\d{9,15}$',
|
||||
message='Phone number must be entered in the format: "+999999999". Up to 15 digits allowed.'
|
||||
)],
|
||||
help_text='Mobile phone number'
|
||||
)
|
||||
|
||||
# Professional Information
|
||||
employee_id = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Employee ID'
|
||||
)
|
||||
department = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Department'
|
||||
)
|
||||
job_title = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Job title'
|
||||
)
|
||||
|
||||
# Role and Permissions
|
||||
role = models.CharField(
|
||||
max_length=50,
|
||||
choices=ROLE_CHOICES,
|
||||
default='CLERICAL'
|
||||
)
|
||||
|
||||
# License and Certification
|
||||
license_number = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Professional license number'
|
||||
)
|
||||
license_state = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='License issuing state'
|
||||
)
|
||||
license_expiry = models.DateField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='License expiry date'
|
||||
)
|
||||
dea_number = models.CharField(
|
||||
max_length=20,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='DEA number for prescribing'
|
||||
)
|
||||
npi_number = models.CharField(
|
||||
max_length=10,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='National Provider Identifier'
|
||||
)
|
||||
|
||||
# Security Settings
|
||||
# --- Security & session controls kept on User (auth-level concerns) ---
|
||||
force_password_change = models.BooleanField(
|
||||
default=False,
|
||||
help_text='User must change password on next login'
|
||||
@ -185,8 +408,6 @@ class User(AbstractUser):
|
||||
default=False,
|
||||
help_text='Two-factor authentication enabled'
|
||||
)
|
||||
|
||||
# Session Management
|
||||
max_concurrent_sessions = models.PositiveIntegerField(
|
||||
default=3,
|
||||
help_text='Maximum concurrent sessions allowed'
|
||||
@ -195,160 +416,64 @@ class User(AbstractUser):
|
||||
default=30,
|
||||
help_text='Session timeout in minutes'
|
||||
)
|
||||
|
||||
# Preferences
|
||||
user_timezone = models.CharField(
|
||||
max_length=50,
|
||||
default='UTC',
|
||||
help_text='User timezone'
|
||||
)
|
||||
language = models.CharField(
|
||||
max_length=10,
|
||||
default='en',
|
||||
help_text='Preferred language'
|
||||
)
|
||||
theme = models.CharField(
|
||||
max_length=20,
|
||||
choices=THEME_CHOICES,
|
||||
default='LIGHT'
|
||||
)
|
||||
|
||||
# Profile Information
|
||||
profile_picture = models.ImageField(
|
||||
upload_to='profile_pictures/',
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Profile picture'
|
||||
)
|
||||
bio = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Professional bio'
|
||||
)
|
||||
|
||||
# Status
|
||||
is_verified = models.BooleanField(
|
||||
default=False,
|
||||
help_text='User account is verified'
|
||||
)
|
||||
is_approved = models.BooleanField(
|
||||
default=False,
|
||||
help_text='User account is approved'
|
||||
)
|
||||
approval_date = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Account approval date'
|
||||
)
|
||||
approved_by = models.ForeignKey(
|
||||
'self',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='approved_users',
|
||||
help_text='User who approved this account'
|
||||
)
|
||||
|
||||
# Metadata
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
last_password_change = models.DateTimeField(
|
||||
default=timezone.now,
|
||||
help_text='Last password change date'
|
||||
)
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
help_text='User account is active'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = 'accounts_user'
|
||||
verbose_name = 'User'
|
||||
verbose_name_plural = 'Users'
|
||||
ordering = ['last_name', 'first_name']
|
||||
indexes = [
|
||||
models.Index(fields=['tenant', 'role']),
|
||||
models.Index(fields=['employee_id']),
|
||||
models.Index(fields=['license_number']),
|
||||
models.Index(fields=['npi_number']),
|
||||
models.Index(fields=['tenant', 'email']),
|
||||
models.Index(fields=['tenant', 'username']),
|
||||
models.Index(fields=['tenant', 'user_id']),
|
||||
models.Index(fields=['tenant', 'is_active']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_full_name()} ({self.username})"
|
||||
full = super().get_full_name().strip()
|
||||
return f"{full or self.username} (tenant={self.tenant_id})"
|
||||
|
||||
def get_full_name(self):
|
||||
"""
|
||||
Return the full name for the user.
|
||||
"""
|
||||
if self.preferred_name:
|
||||
return f"{self.preferred_name} {self.last_name}"
|
||||
return super().get_full_name()
|
||||
|
||||
def get_display_name(self):
|
||||
"""
|
||||
Return the display name for the user.
|
||||
"""
|
||||
full_name = self.get_full_name()
|
||||
if full_name.strip():
|
||||
return full_name
|
||||
return self.username
|
||||
# ---- Security helpers ----
|
||||
@property
|
||||
def is_account_locked(self) -> bool:
|
||||
return bool(self.locked_until and timezone.now() < self.locked_until)
|
||||
|
||||
@property
|
||||
def is_account_locked(self):
|
||||
"""
|
||||
Check if account is currently locked.
|
||||
"""
|
||||
if self.locked_until:
|
||||
return timezone.now() < self.locked_until
|
||||
return False
|
||||
def is_password_expired(self) -> bool:
|
||||
return bool(self.password_expires_at and timezone.now() > self.password_expires_at)
|
||||
|
||||
@property
|
||||
def is_password_expired(self):
|
||||
"""
|
||||
Check if password has expired.
|
||||
"""
|
||||
if self.password_expires_at:
|
||||
return timezone.now() > self.password_expires_at
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_license_expired(self):
|
||||
"""
|
||||
Check if professional license has expired.
|
||||
"""
|
||||
if self.license_expiry:
|
||||
return timezone.now().date() > self.license_expiry
|
||||
return False
|
||||
|
||||
def lock_account(self, duration_minutes=15):
|
||||
"""
|
||||
Lock the user account for specified duration.
|
||||
"""
|
||||
self.locked_until = timezone.now() + timezone.timedelta(minutes=duration_minutes)
|
||||
def lock_account(self, duration_minutes: int = 15):
|
||||
self.locked_until = timezone.now() + timedelta(minutes=duration_minutes)
|
||||
self.save(update_fields=['locked_until'])
|
||||
|
||||
def unlock_account(self):
|
||||
"""
|
||||
Unlock the user account.
|
||||
"""
|
||||
self.locked_until = None
|
||||
self.failed_login_attempts = 0
|
||||
self.save(update_fields=['locked_until', 'failed_login_attempts'])
|
||||
|
||||
def increment_failed_login(self):
|
||||
def increment_failed_login(self, *, max_attempts: int | None = None, lockout_minutes: int | None = None):
|
||||
"""
|
||||
Increment failed login attempts and lock if threshold reached.
|
||||
Increment failed login attempts and lock the account if threshold reached.
|
||||
Defaults pulled from settings.HOSPITAL_SETTINGS if provided, else (5, 15).
|
||||
"""
|
||||
self.failed_login_attempts += 1
|
||||
|
||||
# Lock account after 5 failed attempts
|
||||
if max_attempts is None:
|
||||
max_attempts = getattr(settings, 'HOSPITAL_SETTINGS', {}).get('MAX_LOGIN_ATTEMPTS', 5)
|
||||
if self.failed_login_attempts >= max_attempts:
|
||||
lockout_duration = getattr(settings, 'HOSPITAL_SETTINGS', {}).get('LOCKOUT_DURATION_MINUTES', 15)
|
||||
self.lock_account(lockout_duration)
|
||||
if lockout_minutes is None:
|
||||
lockout_minutes = getattr(settings, 'HOSPITAL_SETTINGS', {}).get('LOCKOUT_DURATION_MINUTES', 15)
|
||||
|
||||
if self.failed_login_attempts >= max_attempts:
|
||||
self.lock_account(lockout_minutes)
|
||||
else:
|
||||
self.save(update_fields=['failed_login_attempts'])
|
||||
|
||||
def reset_failed_login(self):
|
||||
"""
|
||||
Reset failed login attempts.
|
||||
"""
|
||||
self.failed_login_attempts = 0
|
||||
self.save(update_fields=['failed_login_attempts'])
|
||||
|
||||
|
||||
41
accounts/signals.py
Normal file
41
accounts/signals.py
Normal file
@ -0,0 +1,41 @@
|
||||
# accounts/signals.py
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.db import transaction
|
||||
from django.utils.text import slugify
|
||||
|
||||
from hr.models import Employee
|
||||
from .models import User
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def create_employee_for_user(sender, instance: User, created: bool, **kwargs):
|
||||
"""
|
||||
Auto-create an Employee profile when a User is created.
|
||||
Idempotent, tenant-aligned, and makes a unique employee_number per tenant.
|
||||
"""
|
||||
if not created:
|
||||
return
|
||||
|
||||
def _make_employee():
|
||||
# Generate a readable employee_number from username/email, unique within tenant
|
||||
base = (instance.username or (instance.email or 'user')).split('@')[0]
|
||||
candidate = slugify(base)[:16] or 'emp'
|
||||
suffix = 1
|
||||
emp_no = candidate
|
||||
while Employee.objects.filter(tenant=instance.tenant, employee_number=emp_no).exists():
|
||||
suffix += 1
|
||||
emp_no = f"{candidate}-{suffix}"
|
||||
|
||||
# Create with basic info mirrored from User
|
||||
Employee.objects.create(
|
||||
tenant=instance.tenant,
|
||||
user=instance,
|
||||
employee_number=emp_no,
|
||||
first_name=instance.first_name or '',
|
||||
last_name=instance.last_name or '',
|
||||
email=instance.email
|
||||
)
|
||||
|
||||
# Defer until after outer transaction commits (avoids race conditions in tests/views)
|
||||
transaction.on_commit(_make_employee)
|
||||
@ -124,8 +124,8 @@
|
||||
<div class="row align-items-center">
|
||||
<div class="col-auto">
|
||||
<div class="avatar-upload">
|
||||
{% if user.profile_picture %}
|
||||
<img src="{{ user.profile_picture.url }}" alt="{{ user.get_full_name }}" class="profile-avatar">
|
||||
{% if user.employee_profile.profile_picture %}
|
||||
<img src="{{ user.employee_profile.profile_picture.url }}" alt="{{ user.employee_profile.get_full_name }}" class="profile-avatar">
|
||||
{% else %}
|
||||
<div class="profile-avatar bg-secondary d-flex align-items-center justify-content-center">
|
||||
<i class="fas fa-user fa-2x text-white"></i>
|
||||
@ -138,8 +138,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<h1 class="mb-2">{{ user.get_full_name }}</h1>
|
||||
<p class="mb-0 opacity-75">{{ user.role|title }} • {{ user.department|default:"No Department" }}</p>
|
||||
<h1 class="mb-2">{{ user.employee_profile.get_full_name }}</h1>
|
||||
<p class="mb-0 opacity-75">{{ user.employee_profile.get_role_display }} • {{ user.employee_profile.department|default:"No Department" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -167,14 +167,14 @@
|
||||
<div class="mb-3">
|
||||
<label for="first_name" class="form-label">First Name</label>
|
||||
<input type="text" class="form-control" id="first_name" name="first_name"
|
||||
value="{{ user.first_name }}" required>
|
||||
value="{{ user.employee_profile.first_name }}" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="last_name" class="form-label">Last Name</label>
|
||||
<input type="text" class="form-control" id="last_name" name="last_name"
|
||||
value="{{ user.last_name }}" required>
|
||||
value="{{ user.employee_profile.last_name }}" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -184,14 +184,14 @@
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email Address</label>
|
||||
<input type="email" class="form-control" id="email" name="email"
|
||||
value="{{ user.email }}" required>
|
||||
value="{{ user.employee_profile.email }}" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="phone_number" class="form-label">Phone Number</label>
|
||||
<input type="tel" class="form-control" id="phone_number" name="phone_number"
|
||||
value="{{ user.phone_number }}">
|
||||
<label for="phone" class="form-label">Phone Number</label>
|
||||
<input type="tel" class="form-control" id="phone" name="phone"
|
||||
value="{{ user.employee_profile.phone }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -199,9 +199,9 @@
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="mobile_number" class="form-label">Mobile Number</label>
|
||||
<input type="tel" class="form-control" id="mobile_number" name="mobile_number"
|
||||
value="{{ user.mobile_number }}">
|
||||
<label for="mobile_phone" class="form-label">Mobile Number</label>
|
||||
<input type="tel" class="form-control" id="mobile_phone" name="mobile_phone"
|
||||
value="{{ user.employee_profile.mobile_phone }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -209,7 +209,7 @@
|
||||
<div class="mb-3">
|
||||
<label for="bio" class="form-label">Bio</label>
|
||||
<textarea class="form-control" id="bio" name="bio" rows="3"
|
||||
placeholder="Tell us about yourself...">{{ user.bio }}</textarea>
|
||||
placeholder="Tell us about yourself...">{{ user.employee_profile.bio }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -219,29 +219,29 @@
|
||||
<div class="preference-item">
|
||||
<label for="timezone" class="form-label">Timezone</label>
|
||||
<select class="form-select" id="timezone" name="timezone">
|
||||
<option value="UTC" {% if user.timezone == 'UTC' %}selected{% endif %}>UTC</option>
|
||||
<option value="America/New_York" {% if user.timezone == 'America/New_York' %}selected{% endif %}>Eastern Time</option>
|
||||
<option value="America/Chicago" {% if user.timezone == 'America/Chicago' %}selected{% endif %}>Central Time</option>
|
||||
<option value="America/Denver" {% if user.timezone == 'America/Denver' %}selected{% endif %}>Mountain Time</option>
|
||||
<option value="America/Los_Angeles" {% if user.timezone == 'America/Los_Angeles' %}selected{% endif %}>Pacific Time</option>
|
||||
<option value="Asia/Riyadh" {% if user.employee_profile.timezone == 'Asia/Riyadh' %}selected{% endif %}>Asia/Riyadh</option>
|
||||
<option value="America/New_York" {% if user.employee_profile.timezone == 'America/New_York' %}selected{% endif %}>Eastern Time</option>
|
||||
<option value="America/Chicago" {% if user.employee_profile.timezone == 'America/Chicago' %}selected{% endif %}>Central Time</option>
|
||||
<option value="America/Denver" {% if user.employee_profile.timezone == 'America/Denver' %}selected{% endif %}>Mountain Time</option>
|
||||
<option value="America/Los_Angeles" {% if user.employee_profile.timezone == 'America/Los_Angeles' %}selected{% endif %}>Pacific Time</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="preference-item">
|
||||
<label for="language" class="form-label">Language</label>
|
||||
<select class="form-select" id="language" name="language">
|
||||
<option value="en" {% if user.language == 'en' %}selected{% endif %}>English</option>
|
||||
<option value="es" {% if user.language == 'es' %}selected{% endif %}>Spanish</option>
|
||||
<option value="fr" {% if user.language == 'fr' %}selected{% endif %}>French</option>
|
||||
<option value="en" {% if user.employee_profile.language == 'en' %}selected{% endif %}>English</option>
|
||||
<option value="ar" {% if user.employee_profile.language == 'ar' %}selected{% endif %}>Arabic</option>
|
||||
<option value="fr" {% if user.employee_profile.language == 'fr' %}selected{% endif %}>French</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="preference-item">
|
||||
<label for="theme" class="form-label">Theme</label>
|
||||
<select class="form-select" id="theme" name="theme">
|
||||
<option value="light" {% if user.theme == 'light' %}selected{% endif %}>Light</option>
|
||||
<option value="dark" {% if user.theme == 'dark' %}selected{% endif %}>Dark</option>
|
||||
<option value="auto" {% if user.theme == 'auto' %}selected{% endif %}>Auto</option>
|
||||
<option value="light" {% if user.employee_profile.theme == 'light' %}selected{% endif %}>Light</option>
|
||||
<option value="dark" {% if user.employee_profile.theme == 'dark' %}selected{% endif %}>Dark</option>
|
||||
<option value="auto" {% if user.employee_profile.theme == 'auto' %}selected{% endif %}>Auto</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -18,6 +18,8 @@ from django.utils import timezone
|
||||
from django.urls import reverse_lazy, reverse
|
||||
from django.core.paginator import Paginator
|
||||
from datetime import timedelta
|
||||
|
||||
from hr.models import Employee
|
||||
from .models import User, TwoFactorDevice, SocialAccount, UserSession, PasswordHistory
|
||||
from .forms import (
|
||||
UserForm, UserCreateForm, TwoFactorDeviceForm, SocialAccountForm,
|
||||
@ -26,17 +28,6 @@ from .forms import (
|
||||
from core.utils import AuditLogger
|
||||
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# USER VIEWS (FULL CRUD - Master Data)
|
||||
# ============================================================================ession, PasswordHistory
|
||||
from .forms import (
|
||||
UserForm, UserCreateForm, TwoFactorDeviceForm, SocialAccountForm,
|
||||
AccountsSearchForm, PasswordChangeForm
|
||||
)
|
||||
from core.utils import AuditLogger
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# USER VIEWS (FULL CRUD - Master Data)
|
||||
# ============================================================================
|
||||
@ -1024,22 +1015,22 @@ def user_profile_update(request):
|
||||
HTMX view for user profile update.
|
||||
"""
|
||||
if request.method == 'POST':
|
||||
user = request.user
|
||||
employee = Employee.objects.get(user=request.user)
|
||||
|
||||
# Update basic information
|
||||
user.first_name = request.POST.get('first_name', user.first_name)
|
||||
user.last_name = request.POST.get('last_name', user.last_name)
|
||||
user.email = request.POST.get('email', user.email)
|
||||
user.phone_number = request.POST.get('phone_number', user.phone_number)
|
||||
user.mobile_number = request.POST.get('mobile_number', user.mobile_number)
|
||||
user.bio = request.POST.get('bio', user.bio)
|
||||
employee.first_name = request.POST.get('first_name', employee.first_name)
|
||||
employee.last_name = request.POST.get('last_name', employee.last_name)
|
||||
employee.email = request.POST.get('email', employee.email)
|
||||
employee.phone = request.POST.get('phone', employee.phone)
|
||||
employee.mobile_phone = request.POST.get('mobile_phone', employee.mobile_phone)
|
||||
employee.bio = request.POST.get('bio', employee.bio)
|
||||
|
||||
# Update preferences
|
||||
user.timezone = request.POST.get('timezone', user.timezone)
|
||||
user.language = request.POST.get('language', user.language)
|
||||
user.theme = request.POST.get('theme', user.theme)
|
||||
employee.user_timezone = request.POST.get('timezone', employee.user_timezone)
|
||||
employee.language = request.POST.get('language', employee.language)
|
||||
employee.theme = request.POST.get('theme', employee.theme)
|
||||
|
||||
user.save()
|
||||
employee.save()
|
||||
|
||||
# Log the update
|
||||
AuditLogger.log_event(
|
||||
@ -1047,14 +1038,15 @@ def user_profile_update(request):
|
||||
event_type='UPDATE',
|
||||
event_category='DATA_MODIFICATION',
|
||||
action='Update User Profile',
|
||||
description=f'User updated their profile: {user.username}',
|
||||
description=f'User updated their profile: {employee.user.username}',
|
||||
user=request.user,
|
||||
content_object=user,
|
||||
content_object=employee,
|
||||
request=request
|
||||
)
|
||||
|
||||
messages.success(request, 'Profile updated successfully.')
|
||||
return JsonResponse({'status': 'success'})
|
||||
return redirect('accounts:user_profile')
|
||||
|
||||
|
||||
return JsonResponse({'error': 'Invalid request'}, status=400)
|
||||
|
||||
|
||||
352
accounts_data.py
352
accounts_data.py
@ -1,3 +1,5 @@
|
||||
# scripts/seed_saudi_accounts.py
|
||||
|
||||
import os
|
||||
import django
|
||||
|
||||
@ -5,56 +7,59 @@ import django
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'hospital_management.settings')
|
||||
django.setup()
|
||||
|
||||
|
||||
import random
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.utils import timezone as django_timezone
|
||||
from accounts.models import User, TwoFactorDevice, SocialAccount, UserSession, PasswordHistory
|
||||
from core.models import Tenant
|
||||
import uuid
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.utils import timezone as django_timezone
|
||||
from django.db import transaction
|
||||
|
||||
from accounts.models import User
|
||||
from core.models import Tenant
|
||||
from hr.models import Employee
|
||||
from hr.models import Department # make sure hr.Department exists
|
||||
|
||||
# If these exist in your project, keep them. If not, comment them out.
|
||||
from accounts.models import TwoFactorDevice, SocialAccount, UserSession, PasswordHistory # noqa
|
||||
|
||||
# -------------------------------
|
||||
# Saudi-specific data constants
|
||||
# -------------------------------
|
||||
SAUDI_FIRST_NAMES_MALE = [
|
||||
'Mohammed', 'Abdullah', 'Ahmed', 'Omar', 'Ali', 'Hassan', 'Khalid', 'Faisal',
|
||||
'Saad', 'Fahd', 'Bandar', 'Turki', 'Nasser', 'Saud', 'Abdulrahman',
|
||||
'Abdulaziz', 'Salman', 'Waleed', 'Majid', 'Rayan', 'Yazeed', 'Mansour',
|
||||
'Osama', 'Tariq', 'Adel', 'Nawaf', 'Sultan', 'Mishaal', 'Badr', 'Ziad'
|
||||
]
|
||||
|
||||
SAUDI_FIRST_NAMES_FEMALE = [
|
||||
'Fatima', 'Aisha', 'Maryam', 'Khadija', 'Sarah', 'Noura', 'Hala', 'Reem',
|
||||
'Lina', 'Dana', 'Rana', 'Nada', 'Layla', 'Amira', 'Zahra', 'Yasmin',
|
||||
'Dina', 'Noor', 'Rahma', 'Salma', 'Lama', 'Ghada', 'Rania', 'Maha',
|
||||
'Wedad', 'Najla', 'Shahd', 'Jood', 'Rand', 'Malak'
|
||||
]
|
||||
|
||||
SAUDI_FAMILY_NAMES = [
|
||||
'Al-Rashid', 'Al-Harbi', 'Al-Qahtani', 'Al-Dosari', 'Al-Otaibi', 'Al-Mutairi',
|
||||
'Al-Shammari', 'Al-Zahrani', 'Al-Ghamdi', 'Al-Maliki', 'Al-Subai', 'Al-Jubayr',
|
||||
'Al-Faisal', 'Al-Saud', 'Al-Thani', 'Al-Maktoum', 'Al-Sabah', 'Al-Khalifa',
|
||||
'Bin-Laden', 'Al-Rajhi', 'Al-Sudairy', 'Al-Shaalan', 'Al-Kabeer', 'Al-Ajmi',
|
||||
'Al-Faisal', 'Al-Saud', 'Al-Shaalan', 'Al-Rajhi', 'Al-Sudairy', 'Al-Ajmi',
|
||||
'Al-Anzi', 'Al-Dawsari', 'Al-Shamrani', 'Al-Balawi', 'Al-Juhani', 'Al-Sulami'
|
||||
]
|
||||
|
||||
SAUDI_MIDDLE_NAMES = [
|
||||
'bin Ahmed', 'bin Mohammed', 'bin Abdullah', 'bin Omar', 'bin Ali', 'bin Hassan',
|
||||
'bin Khalid', 'bin Faisal', 'bin Saad', 'bin Fahd', 'bin Abdulaziz', 'bin Salman'
|
||||
]
|
||||
|
||||
SAUDI_CITIES = [
|
||||
'Riyadh', 'Jeddah', 'Mecca', 'Medina', 'Dammam', 'Khobar', 'Dhahran',
|
||||
'Taif', 'Tabuk', 'Buraidah', 'Khamis Mushait', 'Hofuf', 'Mubarraz',
|
||||
'Jubail', 'Yanbu', 'Abha', 'Najran', 'Jazan', 'Hail', 'Arar'
|
||||
]
|
||||
|
||||
SAUDI_PROVINCES = [
|
||||
'Riyadh Province', 'Makkah Province', 'Eastern Province', 'Asir Province',
|
||||
'Jazan Province', 'Medina Province', 'Qassim Province', 'Tabuk Province',
|
||||
'Hail Province', 'Northern Borders Province', 'Najran Province', 'Al Bahah Province'
|
||||
]
|
||||
|
||||
SAUDI_DEPARTMENTS = [
|
||||
'Internal Medicine', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology',
|
||||
'Pediatrics', 'Emergency Medicine', 'Radiology', 'Laboratory Medicine',
|
||||
'Pharmacy', 'Surgery', 'Obstetrics and Gynecology', 'Dermatology',
|
||||
'Ophthalmology', 'ENT', 'Anesthesiology', 'Pathology', 'Psychiatry'
|
||||
]
|
||||
SAUDI_JOB_TITLES = {
|
||||
'PHYSICIAN': ['Consultant Physician', 'Senior Physician', 'Staff Physician', 'Resident Physician',
|
||||
'Chief Medical Officer'],
|
||||
@ -63,50 +68,13 @@ SAUDI_JOB_TITLES = {
|
||||
'ADMIN': ['Medical Director', 'Hospital Administrator', 'Department Manager', 'Operations Manager'],
|
||||
'LAB_TECH': ['Senior Lab Technician', 'Medical Laboratory Scientist', 'Lab Supervisor'],
|
||||
'RAD_TECH': ['Senior Radiologic Technologist', 'CT Technologist', 'MRI Technologist'],
|
||||
'RADIOLOGIST': ['Consultant Radiologist', 'Senior Radiologist', 'Interventional Radiologist']
|
||||
'RADIOLOGIST': ['Consultant Radiologist', 'Senior Radiologist', 'Interventional Radiologist'],
|
||||
'MEDICAL_ASSISTANT': ['Medical Assistant'],
|
||||
'CLERICAL': ['Clerical Staff'],
|
||||
}
|
||||
|
||||
SAUDI_DEPARTMENTS = [
|
||||
'Internal Medicine', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology',
|
||||
'Pediatrics', 'Emergency Medicine', 'Radiology', 'Laboratory Medicine',
|
||||
'Pharmacy', 'Surgery', 'Obstetrics and Gynecology', 'Dermatology',
|
||||
'Ophthalmology', 'ENT', 'Anesthesiology', 'Pathology', 'Psychiatry'
|
||||
]
|
||||
|
||||
# Saudi Medical License Formats
|
||||
SAUDI_LICENSE_PREFIXES = ['MOH', 'SCFHS', 'SMLE', 'SFH']
|
||||
|
||||
|
||||
def generate_saudi_phone():
|
||||
"""Generate Saudi phone number"""
|
||||
area_codes = ['11', '12', '13', '14', '16', '17'] # Major Saudi area codes
|
||||
return f"+966-{random.choice(area_codes)}-{random.randint(100, 999)}-{random.randint(1000, 9999)}"
|
||||
|
||||
|
||||
def generate_saudi_mobile():
|
||||
"""Generate Saudi mobile number"""
|
||||
mobile_prefixes = ['50', '53', '54', '55', '56', '57', '58', '59'] # Saudi mobile prefixes
|
||||
return f"+966-{random.choice(mobile_prefixes)}-{random.randint(100, 999)}-{random.randint(1000, 9999)}"
|
||||
|
||||
|
||||
def generate_saudi_license():
|
||||
"""Generate Saudi medical license number"""
|
||||
prefix = random.choice(SAUDI_LICENSE_PREFIXES)
|
||||
return f"{prefix}-{random.randint(100000, 999999)}"
|
||||
|
||||
|
||||
def generate_saudi_employee_id(tenant_name, role):
|
||||
"""Generate Saudi employee ID"""
|
||||
tenant_code = ''.join([c for c in tenant_name.upper() if c.isalpha()])[:3]
|
||||
role_code = role[:3].upper()
|
||||
return f"{tenant_code}-{role_code}-{random.randint(1000, 9999)}"
|
||||
|
||||
|
||||
def create_saudi_users(tenants, users_per_tenant=50):
|
||||
"""Create Saudi healthcare users"""
|
||||
users = []
|
||||
|
||||
role_distribution = {
|
||||
ROLE_DISTRIBUTION = {
|
||||
'PHYSICIAN': 0.15,
|
||||
'NURSE': 0.25,
|
||||
'PHARMACIST': 0.08,
|
||||
@ -116,124 +84,173 @@ def create_saudi_users(tenants, users_per_tenant=50):
|
||||
'ADMIN': 0.07,
|
||||
'MEDICAL_ASSISTANT': 0.12,
|
||||
'CLERICAL': 0.10
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# -------------------------------
|
||||
# Helpers
|
||||
# -------------------------------
|
||||
def ensure_departments(tenant):
|
||||
"""
|
||||
Ensure Department objects exist for this tenant; return a list of them.
|
||||
Adjust if your Department is global (then drop tenant filtering).
|
||||
"""
|
||||
existing = list(Department.objects.filter(tenant=tenant)) if 'tenant' in [f.name for f in Department._meta.fields] else list(Department.objects.all())
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
# create seed departments
|
||||
bulk = []
|
||||
for name in SAUDI_DEPARTMENTS:
|
||||
if 'tenant' in [f.name for f in Department._meta.fields]:
|
||||
bulk.append(Department(name=name, tenant=tenant))
|
||||
else:
|
||||
bulk.append(Department(name=name))
|
||||
Department.objects.bulk_create(bulk, ignore_conflicts=True)
|
||||
|
||||
return list(Department.objects.filter(tenant=tenant)) if 'tenant' in [f.name for f in Department._meta.fields] else list(Department.objects.all())
|
||||
|
||||
|
||||
def generate_saudi_mobile_e164():
|
||||
"""Generate Saudi E.164 mobile: +9665XXXXXXXX"""
|
||||
return f"+9665{random.randint(10000000, 99999999)}"
|
||||
|
||||
|
||||
def generate_saudi_license():
|
||||
"""Generate Saudi medical license number (fictional format)"""
|
||||
prefix = random.choice(SAUDI_LICENSE_PREFIXES)
|
||||
return f"{prefix}-{random.randint(100000, 999999)}"
|
||||
|
||||
|
||||
def tenant_scoped_unique_username(tenant, base_username: str) -> str:
|
||||
"""
|
||||
Make username unique within a tenant (your User has tenant-scoped unique constraint).
|
||||
"""
|
||||
username = base_username
|
||||
i = 1
|
||||
while User.objects.filter(tenant=tenant, username=username).exists():
|
||||
i += 1
|
||||
username = f"{base_username}{i}"
|
||||
return username
|
||||
|
||||
|
||||
def pick_job_title(role: str) -> str:
|
||||
titles = SAUDI_JOB_TITLES.get(role)
|
||||
if titles:
|
||||
return random.choice(titles)
|
||||
# fallback
|
||||
return role.replace('_', ' ').title()
|
||||
|
||||
|
||||
# -------------------------------
|
||||
# Generators
|
||||
# -------------------------------
|
||||
def create_saudi_users(tenants, users_per_tenant=50):
|
||||
"""
|
||||
Create Users (auth + security), then populate Employee profile.
|
||||
Relies on the post_save signal to create Employee automatically.
|
||||
"""
|
||||
all_users = []
|
||||
|
||||
for tenant in tenants:
|
||||
departments = ensure_departments(tenant)
|
||||
tenant_users = []
|
||||
|
||||
for role, percentage in role_distribution.items():
|
||||
user_count = max(1, int(users_per_tenant * percentage))
|
||||
for role, pct in ROLE_DISTRIBUTION.items():
|
||||
count = max(1, int(users_per_tenant * pct))
|
||||
|
||||
for i in range(user_count):
|
||||
# Determine gender for Arabic naming
|
||||
for _ in range(count):
|
||||
is_male = random.choice([True, False])
|
||||
first_name = random.choice(SAUDI_FIRST_NAMES_MALE if is_male else SAUDI_FIRST_NAMES_FEMALE)
|
||||
last_name = random.choice(SAUDI_FAMILY_NAMES)
|
||||
middle_name = random.choice(SAUDI_MIDDLE_NAMES) if random.choice([True, False]) else None
|
||||
|
||||
# Generate username
|
||||
username = f"{first_name.lower()}.{last_name.lower().replace('-', '').replace('al', '')}"
|
||||
counter = 1
|
||||
original_username = username
|
||||
while User.objects.filter(username=username).exists():
|
||||
username = f"{original_username}{counter}"
|
||||
counter += 1
|
||||
|
||||
# Generate email
|
||||
# base username like "mohammed.alrashid"
|
||||
base_username = f"{first_name.lower()}.{last_name.lower().replace('-', '').replace('al', '')}"
|
||||
username = tenant_scoped_unique_username(tenant, base_username)
|
||||
email = f"{username}@{tenant.name.lower().replace(' ', '').replace('-', '')}.sa"
|
||||
|
||||
# Professional information
|
||||
department = random.choice(SAUDI_DEPARTMENTS)
|
||||
job_title = random.choice(SAUDI_JOB_TITLES.get(role, [f"{role.replace('_', ' ').title()}"]))
|
||||
|
||||
# License information for medical professionals
|
||||
license_number = None
|
||||
license_state = None
|
||||
license_expiry = None
|
||||
npi_number = None
|
||||
|
||||
if role in ['PHYSICIAN', 'NURSE', 'PHARMACIST', 'RADIOLOGIST']:
|
||||
license_number = generate_saudi_license()
|
||||
license_state = random.choice(SAUDI_PROVINCES)
|
||||
license_expiry = django_timezone.now().date() + timedelta(days=random.randint(365, 1095))
|
||||
if role == 'PHYSICIAN':
|
||||
npi_number = f"SA{random.randint(1000000, 9999999)}"
|
||||
is_admin = role in ['ADMIN', 'SUPER_ADMIN']
|
||||
is_superuser = role == 'SUPER_ADMIN'
|
||||
|
||||
# Auth-level fields only
|
||||
user = User.objects.create(
|
||||
tenant=tenant,
|
||||
username=username,
|
||||
email=email,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
middle_name=middle_name,
|
||||
preferred_name=first_name if random.choice([True, False]) else None,
|
||||
tenant=tenant,
|
||||
is_active=True,
|
||||
is_staff=is_admin,
|
||||
is_superuser=is_superuser,
|
||||
|
||||
# Contact information
|
||||
phone_number=generate_saudi_phone(),
|
||||
mobile_number=generate_saudi_mobile(),
|
||||
|
||||
# Professional information
|
||||
employee_id=generate_saudi_employee_id(tenant.name, role),
|
||||
department=department,
|
||||
job_title=job_title,
|
||||
role=role,
|
||||
|
||||
# License information
|
||||
license_number=license_number,
|
||||
license_state=license_state,
|
||||
license_expiry=license_expiry,
|
||||
npi_number=npi_number,
|
||||
|
||||
# Security settings
|
||||
# security/session (these live on User by design)
|
||||
force_password_change=random.choice([True, False]),
|
||||
password_expires_at=django_timezone.now() + timedelta(days=random.randint(90, 365)),
|
||||
failed_login_attempts=random.randint(0, 2),
|
||||
two_factor_enabled=random.choice([True, False]) if role in ['PHYSICIAN', 'ADMIN',
|
||||
'PHARMACIST'] else False,
|
||||
|
||||
# Session settings
|
||||
two_factor_enabled=random.choice([True, False]) if role in ['PHYSICIAN', 'ADMIN', 'PHARMACIST'] else False,
|
||||
max_concurrent_sessions=random.choice([1, 2, 3, 5]),
|
||||
session_timeout_minutes=random.choice([30, 60, 120, 240]),
|
||||
last_password_change=django_timezone.now() - timedelta(days=random.randint(1, 90)),
|
||||
date_joined=django_timezone.now() - timedelta(days=random.randint(1, 365)),
|
||||
)
|
||||
user.set_password('Hospital@123')
|
||||
user.save()
|
||||
|
||||
# Signal should have created Employee; now populate Employee fields
|
||||
emp: Employee = user.employee_profile # created by signal
|
||||
emp.tenant = tenant # ensure alignment
|
||||
emp.first_name = first_name
|
||||
emp.last_name = last_name
|
||||
emp.preferred_name = first_name if random.choice([True, False]) else None
|
||||
|
||||
# Contact (E.164 KSA)
|
||||
mobile = generate_saudi_mobile_e164()
|
||||
emp.phone = mobile
|
||||
emp.mobile_phone = mobile
|
||||
emp.email = email
|
||||
|
||||
# Role/Org
|
||||
emp.role = role
|
||||
emp.department = random.choice(departments) if departments else None
|
||||
emp.job_title = pick_job_title(role)
|
||||
|
||||
# License (only some roles)
|
||||
if role in ['PHYSICIAN', 'NURSE', 'PHARMACIST', 'RADIOLOGIST']:
|
||||
emp.license_number = generate_saudi_license()
|
||||
emp.license_state = random.choice(SAUDI_PROVINCES)
|
||||
emp.license_expiry_date = django_timezone.now().date() + timedelta(days=random.randint(365, 1095))
|
||||
if role == 'PHYSICIAN':
|
||||
# fictitious local analogue to NPI
|
||||
emp.npi_number = f"SA{random.randint(1000000, 9999999)}"
|
||||
|
||||
# Preferences
|
||||
user_timezone='Asia/Riyadh',
|
||||
language=random.choice(['ar', 'en', 'ar_SA']),
|
||||
theme=random.choice(['LIGHT', 'DARK', 'AUTO']),
|
||||
emp.user_timezone = 'Asia/Riyadh'
|
||||
emp.language = random.choice(['ar', 'en', 'ar_SA'])
|
||||
emp.theme = random.choice([Employee.Theme.LIGHT, Employee.Theme.DARK, Employee.Theme.AUTO])
|
||||
|
||||
# Status
|
||||
is_verified=True,
|
||||
is_approved=True,
|
||||
approval_date=django_timezone.now() - timedelta(days=random.randint(1, 180)),
|
||||
is_active=True,
|
||||
is_staff=role in ['ADMIN', 'SUPER_ADMIN'],
|
||||
is_superuser=role == 'SUPER_ADMIN',
|
||||
# Status / approval (approved later per-tenant)
|
||||
emp.is_verified = True
|
||||
emp.is_approved = True
|
||||
emp.approval_date = django_timezone.now() - timedelta(days=random.randint(1, 180))
|
||||
|
||||
# Metadata
|
||||
created_at=django_timezone.now() - timedelta(days=random.randint(1, 365)),
|
||||
updated_at=django_timezone.now() - timedelta(days=random.randint(0, 30)),
|
||||
last_password_change=django_timezone.now() - timedelta(days=random.randint(1, 90)),
|
||||
date_joined=django_timezone.now() - timedelta(days=random.randint(1, 365))
|
||||
)
|
||||
emp.save()
|
||||
|
||||
# Set password
|
||||
user.set_password('Hospital@123') # Default password
|
||||
user.save()
|
||||
|
||||
users.append(user)
|
||||
tenant_users.append(user)
|
||||
all_users.append(user)
|
||||
|
||||
# Set approval relationships
|
||||
admin_users = [u for u in tenant_users if u.role in ['ADMIN', 'SUPER_ADMIN']]
|
||||
# Approval relationships: choose an approver among admins in this tenant
|
||||
admin_users = [u for u in tenant_users if u.is_staff or u.is_superuser]
|
||||
if admin_users:
|
||||
approver = random.choice(admin_users)
|
||||
for user in tenant_users:
|
||||
if user != approver and user.role != 'SUPER_ADMIN':
|
||||
user.approved_by = approver
|
||||
user.save()
|
||||
for u in tenant_users:
|
||||
if u != approver:
|
||||
emp = u.employee_profile
|
||||
emp.approved_by = approver
|
||||
emp.save(update_fields=['approved_by'])
|
||||
|
||||
print(f"Created {len(tenant_users)} users for {tenant.name}")
|
||||
|
||||
return users
|
||||
return all_users
|
||||
|
||||
|
||||
def create_saudi_two_factor_devices(users):
|
||||
@ -249,8 +266,8 @@ def create_saudi_two_factor_devices(users):
|
||||
|
||||
for user in users:
|
||||
if user.two_factor_enabled:
|
||||
# Create 1-3 devices per user
|
||||
device_count = random.randint(1, 3)
|
||||
emp = getattr(user, 'employee_profile', None)
|
||||
|
||||
for _ in range(device_count):
|
||||
device_type = random.choice(device_types)
|
||||
@ -271,9 +288,9 @@ def create_saudi_two_factor_devices(users):
|
||||
if device_type == 'TOTP':
|
||||
device_data['secret_key'] = secrets.token_urlsafe(32)
|
||||
elif device_type == 'SMS':
|
||||
device_data['phone_number'] = user.mobile_number
|
||||
device_data['phone_number'] = emp.mobile_phone if emp else None
|
||||
elif device_type == 'EMAIL':
|
||||
device_data['email_address'] = user.email
|
||||
device_data['email_address'] = emp.email if emp and emp.email else user.email
|
||||
|
||||
device = TwoFactorDevice.objects.create(**device_data)
|
||||
devices.append(device)
|
||||
@ -285,21 +302,19 @@ def create_saudi_two_factor_devices(users):
|
||||
def create_saudi_social_accounts(users):
|
||||
"""Create social authentication accounts for Saudi users"""
|
||||
social_accounts = []
|
||||
|
||||
# Common providers in Saudi Arabia
|
||||
providers = ['GOOGLE', 'MICROSOFT', 'APPLE', 'LINKEDIN']
|
||||
|
||||
for user in users:
|
||||
# 30% chance of having social accounts
|
||||
if random.choice([True, False, False, False]):
|
||||
if random.choice([True, False, False, False]): # ~25% chance
|
||||
provider = random.choice(providers)
|
||||
display_name = user.get_full_name() or (user.employee_profile.get_display_name() if hasattr(user, 'employee_profile') else user.username)
|
||||
|
||||
social_account = SocialAccount.objects.create(
|
||||
user=user,
|
||||
provider=provider,
|
||||
provider_id=f"{provider.lower()}_{random.randint(100000000, 999999999)}",
|
||||
provider_email=user.email,
|
||||
display_name=user.get_full_name(),
|
||||
display_name=display_name,
|
||||
profile_url=f"https://{provider.lower()}.com/profile/{user.username}",
|
||||
avatar_url=f"https://{provider.lower()}.com/avatar/{user.username}.jpg",
|
||||
access_token=secrets.token_urlsafe(64),
|
||||
@ -323,30 +338,27 @@ def create_saudi_user_sessions(users):
|
||||
'37.99.', '37.200.', '31.9.', '31.173.', '188.161.',
|
||||
'185.84.', '188.245.', '217.9.', '82.205.', '5.63.'
|
||||
]
|
||||
|
||||
browsers = [
|
||||
'Chrome 120.0.0.0', 'Safari 17.1.2', 'Firefox 121.0.0', 'Edge 120.0.0.0',
|
||||
'Chrome Mobile 120.0.0.0', 'Safari Mobile 17.1.2'
|
||||
]
|
||||
|
||||
operating_systems = [
|
||||
'Windows 11', 'Windows 10', 'macOS 14.0', 'iOS 17.1.2',
|
||||
'Android 14', 'Ubuntu 22.04'
|
||||
]
|
||||
|
||||
device_types = ['DESKTOP', 'MOBILE', 'TABLET']
|
||||
login_methods = ['PASSWORD', 'TWO_FACTOR', 'SOCIAL', 'SSO']
|
||||
|
||||
for user in users:
|
||||
# Create 1-5 sessions per user
|
||||
session_count = random.randint(1, 5)
|
||||
timeout_minutes = user.session_timeout_minutes or 30
|
||||
|
||||
for i in range(session_count):
|
||||
ip_prefix = random.choice(saudi_ips)
|
||||
ip_address = f"{ip_prefix}{random.randint(1, 255)}.{random.randint(1, 255)}"
|
||||
|
||||
session_start = django_timezone.now() - timedelta(hours=random.randint(1, 720))
|
||||
is_active = i == 0 and random.choice([True, True, False]) # Most recent session likely active
|
||||
is_active = (i == 0) and random.choice([True, True, False]) # recent likely active
|
||||
|
||||
session = UserSession.objects.create(
|
||||
user=user,
|
||||
@ -364,7 +376,7 @@ def create_saudi_user_sessions(users):
|
||||
login_method=random.choice(login_methods),
|
||||
created_at=session_start,
|
||||
last_activity_at=session_start + timedelta(minutes=random.randint(1, 480)),
|
||||
expires_at=session_start + timedelta(hours=user.session_timeout_minutes // 60),
|
||||
expires_at=session_start + timedelta(minutes=timeout_minutes),
|
||||
ended_at=None if is_active else session_start + timedelta(hours=random.randint(1, 8))
|
||||
)
|
||||
sessions.append(session)
|
||||
@ -376,16 +388,12 @@ def create_saudi_user_sessions(users):
|
||||
def create_saudi_password_history(users):
|
||||
"""Create password history for Saudi users"""
|
||||
password_history = []
|
||||
|
||||
passwords = ['Hospital@123', 'Medical@456', 'Health@789', 'Saudi@2024', 'Secure@Pass']
|
||||
|
||||
for user in users:
|
||||
# Create 1-5 password history entries per user
|
||||
history_count = random.randint(1, 5)
|
||||
|
||||
for i in range(history_count):
|
||||
password = random.choice(passwords)
|
||||
|
||||
history_entry = PasswordHistory.objects.create(
|
||||
user=user,
|
||||
password_hash=make_password(password),
|
||||
@ -397,33 +405,29 @@ def create_saudi_password_history(users):
|
||||
return password_history
|
||||
|
||||
|
||||
# -------------------------------
|
||||
# Main
|
||||
# -------------------------------
|
||||
def main():
|
||||
"""Main function to generate all Saudi accounts data"""
|
||||
print("Starting Saudi Healthcare Accounts Data Generation...")
|
||||
|
||||
# Get existing tenants
|
||||
tenants = list(Tenant.objects.all())
|
||||
if not tenants:
|
||||
print("❌ No tenants found. Please run the core data generator first.")
|
||||
print("❌ No tenants found. Please seed core tenants first.")
|
||||
return
|
||||
|
||||
# Create users
|
||||
print("\n1. Creating Saudi Healthcare Users...")
|
||||
users = create_saudi_users(tenants, 40) # 40 users per tenant
|
||||
print("\n1. Creating Saudi Healthcare Users (with Employee profiles)...")
|
||||
users = create_saudi_users(tenants, users_per_tenant=40)
|
||||
|
||||
# Create two-factor devices
|
||||
print("\n2. Creating Two-Factor Authentication Devices...")
|
||||
devices = create_saudi_two_factor_devices(users)
|
||||
|
||||
# Create social accounts
|
||||
print("\n3. Creating Social Authentication Accounts...")
|
||||
social_accounts = create_saudi_social_accounts(users)
|
||||
|
||||
# Create user sessions
|
||||
print("\n4. Creating User Sessions...")
|
||||
sessions = create_saudi_user_sessions(users)
|
||||
|
||||
# Create password history
|
||||
print("\n5. Creating Password History...")
|
||||
password_history = create_saudi_password_history(users)
|
||||
|
||||
@ -435,10 +439,10 @@ def main():
|
||||
print(f" - User Sessions: {len(sessions)}")
|
||||
print(f" - Password History Entries: {len(password_history)}")
|
||||
|
||||
# Role distribution summary
|
||||
role_counts = {}
|
||||
for user in users:
|
||||
role_counts[user.role] = role_counts.get(user.role, 0) + 1
|
||||
for u in users:
|
||||
role = u.employee_profile.role if hasattr(u, 'employee_profile') else 'UNKNOWN'
|
||||
role_counts[role] = role_counts.get(role, 0) + 1
|
||||
|
||||
print(f"\n👥 User Role Distribution:")
|
||||
for role, count in sorted(role_counts.items()):
|
||||
@ -449,7 +453,7 @@ def main():
|
||||
'devices': devices,
|
||||
'social_accounts': social_accounts,
|
||||
'sessions': sessions,
|
||||
'password_history': password_history
|
||||
'password_history': password_history,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.6 on 2025-09-08 07:28
|
||||
# Generated by Django 5.2.6 on 2025-09-15 14:05
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.6 on 2025-09-08 07:28
|
||||
# Generated by Django 5.2.6 on 2025-09-15 14:05
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -8,8 +8,8 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content" class="app-content">
|
||||
<div class="container">
|
||||
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-xl-10">
|
||||
<div class="row">
|
||||
@ -228,8 +228,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
|
||||
@ -8,8 +8,8 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content" class="app-content">
|
||||
<div class="container">
|
||||
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-xl-10">
|
||||
<div class="row">
|
||||
@ -185,8 +185,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
|
||||
@ -8,8 +8,8 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content" class="app-content">
|
||||
<div class="container">
|
||||
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-xl-10">
|
||||
<div class="row">
|
||||
@ -133,8 +133,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
|
||||
@ -56,7 +56,7 @@ def generate_dashboards(tenants, users):
|
||||
created_by=creator
|
||||
)
|
||||
dashboard.allowed_users.add(creator)
|
||||
dashboard.allowed_roles = [creator.role]
|
||||
dashboard.allowed_roles = [creator.employee_profile.role]
|
||||
dashboard.save()
|
||||
dashboards.append(dashboard)
|
||||
print(f"✅ Created {len(dashboards)} dashboards")
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
appointments/api/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
appointments/api/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
appointments/api/__pycache__/serializers.cpython-312.pyc
Normal file
BIN
appointments/api/__pycache__/serializers.cpython-312.pyc
Normal file
Binary file not shown.
BIN
appointments/api/__pycache__/urls.cpython-312.pyc
Normal file
BIN
appointments/api/__pycache__/urls.cpython-312.pyc
Normal file
Binary file not shown.
BIN
appointments/api/__pycache__/views.cpython-312.pyc
Normal file
BIN
appointments/api/__pycache__/views.cpython-312.pyc
Normal file
Binary file not shown.
@ -353,7 +353,7 @@ class AppointmentSearchForm(forms.Form):
|
||||
self.fields['provider'].queryset = User.objects.filter(
|
||||
tenant=user.tenant,
|
||||
is_active=True,
|
||||
role__in=['PHYSICIAN', 'NURSE', 'NURSE_PRACTITIONER', 'PHYSICIAN_ASSISTANT']
|
||||
employee_profile__role__in=['PHYSICIAN', 'NURSE', 'NURSE_PRACTITIONER', 'PHYSICIAN_ASSISTANT']
|
||||
).order_by('last_name', 'first_name')
|
||||
|
||||
|
||||
@ -645,34 +645,34 @@ class WaitingListContactLogForm(forms.ModelForm):
|
||||
|
||||
widgets = {
|
||||
'contact_method': forms.Select(attrs={
|
||||
'class': 'form-select',
|
||||
'class': 'form-select form-select-sm',
|
||||
'required': True
|
||||
}),
|
||||
'contact_outcome': forms.Select(attrs={
|
||||
'class': 'form-select',
|
||||
'class': 'form-select form-select-sm',
|
||||
'required': True
|
||||
}),
|
||||
'appointment_offered': forms.CheckboxInput(attrs={
|
||||
'class': 'form-check-input'
|
||||
}),
|
||||
'offered_date': forms.DateInput(attrs={
|
||||
'class': 'form-control',
|
||||
'class': 'form-control form-control-sm',
|
||||
'type': 'date'
|
||||
}),
|
||||
'offered_time': forms.TimeInput(attrs={
|
||||
'class': 'form-control',
|
||||
'class': 'form-control form-control-sm',
|
||||
'type': 'time'
|
||||
}),
|
||||
'patient_response': forms.Select(attrs={
|
||||
'class': 'form-select'
|
||||
'class': 'form-select form-select-sm'
|
||||
}),
|
||||
'notes': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'class': 'form-control form-control-sm',
|
||||
'rows': 4,
|
||||
'placeholder': 'Notes from contact attempt...'
|
||||
}),
|
||||
'next_contact_date': forms.DateInput(attrs={
|
||||
'class': 'form-control',
|
||||
'class': 'form-control form-control-sm',
|
||||
'type': 'date',
|
||||
'min': date.today().isoformat()
|
||||
}),
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.6 on 2025-09-08 07:28
|
||||
# Generated by Django 5.2.6 on 2025-09-15 14:05
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
@ -549,6 +549,526 @@ class Migration(migrations.Migration):
|
||||
"ordering": ["-scheduled_start"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="WaitingList",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"waiting_list_id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
help_text="Unique waiting list entry identifier",
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"appointment_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("CONSULTATION", "Consultation"),
|
||||
("FOLLOW_UP", "Follow-up"),
|
||||
("PROCEDURE", "Procedure"),
|
||||
("SURGERY", "Surgery"),
|
||||
("DIAGNOSTIC", "Diagnostic"),
|
||||
("THERAPY", "Therapy"),
|
||||
("VACCINATION", "Vaccination"),
|
||||
("SCREENING", "Screening"),
|
||||
("EMERGENCY", "Emergency"),
|
||||
("TELEMEDICINE", "Telemedicine"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
help_text="Type of appointment requested",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
(
|
||||
"specialty",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("FAMILY_MEDICINE", "Family Medicine"),
|
||||
("INTERNAL_MEDICINE", "Internal Medicine"),
|
||||
("PEDIATRICS", "Pediatrics"),
|
||||
("CARDIOLOGY", "Cardiology"),
|
||||
("DERMATOLOGY", "Dermatology"),
|
||||
("ENDOCRINOLOGY", "Endocrinology"),
|
||||
("GASTROENTEROLOGY", "Gastroenterology"),
|
||||
("NEUROLOGY", "Neurology"),
|
||||
("ONCOLOGY", "Oncology"),
|
||||
("ORTHOPEDICS", "Orthopedics"),
|
||||
("PSYCHIATRY", "Psychiatry"),
|
||||
("RADIOLOGY", "Radiology"),
|
||||
("SURGERY", "Surgery"),
|
||||
("UROLOGY", "Urology"),
|
||||
("GYNECOLOGY", "Gynecology"),
|
||||
("OPHTHALMOLOGY", "Ophthalmology"),
|
||||
("ENT", "Ear, Nose & Throat"),
|
||||
("EMERGENCY", "Emergency Medicine"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
help_text="Medical specialty required",
|
||||
max_length=100,
|
||||
),
|
||||
),
|
||||
(
|
||||
"priority",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("ROUTINE", "Routine"),
|
||||
("URGENT", "Urgent"),
|
||||
("STAT", "STAT"),
|
||||
("EMERGENCY", "Emergency"),
|
||||
],
|
||||
default="ROUTINE",
|
||||
help_text="Clinical priority level",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"urgency_score",
|
||||
models.PositiveIntegerField(
|
||||
default=1,
|
||||
help_text="Clinical urgency score (1-10, 10 being most urgent)",
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(1),
|
||||
django.core.validators.MaxValueValidator(10),
|
||||
],
|
||||
),
|
||||
),
|
||||
(
|
||||
"clinical_indication",
|
||||
models.TextField(
|
||||
help_text="Clinical reason for appointment request"
|
||||
),
|
||||
),
|
||||
(
|
||||
"diagnosis_codes",
|
||||
models.JSONField(
|
||||
blank=True, default=list, help_text="ICD-10 diagnosis codes"
|
||||
),
|
||||
),
|
||||
(
|
||||
"preferred_date",
|
||||
models.DateField(
|
||||
blank=True,
|
||||
help_text="Patient preferred appointment date",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"preferred_time",
|
||||
models.TimeField(
|
||||
blank=True,
|
||||
help_text="Patient preferred appointment time",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"flexible_scheduling",
|
||||
models.BooleanField(
|
||||
default=True,
|
||||
help_text="Patient accepts alternative dates/times",
|
||||
),
|
||||
),
|
||||
(
|
||||
"earliest_acceptable_date",
|
||||
models.DateField(
|
||||
blank=True,
|
||||
help_text="Earliest acceptable appointment date",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"latest_acceptable_date",
|
||||
models.DateField(
|
||||
blank=True,
|
||||
help_text="Latest acceptable appointment date",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"acceptable_days",
|
||||
models.JSONField(
|
||||
blank=True,
|
||||
default=list,
|
||||
help_text="Acceptable days of week (0=Monday, 6=Sunday)",
|
||||
),
|
||||
),
|
||||
(
|
||||
"acceptable_times",
|
||||
models.JSONField(
|
||||
blank=True, default=list, help_text="Acceptable time ranges"
|
||||
),
|
||||
),
|
||||
(
|
||||
"contact_method",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("PHONE", "Phone"),
|
||||
("EMAIL", "Email"),
|
||||
("SMS", "SMS"),
|
||||
("PORTAL", "Patient Portal"),
|
||||
("MAIL", "Mail"),
|
||||
],
|
||||
default="PHONE",
|
||||
help_text="Preferred contact method",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"contact_phone",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Contact phone number",
|
||||
max_length=20,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"contact_email",
|
||||
models.EmailField(
|
||||
blank=True,
|
||||
help_text="Contact email address",
|
||||
max_length=254,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("ACTIVE", "Active"),
|
||||
("CONTACTED", "Contacted"),
|
||||
("OFFERED", "Appointment Offered"),
|
||||
("SCHEDULED", "Scheduled"),
|
||||
("CANCELLED", "Cancelled"),
|
||||
("EXPIRED", "Expired"),
|
||||
("TRANSFERRED", "Transferred"),
|
||||
],
|
||||
default="ACTIVE",
|
||||
help_text="Waiting list status",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"position",
|
||||
models.PositiveIntegerField(
|
||||
blank=True,
|
||||
help_text="Position in waiting list queue",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"estimated_wait_time",
|
||||
models.PositiveIntegerField(
|
||||
blank=True, help_text="Estimated wait time in days", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"last_contacted",
|
||||
models.DateTimeField(
|
||||
blank=True,
|
||||
help_text="Last contact attempt date/time",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"contact_attempts",
|
||||
models.PositiveIntegerField(
|
||||
default=0, help_text="Number of contact attempts made"
|
||||
),
|
||||
),
|
||||
(
|
||||
"max_contact_attempts",
|
||||
models.PositiveIntegerField(
|
||||
default=3, help_text="Maximum contact attempts before expiring"
|
||||
),
|
||||
),
|
||||
(
|
||||
"appointments_offered",
|
||||
models.PositiveIntegerField(
|
||||
default=0, help_text="Number of appointments offered"
|
||||
),
|
||||
),
|
||||
(
|
||||
"appointments_declined",
|
||||
models.PositiveIntegerField(
|
||||
default=0, help_text="Number of appointments declined"
|
||||
),
|
||||
),
|
||||
(
|
||||
"last_offer_date",
|
||||
models.DateTimeField(
|
||||
blank=True,
|
||||
help_text="Date of last appointment offer",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"requires_interpreter",
|
||||
models.BooleanField(
|
||||
default=False, help_text="Patient requires interpreter services"
|
||||
),
|
||||
),
|
||||
(
|
||||
"interpreter_language",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Required interpreter language",
|
||||
max_length=50,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"accessibility_requirements",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
help_text="Special accessibility requirements",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"transportation_needed",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Patient needs transportation assistance",
|
||||
),
|
||||
),
|
||||
(
|
||||
"insurance_verified",
|
||||
models.BooleanField(
|
||||
default=False, help_text="Insurance coverage verified"
|
||||
),
|
||||
),
|
||||
(
|
||||
"authorization_required",
|
||||
models.BooleanField(
|
||||
default=False, help_text="Prior authorization required"
|
||||
),
|
||||
),
|
||||
(
|
||||
"authorization_status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("NOT_REQUIRED", "Not Required"),
|
||||
("PENDING", "Pending"),
|
||||
("APPROVED", "Approved"),
|
||||
("DENIED", "Denied"),
|
||||
("EXPIRED", "Expired"),
|
||||
],
|
||||
default="NOT_REQUIRED",
|
||||
help_text="Authorization status",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"authorization_number",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Authorization number",
|
||||
max_length=100,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"referring_provider",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Referring provider name",
|
||||
max_length=200,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"referral_date",
|
||||
models.DateField(
|
||||
blank=True, help_text="Date of referral", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"referral_urgency",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("ROUTINE", "Routine"),
|
||||
("URGENT", "Urgent"),
|
||||
("STAT", "STAT"),
|
||||
],
|
||||
default="ROUTINE",
|
||||
help_text="Referral urgency level",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"removal_reason",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("SCHEDULED", "Appointment Scheduled"),
|
||||
("PATIENT_CANCELLED", "Patient Cancelled"),
|
||||
("PROVIDER_CANCELLED", "Provider Cancelled"),
|
||||
("NO_RESPONSE", "No Response to Contact"),
|
||||
("INSURANCE_ISSUE", "Insurance Issue"),
|
||||
("TRANSFERRED", "Transferred to Another Provider"),
|
||||
("EXPIRED", "Entry Expired"),
|
||||
("DUPLICATE", "Duplicate Entry"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
help_text="Reason for removal from waiting list",
|
||||
max_length=50,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"removal_notes",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
help_text="Additional notes about removal",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"removed_at",
|
||||
models.DateTimeField(
|
||||
blank=True,
|
||||
help_text="Date/time removed from waiting list",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"notes",
|
||||
models.TextField(
|
||||
blank=True, help_text="Additional notes and comments", null=True
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Waiting List Entry",
|
||||
"verbose_name_plural": "Waiting List Entries",
|
||||
"db_table": "appointments_waiting_list",
|
||||
"ordering": ["priority", "urgency_score", "created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="WaitingListContactLog",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"contact_date",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True, help_text="Date and time of contact attempt"
|
||||
),
|
||||
),
|
||||
(
|
||||
"contact_method",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("PHONE", "Phone Call"),
|
||||
("EMAIL", "Email"),
|
||||
("SMS", "SMS"),
|
||||
("PORTAL", "Patient Portal Message"),
|
||||
("MAIL", "Mail"),
|
||||
("IN_PERSON", "In Person"),
|
||||
],
|
||||
help_text="Method of contact used",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"contact_outcome",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("SUCCESSFUL", "Successful Contact"),
|
||||
("NO_ANSWER", "No Answer"),
|
||||
("BUSY", "Line Busy"),
|
||||
("VOICEMAIL", "Left Voicemail"),
|
||||
("EMAIL_SENT", "Email Sent"),
|
||||
("EMAIL_BOUNCED", "Email Bounced"),
|
||||
("SMS_SENT", "SMS Sent"),
|
||||
("SMS_FAILED", "SMS Failed"),
|
||||
("WRONG_NUMBER", "Wrong Number"),
|
||||
("DECLINED", "Patient Declined"),
|
||||
],
|
||||
help_text="Outcome of contact attempt",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"appointment_offered",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Appointment was offered during contact",
|
||||
),
|
||||
),
|
||||
(
|
||||
"offered_date",
|
||||
models.DateField(
|
||||
blank=True, help_text="Date of offered appointment", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"offered_time",
|
||||
models.TimeField(
|
||||
blank=True, help_text="Time of offered appointment", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"patient_response",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("ACCEPTED", "Accepted Appointment"),
|
||||
("DECLINED", "Declined Appointment"),
|
||||
("REQUESTED_DIFFERENT", "Requested Different Time"),
|
||||
("WILL_CALL_BACK", "Will Call Back"),
|
||||
("NO_LONGER_NEEDED", "No Longer Needed"),
|
||||
("INSURANCE_ISSUE", "Insurance Issue"),
|
||||
("NO_RESPONSE", "No Response"),
|
||||
],
|
||||
help_text="Patient response to contact",
|
||||
max_length=20,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"notes",
|
||||
models.TextField(
|
||||
blank=True, help_text="Notes from contact attempt", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"next_contact_date",
|
||||
models.DateField(
|
||||
blank=True,
|
||||
help_text="Scheduled date for next contact attempt",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Waiting List Contact Log",
|
||||
"verbose_name_plural": "Waiting List Contact Logs",
|
||||
"db_table": "appointments_waiting_list_contact_log",
|
||||
"ordering": ["-contact_date"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="WaitingQueue",
|
||||
fields=[
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.6 on 2025-09-08 07:28
|
||||
# Generated by Django 5.2.6 on 2025-09-15 14:05
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
@ -12,6 +12,7 @@ class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("appointments", "0001_initial"),
|
||||
("core", "0001_initial"),
|
||||
("hr", "0001_initial"),
|
||||
("patients", "0001_initial"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
@ -179,6 +180,105 @@ class Migration(migrations.Migration):
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="waitinglist",
|
||||
name="created_by",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="User who created the waiting list entry",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="created_waiting_list_entries",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="waitinglist",
|
||||
name="department",
|
||||
field=models.ForeignKey(
|
||||
help_text="Department for appointment",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="waiting_list_entries",
|
||||
to="hr.department",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="waitinglist",
|
||||
name="patient",
|
||||
field=models.ForeignKey(
|
||||
help_text="Patient on waiting list",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="waiting_list_entries",
|
||||
to="patients.patientprofile",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="waitinglist",
|
||||
name="provider",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Preferred healthcare provider",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="provider_waiting_list",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="waitinglist",
|
||||
name="removed_by",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="User who removed entry from waiting list",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="removed_waiting_list_entries",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="waitinglist",
|
||||
name="scheduled_appointment",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Scheduled appointment from waiting list",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="waiting_list_entry",
|
||||
to="appointments.appointmentrequest",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="waitinglist",
|
||||
name="tenant",
|
||||
field=models.ForeignKey(
|
||||
help_text="Organization tenant",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="waiting_list_entries",
|
||||
to="core.tenant",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="waitinglistcontactlog",
|
||||
name="contacted_by",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Staff member who made contact",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="waitinglistcontactlog",
|
||||
name="waiting_list_entry",
|
||||
field=models.ForeignKey(
|
||||
help_text="Associated waiting list entry",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="contact_logs",
|
||||
to="appointments.waitinglist",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="waitingqueue",
|
||||
name="created_by",
|
||||
@ -324,6 +424,63 @@ class Migration(migrations.Migration):
|
||||
fields=["scheduled_start"], name="appointment_schedul_8a4e8e_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="waitinglist",
|
||||
index=models.Index(
|
||||
fields=["tenant", "status"], name="appointment_tenant__a558da_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="waitinglist",
|
||||
index=models.Index(
|
||||
fields=["patient", "status"], name="appointment_patient_73f03d_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="waitinglist",
|
||||
index=models.Index(
|
||||
fields=["department", "specialty", "status"],
|
||||
name="appointment_departm_78fd70_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="waitinglist",
|
||||
index=models.Index(
|
||||
fields=["priority", "urgency_score"],
|
||||
name="appointment_priorit_30fb90_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="waitinglist",
|
||||
index=models.Index(
|
||||
fields=["status", "created_at"], name="appointment_status_cfe551_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="waitinglist",
|
||||
index=models.Index(
|
||||
fields=["provider", "status"], name="appointment_provide_dd6c2b_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="waitinglistcontactlog",
|
||||
index=models.Index(
|
||||
fields=["waiting_list_entry", "contact_date"],
|
||||
name="appointment_waiting_50d8ac_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="waitinglistcontactlog",
|
||||
index=models.Index(
|
||||
fields=["contact_outcome"], name="appointment_contact_ad9c45_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="waitinglistcontactlog",
|
||||
index=models.Index(
|
||||
fields=["next_contact_date"], name="appointment_next_co_b29984_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="waitingqueue",
|
||||
index=models.Index(
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.6 on 2025-09-16 12:04
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("appointments", "0002_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="waitinglist",
|
||||
name="acceptable_days",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
default=list,
|
||||
help_text="Acceptable days of week (0=Monday, 6=Sunday)",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -1,688 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-09-11 17:03
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("appointments", "0002_initial"),
|
||||
("core", "0001_initial"),
|
||||
("hr", "0001_initial"),
|
||||
("patients", "0003_remove_insuranceinfo_subscriber_ssn_and_more"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="WaitingList",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"waiting_list_id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
help_text="Unique waiting list entry identifier",
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"appointment_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("CONSULTATION", "Consultation"),
|
||||
("FOLLOW_UP", "Follow-up"),
|
||||
("PROCEDURE", "Procedure"),
|
||||
("SURGERY", "Surgery"),
|
||||
("DIAGNOSTIC", "Diagnostic"),
|
||||
("THERAPY", "Therapy"),
|
||||
("VACCINATION", "Vaccination"),
|
||||
("SCREENING", "Screening"),
|
||||
("EMERGENCY", "Emergency"),
|
||||
("TELEMEDICINE", "Telemedicine"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
help_text="Type of appointment requested",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
(
|
||||
"specialty",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("FAMILY_MEDICINE", "Family Medicine"),
|
||||
("INTERNAL_MEDICINE", "Internal Medicine"),
|
||||
("PEDIATRICS", "Pediatrics"),
|
||||
("CARDIOLOGY", "Cardiology"),
|
||||
("DERMATOLOGY", "Dermatology"),
|
||||
("ENDOCRINOLOGY", "Endocrinology"),
|
||||
("GASTROENTEROLOGY", "Gastroenterology"),
|
||||
("NEUROLOGY", "Neurology"),
|
||||
("ONCOLOGY", "Oncology"),
|
||||
("ORTHOPEDICS", "Orthopedics"),
|
||||
("PSYCHIATRY", "Psychiatry"),
|
||||
("RADIOLOGY", "Radiology"),
|
||||
("SURGERY", "Surgery"),
|
||||
("UROLOGY", "Urology"),
|
||||
("GYNECOLOGY", "Gynecology"),
|
||||
("OPHTHALMOLOGY", "Ophthalmology"),
|
||||
("ENT", "Ear, Nose & Throat"),
|
||||
("EMERGENCY", "Emergency Medicine"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
help_text="Medical specialty required",
|
||||
max_length=100,
|
||||
),
|
||||
),
|
||||
(
|
||||
"priority",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("ROUTINE", "Routine"),
|
||||
("URGENT", "Urgent"),
|
||||
("STAT", "STAT"),
|
||||
("EMERGENCY", "Emergency"),
|
||||
],
|
||||
default="ROUTINE",
|
||||
help_text="Clinical priority level",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"urgency_score",
|
||||
models.PositiveIntegerField(
|
||||
default=1,
|
||||
help_text="Clinical urgency score (1-10, 10 being most urgent)",
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(1),
|
||||
django.core.validators.MaxValueValidator(10),
|
||||
],
|
||||
),
|
||||
),
|
||||
(
|
||||
"clinical_indication",
|
||||
models.TextField(
|
||||
help_text="Clinical reason for appointment request"
|
||||
),
|
||||
),
|
||||
(
|
||||
"diagnosis_codes",
|
||||
models.JSONField(
|
||||
blank=True, default=list, help_text="ICD-10 diagnosis codes"
|
||||
),
|
||||
),
|
||||
(
|
||||
"preferred_date",
|
||||
models.DateField(
|
||||
blank=True,
|
||||
help_text="Patient preferred appointment date",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"preferred_time",
|
||||
models.TimeField(
|
||||
blank=True,
|
||||
help_text="Patient preferred appointment time",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"flexible_scheduling",
|
||||
models.BooleanField(
|
||||
default=True,
|
||||
help_text="Patient accepts alternative dates/times",
|
||||
),
|
||||
),
|
||||
(
|
||||
"earliest_acceptable_date",
|
||||
models.DateField(
|
||||
blank=True,
|
||||
help_text="Earliest acceptable appointment date",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"latest_acceptable_date",
|
||||
models.DateField(
|
||||
blank=True,
|
||||
help_text="Latest acceptable appointment date",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"acceptable_days",
|
||||
models.JSONField(
|
||||
blank=True,
|
||||
default=list,
|
||||
help_text="Acceptable days of week (0=Monday, 6=Sunday)",
|
||||
),
|
||||
),
|
||||
(
|
||||
"acceptable_times",
|
||||
models.JSONField(
|
||||
blank=True, default=list, help_text="Acceptable time ranges"
|
||||
),
|
||||
),
|
||||
(
|
||||
"contact_method",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("PHONE", "Phone"),
|
||||
("EMAIL", "Email"),
|
||||
("SMS", "SMS"),
|
||||
("PORTAL", "Patient Portal"),
|
||||
("MAIL", "Mail"),
|
||||
],
|
||||
default="PHONE",
|
||||
help_text="Preferred contact method",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"contact_phone",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Contact phone number",
|
||||
max_length=20,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"contact_email",
|
||||
models.EmailField(
|
||||
blank=True,
|
||||
help_text="Contact email address",
|
||||
max_length=254,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("ACTIVE", "Active"),
|
||||
("CONTACTED", "Contacted"),
|
||||
("OFFERED", "Appointment Offered"),
|
||||
("SCHEDULED", "Scheduled"),
|
||||
("CANCELLED", "Cancelled"),
|
||||
("EXPIRED", "Expired"),
|
||||
("TRANSFERRED", "Transferred"),
|
||||
],
|
||||
default="ACTIVE",
|
||||
help_text="Waiting list status",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"position",
|
||||
models.PositiveIntegerField(
|
||||
blank=True,
|
||||
help_text="Position in waiting list queue",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"estimated_wait_time",
|
||||
models.PositiveIntegerField(
|
||||
blank=True, help_text="Estimated wait time in days", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"last_contacted",
|
||||
models.DateTimeField(
|
||||
blank=True,
|
||||
help_text="Last contact attempt date/time",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"contact_attempts",
|
||||
models.PositiveIntegerField(
|
||||
default=0, help_text="Number of contact attempts made"
|
||||
),
|
||||
),
|
||||
(
|
||||
"max_contact_attempts",
|
||||
models.PositiveIntegerField(
|
||||
default=3, help_text="Maximum contact attempts before expiring"
|
||||
),
|
||||
),
|
||||
(
|
||||
"appointments_offered",
|
||||
models.PositiveIntegerField(
|
||||
default=0, help_text="Number of appointments offered"
|
||||
),
|
||||
),
|
||||
(
|
||||
"appointments_declined",
|
||||
models.PositiveIntegerField(
|
||||
default=0, help_text="Number of appointments declined"
|
||||
),
|
||||
),
|
||||
(
|
||||
"last_offer_date",
|
||||
models.DateTimeField(
|
||||
blank=True,
|
||||
help_text="Date of last appointment offer",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"requires_interpreter",
|
||||
models.BooleanField(
|
||||
default=False, help_text="Patient requires interpreter services"
|
||||
),
|
||||
),
|
||||
(
|
||||
"interpreter_language",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Required interpreter language",
|
||||
max_length=50,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"accessibility_requirements",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
help_text="Special accessibility requirements",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"transportation_needed",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Patient needs transportation assistance",
|
||||
),
|
||||
),
|
||||
(
|
||||
"insurance_verified",
|
||||
models.BooleanField(
|
||||
default=False, help_text="Insurance coverage verified"
|
||||
),
|
||||
),
|
||||
(
|
||||
"authorization_required",
|
||||
models.BooleanField(
|
||||
default=False, help_text="Prior authorization required"
|
||||
),
|
||||
),
|
||||
(
|
||||
"authorization_status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("NOT_REQUIRED", "Not Required"),
|
||||
("PENDING", "Pending"),
|
||||
("APPROVED", "Approved"),
|
||||
("DENIED", "Denied"),
|
||||
("EXPIRED", "Expired"),
|
||||
],
|
||||
default="NOT_REQUIRED",
|
||||
help_text="Authorization status",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"authorization_number",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Authorization number",
|
||||
max_length=100,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"referring_provider",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Referring provider name",
|
||||
max_length=200,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"referral_date",
|
||||
models.DateField(
|
||||
blank=True, help_text="Date of referral", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"referral_urgency",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("ROUTINE", "Routine"),
|
||||
("URGENT", "Urgent"),
|
||||
("STAT", "STAT"),
|
||||
],
|
||||
default="ROUTINE",
|
||||
help_text="Referral urgency level",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"removal_reason",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("SCHEDULED", "Appointment Scheduled"),
|
||||
("PATIENT_CANCELLED", "Patient Cancelled"),
|
||||
("PROVIDER_CANCELLED", "Provider Cancelled"),
|
||||
("NO_RESPONSE", "No Response to Contact"),
|
||||
("INSURANCE_ISSUE", "Insurance Issue"),
|
||||
("TRANSFERRED", "Transferred to Another Provider"),
|
||||
("EXPIRED", "Entry Expired"),
|
||||
("DUPLICATE", "Duplicate Entry"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
help_text="Reason for removal from waiting list",
|
||||
max_length=50,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"removal_notes",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
help_text="Additional notes about removal",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"removed_at",
|
||||
models.DateTimeField(
|
||||
blank=True,
|
||||
help_text="Date/time removed from waiting list",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"notes",
|
||||
models.TextField(
|
||||
blank=True, help_text="Additional notes and comments", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="User who created the waiting list entry",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="created_waiting_list_entries",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"department",
|
||||
models.ForeignKey(
|
||||
help_text="Department for appointment",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="waiting_list_entries",
|
||||
to="hr.department",
|
||||
),
|
||||
),
|
||||
(
|
||||
"patient",
|
||||
models.ForeignKey(
|
||||
help_text="Patient on waiting list",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="waiting_list_entries",
|
||||
to="patients.patientprofile",
|
||||
),
|
||||
),
|
||||
(
|
||||
"provider",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Preferred healthcare provider",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="provider_waiting_list",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"removed_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="User who removed entry from waiting list",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="removed_waiting_list_entries",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"scheduled_appointment",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Scheduled appointment from waiting list",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="waiting_list_entry",
|
||||
to="appointments.appointmentrequest",
|
||||
),
|
||||
),
|
||||
(
|
||||
"tenant",
|
||||
models.ForeignKey(
|
||||
help_text="Organization tenant",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="waiting_list_entries",
|
||||
to="core.tenant",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Waiting List Entry",
|
||||
"verbose_name_plural": "Waiting List Entries",
|
||||
"db_table": "appointments_waiting_list",
|
||||
"ordering": ["priority", "urgency_score", "created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="WaitingListContactLog",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"contact_date",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True, help_text="Date and time of contact attempt"
|
||||
),
|
||||
),
|
||||
(
|
||||
"contact_method",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("PHONE", "Phone Call"),
|
||||
("EMAIL", "Email"),
|
||||
("SMS", "SMS"),
|
||||
("PORTAL", "Patient Portal Message"),
|
||||
("MAIL", "Mail"),
|
||||
("IN_PERSON", "In Person"),
|
||||
],
|
||||
help_text="Method of contact used",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"contact_outcome",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("SUCCESSFUL", "Successful Contact"),
|
||||
("NO_ANSWER", "No Answer"),
|
||||
("BUSY", "Line Busy"),
|
||||
("VOICEMAIL", "Left Voicemail"),
|
||||
("EMAIL_SENT", "Email Sent"),
|
||||
("EMAIL_BOUNCED", "Email Bounced"),
|
||||
("SMS_SENT", "SMS Sent"),
|
||||
("SMS_FAILED", "SMS Failed"),
|
||||
("WRONG_NUMBER", "Wrong Number"),
|
||||
("DECLINED", "Patient Declined"),
|
||||
],
|
||||
help_text="Outcome of contact attempt",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"appointment_offered",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Appointment was offered during contact",
|
||||
),
|
||||
),
|
||||
(
|
||||
"offered_date",
|
||||
models.DateField(
|
||||
blank=True, help_text="Date of offered appointment", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"offered_time",
|
||||
models.TimeField(
|
||||
blank=True, help_text="Time of offered appointment", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"patient_response",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("ACCEPTED", "Accepted Appointment"),
|
||||
("DECLINED", "Declined Appointment"),
|
||||
("REQUESTED_DIFFERENT", "Requested Different Time"),
|
||||
("WILL_CALL_BACK", "Will Call Back"),
|
||||
("NO_LONGER_NEEDED", "No Longer Needed"),
|
||||
("INSURANCE_ISSUE", "Insurance Issue"),
|
||||
("NO_RESPONSE", "No Response"),
|
||||
],
|
||||
help_text="Patient response to contact",
|
||||
max_length=20,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"notes",
|
||||
models.TextField(
|
||||
blank=True, help_text="Notes from contact attempt", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"next_contact_date",
|
||||
models.DateField(
|
||||
blank=True,
|
||||
help_text="Scheduled date for next contact attempt",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"contacted_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Staff member who made contact",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"waiting_list_entry",
|
||||
models.ForeignKey(
|
||||
help_text="Associated waiting list entry",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="contact_logs",
|
||||
to="appointments.waitinglist",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Waiting List Contact Log",
|
||||
"verbose_name_plural": "Waiting List Contact Logs",
|
||||
"db_table": "appointments_waiting_list_contact_log",
|
||||
"ordering": ["-contact_date"],
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="waitinglist",
|
||||
index=models.Index(
|
||||
fields=["tenant", "status"], name="appointment_tenant__a558da_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="waitinglist",
|
||||
index=models.Index(
|
||||
fields=["patient", "status"], name="appointment_patient_73f03d_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="waitinglist",
|
||||
index=models.Index(
|
||||
fields=["department", "specialty", "status"],
|
||||
name="appointment_departm_78fd70_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="waitinglist",
|
||||
index=models.Index(
|
||||
fields=["priority", "urgency_score"],
|
||||
name="appointment_priorit_30fb90_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="waitinglist",
|
||||
index=models.Index(
|
||||
fields=["status", "created_at"], name="appointment_status_cfe551_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="waitinglist",
|
||||
index=models.Index(
|
||||
fields=["provider", "status"], name="appointment_provide_dd6c2b_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="waitinglistcontactlog",
|
||||
index=models.Index(
|
||||
fields=["waiting_list_entry", "contact_date"],
|
||||
name="appointment_waiting_50d8ac_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="waitinglistcontactlog",
|
||||
index=models.Index(
|
||||
fields=["contact_outcome"], name="appointment_contact_ad9c45_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="waitinglistcontactlog",
|
||||
index=models.Index(
|
||||
fields=["next_contact_date"], name="appointment_next_co_b29984_idx"
|
||||
),
|
||||
),
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1371,6 +1371,7 @@ class WaitingList(models.Model):
|
||||
|
||||
acceptable_days = models.JSONField(
|
||||
default=list,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text='Acceptable days of week (0=Monday, 6=Sunday)'
|
||||
)
|
||||
|
||||
BIN
appointments/templates/appointments/.DS_Store
vendored
BIN
appointments/templates/appointments/.DS_Store
vendored
Binary file not shown.
@ -9,8 +9,8 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content" class="app-content">
|
||||
<div class="container">
|
||||
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-xl-12">
|
||||
<ul class="breadcrumb">
|
||||
@ -187,8 +187,8 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
|
||||
@ -8,8 +8,8 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content" class="app-content">
|
||||
<div class="container">
|
||||
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-xl-12">
|
||||
<ul class="breadcrumb">
|
||||
@ -240,8 +240,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
|
||||
182
appointments/templates/appointments/calendar.html
Normal file
182
appointments/templates/appointments/calendar.html
Normal file
@ -0,0 +1,182 @@
|
||||
{# templates/appointments/calendar.html #}
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Appointments Calendar{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
.fc .fc-toolbar-title { font-weight: 600; }
|
||||
.calendar-wrapper { min-height: 70vh; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<div>
|
||||
<h1 class="h2">
|
||||
<i class="fas fa-calendar-alt"></i> Scheduling<span class="fw-light">Calendar</span>
|
||||
</h1>
|
||||
<p class="text-muted">View your calendar and manage schedules.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-9">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div id="calendar" class="calendar-wrapper"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3">
|
||||
<div class="card">
|
||||
<div class="card-header fw-bold">Appointment Details</div>
|
||||
<div class="card-body" id="appt-details">
|
||||
<div class="text-muted small">Click an event to see details.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header fw-bold">Filters</div>
|
||||
<div class="card-body">
|
||||
<form id="calendarFilters">
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Status</label>
|
||||
<select class="form-select" name="status">
|
||||
<option value="">All</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
<option value="CONFIRMED">Confirmed</option>
|
||||
<option value="CHECKED_IN">Checked-in</option>
|
||||
<option value="IN_PROGRESS">In progress</option>
|
||||
<option value="COMPLETED">Completed</option>
|
||||
<option value="CANCELLED">Cancelled</option>
|
||||
<option value="NO_SHOW">No show</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label for="provider" class="form-label">Provider</label>
|
||||
<input id="provider" class="form-control" name="provider_id" placeholder="Provider ID (optional)">
|
||||
</div>
|
||||
<button type="button" id="applyFilters" class="btn btn-theme w-100">Apply</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Optional: Bootstrap modal for full details #}
|
||||
<div class="modal fade" id="apptModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title">Appointment</h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="apptModalBody">
|
||||
<!-- HTMX fills here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
<script src="{% static 'plugins/moment/min/moment.min.js' %}"></script>
|
||||
<script src="{% static 'plugins/@fullcalendar/core/index.global.js' %}"></script>
|
||||
<script src="{% static 'plugins/@fullcalendar/daygrid/index.global.js' %}"></script>
|
||||
<script src="{% static 'plugins/@fullcalendar/timegrid/index.global.js' %}"></script>
|
||||
<script src="{% static 'plugins/@fullcalendar/interaction/index.global.js' %}"></script>
|
||||
<script src="{% static 'plugins/@fullcalendar/list/index.global.js' %}"></script>
|
||||
<script src="{% static 'plugins/@fullcalendar/bootstrap/index.global.js' %}"></script>
|
||||
<script>
|
||||
(function(){
|
||||
const calEl = document.getElementById('calendar');
|
||||
const detailsEl = document.getElementById('appt-details');
|
||||
const filtersForm = document.getElementById('calendarFilters');
|
||||
|
||||
function buildEventsUrl(info){
|
||||
const base = "{% url 'appointments:calendar_events' %}";
|
||||
const params = new URLSearchParams({
|
||||
start: info.startStr,
|
||||
end: info.endStr,
|
||||
});
|
||||
|
||||
const status = filtersForm.querySelector('[name=status]').value;
|
||||
const provider_id = filtersForm.querySelector('[name=provider_id]').value;
|
||||
if (status) params.set('status', status);
|
||||
if (provider_id) params.set('provider_id', provider_id);
|
||||
return `${base}?${params.toString()}`;
|
||||
}
|
||||
|
||||
const calendar = new FullCalendar.Calendar(calEl, {
|
||||
timeZone: 'Asia/Riyadh',
|
||||
initialView: 'timeGridWeek',
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
|
||||
},
|
||||
slotMinTime: '07:00:00',
|
||||
slotMaxTime: '22:00:00',
|
||||
nowIndicator: true,
|
||||
navLinks: true,
|
||||
selectable: false,
|
||||
editable: true, // allow drag/resize
|
||||
eventDurationEditable: true,
|
||||
eventSources: [{
|
||||
events: function(info, success, failure){
|
||||
fetch(buildEventsUrl(info), {credentials: 'same-origin'})
|
||||
.then(r => r.ok ? r.json() : Promise.reject(r))
|
||||
.then(data => success(data))
|
||||
.catch(() => failure());
|
||||
}
|
||||
}],
|
||||
eventClick: function(info){
|
||||
// Sidebar card via HTMX-like fetch
|
||||
fetch("{% url 'appointments:appointment_detail_card' 0 %}".replace('0', info.event.id), {credentials:'same-origin'})
|
||||
.then(r => r.text())
|
||||
.then(html => { detailsEl.innerHTML = html; });
|
||||
|
||||
// Also open modal
|
||||
{#const modalBody = document.getElementById('apptModalBody');#}
|
||||
{#modalBody.innerHTML = '<div class="text-center text-muted py-3">Loading...</div>';#}
|
||||
{#fetch("{% url 'appointments:appointment_detail_card' 0 %}".replace('0', info.event.id), {credentials:'same-origin'})#}
|
||||
{# .then(r => r.text())#}
|
||||
{# .then(html => { modalBody.innerHTML = html; new bootstrap.Modal('#apptModal').show(); });#}
|
||||
},
|
||||
eventDrop: function(info){ sendReschedule(info); },
|
||||
eventResize: function(info){ sendReschedule(info); }
|
||||
});
|
||||
|
||||
function getCsrf(){
|
||||
// works with Django’s default CSRF cookie name
|
||||
const m = document.cookie.match(/csrftoken=([^;]+)/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
function sendReschedule(info){
|
||||
const url = "{% url 'appointments:reschedule_appointment' 0 %}".replace('0', info.event.id);
|
||||
const payload = new URLSearchParams({
|
||||
start: info.event.start.toISOString(),
|
||||
end: (info.event.end ? info.event.end : info.event.start).toISOString()
|
||||
});
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {'X-CSRFToken': getCsrf(), 'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: payload.toString()
|
||||
}).then(r => {
|
||||
if(!r.ok){ info.revert(); }
|
||||
}).catch(()=> info.revert());
|
||||
}
|
||||
|
||||
document.getElementById('applyFilters').addEventListener('click', function(){
|
||||
calendar.refetchEvents();
|
||||
});
|
||||
|
||||
calendar.render();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -7,8 +7,8 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content" class="app-content">
|
||||
<div class="container-fluid">
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-xl-12">
|
||||
<ul class="breadcrumb">
|
||||
@ -158,9 +158,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Appointment Detail Modal -->
|
||||
<div class="modal fade" id="appointmentModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
|
||||
@ -2,67 +2,86 @@
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Cancel Appointment{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
.modal-content {
|
||||
border: none;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.modal-header {
|
||||
border-bottom: none;
|
||||
padding: 1.5rem 1.5rem 0.5rem;
|
||||
border-radius: 1rem 1rem 0 0;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div id="content" class="app-content">
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<div>
|
||||
<h1 class="h2">
|
||||
<i class="fas fa-calendar-alt"></i> Cancel<span class="fw-light">Appointment</span>
|
||||
</h1>
|
||||
<p class="text-muted">Appointment cancellation form.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container-fluid">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-xl-8">
|
||||
<ul class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{% url 'core:dashboard' %}">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'appointments:appointment_list' %}">Appointments</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'appointments:appointment_detail' appointment.id %}">{{ appointment.patient.first_name }} {{ appointment.patient.last_name }}</a></li>
|
||||
<li class="breadcrumb-item active">Cancel</li>
|
||||
</ul>
|
||||
|
||||
<h1 class="page-header">Cancel Appointment</h1>
|
||||
|
||||
<!-- Appointment Info -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title">Appointment Details</h4>
|
||||
<div class="panel panel-inverse mb-4" data-sortable-id="index-1">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<i class="fas fa-calendar-day"></i> Appointment Details
|
||||
</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>
|
||||
<a href="javascript:;" class="btn btn-xs btn-icon btn-danger" data-toggle="panel-remove"><i class="fa fa-times"></i></a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="row mb-2">
|
||||
<div class="col-4"><strong>Patient:</strong></div>
|
||||
<div class="col-8">{{ appointment.patient.first_name }} {{ appointment.patient.last_name }}</div>
|
||||
<div class="col-8">{{ appointment.patient.get_full_name }}</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-4"><strong>Patient ID:</strong></div>
|
||||
<div class="col-8">{{ appointment.patient.patient_id }}</div>
|
||||
<div class="col-8">{{ appointment.patient.mrn }}</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-4"><strong>Provider:</strong></div>
|
||||
<div class="col-8">{{ appointment.provider.first_name }} {{ appointment.provider.last_name }}</div>
|
||||
<div class="col-8">{{ appointment.provider.get_full_name }}</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-4"><strong>Department:</strong></div>
|
||||
<div class="col-8">{{ appointment.department.name }}</div>
|
||||
<div class="col-8">{{ appointment.provider.department }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="row mb-2">
|
||||
<div class="col-4"><strong>Date:</strong></div>
|
||||
<div class="col-8">{{ appointment.appointment_date|date:"M d, Y" }}</div>
|
||||
<div class="col-8">{{ appointment.preferred_date|date:"M d, Y" }}</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-4"><strong>Time:</strong></div>
|
||||
<div class="col-8">{{ appointment.appointment_time|time:"g:i A" }}</div>
|
||||
<div class="col-8">{{ appointment.preferred_time|time:"g:i A" }}</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-4"><strong>Type:</strong></div>
|
||||
<div class="col-8">{{ appointment.appointment_type.name }}</div>
|
||||
<div class="col-8">{{ appointment.get_appointment_type_display }}</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-4"><strong>Status:</strong></div>
|
||||
<div class="col-8">
|
||||
{% if appointment.status == 'scheduled' %}
|
||||
{% if appointment.status == 'SCHEDULED' %}
|
||||
<span class="badge bg-info">Scheduled</span>
|
||||
{% elif appointment.status == 'confirmed' %}
|
||||
{% elif appointment.status == 'CONFIRMED' %}
|
||||
<span class="badge bg-success">Confirmed</span>
|
||||
{% elif appointment.status == 'checked_in' %}
|
||||
{% elif appointment.status == 'CHECKED_IN' %}
|
||||
<span class="badge bg-primary">Checked In</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
@ -82,11 +101,19 @@
|
||||
</div>
|
||||
|
||||
<!-- Cancellation Form -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title">Cancellation Details</h4>
|
||||
<div class="panel panel-inverse mb-4" data-sortable-id="index-2">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<i class="fas fa-times-rectangle"></i> Cancellation Details
|
||||
</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>
|
||||
<a href="javascript:;" class="btn btn-xs btn-icon btn-danger" data-toggle="panel-remove"><i class="fa fa-times"></i></a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
||||
@ -129,7 +156,7 @@
|
||||
<label class="col-form-label col-md-3">Cancellation Notes</label>
|
||||
<div class="col-md-9">
|
||||
<textarea name="cancellation_notes" class="form-control" rows="4"
|
||||
placeholder="Additional details about the cancellation">{{ form.cancellation_notes.value|default:'' }}</textarea>
|
||||
placeholder="Additional details about the cancellation">{{ form.cancellation_notes.value }}</textarea>
|
||||
{% if form.cancellation_notes.errors %}
|
||||
<div class="text-danger">{{ form.cancellation_notes.errors.0 }}</div>
|
||||
{% endif %}
|
||||
@ -140,7 +167,9 @@
|
||||
<label class="col-form-label col-md-3">Cancellation Fee</label>
|
||||
<div class="col-md-9">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<span class="input-group-text">
|
||||
<span class="symbol m-0 p-0">ê</span>
|
||||
</span>
|
||||
<input type="number" name="cancellation_fee" class="form-control"
|
||||
step="0.01" min="0" value="{{ form.cancellation_fee.value|default:'0.00' }}">
|
||||
</div>
|
||||
@ -179,24 +208,48 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-9 offset-md-3">
|
||||
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to cancel this appointment? This action cannot be undone.')">
|
||||
<button type="button" class="btn btn-sm btn-danger" data-bs-toggle="modal" data-bs-target="#confirm-cancellation">
|
||||
<i class="fa fa-times me-2"></i>Cancel Appointment
|
||||
</button>
|
||||
<a href="{% url 'appointments:appointment_detail' appointment.id %}" class="btn btn-secondary ms-2">
|
||||
<a href="{% url 'appointments:appointment_detail' appointment.id %}" class="btn btn-sm btn-secondary ms-2">
|
||||
<i class="fa fa-arrow-left me-2"></i>Go Back
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="modal fade" id="confirm-cancellation" tabindex="-1" aria-labelledby="cancelModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-gradient-danger text-white">
|
||||
<h5 class="modal-title" id="cancelModalLabel">Confirm Cancellation</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="mb-0">Are you sure you want to cancel this appointment? This action cannot be undone.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-danger" id="confirmDelete">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cancellation Policy -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title">Cancellation Policy</h4>
|
||||
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="col-xl-4">
|
||||
<!-- Cancellation Policy -->
|
||||
<div class="panel panel-inverse mb-4" data-sortable-id="index-3">
|
||||
<div class="panel-heading bg-gradient-danger">
|
||||
<h4 class="panel-title">
|
||||
<i class="fas fa-calendar-times"></i> Cancellation Policy
|
||||
</h4>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<ul class="mb-0">
|
||||
<li>Appointments cancelled with less than 24 hours notice may incur a cancellation fee</li>
|
||||
<li>Emergency cancellations are exempt from cancellation fees</li>
|
||||
@ -208,8 +261,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
@ -220,13 +273,6 @@ $(document).ready(function() {
|
||||
var now = new Date();
|
||||
var hoursNotice = (appointmentDate - now) / (1000 * 60 * 60);
|
||||
|
||||
// Auto-set cancellation fee based on policy
|
||||
if (hoursNotice < 24 && hoursNotice > 0) {
|
||||
var reason = $('select[name="cancellation_reason"]').val();
|
||||
if (reason !== 'emergency' && reason !== 'provider_unavailable') {
|
||||
$('input[name="cancellation_fee"]').val('25.00');
|
||||
}
|
||||
}
|
||||
|
||||
// Update cancellation fee when reason changes
|
||||
$('select[name="cancellation_reason"]').change(function() {
|
||||
|
||||
@ -4,8 +4,8 @@
|
||||
{% block title %}Check In Patient{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content" class="app-content">
|
||||
<div class="container">
|
||||
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-xl-10">
|
||||
<ul class="breadcrumb">
|
||||
@ -306,8 +306,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
|
||||
@ -4,8 +4,8 @@
|
||||
{% block title %}Confirm Appointment{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content" class="app-content">
|
||||
<div class="container">
|
||||
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-xl-8">
|
||||
<ul class="breadcrumb">
|
||||
@ -250,8 +250,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
|
||||
@ -4,28 +4,18 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<div>
|
||||
<h1 class="h2">
|
||||
<i class="fas fa-calendar-alt"></i> Appointment Dashboard
|
||||
<i class="fas fa-calendar-alt"></i> Appointment<span class="fw-light">Dashboard</span>
|
||||
</h1>
|
||||
<div class="btn-toolbar mb-2 mb-md-0">
|
||||
<div class="btn-group me-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="fas fa-calendar-plus"></i> Schedule
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="fas fa-users"></i> Queue
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-primary">
|
||||
<i class="fas fa-video"></i> Telemedicine
|
||||
</button>
|
||||
<p class="text-muted">View your appointments, manage queues, and track your progress.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Appointment Statistics -->
|
||||
<div id="appointment-stats"
|
||||
hx-get="{% url 'appointments:appointment_stats' %}"
|
||||
hx-trigger="load, every 30s"
|
||||
hx-trigger="load, every 60s"
|
||||
class="auto-refresh mb-4">
|
||||
<div class="htmx-indicator">
|
||||
<div class="spinner-border spinner-border-sm" role="status">
|
||||
@ -43,11 +33,11 @@
|
||||
<i class="fas fa-calendar-day"></i> Today's Appointments
|
||||
</h4>
|
||||
<div class="panel-heading-btn">
|
||||
<a href="{% url 'appointments:appointment_list' %}" class="btn btn-xs btn-outline-primary me-2">View All</a>
|
||||
<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>
|
||||
<a href="javascript:;" class="btn btn-xs btn-icon btn-danger" data-toggle="panel-remove"><i class="fa fa-times"></i></a>
|
||||
<a href="{% url 'appointments:appointment_list' %}" class="btn btn-xs btn-outline-theme me-2">View All</a>
|
||||
<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>
|
||||
<a href="javascript:" class="btn btn-xs btn-icon btn-danger" data-toggle="panel-remove"><i class="fa fa-times"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
@ -149,11 +139,11 @@
|
||||
<i class="fas fa-users"></i> Active Queues
|
||||
</h4>
|
||||
<div class="panel-heading-btn">
|
||||
<a href="{% url 'appointments:queue_management' %}" class="btn btn-xs btn-outline-primary me-2">View All</a>
|
||||
<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>
|
||||
<a href="javascript:;" class="btn btn-xs btn-icon btn-danger" data-toggle="panel-remove"><i class="fa fa-times"></i></a>
|
||||
<a href="{% url 'appointments:waiting_queue_list' %}" class="btn btn-xs btn-outline-theme me-2">View All</a>
|
||||
<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>
|
||||
<a href="javascript:" class="btn btn-xs btn-icon btn-danger" data-toggle="panel-remove"><i class="fa fa-times"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
@ -174,14 +164,6 @@
|
||||
<br>No active queues.
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% if active_queues %}
|
||||
<div class="d-grid">
|
||||
<a href="{% url 'appointments:queue_management' %}" class="btn btn-outline-primary">
|
||||
<i class="fas fa-cog"></i> Manage Queues
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -193,22 +175,31 @@
|
||||
<i class="fas fa-bolt"></i> Quick Actions
|
||||
</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>
|
||||
<a href="javascript:;" class="btn btn-xs btn-icon btn-danger" data-toggle="panel-remove"><i class="fa fa-times"></i></a>
|
||||
<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>
|
||||
<a href="javascript:" class="btn btn-xs btn-icon btn-danger" data-toggle="panel-remove"><i class="fa fa-times"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="d-grid gap-2">
|
||||
<a href="{% url 'appointments:scheduling_calendar' %}" class="btn btn-outline-primary">
|
||||
<a href="{% url 'appointments:calendar' %}" class="btn btn-outline-primary">
|
||||
<i class="fas fa-calendar-plus"></i> Schedule Appointment
|
||||
</a>
|
||||
<a href="{% url 'appointments:appointment_list' %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-list"></i> View All Appointments
|
||||
<i class="fas fa-list"></i> Appointments
|
||||
</a>
|
||||
<a href="{% url 'appointments:telemedicine' %}" class="btn btn-outline-info">
|
||||
<i class="fas fa-video"></i> Telemedicine Sessions
|
||||
<i class="fas fa-video"></i> Telemedicine
|
||||
</a>
|
||||
<a href="{% url 'appointments:waiting_queue_list' %}" class="btn btn-outline-success">
|
||||
<i class="fas fa-users"></i> Queues
|
||||
</a>
|
||||
<a href="{% url 'appointments:calendar' %}" class="btn btn-outline-warning">
|
||||
<i class="fas fa-calendar-days"></i> Calendar
|
||||
</a>
|
||||
<a href="{% url 'appointments:waiting_list' %}" class="btn btn-outline-danger">
|
||||
<i class="fas fa-calendar-check"></i> Waiting List
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -4,8 +4,8 @@
|
||||
{% block title %}Mark No Show{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content" class="app-content">
|
||||
<div class="container">
|
||||
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-xl-8">
|
||||
<ul class="breadcrumb">
|
||||
@ -274,8 +274,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
<div class="fw-bold">{{ appointment.patient.get_full_name }}</div>
|
||||
<div class="text-muted small">
|
||||
{{ appointment.provider.get_full_name }} • {{ appointment.get_appointment_type_display }}
|
||||
</div>
|
||||
<div class="mt-2 small">
|
||||
<div><i class="far fa-clock me-1"></i>{{ appointment.scheduled_datetime|date:"M d, Y H:i" }}</div>
|
||||
{% if appointment.chief_complaint %}
|
||||
<div class="mt-1"><i class="fas fa-notes-medical me-1"></i>{{ appointment.chief_complaint }}</div>
|
||||
{% endif %}
|
||||
{% if appointment.is_telemedicine %}
|
||||
<div class="mt-1"><i class="fas fa-video me-1"></i>Telemedicine</div>
|
||||
{% endif %}
|
||||
<div class="mt-2">
|
||||
<span class="badge
|
||||
{% if appointment.status == 'PENDING' %}bg-warning
|
||||
{% elif appointment.status == 'CONFIRMED' %}bg-info
|
||||
{% elif appointment.status == 'CHECKED_IN' %}bg-primary
|
||||
{% elif appointment.status == 'IN_PROGRESS' %}bg-success
|
||||
{% elif appointment.status == 'COMPLETED' %}bg-success
|
||||
{% elif appointment.status == 'CANCELLED' %}bg-danger
|
||||
{% elif appointment.status == 'NO_SHOW' %}bg-secondary
|
||||
{% endif %}">
|
||||
{{ appointment.get_status_display }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<a href="{% url 'appointments:appointment_detail' appointment.pk %}" class="btn btn-sm btn-outline-primary">{{ _("View") }}</a>
|
||||
{% if appointment.status in 'PENDING, CONFIRMED ,SCHEDULED' %}
|
||||
<a href="{% url 'appointments:cancel_appointment' appointment.pk %}" class="btn btn-sm btn-outline-danger">{{ _("Cancel") }}</a>
|
||||
<a href="{% url 'appointments:reschedule_appointment' appointment.pk %}" class="btn btn-sm btn-outline-warning">{{ _("Reschedule") }}</a>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1,84 +1,61 @@
|
||||
<div class="row">
|
||||
<div class="col-md-2 mb-3">
|
||||
<div class="card stat-card bg-primary text-white">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h4 class="card-title">{{ stats.total_appointments }}</h4>
|
||||
<p class="card-text">Total Appointments</p>
|
||||
</div>
|
||||
<i class="fas fa-calendar-alt fa-2x opacity-75"></i>
|
||||
<div class="col-lg-2 col-sm-4">
|
||||
<div class="widget widget-stats bg-blue mb-7px">
|
||||
<div class="stats-icon stats-icon-lg"><i class="fas fa-calendar-alt fa-fw"></i></div>
|
||||
<div class="stats-content">
|
||||
<div class="stats-title">Total Appointments</div>
|
||||
<div class="stats-number">{{ stats.total_appointments }}</div>
|
||||
<div class="stats-desc">Better than last week (40.5%)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 mb-3">
|
||||
<div class="card stat-card bg-info text-white">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h4 class="card-title">{{ stats.todays_appointments }}</h4>
|
||||
<p class="card-text">Today's Appointments</p>
|
||||
</div>
|
||||
<i class="fas fa-calendar-day fa-2x opacity-75"></i>
|
||||
<div class="col-lg-2 col-sm-4">
|
||||
<div class="widget widget-stats bg-info mb-7px">
|
||||
<div class="stats-icon stats-icon-lg"><i class="fas fa-calendar-day fa-fw"></i></div>
|
||||
<div class="stats-content">
|
||||
<div class="stats-title">Today's Appointments</div>
|
||||
<div class="stats-number">{{ stats.total_appointments_today }}</div>
|
||||
<div class="stats-desc">Better than last week (40.5%)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 mb-3">
|
||||
<div class="card stat-card bg-warning text-white">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h4 class="card-title">{{ stats.pending_appointments }}</h4>
|
||||
<p class="card-text">Pending</p>
|
||||
</div>
|
||||
<i class="fas fa-clock fa-2x opacity-75"></i>
|
||||
<div class="col-lg-2 col-sm-4">
|
||||
<div class="widget widget-stats bg-warning mb-7px">
|
||||
<div class="stats-icon stats-icon-lg"><i class="fas fa-clock fa-fw"></i></div>
|
||||
<div class="stats-content">
|
||||
<div class="stats-title">Pending</div>
|
||||
<div class="stats-number">{{ stats.pending_appointments }}</div>
|
||||
<div class="stats-desc">Better than last week (40.5%)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 mb-3">
|
||||
<div class="card stat-card bg-success text-white">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h4 class="card-title">{{ stats.completed_today }}</h4>
|
||||
<p class="card-text">Completed Today</p>
|
||||
</div>
|
||||
<i class="fas fa-check-circle fa-2x opacity-75"></i>
|
||||
<div class="col-lg-2 col-sm-4">
|
||||
<div class="widget widget-stats bg-success mb-7px">
|
||||
<div class="stats-icon stats-icon-lg"><i class="fas fa-check-circle fa-fw"></i></div>
|
||||
<div class="stats-content">
|
||||
<div class="stats-title">Completed Today</div>
|
||||
<div class="stats-number">{{ stats.completed_appointments }}</div>
|
||||
<div class="stats-desc">Better than last week (40.5%)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 mb-3">
|
||||
<div class="card stat-card bg-secondary text-white">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h4 class="card-title">{{ stats.total_in_queue }}</h4>
|
||||
<p class="card-text">In Queue</p>
|
||||
</div>
|
||||
<i class="fas fa-users fa-2x opacity-75"></i>
|
||||
<div class="col-lg-2 col-sm-4">
|
||||
<div class="widget widget-stats bg-danger mb-7px">
|
||||
<div class="stats-icon stats-icon-lg"><i class="fas fa-users fa-fw"></i></div>
|
||||
<div class="stats-content">
|
||||
<div class="stats-title">Active Queue</div>
|
||||
<div class="stats-number">{{ stats.active_queues }}</div>
|
||||
<div class="stats-desc">Better than last week (40.5%)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 mb-3">
|
||||
<div class="card stat-card bg-dark text-white">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h4 class="card-title">{{ stats.telemedicine_today }}</h4>
|
||||
<p class="card-text">Telemedicine</p>
|
||||
</div>
|
||||
<i class="fas fa-video fa-2x opacity-75"></i>
|
||||
</div>
|
||||
<div class="col-lg-2 col-sm-4">
|
||||
<div class="widget widget-stats bg-dark mb-7px">
|
||||
<div class="stats-icon stats-icon-lg"><i class="fas fa-video fa-fw"></i></div>
|
||||
<div class="stats-content">
|
||||
<div class="stats-title">Telemedicine</div>
|
||||
<div class="stats-number">{{ stats.telemedicine_sessions }}</div>
|
||||
<div class="stats-desc">Better than last week (40.5%)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,39 +1,50 @@
|
||||
<div class="accordion" id="accordion">
|
||||
{% for log in contact_logs %}
|
||||
<div class="contact-log-item">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-{{ log.contact_method|lower }} me-1"></i>
|
||||
{{ log.get_contact_method_display }} -
|
||||
<span class="badge bg-{% if log.contact_outcome == 'SUCCESSFUL' %}success{% elif log.contact_outcome == 'DECLINED' %}danger{% else %}info{% endif %}">
|
||||
<div class="accordion-item border-0">
|
||||
<div class="accordion-header mb-2" id="heading-{{ log.id }}">
|
||||
<button class="accordion-button bg-gray-900 text-white px-3 py-1 pointer-cursor" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-{{ log.id }}">
|
||||
{{ log.contact_date|date:"M d, Y H:i" }}
|
||||
</button>
|
||||
</div>
|
||||
<div id="collapse-{{ log.id }}" class="accordion-collapse collapse show" data-bs-parent="#accordion">
|
||||
<div class="accordion-body">
|
||||
<i class="fas fa-{{ log.contact_method|capfirst }}"></i>{{ log.get_contact_method_display }} -
|
||||
<span class="text-{% if log.contact_outcome == 'SUCCESSFUL' %}success{% elif log.contact_outcome == 'DECLINED' %}danger{% else %}info{% endif %}">
|
||||
{{ log.get_contact_outcome_display }}
|
||||
</span>
|
||||
</h6>
|
||||
<small class="text-muted">{{ log.contact_date|date:"M d, Y H:i" }}</small>
|
||||
</div>
|
||||
<p class="mb-1">{{ log.notes|default:"No notes." }}</p>
|
||||
{% if log.appointment_offered %}
|
||||
<p class="mb-1 text-primary">
|
||||
<i class="fas fa-calendar-check me-1"></i>Appointment Offered:
|
||||
<div class="row mb-1">
|
||||
<div class="col-md-8">
|
||||
<p class="fw-light">
|
||||
<i class="fas fa-calendar-check"></i>Appointment Offered:
|
||||
{{ log.offered_date|date:"M d, Y" }} at {{ log.offered_time|time:"g:i A" }}
|
||||
</p>
|
||||
<p class="mb-0 text-primary">
|
||||
<i class="fas fa-reply me-1"></i>Patient Response:
|
||||
<span class="badge bg-{% if log.patient_response == 'ACCEPTED' %}success{% elif log.patient_response == 'DECLINED' %}danger{% else %}secondary{% endif %}">
|
||||
{{ log.get_patient_response_display }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row mb-1">
|
||||
<div class="col-md-6">
|
||||
{% if log.next_contact_date %}
|
||||
<p class="mb-0 text-info">
|
||||
<i class="fas fa-calendar-alt me-1"></i>Next Contact: {{ log.next_contact_date|date:"M d, Y" }}
|
||||
</p>
|
||||
<small class="text-muted">
|
||||
<span class="fw-bold">Contacted by:</span> {{ log.contacted_by.get_full_name }}
|
||||
</small>
|
||||
{% endif %}
|
||||
<small class="text-muted">Contacted by: {{ log.contacted_by.get_full_name|default:"N/A" }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{% if log.next_contact_date %}
|
||||
<small class="text-muted">
|
||||
<span class="fw-bold">Next Contact:</span> {{ log.next_contact_date|date:"M d, Y" }}
|
||||
</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="text-center text-muted py-3">
|
||||
<div class="text-center text-muted py-3">
|
||||
<i class="fas fa-comment-slash fa-2x mb-2"></i>
|
||||
<p class="mb-0">No contact logs available for this entry.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
@ -183,7 +183,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content" class="app-content">
|
||||
<!-- Page Header -->
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<div>
|
||||
@ -367,7 +366,7 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
|
||||
@ -156,7 +156,7 @@
|
||||
}
|
||||
|
||||
.wait-time-number {
|
||||
font-size: 2.5rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: #495057;
|
||||
}
|
||||
@ -200,22 +200,16 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content" class="app-content">
|
||||
<div class="container-fluid">
|
||||
<!-- Page Header -->
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<div>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{% url 'core:dashboard' %}">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'appointments:dashboard' %}">Appointments</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'appointments:queue_entry_list' %}">Queue Entries</a></li>
|
||||
<li class="breadcrumb-item active">{{ entry.patient.get_full_name }}</li>
|
||||
</ol>
|
||||
<h1 class="page-header mb-0">
|
||||
<i class="fas fa-user-clock me-2"></i>Queue Entry Details
|
||||
</h1>
|
||||
</div>
|
||||
<div class="ms-auto">
|
||||
<a href="{% url 'appointments:queue_entry_list' %}?queue={{ entry.queue.pk }}" class="btn btn-outline-secondary">
|
||||
<a href="{% url 'appointments:waiting_queue_detail' entry.queue.pk %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i>Back to Queue
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@ -3,9 +3,8 @@
|
||||
|
||||
{% block title %}{% if form.instance.pk %}Edit{% else %}Add to{% endif %} Queue Entry{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link href="{% static 'assets/plugins/select2/dist/css/select2.min.css' %}" rel="stylesheet" />
|
||||
<link href="{% static 'assets/plugins/select2-bootstrap5-theme/dist/select2-bootstrap-5-theme.min.css' %}" rel="stylesheet" />
|
||||
{% block css %}
|
||||
<link href="{% static 'plugins/select2/dist/css/select2.min.css' %}" rel="stylesheet" />
|
||||
<style>
|
||||
.form-section {
|
||||
background: white;
|
||||
@ -151,25 +150,17 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content" class="app-content">
|
||||
<!-- Page Header -->
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<div>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{% url 'core:dashboard' %}">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'appointments:dashboard' %}">Appointments</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'appointments:queueentry_list' %}">Queue Entries</a></li>
|
||||
<li class="breadcrumb-item active">
|
||||
{% if form.instance.pk %}Edit Entry{% else %}Add to Queue{% endif %}
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<h1 class="page-header mb-0">
|
||||
<i class="fas fa-user-plus me-2"></i>
|
||||
{% if form.instance.pk %}Edit Queue Entry{% else %}Add Patient to Queue{% endif %}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="ms-auto">
|
||||
<a href="{% url 'appointments:queueentry_list' %}" class="btn btn-outline-secondary">
|
||||
<a href="{% url 'appointments:queue_entry_list' %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i>Back to List
|
||||
</a>
|
||||
</div>
|
||||
@ -465,7 +456,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<a href="{% url 'appointments:queueentry_list' %}" class="btn btn-outline-secondary me-2">
|
||||
<a href="{% url 'appointments:queue_entry_list' %}" class="btn btn-outline-secondary me-2">
|
||||
<i class="fas fa-times me-1"></i>Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
@ -476,11 +467,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{% static 'assets/plugins/select2/dist/js/select2.min.js' %}"></script>
|
||||
{% block js %}
|
||||
<script src="{% static 'plugins/select2/dist/js/select2.min.js' %}"></script>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
|
||||
@ -130,27 +130,24 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<!-- Page Header -->
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<div>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{% url 'core:dashboard' %}">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'appointments:dashboard' %}">Appointments</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'appointments:waiting_queue_list' %}">Waiting Queues</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'appointments:waiting_queue_detail' queue.pk %}">{{ queue.name }}</a></li>
|
||||
<li class="breadcrumb-item active">Delete</li>
|
||||
</ol>
|
||||
<h1 class="page-header mb-0">
|
||||
<i class="fas fa-trash me-2"></i>Delete Waiting Queue
|
||||
<h1 class="h2">
|
||||
<i class="fas fa-trash me-2"></i>Delete<span class="fw-light">Queue</span>
|
||||
</h1>
|
||||
<p class="text-muted">View your appointments, manage queues, and track your progress.</p>
|
||||
</div>
|
||||
<div class="ms-auto">
|
||||
<div class="btn-toolbar mb-2 mb-md-0">
|
||||
<div class="btn-group me-2">
|
||||
<a href="{% url 'appointments:waiting_queue_detail' queue.pk %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i>Back to Queue
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
|
||||
<!-- Delete Warning -->
|
||||
<div class="delete-warning text-center">
|
||||
|
||||
@ -6,334 +6,186 @@
|
||||
{% block css %}
|
||||
<link href="{% static 'plugins/datatables.net-bs5/css/dataTables.bootstrap5.min.css' %}" rel="stylesheet" />
|
||||
<link href="{% static 'plugins/datatables.net-responsive-bs5/css/responsive.bootstrap5.min.css' %}" rel="stylesheet" />
|
||||
<link href="{% static 'plugins/sweetalert2/css/sweetalert2.all.min.css' %}" rel="stylesheet" />
|
||||
<style>
|
||||
.queue-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-radius: 0.5rem;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
/* The modal window itself */
|
||||
.swal2-popup {
|
||||
border-radius: 1.5rem;
|
||||
background: #f8f9fa;
|
||||
font-family: 'Tajawal', sans-serif; /* Example: Arabic-friendly font */
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 1rem;
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-icon.primary { background: #007bff; }
|
||||
.stat-icon.success { background: #28a745; }
|
||||
.stat-icon.warning { background: #ffc107; }
|
||||
.stat-icon.info { background: #17a2b8; }
|
||||
.stat-icon.danger { background: #dc3545; }
|
||||
|
||||
.stat-number {
|
||||
font-size: 2.5rem;
|
||||
/* Title */
|
||||
.swal2-title {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #495057;
|
||||
margin-bottom: 0.5rem;
|
||||
{#color: #0b505d;#}
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #6c757d;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
/* Confirm button */
|
||||
.swal2-confirm {
|
||||
background-color: #155724 !important;
|
||||
color: #fff !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
.queue-info-card {
|
||||
background: white;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
/* Cancel button */
|
||||
.swal2-cancel {
|
||||
background-color: #adb5bd !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid #f8f9fa;
|
||||
/* Icon color override */
|
||||
.swal2-icon.swal2-warning {
|
||||
border-color: #f59c1a !important;
|
||||
color: #f59c1a !important;
|
||||
}
|
||||
|
||||
.info-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.queue-type-badge {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.type-provider { background: #d4edda; color: #155724; }
|
||||
.type-specialty { background: #d1ecf1; color: #0c5460; }
|
||||
.type-location { background: #fff3cd; color: #856404; }
|
||||
.type-procedure { background: #f8d7da; color: #721c24; }
|
||||
.type-emergency { background: #f5c6cb; color: #721c24; }
|
||||
|
||||
.status-badge {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-active { background: #d4edda; color: #155724; }
|
||||
.status-inactive { background: #f8d7da; color: #721c24; }
|
||||
|
||||
.entry-status {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-waiting { background: #fff3cd; color: #856404; }
|
||||
.status-called { background: #d1ecf1; color: #0c5460; }
|
||||
.status-in-service { background: #cce5ff; color: #004085; }
|
||||
.status-completed { background: #d4edda; color: #155724; }
|
||||
.status-left { background: #f8d7da; color: #721c24; }
|
||||
.status-no-show { background: #f5c6cb; color: #721c24; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content" class="app-content">
|
||||
<!-- Page Header -->
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<div>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{% url 'core:dashboard' %}">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'appointments:dashboard' %}">Appointments</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'appointments:waiting_queue_list' %}">Waiting Queues</a></li>
|
||||
<li class="breadcrumb-item active">{{ queue.name }}</li>
|
||||
</ol>
|
||||
<h1 class="page-header mb-0">
|
||||
<i class="fas fa-users me-2"></i>Queue Details
|
||||
<h1 class="h2">
|
||||
<i class="fas fa-users me-2"></i> Queue<span class="fw-light">Details</span>
|
||||
</h1>
|
||||
<p class="text-muted">View your appointments, manage queues, and track your progress.</p>
|
||||
</div>
|
||||
<div class="ms-auto">
|
||||
<div class="btn-group">
|
||||
<a href="{% url 'appointments:waiting_queue_update' queue.pk %}" class="btn btn-warning">
|
||||
<div class="btn-toolbar mb-2 mb-md-0">
|
||||
<div class="btn-group me-2">
|
||||
<a href="{% url 'appointments:waiting_queue_update' queue.pk %}" class="btn btn-sm btn-warning">
|
||||
<i class="fas fa-edit me-1"></i>Edit Queue
|
||||
</a>
|
||||
<button class="btn btn-success" onclick="refreshQueue()">
|
||||
<button class="btn btn-sm btn-success" onclick="refreshQueue()">
|
||||
<i class="fas fa-sync-alt me-1"></i>Refresh
|
||||
</button>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown">
|
||||
<i class="fas fa-cog me-1"></i>Actions
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="#" onclick="callNextPatient()">
|
||||
<i class="fas fa-phone me-2"></i>Call Next Patient
|
||||
</a></li>
|
||||
<li><a class="dropdown-item" href="#" onclick="pauseQueue()">
|
||||
<i class="fas fa-pause me-2"></i>Pause Queue
|
||||
</a></li>
|
||||
<li><a class="dropdown-item" href="#" onclick="clearQueue()">
|
||||
<i class="fas fa-broom me-2"></i>Clear Queue
|
||||
</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="#" onclick="exportQueue()">
|
||||
<button class="btn btn-sm btn-danger" onclick="exportQueue()">
|
||||
<i class="fas fa-download me-2"></i>Export Data
|
||||
</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container-fluid">
|
||||
|
||||
<!-- Queue Header -->
|
||||
<div class="queue-header">
|
||||
<div class="row align-items-center">
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<h2 class="mb-2">{{ queue.name }}</h2>
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<span class="queue-type-badge type-{{ queue.queue_type|lower }} me-3">
|
||||
<div class="align-items-center g-3">
|
||||
<h4 class="fw-bold me-2">{{ queue.name }}</h4>
|
||||
{% if queue.queue_type == 'PROVIDER' %}
|
||||
<span class="badge bg-success fw-bold me-2">
|
||||
{% elif queue.queue_type == 'SPECIALTY' %}
|
||||
<span class="badge bg-purple fw-bold me-2">
|
||||
{% elif queue.queue_type == 'LOCATION' %}
|
||||
<span class="badge bg-warning fw-bold me-2">
|
||||
{% elif queue.queue_type == 'PROCEDURE' %}
|
||||
<span class="badge bg-info fw-bold me-2">
|
||||
{% elif queue.queue_type == 'EMERGENCY' %}
|
||||
<span class="badge bg-danger fw-bold me-2">
|
||||
{% endif %}
|
||||
{{ queue.get_queue_type_display }}
|
||||
</span>
|
||||
<span class="status-badge status-{% if queue.is_active %}active{% else %}inactive{% endif %}">
|
||||
{% if queue.is_active %}Active{% else %}Inactive{% endif %}
|
||||
<span class="badge bg-{% if queue.is_active %}success{% else %}danger{% endif %} me-2">
|
||||
{% if queue.is_active %}{{ _("Active") }}{% else %}{{ _("Inactive") }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% if queue.description %}
|
||||
<p class="mb-0 opacity-75">{{ queue.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-4 text-md-end">
|
||||
<div class="text-white">
|
||||
<div class="align-items-center g-3">
|
||||
<div class="h4 mb-1">{{ queue.current_queue_size }}/{{ queue.max_queue_size }}</div>
|
||||
<div class="opacity-75">Current Capacity</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon warning">
|
||||
<i class="fas fa-user-clock"></i>
|
||||
</div>
|
||||
<div class="stat-number">{{ queue.current_queue_size }}</div>
|
||||
<div class="stat-label">Patients Waiting</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon info">
|
||||
<i class="fas fa-clock"></i>
|
||||
</div>
|
||||
<div class="stat-number">{{ queue.estimated_wait_time_minutes }}</div>
|
||||
<div class="stat-label">Est. Wait Time (min)</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon success">
|
||||
<i class="fas fa-user-check"></i>
|
||||
</div>
|
||||
<div class="stat-number">{{ stats.served_today|default:0 }}</div>
|
||||
<div class="stat-label">Served Today</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon primary">
|
||||
<i class="fas fa-chart-line"></i>
|
||||
</div>
|
||||
<div class="stat-number">{{ queue.average_service_time_minutes }}</div>
|
||||
<div class="stat-label">Avg Service (min)</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon danger">
|
||||
<i class="fas fa-user-times"></i>
|
||||
</div>
|
||||
<div class="stat-number">{{ stats.no_shows_today|default:0 }}</div>
|
||||
<div class="stat-label">No Shows Today</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Queue Information -->
|
||||
<div class="row">
|
||||
<div class="col-lg-4">
|
||||
<div class="queue-info-card">
|
||||
<h5 class="mb-3">
|
||||
<!-- Queue Information -->
|
||||
<div class="panel panel-inverse mb-4" data-sortable-id="index-1">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<i class="fas fa-info-circle me-2"></i>Queue Information
|
||||
</h5>
|
||||
</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>
|
||||
<a href="javascript:" class="btn btn-xs btn-icon btn-danger" data-toggle="panel-remove"><i class="fa fa-times"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
|
||||
<div class="info-item">
|
||||
<span class="info-label">Queue ID:</span>
|
||||
<span class="info-value">{{ queue.queue_id }}</span>
|
||||
<div class="row mb-1">
|
||||
<div class="col-4 fw-bold">Queue ID:</div>
|
||||
<div class="col-8">{{ queue.queue_id }}</div>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<span class="info-label">Type:</span>
|
||||
<span class="info-value">{{ queue.get_queue_type_display }}</span>
|
||||
<div class="row mb-1">
|
||||
<div class="col-4 fw-bold">Type:</div>
|
||||
<div class="col-8">{{ queue.get_queue_type_display }}</div>
|
||||
</div>
|
||||
|
||||
{% if queue.specialty %}
|
||||
<div class="info-item">
|
||||
<span class="info-label">Specialty:</span>
|
||||
<span class="info-value">{{ queue.specialty }}</span>
|
||||
<div class="row mb-1">
|
||||
<div class="col-4 fw-bold">Specialty:</div>
|
||||
<div class="col-8">{{ queue.specialty }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if queue.location %}
|
||||
<div class="info-item">
|
||||
<span class="info-label">Location:</span>
|
||||
<span class="info-value">{{ queue.location }}</span>
|
||||
<div class="row mb-1">
|
||||
<div class="col-4 fw-bold">Location:</div>
|
||||
<div class="col-8">{{ queue.location }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="info-item">
|
||||
<span class="info-label">Max Capacity:</span>
|
||||
<span class="info-value">{{ queue.max_queue_size }} patients</span>
|
||||
<div class="row mb-1">
|
||||
<div class="col-4 fw-bold">Max Capacity:</div>
|
||||
<div class="col-8">{{ queue.max_queue_size }} patients</div>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<span class="info-label">Accepting Patients:</span>
|
||||
<span class="info-value">
|
||||
<div class="row mb-1">
|
||||
<div class="col-4 fw-bold">Accepting Patients:</div>
|
||||
<div class="col-8">
|
||||
{% if queue.is_accepting_patients %}
|
||||
<i class="fas fa-check text-success"></i> Yes
|
||||
{% else %}
|
||||
<i class="fas fa-times text-danger"></i> No
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<span class="info-label">Created:</span>
|
||||
<span class="info-value">{{ queue.created_at|date:"M d, Y g:i A" }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<span class="info-label">Last Updated:</span>
|
||||
<span class="info-value">{{ queue.updated_at|timesince }} ago</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-1">
|
||||
<div class="col-4 fw-bold">Created:</div>
|
||||
<div class="col-8">{{ queue.created_at|date:"M d, Y g:i A" }}</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-1">
|
||||
<div class="col-4 fw-bold">Last Updated:</div>
|
||||
<div class="col-8">{{ queue.updated_at|timesince }} ago</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Assigned Providers -->
|
||||
<div class="queue-info-card">
|
||||
<h5 class="mb-3">
|
||||
<div class="panel panel-inverse mb-4" data-sortable-id="index-2">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<i class="fas fa-user-md me-2"></i>Assigned Providers
|
||||
</h5>
|
||||
|
||||
</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>
|
||||
<a href="javascript:" class="btn btn-xs btn-icon btn-danger" data-toggle="panel-remove"><i class="fa fa-times"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{% for provider in queue.providers.all %}
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<div class="avatar avatar-sm me-2">
|
||||
@ -349,24 +201,35 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="col-lg-8">
|
||||
<!-- Current Queue -->
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<div class="panel panel-inverse mb-4" data-sortable-id="index-3">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<i class="fas fa-list me-2"></i>Current Queue
|
||||
</h5>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-outline-primary" onclick="callNextPatient()">
|
||||
<i class="fas fa-phone me-1"></i>Call Next
|
||||
</button>
|
||||
<button class="btn btn-outline-success" onclick="addPatientToQueue()">
|
||||
</h4>
|
||||
<div class="panel-heading-btn">
|
||||
<a href="{% url 'appointments:queue_entry_create' %}" class="btn btn-xs btn-outline-success me-2">
|
||||
<i class="fas fa-plus me-1"></i>Add Patient
|
||||
</a>
|
||||
<button class="btn btn-xs btn-outline-warning me-2" onclick="pauseQueue()">
|
||||
<i class="fas fa-pause me-2"></i>Pause Queue
|
||||
</button>
|
||||
<button class="btn btn-xs btn-outline-danger me-2" onclick="clearQueue()">
|
||||
<i class="fas fa-broom me-2"></i>Clear Queue
|
||||
</button>
|
||||
<a href="{% url 'appointments:call_next_patient' queue.id %}" class="btn btn-xs btn-outline-primary me-2">
|
||||
<i class="fas fa-phone me-1"></i>Call Next
|
||||
</a>
|
||||
|
||||
<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>
|
||||
<a href="javascript:" class="btn btn-xs btn-icon btn-danger" data-toggle="panel-remove"><i class="fa fa-times"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="panel-body">
|
||||
<div class="table-responsive">
|
||||
<table id="queueTable" class="table table-striped table-bordered align-middle">
|
||||
<thead>
|
||||
@ -393,7 +256,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-bold">{{ entry.patient.get_full_name }}</div>
|
||||
<small class="text-muted">ID: {{ entry.patient.patient_id }}</small>
|
||||
<small class="text-muted">MRN: {{ entry.patient.mrn }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@ -409,7 +272,19 @@
|
||||
<span class="fw-bold">{{ entry.joined_at|timesince }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="entry-status status-{{ entry.status|lower }}">
|
||||
{% if entry.status == 'WAITING' %}
|
||||
<span class="badge bg-warning">
|
||||
{% elif entry.status == 'CALLED' %}
|
||||
<span class="badge bg-info">
|
||||
{% elif entry.status == 'IN_SERVICE' %}
|
||||
<span class="badge bg-primary">
|
||||
{% elif entry.status == 'COMPLETED' %}
|
||||
<span class="badge bg-success">
|
||||
{% elif entry.status == 'LEFT' %}
|
||||
<span class="badge bg-cyan">
|
||||
{% elif entry.status == 'NO_SHOW' %}
|
||||
<span class="badge bg-danger">
|
||||
{% endif %}
|
||||
{{ entry.get_status_display }}
|
||||
</span>
|
||||
</td>
|
||||
@ -450,11 +325,12 @@
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{% block js %}
|
||||
<script src="{% static 'plugins/datatables.net/js/dataTables.min.js' %}"></script>
|
||||
<script src="{% static 'plugins/datatables.net-bs5/js/dataTables.bootstrap5.min.js' %}"></script>
|
||||
<script src="{% static 'plugins/datatables.net-responsive/js/dataTables.responsive.min.js' %}"></script>
|
||||
<script src="{% static 'plugins/datatables.net-responsive-bs5/js/responsive.bootstrap5.min.js' %}"></script>
|
||||
<script src="{% static 'plugins/sweetalert2/js/sweetalert2.all.min.js' %}"></script>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
@ -462,49 +338,70 @@ $(document).ready(function() {
|
||||
$('#queueTable').DataTable({
|
||||
responsive: true,
|
||||
pageLength: 25,
|
||||
order: [[0, 'asc']], // Sort by position
|
||||
columnDefs: [
|
||||
{ orderable: false, targets: [6] } // Disable sorting for actions
|
||||
],
|
||||
language: {
|
||||
search: "",
|
||||
searchPlaceholder: "Search queue entries...",
|
||||
lengthMenu: "Show _MENU_ entries per page",
|
||||
info: "Showing _START_ to _END_ of _TOTAL_ entries",
|
||||
infoEmpty: "No entries in queue",
|
||||
infoFiltered: "(filtered from _MAX_ total entries)"
|
||||
}
|
||||
order: [[0, 'asc']],
|
||||
});
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
// Auto-refresh every 60 seconds
|
||||
setInterval(function() {
|
||||
refreshQueue();
|
||||
}, 30000);
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
function refreshQueue() {
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function callNextPatient() {
|
||||
if (confirm('Call the next patient in queue?')) {
|
||||
$.post('{% url "appointments:call_next_patient" queue.pk %}', {
|
||||
csrfmiddlewaretoken: $('[name=csrfmiddlewaretoken]').val()
|
||||
}).done(function(response) {
|
||||
if (response.success) {
|
||||
showAlert('success', 'Next patient called successfully');
|
||||
setTimeout(function() {
|
||||
location.reload();
|
||||
}, 1000);
|
||||
} else {
|
||||
showAlert('error', response.message || 'Failed to call next patient');
|
||||
function getCookie(name) {
|
||||
let cookieValue = null;
|
||||
if (document.cookie && document.cookie !== '') {
|
||||
const cookies = document.cookie.split(';');
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
const cookie = cookies[i].trim();
|
||||
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||
break;
|
||||
}
|
||||
}).fail(function() {
|
||||
showAlert('error', 'Failed to call next patient');
|
||||
});
|
||||
}
|
||||
}
|
||||
return cookieValue;
|
||||
}
|
||||
|
||||
{#function callNextPatient(queueId) {#}
|
||||
{# Swal.fire({#}
|
||||
{# title: "Call the next patient?",#}
|
||||
{# text: "This will notify the next patient in the queue.",#}
|
||||
{# icon: "warning",#}
|
||||
{# showCancelButton: true,#}
|
||||
{# confirmButtonText: "Yes",#}
|
||||
{# cancelButtonText: "Cancel"#}
|
||||
{# }).then((result) => {#}
|
||||
{# if (result.isConfirmed) {#}
|
||||
{# $.ajax({#}
|
||||
{# url: "{% url 'appointments:call_next_patient' 0 %}".replace("0", queueId),#}
|
||||
{# type: "POST",#}
|
||||
{# headers: { "X-CSRFToken": getCookie("csrftoken") },#}
|
||||
{# success: function(response) {#}
|
||||
{# if (response.success) {#}
|
||||
{# Swal.fire({#}
|
||||
{# icon: "success",#}
|
||||
{# title: "Next patient called",#}
|
||||
{# showConfirmButton: false,#}
|
||||
{# timer: 1200#}
|
||||
{# }).then(() => {#}
|
||||
{# location.reload();#}
|
||||
{# });#}
|
||||
{# } else {#}
|
||||
{# Swal.fire("Error", response.message || "Failed to call next patient", "error");#}
|
||||
{# }#}
|
||||
{# },#}
|
||||
{# error: function() {#}
|
||||
{# Swal.fire("Error", "Failed to call next patient", "error");#}
|
||||
{# }#}
|
||||
{# });#}
|
||||
{# }#}
|
||||
{# });#}
|
||||
{#}#}
|
||||
|
||||
{#function callPatient(entryId) {#}
|
||||
{# if (confirm('Call this patient?')) {#}
|
||||
{# $.post('{% url "appointments:call_patient" %}', {#}
|
||||
@ -545,11 +442,6 @@ function callNextPatient() {
|
||||
{# }#}
|
||||
{# }#}
|
||||
|
||||
{#function addPatientToQueue() {#}
|
||||
{# // Redirect to add patient form#}
|
||||
{# window.location.href = '{% url "appointments:add_to_queue" queue.pk %}';#}
|
||||
{# }#}
|
||||
|
||||
{#function pauseQueue() {#}
|
||||
{# if (confirm('Pause this queue? No new patients will be accepted.')) {#}
|
||||
{# $.post('{% url "appointments:pause_queue" queue.pk %}', {#}
|
||||
@ -592,26 +484,6 @@ function callNextPatient() {
|
||||
{# window.location.href = '{% url "appointments:export_queue" queue.pk %}';#}
|
||||
{# }#}
|
||||
|
||||
function showAlert(type, message) {
|
||||
const alertClass = type === 'success' ? 'alert-success' : 'alert-danger';
|
||||
const alertHtml = `
|
||||
<div class="alert ${alertClass} alert-dismissible fade show" role="alert">
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Remove existing alerts
|
||||
$('.alert').remove();
|
||||
|
||||
// Add new alert at the top of content
|
||||
$('#content').prepend(alertHtml);
|
||||
|
||||
// Auto-dismiss after 5 seconds
|
||||
setTimeout(function() {
|
||||
$('.alert').fadeOut();
|
||||
}, 5000);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@ -123,7 +123,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content" class="app-content">
|
||||
<div class="container-fluid">
|
||||
<!-- Page Header -->
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<div>
|
||||
@ -464,7 +464,7 @@
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Initialize Select2
|
||||
$('#id_providers').select2({
|
||||
$('.form-select').select2({
|
||||
theme: 'bootstrap-5',
|
||||
placeholder: 'Select providers...',
|
||||
allowClear: true
|
||||
|
||||
@ -7,33 +7,6 @@
|
||||
<link href="{% static 'plugins/datatables.net-bs5/css/dataTables.bootstrap5.min.css' %}" rel="stylesheet" />
|
||||
<link href="{% static 'plugins/datatables.net-responsive-bs5/css/responsive.bootstrap5.min.css' %}" rel="stylesheet" />
|
||||
<style>
|
||||
.queue-card {
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.3s ease;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.queue-card:hover {
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.queue-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.5rem 0.5rem 0 0;
|
||||
}
|
||||
|
||||
.queue-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
@ -46,7 +19,7 @@
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.5rem;
|
||||
color: #6c757d;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@ -85,148 +58,23 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<!-- Page Header -->
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<div>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{% url 'core:dashboard' %}">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'appointments:dashboard' %}">Appointments</a></li>
|
||||
<li class="breadcrumb-item active">Waiting Queues</li>
|
||||
</ol>
|
||||
<h1 class="page-header mb-0">
|
||||
<i class="fas fa-users me-2"></i>Waiting Queues Management
|
||||
<h1 class="h2">
|
||||
<i class="fas fa-calendar-alt"></i> Queue<span class="fw-light">Management</span>
|
||||
</h1>
|
||||
<p class="text-muted">Manage queues and track patient's journey.</p>
|
||||
</div>
|
||||
<div class="ms-auto">
|
||||
<div class="btn-toolbar mb-2 mb-md-0">
|
||||
<div class="btn-group me-2">
|
||||
<a href="{% url 'appointments:waiting_queue_create' %}" class="btn btn-primary">
|
||||
<i class="fas fa-plus me-1"></i>Create Queue
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Overview -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card bg-primary text-white">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-grow-1">
|
||||
<h4 class="mb-0">{{ stats.total_queues|default:0 }}</h4>
|
||||
<p class="mb-0">Total Queues</p>
|
||||
</div>
|
||||
<div class="ms-3">
|
||||
<i class="fas fa-list fa-2x"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card bg-success text-white">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-grow-1">
|
||||
<h4 class="mb-0">{{ stats.active_queues|default:0 }}</h4>
|
||||
<p class="mb-0">Active Queues</p>
|
||||
</div>
|
||||
<div class="ms-3">
|
||||
<i class="fas fa-check-circle fa-2x"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card bg-warning text-white">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-grow-1">
|
||||
<h4 class="mb-0">{{ stats.total_patients|default:0 }}</h4>
|
||||
<p class="mb-0">Patients Waiting</p>
|
||||
</div>
|
||||
<div class="ms-3">
|
||||
<i class="fas fa-user-clock fa-2x"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card bg-info text-white">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-grow-1">
|
||||
<h4 class="mb-0">{{ stats.avg_wait_time|default:"0" }} min</h4>
|
||||
<p class="mb-0">Avg Wait Time</p>
|
||||
</div>
|
||||
<div class="ms-3">
|
||||
<i class="fas fa-clock fa-2x"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Queue Type</label>
|
||||
<select class="form-select" id="type-filter">
|
||||
<option value="">All Types</option>
|
||||
<option value="PROVIDER">Provider Queue</option>
|
||||
<option value="SPECIALTY">Specialty Queue</option>
|
||||
<option value="LOCATION">Location Queue</option>
|
||||
<option value="PROCEDURE">Procedure Queue</option>
|
||||
<option value="EMERGENCY">Emergency Queue</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Status</label>
|
||||
<select class="form-select" id="status-filter">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
<option value="full">Full</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Specialty</label>
|
||||
<select class="form-select" id="specialty-filter">
|
||||
<option value="">All Specialties</option>
|
||||
{% for specialty in specialties %}
|
||||
<option value="{{ specialty }}">{{ specialty }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end">
|
||||
<button class="btn btn-outline-primary me-2" onclick="applyFilters()">
|
||||
<i class="fas fa-filter me-1"></i>Apply
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" onclick="clearFilters()">
|
||||
<i class="fas fa-times me-1"></i>Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- View Toggle -->
|
||||
<div class="d-flex justify-content-end mb-2">
|
||||
<button class="btn btn-primary btn-sm me-2" id="card-view-btn" onclick="toggleView('cards')">
|
||||
<i class="fas fa-th-large"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-primary btn-sm" id="table-view-btn" onclick="toggleView('table')">
|
||||
<i class="fas fa-table"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Queue Cards View -->
|
||||
</div>
|
||||
<div class="container-fluid">
|
||||
<div class="row" id="queue-cards">
|
||||
{% for queue in queues %}
|
||||
<div class="col-lg-6 col-xl-4 "
|
||||
@ -254,15 +102,15 @@
|
||||
</span>
|
||||
</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>
|
||||
<a href="javascript:;" class="btn btn-xs btn-icon btn-danger" data-toggle="panel-remove"><i class="fa fa-times"></i></a>
|
||||
<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>
|
||||
<a href="javascript:" class="btn btn-xs btn-icon btn-danger" data-toggle="panel-remove"><i class="fa fa-times"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="queue-stats">
|
||||
<div class="d-flex justify-content-between align-items-center p-3">
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ queue.current_queue_size }}</div>
|
||||
<div class="stat-label">Waiting</div>
|
||||
@ -298,30 +146,30 @@
|
||||
|
||||
<div class="mb-3">
|
||||
<small class="text-muted">Providers:</small>
|
||||
<span class="fw-bold">{{ queue.providers.count }}</span>
|
||||
<span class="fw-bold">{{ queue.providers.all|length}}</span>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted">
|
||||
Updated: {{ queue.updated_at|timesince }} ago
|
||||
{{ _("Updated") }}: {{ queue.updated_at|timesince }} {{ _("ago") }}
|
||||
</small>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="{% url 'appointments:waiting_queue_detail' queue.pk %}"
|
||||
class="btn btn-outline-primary" title="View Details">
|
||||
class="btn btn-outline-primary" title="{{ _("View Details")}}">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
<a href="{% url 'appointments:queue_entry_list' %}?queue={{ queue.pk }}"
|
||||
class="btn btn-outline-info" title="View Entries">
|
||||
<i class="fas fa-list"></i>
|
||||
</a>
|
||||
{# <a href="{% url 'appointments:queue_entry_list' %}?queue={{ queue.pk }}"#}
|
||||
{# class="btn btn-outline-info" title="View Entries">#}
|
||||
{# <i class="fas fa-list"></i>#}
|
||||
{# </a>#}
|
||||
<a href="{% url 'appointments:waiting_queue_update' queue.pk %}"
|
||||
class="btn btn-outline-warning" title="Edit">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
{# <a href="{% url 'appointments:waiting_queue_delete' queue.pk %}"#}
|
||||
{# class="btn btn-outline-danger" title="Delete">#}
|
||||
{# <i class="fas fa-trash"></i>#}
|
||||
{# </a>#}
|
||||
<a href="{% url 'appointments:waiting_queue_delete' queue.pk %}"
|
||||
class="btn btn-outline-danger" title="Delete">
|
||||
<i class="fas fa-trash"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -331,95 +179,15 @@
|
||||
<div class="col-12">
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-users fa-4x text-muted mb-3"></i>
|
||||
<h4 class="text-muted">No Waiting Queues Found</h4>
|
||||
<p class="text-muted">Create your first waiting queue to start managing patient flow.</p>
|
||||
<h4 class="text-muted">{{ _("No Waiting Queues Found")}}</h4>
|
||||
<p class="text-muted">{{ _("Create your first waiting queue to start managing patient flow")}}.</p>
|
||||
<a href="{% url 'appointments:waiting_queue_create' %}" class="btn btn-primary">
|
||||
<i class="fas fa-plus me-1"></i>Create First Queue
|
||||
<i class="fas fa-plus me-1"></i>{{ _("Create First Queue")}}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Table View (Alternative) -->
|
||||
<div class="card d-none" id="table-view">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-table me-2"></i>Queue List
|
||||
</h5>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-outline-success btn-sm" onclick="exportQueues('excel')">
|
||||
<i class="fas fa-file-excel me-1"></i>Excel
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-sm" onclick="exportQueues('pdf')">
|
||||
<i class="fas fa-file-pdf me-1"></i>PDF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table id="queuesTable" class="table table-striped table-bordered align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Queue Name</th>
|
||||
<th>Type</th>
|
||||
<th>Specialty</th>
|
||||
<th>Location</th>
|
||||
<th>Current Size</th>
|
||||
<th>Max Size</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for queue in queues %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fw-bold">{{ queue.name }}</div>
|
||||
<small class="text-muted">{{ queue.description|truncatechars:50 }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<span class="queue-type-badge type-{{ queue.queue_type|lower }}">
|
||||
{{ queue.get_queue_type_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ queue.specialty }}</td>
|
||||
<td>{{ queue.location }}</td>
|
||||
<td>
|
||||
<span class="fw-bold">{{ queue.current_queue_size }}</span>
|
||||
</td>
|
||||
<td>{{ queue.max_queue_size }}</td>
|
||||
<td>
|
||||
<span class="status-badge status-{% if queue.is_active %}active{% else %}inactive{% endif %}">
|
||||
{% if queue.is_active %}Active{% else %}Inactive{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="{% url 'appointments:waiting_queue_detail' queue.pk %}"
|
||||
class="btn btn-outline-primary" title="View">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
<a href="{% url 'appointments:waiting_queue_update' queue.pk %}"
|
||||
class="btn btn-outline-warning" title="Edit">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
{# <a href="{% url 'appointments:waiting_queue_delete' queue.pk %}"#}
|
||||
{# class="btn btn-outline-danger" title="Delete">#}
|
||||
{# <i class="fas fa-trash"></i>#}
|
||||
{# </a>#}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@ -428,7 +196,7 @@
|
||||
<script src="{% static 'plugins/datatables.net-bs5/js/dataTables.bootstrap5.min.js' %}"></script>
|
||||
<script src="{% static 'plugins/datatables.net-responsive/js/dataTables.responsive.min.js' %}"></script>
|
||||
<script src="{% static 'plugins/datatables.net-responsive-bs5/js/responsive.bootstrap5.min.js' %}"></script>
|
||||
<script src="{% static 'plugins/sweetalert/dist/sweetalert.min.js' %}"></script>
|
||||
<script src="{% static 'plugins/sweetalert/dist/sweetalert.min.js' %}"></script>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
@ -438,55 +206,12 @@ $(document).ready(function() {
|
||||
order: [[0, 'asc']],
|
||||
});
|
||||
|
||||
// Auto-refresh queue stats every 30 seconds
|
||||
// Auto-refresh queue stats every 60 seconds
|
||||
setInterval(function() {
|
||||
location.reload();
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
function applyFilters() {
|
||||
const typeFilter = $('#type-filter').val();
|
||||
const statusFilter = $('#status-filter').val();
|
||||
const specialtyFilter = $('#specialty-filter').val();
|
||||
|
||||
$('.queue-item').each(function() {
|
||||
const $item = $(this);
|
||||
const type = $item.data('type');
|
||||
const status = $item.data('status');
|
||||
const specialty = $item.data('specialty');
|
||||
|
||||
let show = true;
|
||||
|
||||
if (typeFilter && type !== typeFilter) show = false;
|
||||
if (statusFilter && status !== statusFilter) show = false;
|
||||
if (specialtyFilter && specialty !== specialtyFilter) show = false;
|
||||
|
||||
if (show) {
|
||||
$item.show();
|
||||
} else {
|
||||
$item.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
$('#type-filter, #status-filter, #specialty-filter').val('');
|
||||
$('.queue-item').show();
|
||||
}
|
||||
|
||||
function toggleView(view) {
|
||||
if (view === 'cards') {
|
||||
$('#queue-cards').removeClass('d-none');
|
||||
$('#table-view').addClass('d-none');
|
||||
$('#card-view-btn').removeClass('btn-outline-primary').addClass('btn-primary');
|
||||
$('#table-view-btn').removeClass('btn-primary').addClass('btn-outline-primary');
|
||||
} else {
|
||||
$('#queue-cards').addClass('d-none');
|
||||
$('#table-view').removeClass('d-none');
|
||||
$('#table-view-btn').removeClass('btn-outline-primary').addClass('btn-primary');
|
||||
$('#card-view-btn').removeClass('btn-primary').addClass('btn-outline-primary');
|
||||
}
|
||||
}
|
||||
|
||||
{#function exportQueues(format) {#}
|
||||
{# window.location.href = `{% url 'appointments:waiting_queue_export' %}?format=${format}`;#}
|
||||
|
||||
@ -4,56 +4,56 @@
|
||||
{% block title %}Queue Management - {{ block.super }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<div>
|
||||
<h1 class="h2">
|
||||
<i class="fas fa-users-gear"></i> Queue<span class="fw-light">Management</span>
|
||||
</h1>
|
||||
<p class="text-muted">Manage your queues and view their status.</p>
|
||||
</div>
|
||||
<div class="btn-toolbar mb-2 mb-md-0">
|
||||
<div class="btn-group me-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary">
|
||||
<button type="button" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-calendar-plus"></i> Schedule
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary">
|
||||
<button type="button" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-users"></i> Queue
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-primary">
|
||||
<button type="button" class="btn btn-outline-primary">
|
||||
<i class="fas fa-video"></i> Telemedicine
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="panel panel-inverse">
|
||||
<div class="row">
|
||||
{% for queue in queues %}
|
||||
{% if queue %}
|
||||
<div class="col-lg-6 col-xl-4 mb-4">
|
||||
<div class="panel panel-inverse" data-sortable-id="index-{{ queue.queue_id }}">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<i class="fas fa-users me-2"></i>Active Queues
|
||||
{{ queue.name }}
|
||||
<span class="badge bg-primary ms-2">{{ queue.current_queue_size }}</span>
|
||||
</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>
|
||||
<a href="javascript:;" class="btn btn-xs btn-icon btn-danger" data-toggle="panel-remove"><i class="fa fa-times"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="row">
|
||||
{% for queue in queues %}
|
||||
{% if queue %}
|
||||
<div class="col-lg-6 col-xl-4 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">{{ queue.name }}</h5>
|
||||
<span class="badge bg-primary">{{ queue.current_queue_size }}</span>
|
||||
</div>
|
||||
<div class="card-body"
|
||||
<div class="panel-body"
|
||||
hx-get="{% url 'appointments:queue_status' queue.pk %}"
|
||||
hx-trigger="load, every 60s">
|
||||
<div class="text-center">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="panel-footer">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted">
|
||||
Avg Wait: {{ queue.wait_time_minutes }}
|
||||
@ -83,8 +83,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@ -4,15 +4,16 @@
|
||||
{% block title %}Appointment Details - {{ block.super }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-3">
|
||||
<div class="col">
|
||||
<h1>Appointment Details</h1>
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<div>
|
||||
<h1 class="h2">
|
||||
<i class="fas fa-calendar-alt"></i> Appointment<span class="fw-light">Details</span>
|
||||
</h1>
|
||||
<p class="text-muted">{{ appointment.scheduled_datetime|date:"M d, Y H:i" }} • {{ appointment.get_status_display }}</p>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="btn-group">
|
||||
{% if appointment.status == 'PENDING' %}
|
||||
<div class="btn-toolbar mb-2 mb-md-0">
|
||||
<div class="btn-group me-2">
|
||||
{% if appointment.status in 'CONFIRMED, SCHEDULED' %}
|
||||
<button class="btn btn-success"
|
||||
hx-post="{% url 'appointments:check_in_patient' appointment.id %}"
|
||||
hx-confirm="Check in this patient?"
|
||||
@ -28,63 +29,114 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<!-- Appointment Information -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-calendar me-2"></i>Appointment Information</h5>
|
||||
<div class="panel panel-inverse" data-sortable-id="index-1">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<i class="fas fa-calendar me-2"></i>Appointment Information
|
||||
</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>
|
||||
<a href="javascript:;" class="btn btn-xs btn-icon btn-danger" data-toggle="panel-remove"><i class="fa fa-times"></i></a>
|
||||
|
||||
</div>
|
||||
<div class="card-body">
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<table class="table table-sm">
|
||||
<tr><td>Date & Time</td><td>{{ appointment.scheduled_datetime|date:"M d, Y H:i" }}</td></tr>
|
||||
<tr><td>Duration</td><td>{{ appointment.duration_minutes }} minutes</td></tr>
|
||||
<tr><td>Type</td><td>{{ appointment.get_appointment_type_display }}</td></tr>
|
||||
<tr><td>Specialty</td><td>{{ appointment.specialty|default:"General" }}</td></tr>
|
||||
<tr><td>Priority</td><td>{{ appointment.get_priority_display }}</td></tr>
|
||||
<tr><td>Status</td><td>
|
||||
<tr>
|
||||
<td>Date & Time</td>
|
||||
<td>{{ appointment.scheduled_datetime|date:"M d, Y H:i" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Duration</td>
|
||||
<td>{{ appointment.duration_minutes }} minutes</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Type</td>
|
||||
<td>{{ appointment.get_appointment_type_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Specialty</td>
|
||||
<td>{{ appointment.specialty|default:"General" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Priority</td>
|
||||
<td>{{ appointment.get_priority_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Status</td>
|
||||
<td>
|
||||
{% if appointment.status == 'PENDING' %}
|
||||
<span class="badge bg-warning">{{ appointment.get_status_display }}</span>
|
||||
<span class="badge bg-warning">
|
||||
{% elif appointment.status == 'CONFIRMED' %}
|
||||
<span class="badge bg-info">{{ appointment.get_status_display }}</span>
|
||||
<span class="badge bg-info">
|
||||
{% elif appointment.status == 'SCHEDULED' %}
|
||||
<span class="badge bg-purple">
|
||||
{% elif appointment.status == 'CHECKED_IN' %}
|
||||
<span class="badge bg-primary">{{ appointment.get_status_display }}</span>
|
||||
<span class="badge bg-primary">
|
||||
{% elif appointment.status == 'IN_PROGRESS' %}
|
||||
<span class="badge bg-success">{{ appointment.get_status_display }}</span>
|
||||
<span class="badge bg-success">
|
||||
{% elif appointment.status == 'COMPLETED' %}
|
||||
<span class="badge bg-success">{{ appointment.get_status_display }}</span>
|
||||
<span class="badge bg-success">
|
||||
{% elif appointment.status == 'CANCELLED' %}
|
||||
<span class="badge bg-danger">{{ appointment.get_status_display }}</span>
|
||||
<span class="badge bg-danger">
|
||||
{% elif appointment.status == 'NO_SHOW' %}
|
||||
<span class="badge bg-secondary">{{ appointment.get_status_display }}</span>
|
||||
<span class="badge bg-secondary">
|
||||
{% endif %}
|
||||
</td></tr>
|
||||
{{ appointment.get_status_display }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% if appointment.is_telemedicine %}
|
||||
<tr><td>Telemedicine</td><td><span class="badge bg-info">Yes</span></td></tr>
|
||||
<tr>
|
||||
<td>Telemedicine</td>
|
||||
<td><span class="badge bg-info">Yes</span></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if appointment.chief_complaint %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-stethoscope me-2"></i>Chief Complaint</h5>
|
||||
<div class="panel panel-inverse" data-sortable-id="index-2">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<i class="fas fa-stethoscope me-2"></i>Chief Complaint
|
||||
</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>
|
||||
<a href="javascript:" class="btn btn-xs btn-icon btn-danger" data-toggle="panel-remove"><i class="fa fa-times"></i></a>
|
||||
|
||||
</div>
|
||||
<div class="card-body">
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p>{{ appointment.chief_complaint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if appointment.notes %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-sticky-note me-2"></i>Notes</h5>
|
||||
<div class="panel panel-inverse" data-sortable-id="index-3">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<i class="fas fa-sticky-note me-2"></i>Notes
|
||||
</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>
|
||||
<a href="javascript:" class="btn btn-xs btn-icon btn-danger" data-toggle="panel-remove"><i class="fa fa-times"></i></a>
|
||||
|
||||
</div>
|
||||
<div class="card-body">
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p>{{ appointment.notes|linebreaks }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -93,34 +145,71 @@
|
||||
|
||||
<!-- Patient & Provider Information -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-user me-2"></i>Patient Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm">
|
||||
<tr><td>Name</td><td><strong>{{ appointment.patient.get_full_name }}</strong></td></tr>
|
||||
<tr><td>MRN</td><td>{{ appointment.patient.mrn }}</td></tr>
|
||||
<tr><td>Date of Birth</td><td>{{ appointment.patient.date_of_birth|date:"M d, Y" }}</td></tr>
|
||||
<tr><td>Age</td><td>{{ appointment.patient.age }}</td></tr>
|
||||
<tr><td>Gender</td><td>{{ appointment.patient.get_gender_display }}</td></tr>
|
||||
<tr><td>Phone</td><td>{{ appointment.patient.phone_number|default:"Not provided" }}</td></tr>
|
||||
<tr><td>Email</td><td>{{ appointment.patient.email|default:"Not provided" }}</td></tr>
|
||||
</table>
|
||||
<div class="mt-2">
|
||||
<a href="{% url 'patients:patient_detail' appointment.patient.id %}" class="btn btn-sm btn-outline-primary">
|
||||
<div class="panel panel-inverse" data-sortable-id="index-4">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<i class="fas fa-user me-2"></i>Patient Information
|
||||
</h4>
|
||||
<div class="panel-heading-btn">
|
||||
<a href="{% url 'patients:patient_detail' appointment.patient.id %}" class="btn btn-xs btn-outline-primary me-2">
|
||||
<i class="fas fa-eye me-1"></i>View Patient
|
||||
</a>
|
||||
<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>
|
||||
<a href="javascript:" class="btn btn-xs btn-icon btn-danger" data-toggle="panel-remove"><i class="fa fa-times"></i></a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<table class="table table-sm">
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
<td><strong>{{ appointment.patient.get_full_name }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>MRN</td>
|
||||
<td>{{ appointment.patient.mrn }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Date of Birth</td>
|
||||
<td>{{ appointment.patient.date_of_birth|date:"M d, Y" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Age</td>
|
||||
<td>{{ appointment.patient.age }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Gender</td>
|
||||
<td>{{ appointment.patient.get_gender_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Phone</td>
|
||||
<td>{{ appointment.patient.phone_number|default:appointment.patient.mobile_number }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Email</td>
|
||||
<td>{{ appointment.patient.email|default:"Not provided" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if appointment.provider %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-user-md me-2"></i>Provider Information</h5>
|
||||
<div class="panel panel-inverse" data-sortable-id="index-5">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<i class="fas fa-user-md me-2"></i>Provider Information
|
||||
</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>
|
||||
<a href="javascript:" class="btn btn-xs btn-icon btn-danger" data-toggle="panel-remove"><i class="fa fa-times"></i></a>
|
||||
|
||||
</div>
|
||||
<div class="card-body">
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<table class="table table-sm">
|
||||
<tr><td>Name</td><td><strong>{{ appointment.provider.get_full_name }}</strong></td></tr>
|
||||
<tr><td>Role</td><td>{{ appointment.provider.get_role_display }}</td></tr>
|
||||
@ -133,11 +222,20 @@
|
||||
{% endif %}
|
||||
|
||||
<!-- Appointment Timeline -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-clock me-2"></i>Timeline</h5>
|
||||
<div class="panel panel-inverse" data-sortable-id="index-6">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<i class="fas fa-clock me-2"></i>Timeline
|
||||
</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>
|
||||
<a href="javascript:" class="btn btn-xs btn-icon btn-danger" data-toggle="panel-remove"><i class="fa fa-times"></i></a>
|
||||
|
||||
</div>
|
||||
<div class="card-body">
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="timeline">
|
||||
<div class="timeline-item">
|
||||
<strong>Created:</strong> {{ appointment.created_at|date:"M d, Y H:i" }}
|
||||
@ -146,9 +244,9 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if appointment.confirmed_at %}
|
||||
{% if appointment.scheduled_datetime %}
|
||||
<div class="timeline-item">
|
||||
<strong>Confirmed:</strong> {{ appointment.confirmed_at|date:"M d, Y H:i" }}
|
||||
<strong>Scheduled:</strong> {{ appointment.scheduled_datetime|date:"M d, Y H:i" }}
|
||||
{% if appointment.confirmed_by %}
|
||||
<br><small class="text-muted">by {{ appointment.confirmed_by.get_full_name }}</small>
|
||||
{% endif %}
|
||||
|
||||
@ -357,7 +357,7 @@
|
||||
<script>
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
$('#{{form.provider.id_for_label}}').select2({
|
||||
$('.form-select').select2({
|
||||
|
||||
}).on('select2:select', function (e) {
|
||||
loadAvailableSlots();
|
||||
|
||||
@ -1,18 +1,34 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% load static %}
|
||||
{% block title %}Appointments - Hospital Management System{% endblock %}
|
||||
|
||||
|
||||
{% block css %}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<div>
|
||||
<h1 class="h2">
|
||||
<i class="fas fa-calendar-alt"></i> Appointments<span class="fw-light">Requests</span>
|
||||
</h1>
|
||||
<p class="text-muted">Manage your appointments and view their details.</p>
|
||||
</div>
|
||||
<div class="btn-toolbar mb-2 mb-md-0">
|
||||
<div class="btn-group me-2">
|
||||
<button type="button" class="btn btn-outline-secondary"><i class="fas fa-download"></i> Export</button>
|
||||
<button type="button" class="btn btn-outline-secondary"><i class="fas fa-print"></i> Print</button>
|
||||
<button type="button" class="btn btn-outline-primary"><i class="fas fa-calendar-plus"></i> Schedule New</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container-fluid">
|
||||
<div class="panel panel-inverse mb-4" data-sortable-id="index-1" id="availableSlotsCard">
|
||||
<!-- Appointment List -->
|
||||
<div class="panel panel-inverse mb-4" data-sortable-id="index-1" id="appointment-list">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<i class="fas fa-calendar-alt"></i> Appointments
|
||||
</h4>
|
||||
<div class="panel-heading-btn">
|
||||
<button type="button" class="btn btn-xs btn-outline-secondary me-2"><i class="fas fa-download"></i> Export</button>
|
||||
<button type="button" class="btn btn-xs btn-outline-secondary me-2"><i class="fas fa-print"></i> Print</button>
|
||||
<button type="button" class="btn btn-xs btn-primary me-2"><i class="fas fa-calendar-plus"></i> Schedule New</button>
|
||||
<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>
|
||||
@ -93,9 +109,6 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Appointment List -->
|
||||
<div class="card" id="appointment-list">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
@ -231,14 +244,12 @@
|
||||
<i class="fas fa-calendar-alt"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<!-- View Details Button -->
|
||||
<a href="{% url 'appointments:appointment_detail' appointment.pk %}"
|
||||
class="btn btn-outline-info"
|
||||
title="View Details">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
|
||||
<!-- More Actions Dropdown -->
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="button"
|
||||
@ -277,14 +288,13 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
<script src="{% static 'plugins/sweetalert/dist/sweetalert.min.js' %}"></script>
|
||||
<script>
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -4,13 +4,13 @@
|
||||
{% block title %}Appointment Reminders{% endblock %}
|
||||
|
||||
{% block 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" />
|
||||
<link href="{% static 'plugins/datatables.net-bs5/css/dataTables.bootstrap5.min.css' %}" rel="stylesheet" />
|
||||
<link href="{% static 'plugins/datatables.net-responsive-bs5/css/responsive.bootstrap5.min.css' %}" rel="stylesheet" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content" class="app-content">
|
||||
<div class="container">
|
||||
|
||||
<div class="container">
|
||||
<ul class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{% url 'core:dashboard' %}">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'appointments:appointment_list' %}">Appointments</a></li>
|
||||
@ -276,9 +276,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Send Reminders Modal -->
|
||||
<div class="modal fade" id="sendRemindersModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
@ -347,10 +347,10 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block 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 src="{% static 'plugins/datatables.net/js/dataTables.min.js' %}"></script>
|
||||
<script src="{% static 'plugins/datatables.net-bs5/js/dataTables.bootstrap5.min.js' %}"></script>
|
||||
<script src="{% static 'plugins/datatables.net-responsive/js/dataTables.responsive.min.js' %}"></script>
|
||||
<script src="{% static 'plugins/datatables.net-responsive-bs5/js/responsive.bootstrap5.min.js' %}"></script>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
|
||||
@ -3,11 +3,16 @@
|
||||
|
||||
{% block title %}Reschedule Appointment{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<div>
|
||||
<h1 class="h2">
|
||||
<i class="fas fa-calendar-alt"></i> Reschedule<span class="fw-light">Appointments</span>
|
||||
</h1>
|
||||
<p class="text-muted">Something Something Something Something Something</p>
|
||||
</div>
|
||||
<div class="btn-toolbar mb-2 mb-md-0">
|
||||
<div class="btn-group me-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary">
|
||||
@ -16,11 +21,11 @@
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="fas fa-users"></i> Queue
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-primary">
|
||||
<i class="fas fa-video"></i> Telemedicine
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
@ -176,7 +181,7 @@
|
||||
<div class="panel-body">
|
||||
<div id="availableSlots"
|
||||
hx-get="{% url 'appointments:available_slots' %}"
|
||||
hx-trigger="change from:#new_date,change from:#new_provider"
|
||||
hx-trigger="change from:#new_provider"
|
||||
hx-target="#availableSlots"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#new_date,#new_provider"
|
||||
@ -187,8 +192,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
<label for="provider-filter" class="form-label">Provider</label>
|
||||
<select id="provider-filter" class="form-select">
|
||||
<option value="">All Providers</option>
|
||||
{% for provider in providers %}
|
||||
{% for provider in appointments.providers.all %}
|
||||
<option value="{{ provider.id }}">{{ provider.get_full_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
@ -499,7 +499,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content" class="app-content">
|
||||
<div class="container-fluid">
|
||||
<!-- Page Header -->
|
||||
<div class="availability-header fade-in">
|
||||
@ -844,7 +843,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Bulk Create Modal -->
|
||||
<div class="modal fade" id="bulkCreateModal" tabindex="-1">
|
||||
|
||||
@ -539,7 +539,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content" class="app-content">
|
||||
<div class="container-fluid">
|
||||
<!-- Page Header -->
|
||||
<div class="booking-header fade-in">
|
||||
@ -930,7 +929,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Loading Overlay -->
|
||||
<div class="loading-overlay" id="loadingOverlay">
|
||||
|
||||
@ -572,7 +572,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content" class="app-content">
|
||||
<div class="container-fluid">
|
||||
<!-- Page Header -->
|
||||
<div class="calendar-header fade-in">
|
||||
@ -867,7 +866,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slot Details Modal -->
|
||||
<div class="modal fade" id="slotModal" tabindex="-1">
|
||||
|
||||
@ -418,7 +418,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content" class="app-content">
|
||||
|
||||
<div class="container-fluid">
|
||||
<!-- Page Header -->
|
||||
<div class="row">
|
||||
@ -712,7 +712,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
|
||||
@ -391,8 +391,8 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content" class="app-content">
|
||||
<div class="container-fluid">
|
||||
|
||||
<div class="container-fluid">
|
||||
<!-- Page Header -->
|
||||
<div class="slot-detail-header fade-in">
|
||||
<div class="row align-items-center">
|
||||
@ -793,9 +793,9 @@
|
||||
<i class="fas fa-arrow-left"></i>Back to Slots
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Book Appointment Modal -->
|
||||
<div class="modal fade" id="bookAppointmentModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
|
||||
@ -402,7 +402,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content" class="app-content">
|
||||
<div class="container-fluid">
|
||||
<!-- Page Header -->
|
||||
<div class="slot-form-header fade-in">
|
||||
@ -806,7 +805,7 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
|
||||
@ -302,7 +302,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content" class="app-content">
|
||||
<div class="container-fluid">
|
||||
<!-- Page Header -->
|
||||
<div class="slot-management-header fade-in">
|
||||
@ -583,7 +582,7 @@
|
||||
<div id="slotCalendar"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Book Slot Modal -->
|
||||
<div class="modal fade" id="bookSlotModal" tabindex="-1">
|
||||
|
||||
@ -591,7 +591,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content" class="app-content">
|
||||
<div class="container-fluid">
|
||||
<!-- Page Header -->
|
||||
<div class="management-header fade-in">
|
||||
@ -921,7 +920,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Create Modal -->
|
||||
<div class="modal fade" id="bulkCreateModal" tabindex="-1">
|
||||
|
||||
@ -109,10 +109,10 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<button class="btn btn-outline-info"
|
||||
<a href="{% url 'appointments:telemedicine_session_detail' session.pk %}" class="btn btn-outline-info"
|
||||
title="View Details">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</a>
|
||||
|
||||
{% if session.status not in 'COMPLETED,CANCELLED' %}
|
||||
<button class="btn btn-outline-danger"
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Title</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@ -4,23 +4,28 @@
|
||||
{% block title %}Appointment Templates - Appointments{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- BEGIN breadcrumb -->
|
||||
<ol class="breadcrumb float-xl-end">
|
||||
<li class="breadcrumb-item"><a href="{% url 'core:dashboard' %}">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'appointments:dashboard' %}">Appointments</a></li>
|
||||
<li class="breadcrumb-item active">Appointment Templates</li>
|
||||
</ol>
|
||||
<!-- END breadcrumb -->
|
||||
|
||||
<!-- BEGIN page-header -->
|
||||
<h1 class="page-header">
|
||||
Appointment Templates
|
||||
<small>Reusable configurations for common appointment types</small>
|
||||
</h1>
|
||||
<!-- END page-header -->
|
||||
|
||||
|
||||
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<div>
|
||||
<h1 class="h2">
|
||||
<i class="fas fa-book"></i> Appointment<span class="fw-light">Templates</span>
|
||||
</h1>
|
||||
<p class="text-muted">Reusable configurations for common appointment types.</p>
|
||||
</div>
|
||||
{# <div class="btn-toolbar mb-2 mb-md-0">#}
|
||||
{# <div class="btn-group me-2">#}
|
||||
{# <a href="{% url 'appointments:waiting_list_edit' entry.pk %}" class="btn btn-warning">#}
|
||||
{# <i class="fas fa-edit me-1"></i>Edit Entry#}
|
||||
{# </a>#}
|
||||
{# <a href="{% url 'appointments:waiting_list_delete' entry.pk %}" class="btn btn-danger">#}
|
||||
{# <i class="fas fa-trash me-1"></i>Delete Entry#}
|
||||
{# </a>#}
|
||||
{# <a href="{% url 'appointments:waiting_list' %}" class="btn btn-outline-secondary">#}
|
||||
{# <i class="fas fa-arrow-left me-1"></i>Back to List#}
|
||||
{# </a>#}
|
||||
{# </div>#}
|
||||
{# </div>#}
|
||||
</div>
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-xl-12">
|
||||
<div class="panel panel-inverse">
|
||||
@ -51,7 +56,7 @@
|
||||
<div class="row g-2">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Search</label>
|
||||
<input type="text" name="search" value="{{ request.GET.search|default:'' }}" class="form-control" placeholder="Name, description, department...">
|
||||
<input type="text" name="search" value="{{ request.GET.search }}" class="form-control" placeholder="Name, description, department...">
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
@ -66,7 +71,7 @@
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Department</label>
|
||||
<input type="text" name="department" value="{{ request.GET.department|default:'' }}" class="form-control form-control-sm" placeholder="Department">
|
||||
<input type="text" name="department" value="{{ request.GET.department }}" class="form-control form-control-sm" placeholder="Department">
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
@ -163,7 +168,7 @@
|
||||
</td>
|
||||
|
||||
<td class="text-nowrap small">
|
||||
<div><i class="fa fa-user me-1 text-muted"></i>{{ t.created_by.get_full_name|default:"—" }}</div>
|
||||
<div><i class="fa fa-user me-1 text-muted"></i>{{ t.created_by.get_full_name }}</div>
|
||||
<div class="text-muted">{{ t.updated_at|date:"M d, Y H:i" }}</div>
|
||||
</td>
|
||||
|
||||
@ -230,4 +235,5 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -27,21 +27,14 @@
|
||||
{% 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 'appointments:dashboard' %}">Appointments</a></li>
|
||||
<li class="breadcrumb-item active">Waiting List</li>
|
||||
</ol>
|
||||
<!-- END breadcrumb -->
|
||||
|
||||
<!-- BEGIN page-header -->
|
||||
<h1 class="page-header">
|
||||
<i class="fas fa-clock text-primary me-2"></i>
|
||||
Patient Waiting List Management
|
||||
<small class="text-muted ms-2">Manage appointment waiting list and patient queue</small>
|
||||
</h1>
|
||||
<!-- END page-header -->
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<div>
|
||||
<h1 class="h2">
|
||||
<i class="fas fa-clock"></i> Waiting List<span class="fw-light">Management</span>
|
||||
</h1>
|
||||
<p class="text-muted">Manage appointment waiting list and patient queue.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BEGIN statistics cards -->
|
||||
<div class="row mb-4">
|
||||
@ -99,7 +92,7 @@
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="card-title mb-1">Avg. Wait</h6>
|
||||
<h3 class="mb-0" id="avg-wait">{{ stats.avg_wait_days }}</h3>
|
||||
<h3 class="mb-0" id="avg-wait">{{ stats.avg_wait_days|default:0 }}</h3>
|
||||
<small class="opacity-75">Days waiting</small>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
@ -113,18 +106,18 @@
|
||||
<!-- END statistics cards -->
|
||||
|
||||
<!-- BEGIN filter panel -->
|
||||
<div class="panel panel-inverse">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<i class="fas fa-filter me-2"></i>Filter Waiting List
|
||||
</h4>
|
||||
<div class="panel-heading-btn">
|
||||
<a href="javascript:;" class="btn btn-xs btn-icon btn-default" data-toggle="panel-collapse">
|
||||
<i class="fa fa-minus"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{#<div class="panel panel-inverse">#}
|
||||
{# <div class="panel-heading">#}
|
||||
{# <h4 class="panel-title">#}
|
||||
{# <i class="fas fa-filter me-2"></i>Filter Waiting List#}
|
||||
{# </h4>#}
|
||||
{# <div class="panel-heading-btn">#}
|
||||
{# <a href="javascript:;" class="btn btn-xs btn-icon btn-default" data-toggle="panel-collapse">#}
|
||||
{# <i class="fa fa-minus"></i>#}
|
||||
{# </a>#}
|
||||
{# </div>#}
|
||||
{# </div>#}
|
||||
{# <div class="panel-body">#}
|
||||
{# <form method="get" class="row g-3">#}
|
||||
{# <div class="col-md-2">#}
|
||||
{# {{ filter_form.department.label_tag }}#}
|
||||
@ -158,8 +151,8 @@
|
||||
{# </div>#}
|
||||
{# </div>#}
|
||||
{# </form>#}
|
||||
</div>
|
||||
</div>
|
||||
{# </div>#}
|
||||
{#</div>#}
|
||||
<!-- END filter panel -->
|
||||
|
||||
<!-- BEGIN main panel -->
|
||||
@ -170,10 +163,10 @@
|
||||
<i class="fas fa-list me-2"></i>Waiting List Entries
|
||||
</h4>
|
||||
<div class="panel-heading-btn">
|
||||
<a href="{% url 'appointments:waiting_list_create' %}" class="btn btn-primary btn-sm me-2">
|
||||
<a href="{% url 'appointments:waiting_list_create' %}" class="btn btn-primary btn-xs me-2">
|
||||
<i class="fas fa-plus me-1"></i>Add to Waiting List
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="refreshStats()">
|
||||
<button type="button" class="btn btn-outline-secondary btn-xs" onclick="refreshStats()">
|
||||
<i class="fas fa-sync-alt"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
<!-- END breadcrumb -->
|
||||
|
||||
<!-- BEGIN page-header -->
|
||||
<h1 class="page-header">
|
||||
<h1 class="h2">
|
||||
<i class="fas fa-trash-alt text-danger me-2"></i>
|
||||
Confirm Waiting List Entry Cancellation
|
||||
<small class="text-muted ms-2">Permanently remove this patient from the waiting list</small>
|
||||
|
||||
@ -3,83 +3,61 @@
|
||||
|
||||
{% block title %}Waiting List Entry Details{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.detail-section {
|
||||
border-left: 4px solid #007bff;
|
||||
padding-left: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.detail-section h5 {
|
||||
color: #007bff;
|
||||
}
|
||||
.priority-badge {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.4em 0.6em;
|
||||
}
|
||||
.status-badge {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.4em 0.6em;
|
||||
}
|
||||
.contact-log-item {
|
||||
border-bottom: 1px dashed #eee;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.contact-log-item:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
{% block css %}
|
||||
|
||||
{% 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 'appointments:dashboard' %}">Appointments</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'appointments:waiting_list' %}">Waiting List</a></li>
|
||||
<li class="breadcrumb-item active">Entry Details</li>
|
||||
</ol>
|
||||
<!-- END breadcrumb -->
|
||||
|
||||
<!-- BEGIN page-header -->
|
||||
<h1 class="page-header">
|
||||
<i class="fas fa-info-circle text-primary me-2"></i>
|
||||
Waiting List Entry Details
|
||||
<small class="text-muted ms-2">Comprehensive view of patient waiting list entry</small>
|
||||
</h1>
|
||||
<!-- END page-header -->
|
||||
|
||||
<!-- BEGIN panel -->
|
||||
<div class="panel panel-inverse">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<i class="fas fa-user-tag me-2"></i>Patient: {{ entry.patient.get_full_name }}
|
||||
</h4>
|
||||
<div class="panel-heading-btn">
|
||||
<a href="{% url 'appointments:waiting_list_edit' entry.pk %}" class="btn btn-warning btn-sm me-2">
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<div>
|
||||
<h1 class="h2">
|
||||
<i class="fas fa-calendar-alt"></i> Waiting List<span class="fw-light">Entry Details</span>
|
||||
</h1>
|
||||
<p class="text-muted">Comprehensive view of patient waiting list entry.</p>
|
||||
</div>
|
||||
<div class="btn-toolbar mb-2 mb-md-0">
|
||||
<div class="btn-group me-2">
|
||||
<a href="{% url 'appointments:waiting_list_edit' entry.pk %}" class="btn btn-warning">
|
||||
<i class="fas fa-edit me-1"></i>Edit Entry
|
||||
</a>
|
||||
<a href="{% url 'appointments:waiting_list_delete' entry.pk %}" class="btn btn-danger btn-sm me-2">
|
||||
<a href="{% url 'appointments:waiting_list_delete' entry.pk %}" class="btn btn-danger">
|
||||
<i class="fas fa-trash me-1"></i>Delete Entry
|
||||
</a>
|
||||
<a href="{% url 'appointments:waiting_list' %}" class="btn btn-outline-secondary btn-sm">
|
||||
<a href="{% url 'appointments:waiting_list' %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i>Back to List
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BEGIN panel -->
|
||||
<div class="container-fluid">
|
||||
<div class="panel panel-inverse">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<i class="fas fa-user-tag me-2"></i>Patient: {{ entry.patient.get_full_name }}
|
||||
</h4>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="col-lg-4">
|
||||
<!-- Patient & Service Information -->
|
||||
<div class="detail-section mb-4">
|
||||
<h5 class="mb-3"><i class="fas fa-user me-2"></i>Patient & Service Information</h5>
|
||||
<div class="panel panel-inverse border" data-sortable-id="index-1">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<i class="fas fa-user me-2"></i>Patient & Service Information
|
||||
</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>
|
||||
<a href="javascript:" class="btn btn-xs btn-icon btn-danger" data-toggle="panel-remove"><i class="fa fa-times"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-4"><strong>Patient Name:</strong></div>
|
||||
<div class="col-md-8">{{ entry.patient.get_full_name }} (MRN: {{ entry.patient.mrn|default:'N/A' }})</div>
|
||||
<div class="col-md-8">{{ entry.patient.get_full_name }} (MRN: {{ entry.patient.mrn }})</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-4"><strong>Department:</strong></div>
|
||||
@ -98,41 +76,49 @@
|
||||
<div class="col-md-8">{{ entry.get_specialty_display }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clinical Priority & Urgency -->
|
||||
<div class="detail-section mb-4">
|
||||
<h5 class="mb-3"><i class="fas fa-exclamation-triangle me-2"></i>Clinical Priority & Urgency</h5>
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-4"><strong>Priority Level:</strong></div>
|
||||
<div class="col-md-8">
|
||||
{% if entry.priority == 'EMERGENCY' %}
|
||||
<span class="badge bg-danger priority-badge">{{ entry.get_priority_display }}</span>
|
||||
{% elif entry.priority == 'STAT' %}
|
||||
<span class="badge bg-warning priority-badge">{{ entry.get_priority_display }}</span>
|
||||
{% elif entry.priority == 'URGENT' %}
|
||||
<span class="badge bg-warning priority-badge">{{ entry.get_priority_display }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success priority-badge">{{ entry.get_priority_display }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<!-- Contact Information -->
|
||||
<div class="panel panel-inverse border" data-sortable-id="index-2">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<i class="fas fa-phone me-2"></i>Contact Information
|
||||
</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>
|
||||
<a href="javascript:" class="btn btn-xs btn-icon btn-danger" data-toggle="panel-remove"><i class="fa fa-times"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-4"><strong>Preferred Method:</strong></div>
|
||||
<div class="col-md-8">{{ entry.get_contact_method_display }}</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-4"><strong>Urgency Score:</strong></div>
|
||||
<div class="col-md-8">{{ entry.urgency_score }} / 10</div>
|
||||
<div class="col-md-4"><strong>Phone:</strong></div>
|
||||
<div class="col-md-8">{{ entry.contact_phone|default:'N/A' }}</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-4"><strong>Clinical Indication:</strong></div>
|
||||
<div class="col-md-8">{{ entry.clinical_indication|linebreaksbr }}</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-4"><strong>Diagnosis Codes:</strong></div>
|
||||
<div class="col-md-8">{{ entry.diagnosis_codes|join:", "|default:'N/A' }}</div>
|
||||
<div class="col-md-4"><strong>Email:</strong></div>
|
||||
<div class="col-md-8">{{ entry.contact_email|default:'N/A' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Patient Scheduling Preferences -->
|
||||
<div class="detail-section mb-4">
|
||||
<h5 class="mb-3"><i class="fas fa-calendar-alt me-2"></i>Patient Scheduling Preferences</h5>
|
||||
<div class="panel panel-inverse border" data-sortable-id="index-3">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<i class="fas fa-calendar-alt me-2"></i>Patient Scheduling Preferences
|
||||
</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>
|
||||
<a href="javascript:" class="btn btn-xs btn-icon btn-danger" data-toggle="panel-remove"><i class="fa fa-times"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-4"><strong>Preferred Date:</strong></div>
|
||||
<div class="col-md-8">{{ entry.preferred_date|date:"M d, Y"|default:'Any' }}</div>
|
||||
@ -156,27 +142,66 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Contact Information -->
|
||||
<div class="detail-section mb-4">
|
||||
<h5 class="mb-3"><i class="fas fa-phone me-2"></i>Contact Information</h5>
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-4"><strong>Preferred Method:</strong></div>
|
||||
<div class="col-md-8">{{ entry.get_contact_method_display }}</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-4"><strong>Phone:</strong></div>
|
||||
<div class="col-md-8">{{ entry.contact_phone|default:'N/A' }}</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-4"><strong>Email:</strong></div>
|
||||
<div class="col-md-8">{{ entry.contact_email|default:'N/A' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<!-- Clinical Priority & Urgency -->
|
||||
<div class="panel panel-inverse border" data-sortable-id="index-4">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>Clinical Priority & Urgency
|
||||
</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>
|
||||
<a href="javascript:" class="btn btn-xs btn-icon btn-danger" data-toggle="panel-remove"><i class="fa fa-times"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-4"><strong>Priority Level:</strong></div>
|
||||
<div class="col-md-8">
|
||||
{% if entry.priority == 'EMERGENCY' %}
|
||||
<span class="badge bg-danger priority-badge">{{ entry.get_priority_display }}</span>
|
||||
{% elif entry.priority == 'STAT' %}
|
||||
<span class="badge bg-red priority-badge">{{ entry.get_priority_display }}</span>
|
||||
{% elif entry.priority == 'URGENT' %}
|
||||
<span class="badge bg-warning priority-badge">{{ entry.get_priority_display }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success priority-badge">{{ entry.get_priority_display }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-4"><strong>Urgency Score:</strong></div>
|
||||
<div class="col-md-8">{{ entry.urgency_score }} / 10</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-4"><strong>Clinical Indication:</strong></div>
|
||||
<div class="col-md-8">{{ entry.clinical_indication|linebreaksbr }}</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-4"><strong>Diagnosis Codes:</strong></div>
|
||||
<div class="col-md-8">{{ entry.diagnosis_codes|join:", " }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Special Requirements -->
|
||||
<div class="detail-section mb-4">
|
||||
<h5 class="mb-3"><i class="fas fa-universal-access me-2"></i>Special Requirements & Accommodations</h5>
|
||||
<div class="panel panel-inverse border" data-sortable-id="index-5">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<i class="fas fa-universal-access me-2"></i>Special Requirements & Accommodations
|
||||
</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>
|
||||
<a href="javascript:" class="btn btn-xs btn-icon btn-danger" data-toggle="panel-remove"><i class="fa fa-times"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-4"><strong>Interpreter Needed:</strong></div>
|
||||
<div class="col-md-8">{% if entry.requires_interpreter %}Yes ({{ entry.interpreter_language|default:'N/A' }}){% else %}No{% endif %}</div>
|
||||
@ -190,31 +215,21 @@
|
||||
<div class="col-md-8">{{ entry.accessibility_requirements|default:'None'|linebreaksbr }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Insurance & Authorization -->
|
||||
<div class="detail-section mb-4">
|
||||
<h5 class="mb-3"><i class="fas fa-shield-alt me-2"></i>Insurance & Authorization</h5>
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-4"><strong>Insurance Verified:</strong></div>
|
||||
<div class="col-md-8">{% if entry.insurance_verified %}Yes{% else %}No{% endif %}</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-4"><strong>Authorization Required:</strong></div>
|
||||
<div class="col-md-8">{% if entry.authorization_required %}Yes{% else %}No{% endif %}</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-4"><strong>Authorization Status:</strong></div>
|
||||
<div class="col-md-8">{{ entry.get_authorization_status_display }}</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-4"><strong>Authorization Number:</strong></div>
|
||||
<div class="col-md-8">{{ entry.authorization_number|default:'N/A' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Referral Information -->
|
||||
<div class="detail-section mb-4">
|
||||
<h5 class="mb-3"><i class="fas fa-user-md me-2"></i>Referral Information</h5>
|
||||
<div class="panel panel-inverse border" data-sortable-id="index-6">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<i class="fas fa-user-md me-2"></i>Referral Information
|
||||
</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>
|
||||
<a href="javascript:" class="btn btn-xs btn-icon btn-danger" data-toggle="panel-remove"><i class="fa fa-times"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-4"><strong>Referring Provider:</strong></div>
|
||||
<div class="col-md-8">{{ entry.referring_provider|default:'N/A' }}</div>
|
||||
@ -228,33 +243,145 @@
|
||||
<div class="col-md-8">{{ entry.get_referral_urgency_display }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<!-- Waiting List Metrics -->
|
||||
<div class="panel panel-inverse border" data-sortable-id="index-7">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<i class="fas fa-chart-bar me-2"></i>Waiting List Metrics
|
||||
</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>
|
||||
<a href="javascript:" class="btn btn-xs btn-icon btn-danger" data-toggle="panel-remove"><i class="fa fa-times"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div><strong>Current Position:</strong></div>
|
||||
<div class="text-primary">{{ entry.position|default:'N/A' }}</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div><strong>Days Waiting:</strong></div>
|
||||
<div class="text-info">{{ entry.days_waiting }} days</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div><strong>Estimated Wait Time:</strong></div>
|
||||
<div class="text-warning">{{ estimated_wait_time }} days</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div><strong>Contact Attempts:</strong></div>
|
||||
<div class="text-secondary">{{ entry.contact_attempts }}</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div><strong>Overdue Contact:</strong></div>
|
||||
<div class="">{% if entry.is_overdue_contact %}<span class="badge bg-danger">Yes</span>{% else %}<span class="badge bg-success">No</span>{% endif %}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Contact Log -->
|
||||
<div class="panel panel-inverse border" data-sortable-id="index-8">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<i class="fas fa-history me-2"></i>Contact Log
|
||||
</h4>
|
||||
<div class="panel-heading-btn">
|
||||
<button type="button" class="btn btn-outline-theme btn-xs me-2" data-bs-toggle="modal" data-bs-target="#addContactLogModal">
|
||||
<i class="fas fa-plus me-1"></i>Add Contact Log
|
||||
</button>
|
||||
<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>
|
||||
<a href="javascript:" class="btn btn-xs btn-icon btn-danger" data-toggle="panel-remove"><i class="fa fa-times"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body" id="contact-log-container">
|
||||
{% include 'appointments/partials/contact_log_list.html' %}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Insurance & Authorization -->
|
||||
<div class="panel panel-inverse border" data-sortable-id="index-9">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<i class="fas fa-shield-alt me-2"></i>Insurance & Authorization
|
||||
</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>
|
||||
<a href="javascript:" class="btn btn-xs btn-icon btn-danger" data-toggle="panel-remove"><i class="fa fa-times"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-4"><strong>Insurance Verified:</strong></div>
|
||||
<div class="col-md-8">{{ entry.insurance_verified|yesno|capfirst }} </div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-4"><strong>Authorization Required:</strong></div>
|
||||
<div class="col-md-8">{{ entry.authorization_required|yesno|capfirst }}</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-4"><strong>Authorization Status:</strong></div>
|
||||
<div class="col-md-8">{{ entry.get_authorization_status_display }}</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-4"><strong>Authorization Number:</strong></div>
|
||||
<div class="col-md-8">{{ entry.authorization_number|default:'N/A' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Additional Notes -->
|
||||
<div class="detail-section mb-4">
|
||||
<h5 class="mb-3"><i class="fas fa-sticky-note me-2"></i>Additional Notes</h5>
|
||||
<div class="panel panel-inverse border" data-sortable-id="index-10">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<i class="fas fa-sticky-note me-2"></i>Additional Notes
|
||||
</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>
|
||||
<a href="javascript:" class="btn btn-xs btn-icon btn-danger" data-toggle="panel-remove"><i class="fa fa-times"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-12">{{ entry.notes|default:'No additional notes.'|linebreaksbr }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<!-- Outcome Tracking -->
|
||||
<div class="detail-section mb-4">
|
||||
<h5 class="mb-3"><i class="fas fa-check-circle me-2"></i>Outcome Tracking</h5>
|
||||
<div class="panel panel-inverse border" data-sortable-id="index-11">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<i class="fas fa-check-circle me-2"></i>Outcome Tracking
|
||||
</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>
|
||||
<a href="javascript:" class="btn btn-xs btn-icon btn-danger" data-toggle="panel-remove"><i class="fa fa-times"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-4"><strong>Status:</strong></div>
|
||||
<div class="col-md-8">
|
||||
{% if entry.status == 'ACTIVE' %}
|
||||
<span class="badge bg-primary status-badge">{{ entry.get_status_display }}</span>
|
||||
<span class="badge bg-primary">{{ entry.get_status_display }}</span>
|
||||
{% elif entry.status == 'CONTACTED' %}
|
||||
<span class="badge bg-info status-badge">{{ entry.get_status_display }}</span>
|
||||
<span class="badge bg-info">{{ entry.get_status_display }}</span>
|
||||
{% elif entry.status == 'OFFERED' %}
|
||||
<span class="badge bg-warning status-badge">{{ entry.get_status_display }}</span>
|
||||
<span class="badge bg-warning">{{ entry.get_status_display }}</span>
|
||||
{% elif entry.status == 'SCHEDULED' %}
|
||||
<span class="badge bg-success status-badge">{{ entry.get_status_display }}</span>
|
||||
<span class="badge bg-success">{{ entry.get_status_display }}</span>
|
||||
{% elif entry.status == 'CANCELLED' %}
|
||||
<span class="badge bg-danger status-badge">{{ entry.get_status_display }}</span>
|
||||
<span class="badge bg-danger">{{ entry.get_status_display }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary status-badge">{{ entry.get_status_display }}</span>
|
||||
<span class="badge bg-secondary">{{ entry.get_status_display }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@ -289,17 +416,28 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<!-- Metadata -->
|
||||
<div class="detail-section mb-4">
|
||||
<h5 class="mb-3"><i class="fas fa-database me-2"></i>Metadata</h5>
|
||||
<div class="panel panel-inverse border" data-sortable-id="index-12">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<i class="fas fa-database me-2"></i>Metadata
|
||||
</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>
|
||||
<a href="javascript:" class="btn btn-xs btn-icon btn-danger" data-toggle="panel-remove"><i class="fa fa-times"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-4"><strong>Entry ID:</strong></div>
|
||||
<div class="col-md-8">{{ entry.waiting_list_id }}</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-4"><strong>Created At:</strong></div>
|
||||
<div class="col-md-8">{{ entry.created_at|date:"M d, Y H:i" }} by {{ entry.created_by.get_full_name|default:'N/A' }}</div>
|
||||
<div class="col-md-8">{{ entry.created_at|date:"M d, Y H:i" }} by {{ entry.created_by.get_display_name }}</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-4"><strong>Last Updated:</strong></div>
|
||||
@ -307,121 +445,277 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<!-- Waiting List Metrics -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="card-title mb-0"><i class="fas fa-chart-bar me-2"></i>Waiting List Metrics</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div><strong>Current Position:</strong></div>
|
||||
<div class="fs-4 text-primary">{{ entry.position|default:'N/A' }}</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div><strong>Days Waiting:</strong></div>
|
||||
<div class="fs-4 text-info">{{ entry.days_waiting }} days</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div><strong>Estimated Wait Time:</strong></div>
|
||||
<div class="fs-4 text-warning">{{ estimated_wait_time }} days</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div><strong>Contact Attempts:</strong></div>
|
||||
<div class="fs-4 text-secondary">{{ entry.contact_attempts }}</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div><strong>Overdue Contact:</strong></div>
|
||||
<div class="fs-4">{% if entry.is_overdue_contact %}<span class="badge bg-danger">Yes</span>{% else %}<span class="badge bg-success">No</span>{% endif %}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Log -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h5 class="card-title mb-0"><i class="fas fa-history me-2"></i>Contact Log</h5>
|
||||
</div>
|
||||
<div class="card-body" id="contact-log-container">
|
||||
{% include 'appointments/partials/contact_log_list.html' %}
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#addContactLogModal">
|
||||
<i class="fas fa-plus me-1"></i>Add Contact Log
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END panel -->
|
||||
|
||||
<!-- Add Contact Log Modal -->
|
||||
<div class="modal fade" id="addContactLogModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal fade" id="addContactLogModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content border-0 shadow">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Add Contact Log for {{ entry.patient.get_full_name }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
<h5 class="modal-title">
|
||||
<i class="fas fa-phone-alt"></i>Add Contact Log for {{ entry.patient.get_full_name }}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form method="post" action="{% url 'appointments:add_contact_log' entry.pk %}" hx-post="{% url 'appointments:add_contact_log' entry.pk %}" hx-target="#contact-log-container" hx-swap="innerHTML">
|
||||
|
||||
<!-- Use hx-post to refresh the contact log list on success -->
|
||||
<form
|
||||
method="post"
|
||||
action="{% url 'appointments:add_contact_log' entry.pk %}"
|
||||
hx-post="{% url 'appointments:add_contact_log' entry.pk %}"
|
||||
hx-target="#contact-log-container"
|
||||
hx-swap="innerHTML"
|
||||
class="needs-validation"
|
||||
novalidate
|
||||
>
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="modal-body">
|
||||
{% for field in contact_form %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{{ field.label }}</label>
|
||||
{{ field }}
|
||||
{% if field.help_text %}<small class="form-text text-muted">{{ field.help_text }}</small>{% endif %}
|
||||
{% for error in field.errors %}
|
||||
<div class="invalid-feedback d-block">{{ error }}</div>
|
||||
<!-- Top-level (non-field) errors -->
|
||||
{% if contact_form.non_field_errors %}
|
||||
<div class="alert alert-danger mb-3" role="alert">
|
||||
<ul class="mb-0">
|
||||
{% for err in contact_form.non_field_errors %}
|
||||
<li>{{ err }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<div class="row g-3">
|
||||
<!-- Contact method -->
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold" for="{{ contact_form.contact_method.id_for_label }}">
|
||||
Contact Method <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ contact_form.contact_method }}
|
||||
{% if contact_form.contact_method.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ contact_form.contact_method.errors|striptags }}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
{% else %}
|
||||
<div class="form-text">Call, SMS, email, etc.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Contact outcome -->
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold" for="{{ contact_form.contact_outcome.id_for_label }}">
|
||||
Contact Outcome <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ contact_form.contact_outcome }}
|
||||
{% if contact_form.contact_outcome.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ contact_form.contact_outcome.errors|striptags }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-text">Answered, no answer, voicemail, etc.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Appointment offered (toggle) -->
|
||||
<div class="col-12">
|
||||
<div class="form-check form-switch">
|
||||
{{ contact_form.appointment_offered }}
|
||||
<label class="form-check-label fw-semibold ms-1" for="{{ contact_form.appointment_offered.id_for_label }}">
|
||||
Appointment offered?
|
||||
</label>
|
||||
</div>
|
||||
{% if contact_form.appointment_offered.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ contact_form.appointment_offered.errors|striptags }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Offered date/time (conditional) -->
|
||||
<div class="col-md-6 offered-fields">
|
||||
<label class="form-label fw-semibold" for="{{ contact_form.offered_date.id_for_label }}">
|
||||
Offered Date
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="fas fa-calendar-day"></i></span>
|
||||
{{ contact_form.offered_date }}
|
||||
</div>
|
||||
{% if contact_form.offered_date.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ contact_form.offered_date.errors|striptags }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-text">Date of the offered appointment.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 offered-fields">
|
||||
<label class="form-label fw-semibold" for="{{ contact_form.offered_time.id_for_label }}">
|
||||
Offered Time
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="fas fa-clock"></i></span>
|
||||
{{ contact_form.offered_time }}
|
||||
</div>
|
||||
{% if contact_form.offered_time.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ contact_form.offered_time.errors|striptags }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-text">Time of the offered slot.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Patient response -->
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold" for="{{ contact_form.patient_response.id_for_label }}">
|
||||
Patient Response
|
||||
</label>
|
||||
{{ contact_form.patient_response }}
|
||||
{% if contact_form.patient_response.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ contact_form.patient_response.errors|striptags }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-text">Accepted, declined, call back, etc.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Next contact date -->
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold" for="{{ contact_form.next_contact_date.id_for_label }}">
|
||||
Next Contact Date
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="fas fa-bell"></i></span>
|
||||
{{ contact_form.next_contact_date }}
|
||||
</div>
|
||||
{% if contact_form.next_contact_date.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ contact_form.next_contact_date.errors|striptags }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-text">When to follow up if needed.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold" for="{{ contact_form.notes.id_for_label }}">
|
||||
Notes
|
||||
</label>
|
||||
{{ contact_form.notes }}
|
||||
{% if contact_form.notes.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ contact_form.notes.errors|striptags }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-text">Conversation details and any context.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer bg-light">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Contact Log</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save me-1"></i> Save Contact Log
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{% static 'assets/plugins/bootstrap-datepicker/dist/js/bootstrap-datepicker.min.js' %}"></script>
|
||||
{% block js %}
|
||||
<script src="{% static 'plugins/bootstrap-datepicker/dist/js/bootstrap-datepicker.min.js' %}"></script>
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Initialize date picker for offered_date and next_contact_date
|
||||
$("#addContactLogModal input[type='date']").datepicker({
|
||||
format: 'yyyy-mm-dd',
|
||||
autoclose: true,
|
||||
todayHighlight: true,
|
||||
startDate: 'today'
|
||||
});
|
||||
(function() {
|
||||
// Bootstrap validation
|
||||
document.addEventListener('submit', function(e) {
|
||||
const form = e.target.closest('form.needs-validation');
|
||||
if (form) {
|
||||
if (!form.checkValidity()) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
form.classList.add('was-validated');
|
||||
}
|
||||
}, true);
|
||||
|
||||
// Toggle offered date/time and patient response based on appointment_offered checkbox
|
||||
$("input[name='appointment_offered']").change(function() {
|
||||
const isChecked = $(this).is(':checked');
|
||||
$("input[name='offered_date']").prop('required', isChecked);
|
||||
$("input[name='offered_time']").prop('required', isChecked);
|
||||
$("select[name='patient_response']").prop('required', isChecked);
|
||||
}).trigger('change'); // Trigger on load for initial state
|
||||
|
||||
// Handle HTMX after swap to re-initialize datepickers if needed
|
||||
document.body.addEventListener('htmx:afterSwap', function(event) {
|
||||
if (event.detail.target.id === 'contact-log-container') {
|
||||
$("#addContactLogModal input[type='date']").datepicker({
|
||||
// Helpers
|
||||
function initDatePickers(scope) {
|
||||
// jQuery datepicker on any date input inside the modal
|
||||
const $scope = scope ? $(scope) : $("#addContactLogModal");
|
||||
$scope.find("input[type='date']").datepicker({
|
||||
format: 'yyyy-mm-dd',
|
||||
autoclose: true,
|
||||
todayHighlight: true,
|
||||
startDate: 'today'
|
||||
});
|
||||
}
|
||||
|
||||
function toggleOfferedFields(scope) {
|
||||
const root = scope || document;
|
||||
const offeredSwitch = root.querySelector('#{{ contact_form.appointment_offered.id_for_label }}');
|
||||
const offeredFields = (root.querySelectorAll('.offered-fields')) || [];
|
||||
if (!offeredSwitch) return;
|
||||
|
||||
const on = offeredSwitch.checked;
|
||||
offeredFields.forEach(function(el) {
|
||||
el.style.display = on ? '' : 'none';
|
||||
const inputs = el.querySelectorAll('input, select, textarea');
|
||||
inputs.forEach(function(input) {
|
||||
input.required = !!on;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Init on modal show
|
||||
const modalEl = document.getElementById('addContactLogModal');
|
||||
modalEl.addEventListener('shown.bs.modal', function() {
|
||||
initDatePickers(modalEl);
|
||||
toggleOfferedFields(modalEl);
|
||||
});
|
||||
|
||||
// React to switch changes
|
||||
modalEl.addEventListener('change', function(e) {
|
||||
if (e.target && e.target.id === '{{ contact_form.appointment_offered.id_for_label }}') {
|
||||
toggleOfferedFields(modalEl);
|
||||
}
|
||||
});
|
||||
|
||||
// Re-initialize after HTMX swaps (when the contact log list is refreshed)
|
||||
document.body.addEventListener('htmx:afterSwap', function(evt) {
|
||||
// if we just updated the contact log container, close the modal & reset form
|
||||
if (evt.detail && evt.detail.target && evt.detail.target.id === 'contact-log-container') {
|
||||
const modal = bootstrap.Modal.getInstance(modalEl);
|
||||
if (modal) modal.hide();
|
||||
|
||||
// Optional: reset the form for next use
|
||||
const form = modalEl.querySelector('form');
|
||||
if (form) {
|
||||
form.reset();
|
||||
form.classList.remove('was-validated');
|
||||
toggleOfferedFields(modalEl);
|
||||
initDatePickers(modalEl);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Also handle server-sent HX-Trigger (recommended best practice)
|
||||
document.body.addEventListener('contact-log:added', function() {
|
||||
const modal = bootstrap.Modal.getInstance(modalEl);
|
||||
if (modal) modal.hide();
|
||||
});
|
||||
|
||||
// Initial setup (if modal already visible)
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initDatePickers(modalEl);
|
||||
toggleOfferedFields(modalEl);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@ -3,19 +3,19 @@
|
||||
|
||||
{% block title %}{% if object %}Edit{% else %}Add{% endif %} Waiting List Entry{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link href="{% static 'assets/plugins/select2/dist/css/select2.min.css' %}" rel="stylesheet" />
|
||||
<link href="{% static 'assets/plugins/bootstrap-datepicker/dist/css/bootstrap-datepicker.min.css' %}" rel="stylesheet" />
|
||||
{% block css %}
|
||||
<link href="{% static 'plugins/select2/dist/css/select2.min.css' %}" rel="stylesheet" />
|
||||
<link href="{% static 'plugins/bootstrap-datepicker/dist/css/bootstrap-datepicker.min.css' %}" rel="stylesheet" />
|
||||
<style>
|
||||
.required-field::after {
|
||||
content: " *";
|
||||
color: #dc3545;
|
||||
}
|
||||
.form-section {
|
||||
border-left: 4px solid #007bff;
|
||||
padding-left: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
{#.form-section {#}
|
||||
{# border-left: 4px solid #007bff;#}
|
||||
{# padding-left: 1rem;#}
|
||||
{# margin-bottom: 2rem;#}
|
||||
{# }#}
|
||||
.priority-indicator {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
@ -148,7 +148,7 @@
|
||||
<label class="form-label required-field">Urgency Score (1-10)</label>
|
||||
{{ form.urgency_score }}
|
||||
<div class="progress mt-2" style="height: 8px;">
|
||||
<div class="progress-bar bg-success" id="urgency-progress" style="width: 10%"></div>
|
||||
<div class="progress-bar bg-success" id="urgency-progress" aria-valuemax="10" style="width: {{ object.urgency_score }}%"></div>
|
||||
</div>
|
||||
<small class="form-text text-muted">1 = Routine, 10 = Most Urgent</small>
|
||||
{% if form.urgency_score.errors %}
|
||||
@ -431,15 +431,15 @@
|
||||
<!-- END form panel -->
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{% static 'assets/plugins/select2/dist/js/select2.min.js' %}"></script>
|
||||
<script src="{% static 'assets/plugins/bootstrap-datepicker/dist/js/bootstrap-datepicker.min.js' %}"></script>
|
||||
{% block js %}
|
||||
<script src="{% static 'plugins/select2/dist/js/select2.min.js' %}"></script>
|
||||
<script src="{% static 'plugins/bootstrap-datepicker/dist/js/bootstrap-datepicker.min.js' %}"></script>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Initialize Select2 for dropdowns
|
||||
$('.form-select').select2({
|
||||
theme: 'bootstrap-5',
|
||||
{#theme: 'bootstrap-5',#}
|
||||
width: '100%'
|
||||
});
|
||||
|
||||
|
||||
@ -9,7 +9,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content" class="app-content">
|
||||
<div class="container">
|
||||
<ul class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{% url 'core:dashboard' %}">Dashboard</a></li>
|
||||
@ -269,7 +268,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Add to Waitlist Modal -->
|
||||
<div class="modal fade" id="addToWaitlistModal" tabindex="-1">
|
||||
|
||||
@ -13,8 +13,14 @@ urlpatterns = [
|
||||
path('requests/', views.AppointmentRequestListView.as_view(), name='appointment_list'),
|
||||
path('<int:pk>/requests/create/', views.AppointmentRequestCreateView.as_view(), name='appointment_create'),
|
||||
path('requests/<int:pk>/detail/', views.AppointmentRequestDetailView.as_view(), name='appointment_detail'),
|
||||
path('calendar/', views.SchedulingCalendarView.as_view(), name='scheduling_calendar'),
|
||||
path('queue/', views.QueueManagementView.as_view(), name='queue_management'),
|
||||
path('stats/', views.appointment_stats, name='appointment_stats'),
|
||||
|
||||
# Calendar
|
||||
path("calendar/", views.calendar_view, name="calendar"),
|
||||
path("calendar/events/", views.calendar_events, name="calendar_events"),
|
||||
path("<int:pk>/detail-card/", views.appointment_detail_card, name="appointment_detail_card"),
|
||||
# path('calendar/appointments/', views.calendar_appointments, name='calendar_appointments'),
|
||||
|
||||
|
||||
# Telemedicine
|
||||
path('telemedicine/', views.TelemedicineView.as_view(), name='telemedicine'),
|
||||
@ -25,19 +31,18 @@ urlpatterns = [
|
||||
path('telemedicine/<int:pk>/end/', views.end_telemedicine_session, name='stop_telemedicine_session'),
|
||||
path('telemedicine/<int:pk>/cancel/', views.cancel_telemedicine_session, name='cancel_telemedicine_session'),
|
||||
|
||||
# HTMX endpoints
|
||||
path('search/', views.appointment_search, name='appointment_search'),
|
||||
path('stats/', views.appointment_stats, name='appointment_stats'),
|
||||
path('calendar/appointments/', views.calendar_appointments, name='calendar_appointments'),
|
||||
path('slots/available/', views.available_slots, name='available_slots'),
|
||||
path('queue/<int:queue_id>/status/', views.queue_status, name='queue_status'),
|
||||
|
||||
|
||||
# Actions
|
||||
path('check-in/<int:appointment_id>/', views.check_in_patient, name='check_in_patient'),
|
||||
path('complete/<int:pk>/', views.complete_appointment, name='complete_appointment'),
|
||||
path('reschedule/<int:pk>/', views.reschedule_appointment, name='reschedule_appointment'),
|
||||
path('cancel/<int:pk>/', views.cancel_appointment, name='cancel_appointment'),
|
||||
path('search/', views.appointment_search, name='appointment_search'),
|
||||
|
||||
path('queue/<int:queue_id>/call-next/', views.call_next_patient, name='call_next_patient'),
|
||||
|
||||
path('queue/list/', views.WaitingQueueListView.as_view(), name='waiting_queue_list'),
|
||||
# Queue management
|
||||
# path('queue/', views.QueueManagementView.as_view(), name='queue_management'),
|
||||
path('queue/', views.WaitingQueueListView.as_view(), name='waiting_queue_list'),
|
||||
path('queue/create/', views.WaitingQueueCreateView.as_view(), name='waiting_queue_create'),
|
||||
path('queue/<int:pk>/', views.WaitingQueueDetailView.as_view(), name='waiting_queue_detail'),
|
||||
path('queue/<int:pk>/update/', views.WaitingQueueUpdateView.as_view(), name='waiting_queue_update'),
|
||||
@ -46,17 +51,18 @@ urlpatterns = [
|
||||
path('queue/entry/list/', views.QueueEntryListView.as_view(), name='queue_entry_list'),
|
||||
path('queue/entry/<int:pk>/', views.QueueEntryDetailView.as_view(), name='queue_entry_detail'),
|
||||
path('queue/entry/<int:pk>/update/', views.QueueEntryUpdateView.as_view(), name='queue_entry_update'),
|
||||
path('queue/<int:queue_id>/call-next/', views.next_in_queue, name='call_next_patient'),
|
||||
path('queue/<int:queue_id>/status/', views.queue_status, name='queue_status'),
|
||||
|
||||
|
||||
|
||||
path('complete/<int:appointment_id>/', views.complete_appointment, name='complete_appointment'),
|
||||
path('reschedule/<int:pk>/', views.reschedule_appointment, name='reschedule_appointment'),
|
||||
path('cancel/<int:appointment_id>/', views.cancel_appointment, name='cancel_appointment'),
|
||||
|
||||
path('slots/',views.SlotAvailabilityListView.as_view(), name='slot_list'),
|
||||
path('slots/<int:pk>/',views.SlotAvailabilityDetailView.as_view(), name='slot_detail'),
|
||||
path('slots/create/',views.SlotAvailabilityCreateView.as_view(), name='slot_create'),
|
||||
path('slots/<int:pk>/update/',views.SlotAvailabilityUpdateView.as_view(), name='slot_update'),
|
||||
path('slots/<int:pk>/delete/',views.SlotAvailabilityDeleteView.as_view(), name='slot_delete'),
|
||||
path('slots/available/', views.available_slots, name='available_slots'),
|
||||
|
||||
path('templates/', views.AppointmentTemplateListView.as_view(), name='appointment_template_list'),
|
||||
path('templates/<int:pk>/', views.AppointmentTemplateDetailView.as_view(), name='appointment_template_detail'),
|
||||
@ -76,6 +82,6 @@ urlpatterns = [
|
||||
path('waiting-list/stats/', views.waiting_list_stats, name='waiting_list_stats'),
|
||||
|
||||
# API endpoints
|
||||
# path('api/', include('appointments.api.urls')),
|
||||
path('api/', include('appointments.api.urls')),
|
||||
]
|
||||
|
||||
|
||||
@ -1,14 +1,17 @@
|
||||
"""
|
||||
Appointments app views for hospital management system with comprehensive CRUD operations.
|
||||
"""
|
||||
|
||||
from django.contrib.messages import success
|
||||
from django.shortcuts import render, get_object_or_404, redirect
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.decorators import login_required, permission_required
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
|
||||
from django.template.defaulttags import csrf_token
|
||||
from django.utils.dateparse import parse_datetime
|
||||
from django.views.decorators.http import require_GET, require_POST
|
||||
from django.views.generic import (
|
||||
TemplateView, ListView, DetailView, CreateView, UpdateView, DeleteView
|
||||
)
|
||||
from django.http import JsonResponse
|
||||
from django.http import JsonResponse, HttpResponseBadRequest
|
||||
from django.contrib import messages
|
||||
from django.db.models.functions import Now
|
||||
from django.db.models import Q, Count, Avg, Case, When, Value, DurationField, FloatField, F, ExpressionWrapper, IntegerField
|
||||
@ -53,14 +56,17 @@ class AppointmentDashboardView(LoginRequiredMixin, TemplateView):
|
||||
|
||||
# Statistics
|
||||
context['stats'] = {
|
||||
'total_appointments': AppointmentRequest.objects.filter(
|
||||
tenant=tenant,
|
||||
).count(),
|
||||
'total_appointments_today': AppointmentRequest.objects.filter(
|
||||
tenant=tenant,
|
||||
scheduled_datetime__date=today
|
||||
).count(),
|
||||
'confirmed_appointments': AppointmentRequest.objects.filter(
|
||||
'pending_appointments': AppointmentRequest.objects.filter(
|
||||
tenant=tenant,
|
||||
scheduled_datetime__date=today,
|
||||
status='CONFIRMED'
|
||||
status='PENDING'
|
||||
).count(),
|
||||
'active_queues_count': WaitingQueue.objects.filter(
|
||||
tenant=tenant,
|
||||
@ -179,7 +185,7 @@ class AppointmentRequestDetailView(LoginRequiredMixin, DetailView):
|
||||
return context
|
||||
|
||||
|
||||
class AppointmentRequestCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateView):
|
||||
class AppointmentRequestCreateView(LoginRequiredMixin, CreateView):
|
||||
"""
|
||||
Create new appointment request.
|
||||
"""
|
||||
@ -239,7 +245,7 @@ class AppointmentRequestCreateView(LoginRequiredMixin, PermissionRequiredMixin,
|
||||
return response
|
||||
|
||||
|
||||
class AppointmentRequestUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView):
|
||||
class AppointmentRequestUpdateView(LoginRequiredMixin, UpdateView):
|
||||
"""
|
||||
Update appointment request (limited fields after scheduling).
|
||||
"""
|
||||
@ -281,7 +287,7 @@ class AppointmentRequestUpdateView(LoginRequiredMixin, PermissionRequiredMixin,
|
||||
return response
|
||||
|
||||
|
||||
class AppointmentRequestDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteView):
|
||||
class AppointmentRequestDeleteView(LoginRequiredMixin, DeleteView):
|
||||
"""
|
||||
Cancel appointment request.
|
||||
"""
|
||||
@ -394,7 +400,7 @@ class SlotAvailabilityDetailView(LoginRequiredMixin, DetailView):
|
||||
return context
|
||||
|
||||
|
||||
class SlotAvailabilityCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateView):
|
||||
class SlotAvailabilityCreateView(LoginRequiredMixin, CreateView):
|
||||
"""
|
||||
Create new slot availability.
|
||||
"""
|
||||
@ -428,7 +434,7 @@ class SlotAvailabilityCreateView(LoginRequiredMixin, PermissionRequiredMixin, Cr
|
||||
return response
|
||||
|
||||
|
||||
class SlotAvailabilityUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView):
|
||||
class SlotAvailabilityUpdateView(LoginRequiredMixin, UpdateView):
|
||||
"""
|
||||
Update slot availability.
|
||||
"""
|
||||
@ -470,7 +476,7 @@ class SlotAvailabilityUpdateView(LoginRequiredMixin, PermissionRequiredMixin, Up
|
||||
return response
|
||||
|
||||
|
||||
class SlotAvailabilityDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteView):
|
||||
class SlotAvailabilityDeleteView(LoginRequiredMixin, DeleteView):
|
||||
"""
|
||||
Delete slot availability.
|
||||
"""
|
||||
@ -599,6 +605,8 @@ class WaitingQueueDetailView(LoginRequiredMixin, DetailView):
|
||||
'total_entries': QueueEntry.objects.filter(queue=queue).count(),
|
||||
'waiting_entries': QueueEntry.objects.filter(queue=queue, status='WAITING').count(),
|
||||
'in_progress_entries': QueueEntry.objects.filter(queue=queue, status='IN_PROGRESS').count(),
|
||||
'served_today': QueueEntry.objects.filter(queue=queue, status='COMPLETED').count(),
|
||||
'no_show_entries': QueueEntry.objects.filter(queue=queue, status='NO_SHOW').count(),
|
||||
# 'average_wait_time': QueueEntry.objects.filter(
|
||||
# queue=queue,
|
||||
# status='COMPLETED'
|
||||
@ -608,7 +616,7 @@ class WaitingQueueDetailView(LoginRequiredMixin, DetailView):
|
||||
return context
|
||||
|
||||
|
||||
class WaitingQueueCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateView):
|
||||
class WaitingQueueCreateView(LoginRequiredMixin, CreateView):
|
||||
"""
|
||||
Create new waiting queue.
|
||||
"""
|
||||
@ -644,7 +652,7 @@ class WaitingQueueCreateView(LoginRequiredMixin, PermissionRequiredMixin, Create
|
||||
return response
|
||||
|
||||
|
||||
class WaitingQueueUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView):
|
||||
class WaitingQueueUpdateView(LoginRequiredMixin, UpdateView):
|
||||
"""
|
||||
Update waiting queue.
|
||||
"""
|
||||
@ -686,7 +694,7 @@ class WaitingQueueUpdateView(LoginRequiredMixin, PermissionRequiredMixin, Update
|
||||
return response
|
||||
|
||||
|
||||
class WaitingQueueDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteView):
|
||||
class WaitingQueueDeleteView(LoginRequiredMixin, DeleteView):
|
||||
"""
|
||||
Delete waiting queue.
|
||||
"""
|
||||
@ -796,7 +804,7 @@ class QueueEntryDetailView(LoginRequiredMixin, DetailView):
|
||||
return QueueEntry.objects.filter(queue__tenant=tenant)
|
||||
|
||||
|
||||
class QueueEntryCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateView):
|
||||
class QueueEntryCreateView(LoginRequiredMixin, CreateView):
|
||||
"""
|
||||
Create new queue entry.
|
||||
"""
|
||||
@ -836,7 +844,7 @@ class QueueEntryCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateVi
|
||||
return response
|
||||
|
||||
|
||||
class QueueEntryUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView):
|
||||
class QueueEntryUpdateView(LoginRequiredMixin, UpdateView):
|
||||
"""
|
||||
Update queue entry.
|
||||
"""
|
||||
@ -883,7 +891,7 @@ class TelemedicineSessionListView(LoginRequiredMixin, ListView):
|
||||
List telemedicine sessions.
|
||||
"""
|
||||
model = TelemedicineSession
|
||||
template_name = 'appointments/telemedicine_session_list.html'
|
||||
template_name = 'appointments/telemedicine/telemedicine.html'
|
||||
context_object_name = 'sessions'
|
||||
paginate_by = 25
|
||||
|
||||
@ -941,13 +949,13 @@ class TelemedicineSessionDetailView(LoginRequiredMixin, DetailView):
|
||||
return TelemedicineSession.objects.filter(appointment__tenant=tenant)
|
||||
|
||||
|
||||
class TelemedicineSessionCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateView):
|
||||
class TelemedicineSessionCreateView(LoginRequiredMixin, CreateView):
|
||||
"""
|
||||
Create new telemedicine session.
|
||||
"""
|
||||
model = TelemedicineSession
|
||||
form_class = TelemedicineSessionForm
|
||||
template_name = 'appointments/telemedicine_session_form.html'
|
||||
template_name = 'appointments/telemedicine/telemedicine_session_form.html'
|
||||
permission_required = 'appointments.add_telemedicinesession'
|
||||
success_url = reverse_lazy('appointments:telemedicine_session_list')
|
||||
|
||||
@ -975,13 +983,13 @@ class TelemedicineSessionCreateView(LoginRequiredMixin, PermissionRequiredMixin,
|
||||
return response
|
||||
|
||||
|
||||
class TelemedicineSessionUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView):
|
||||
class TelemedicineSessionUpdateView(LoginRequiredMixin, UpdateView):
|
||||
"""
|
||||
Update telemedicine session.
|
||||
"""
|
||||
model = TelemedicineSession
|
||||
form_class = TelemedicineSessionForm
|
||||
template_name = 'appointments/telemedicine_session_form.html'
|
||||
template_name = 'appointments/telemedicine/telemedicine_session_form.html'
|
||||
permission_required = 'appointments.change_telemedicinesession'
|
||||
|
||||
def get_queryset(self):
|
||||
@ -1096,13 +1104,13 @@ class AppointmentTemplateDetailView(LoginRequiredMixin, DetailView):
|
||||
return AppointmentTemplate.objects.filter(tenant=tenant)
|
||||
|
||||
|
||||
class AppointmentTemplateCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateView):
|
||||
class AppointmentTemplateCreateView(LoginRequiredMixin, CreateView):
|
||||
"""
|
||||
Create new appointment template.
|
||||
"""
|
||||
model = AppointmentTemplate
|
||||
form_class = AppointmentTemplateForm
|
||||
template_name = 'appointments/appointment_template_form.html'
|
||||
template_name = 'appointments/templates/appointment_template_form.html'
|
||||
permission_required = 'appointments.add_appointmenttemplate'
|
||||
success_url = reverse_lazy('appointments:appointment_template_list')
|
||||
|
||||
@ -1132,7 +1140,7 @@ class AppointmentTemplateCreateView(LoginRequiredMixin, PermissionRequiredMixin,
|
||||
return response
|
||||
|
||||
|
||||
class AppointmentTemplateUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView):
|
||||
class AppointmentTemplateUpdateView(LoginRequiredMixin, UpdateView):
|
||||
"""
|
||||
Update appointment template.
|
||||
"""
|
||||
@ -1174,7 +1182,7 @@ class AppointmentTemplateUpdateView(LoginRequiredMixin, PermissionRequiredMixin,
|
||||
return response
|
||||
|
||||
|
||||
class AppointmentTemplateDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteView):
|
||||
class AppointmentTemplateDeleteView(LoginRequiredMixin, DeleteView):
|
||||
"""
|
||||
Delete appointment template.
|
||||
"""
|
||||
@ -1292,7 +1300,7 @@ class WaitingListDetailView(LoginRequiredMixin, DetailView):
|
||||
|
||||
def get_queryset(self):
|
||||
return WaitingList.objects.filter(
|
||||
tenant=getattr(self.request.user, 'current_tenant', None)
|
||||
tenant=getattr(self.request.user, 'tenant', None)
|
||||
).select_related(
|
||||
'patient', 'department', 'provider', 'scheduled_appointment',
|
||||
'created_by', 'removed_by'
|
||||
@ -1325,14 +1333,13 @@ class WaitingListCreateView(LoginRequiredMixin, CreateView):
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs['tenant'] = getattr(self.request.user, 'current_tenant', None)
|
||||
kwargs['tenant'] = getattr(self.request.user, 'tenant', None)
|
||||
return kwargs
|
||||
|
||||
def form_valid(self, form):
|
||||
form.instance.tenant = getattr(self.request.user, 'current_tenant', None)
|
||||
form.instance.tenant = getattr(self.request.user, 'tenant', None)
|
||||
form.instance.created_by = self.request.user
|
||||
|
||||
# Calculate initial position and estimated wait time
|
||||
response = super().form_valid(form)
|
||||
self.object.update_position()
|
||||
self.object.estimated_wait_time = self.object.estimate_wait_time()
|
||||
@ -1355,13 +1362,14 @@ class WaitingListUpdateView(LoginRequiredMixin, UpdateView):
|
||||
template_name = 'appointments/waiting_list/waiting_list_form.html'
|
||||
|
||||
def get_queryset(self):
|
||||
tenant = self.request.user.tenant
|
||||
return WaitingList.objects.filter(
|
||||
tenant=getattr(self.request.user, 'current_tenant', None)
|
||||
tenant=tenant
|
||||
)
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs['tenant'] = getattr(self.request.user, 'current_tenant', None)
|
||||
kwargs['tenant'] = getattr(self.request.user, 'tenant', None)
|
||||
return kwargs
|
||||
|
||||
def get_success_url(self):
|
||||
@ -1394,7 +1402,7 @@ class WaitingListDeleteView(LoginRequiredMixin, DeleteView):
|
||||
|
||||
def get_queryset(self):
|
||||
return WaitingList.objects.filter(
|
||||
tenant=getattr(self.request.user, 'current_tenant', None)
|
||||
tenant=getattr(self.request.user, 'tenant', None)
|
||||
)
|
||||
|
||||
def delete(self, request, *args, **kwargs):
|
||||
@ -1424,7 +1432,7 @@ def add_contact_log(request, pk):
|
||||
entry = get_object_or_404(
|
||||
WaitingList,
|
||||
pk=pk,
|
||||
tenant=getattr(request.user, 'current_tenant', None)
|
||||
tenant=getattr(request.user, 'tenant', None)
|
||||
)
|
||||
|
||||
if request.method == 'POST':
|
||||
@ -1473,7 +1481,7 @@ def waiting_list_bulk_action(request):
|
||||
if request.method == 'POST':
|
||||
form = WaitingListBulkActionForm(
|
||||
request.POST,
|
||||
tenant=getattr(request.user, 'current_tenant', None)
|
||||
tenant=getattr(request.user, 'tenant', None)
|
||||
)
|
||||
|
||||
if form.is_valid():
|
||||
@ -1486,7 +1494,7 @@ def waiting_list_bulk_action(request):
|
||||
|
||||
entries = WaitingList.objects.filter(
|
||||
id__in=entry_ids,
|
||||
tenant=getattr(request.user, 'current_tenant', None)
|
||||
tenant=getattr(request.user, 'tenant', None)
|
||||
)
|
||||
|
||||
if action == 'contact':
|
||||
@ -1533,7 +1541,7 @@ def waiting_list_stats(request):
|
||||
"""
|
||||
HTMX endpoint for waiting list statistics.
|
||||
"""
|
||||
tenant = getattr(request.user, 'current_tenant', None)
|
||||
tenant = getattr(request.user, 'tenant', None)
|
||||
if not tenant:
|
||||
return JsonResponse({'error': 'No tenant'})
|
||||
|
||||
@ -1546,9 +1554,9 @@ def waiting_list_stats(request):
|
||||
'scheduled': waiting_list.filter(status='SCHEDULED').count(),
|
||||
'urgent': waiting_list.filter(priority__in=['URGENT', 'STAT', 'EMERGENCY']).count(),
|
||||
'overdue_contact': sum(1 for entry in waiting_list.filter(status='ACTIVE') if entry.is_overdue_contact),
|
||||
'avg_wait_days': int(waiting_list.aggregate(
|
||||
avg_days=Avg(timezone.now().date() - F('created_at__date'))
|
||||
)['avg_days'] or 0),
|
||||
# 'avg_wait_days': int(waiting_list.aggregate(
|
||||
# avg_days=Avg(timezone.now().date() - F('created_at__date'))
|
||||
# )['avg_days'] or 0),
|
||||
}
|
||||
|
||||
return JsonResponse(stats)
|
||||
@ -1595,14 +1603,17 @@ def appointment_stats(request):
|
||||
|
||||
# Calculate appointment statistics
|
||||
stats = {
|
||||
'total_appointments': AppointmentRequest.objects.filter(
|
||||
tenant=tenant,
|
||||
).count(),
|
||||
'total_appointments_today': AppointmentRequest.objects.filter(
|
||||
tenant=tenant,
|
||||
scheduled_datetime__date=today
|
||||
).count(),
|
||||
'confirmed_appointments': AppointmentRequest.objects.filter(
|
||||
'pending_appointments': AppointmentRequest.objects.filter(
|
||||
tenant=tenant,
|
||||
scheduled_datetime__date=today,
|
||||
status='CONFIRMED'
|
||||
status='PENDING'
|
||||
).count(),
|
||||
'completed_appointments': AppointmentRequest.objects.filter(
|
||||
tenant=tenant,
|
||||
@ -1649,22 +1660,16 @@ def available_slots(request):
|
||||
selected_date = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
return render(request, 'appointments/partials/available_slots.html', status=400)
|
||||
print(selected_date)
|
||||
provider = get_object_or_404(User, pk=provider_id)
|
||||
print(provider)
|
||||
slots = SlotAvailability.objects.filter(
|
||||
provider=provider,
|
||||
provider__tenant=tenant,
|
||||
date=selected_date,
|
||||
).order_by('start_time')
|
||||
if slots:
|
||||
print(slots)
|
||||
else:
|
||||
print('no slots')
|
||||
# current_excluded = qs.exclude(pk=exclude_id)
|
||||
# print(current_excluded)
|
||||
|
||||
return render(request, 'appointments/partials/available_slots.html', {'slots': slots}, status=200)
|
||||
current_excluded = slots.exclude(pk=exclude_id)
|
||||
|
||||
return render(request, 'appointments/partials/available_slots.html', {'slots': current_excluded}, status=200)
|
||||
# def available_slots(request):
|
||||
# """
|
||||
# HTMX view for available slots.
|
||||
@ -1846,10 +1851,10 @@ def confirm_appointment(request, pk):
|
||||
|
||||
@login_required
|
||||
def reschedule_appointment(request, pk):
|
||||
tenant = getattr(request.user, 'tenant', None)
|
||||
tenant = request.user.tenant
|
||||
appointment = get_object_or_404(AppointmentRequest, pk=pk, tenant=tenant)
|
||||
providers = User.objects.filter(
|
||||
tenant=tenant, role__in=['PHYSICIAN', 'NURSE', 'NURSE_PRACTITIONER', 'PHYSICIAN_ASSISTANT']
|
||||
tenant=tenant, employee_profile__role__in=['PHYSICIAN', 'NURSE', 'NURSE_PRACTITIONER', 'PHYSICIAN_ASSISTANT']
|
||||
).order_by('last_name', 'first_name')
|
||||
|
||||
if request.method == 'POST':
|
||||
@ -1884,6 +1889,18 @@ def reschedule_appointment(request, pk):
|
||||
# optionally send notifications if notify_patient is True
|
||||
|
||||
messages.success(request, 'Appointment has been rescheduled.')
|
||||
|
||||
AuditLogger.log_event(
|
||||
tenant=tenant,
|
||||
event_type='UPDATE',
|
||||
event_category='APPOINTMENT_MANAGEMENT',
|
||||
action='Reschedule Appointment',
|
||||
description=f'Rescheduled appointment: {appointment.patient} with {appointment.provider}',
|
||||
user=request.user,
|
||||
content_object=appointment,
|
||||
request=request
|
||||
)
|
||||
|
||||
return redirect('appointments:appointment_detail', pk=appointment.pk)
|
||||
|
||||
return render(request, 'appointments/reschedule_appointment.html', {
|
||||
@ -1922,13 +1939,13 @@ def cancel_appointment(request, pk):
|
||||
"""
|
||||
Complete an appointment.
|
||||
"""
|
||||
tenant = getattr(request, 'tenant', None)
|
||||
tenant = request.user.tenant
|
||||
if not tenant:
|
||||
messages.error(request, 'No tenant found.')
|
||||
return redirect('appointments:appointment_request_list')
|
||||
|
||||
appointment = get_object_or_404(AppointmentRequest, pk=pk, tenant=tenant)
|
||||
|
||||
if appointment.status == 'SCHEDULED':
|
||||
appointment.status = 'CANCELLED'
|
||||
# appointment.actual_end_time = timezone.now()
|
||||
appointment.save()
|
||||
@ -1946,7 +1963,11 @@ def cancel_appointment(request, pk):
|
||||
)
|
||||
|
||||
messages.success(request, f'Appointment for {appointment.patient} cancelled successfully.')
|
||||
return redirect('appointments:appointment_request_detail', pk=pk)
|
||||
return redirect('appointments:appointment_detail', pk=pk)
|
||||
|
||||
return render(request, 'appointments/cancel_appointment.html', {
|
||||
'appointment': appointment,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@ -2028,13 +2049,13 @@ def next_in_queue(request, queue_id):
|
||||
next_entry = QueueEntry.objects.filter(
|
||||
queue=queue,
|
||||
status='WAITING'
|
||||
).order_by('position', 'created_at').first()
|
||||
).order_by('queue_position', 'called_at').first()
|
||||
|
||||
if next_entry:
|
||||
next_entry.status = 'IN_PROGRESS'
|
||||
next_entry.status = 'IN_SERVICE'
|
||||
next_entry.called_at = timezone.now()
|
||||
next_entry.save()
|
||||
|
||||
messages.success(request, f"Patient has been called in for appointment.")
|
||||
# Log queue progression
|
||||
AuditLogger.log_event(
|
||||
tenant=tenant,
|
||||
@ -2047,13 +2068,10 @@ def next_in_queue(request, queue_id):
|
||||
request=request
|
||||
)
|
||||
|
||||
return JsonResponse({
|
||||
'status': 'success',
|
||||
'patient': str(next_entry.patient),
|
||||
'position': next_entry.queue_position
|
||||
})
|
||||
return redirect('appointments:waiting_queue_detail', pk=queue.pk)
|
||||
else:
|
||||
return JsonResponse({'status': 'no_patients'})
|
||||
messages.error(request, f"No more patients in queue.")
|
||||
return redirect('appointments:waiting_queue_detail', pk=queue.pk)
|
||||
|
||||
|
||||
@login_required
|
||||
@ -2070,17 +2088,7 @@ def check_in_patient(request, appointment_id):
|
||||
appointment.save()
|
||||
|
||||
messages.success(request, f"Patient {appointment.patient} has been checked in.")
|
||||
return redirect('appointments:queue_management')
|
||||
|
||||
|
||||
@login_required
|
||||
def call_next_patient(request, queue_id):
|
||||
"""
|
||||
Call the next patient in the queue.
|
||||
"""
|
||||
# Mock implementation - in real system, this would manage actual queue
|
||||
messages.success(request, 'Next patient has been called.')
|
||||
return redirect('appointments:queue_management')
|
||||
return redirect('appointments:waiting_queue_list')
|
||||
|
||||
|
||||
@login_required
|
||||
@ -2107,6 +2115,7 @@ def complete_queue_entry(request, pk):
|
||||
queue_entry.actual_wait_time_minutes = int(wait_time)
|
||||
|
||||
queue_entry.save()
|
||||
messages.success(request, f"Queue entry {queue_entry.pk} completed successfully.")
|
||||
|
||||
# Log completion
|
||||
AuditLogger.log_event(
|
||||
@ -2120,7 +2129,7 @@ def complete_queue_entry(request, pk):
|
||||
request=request
|
||||
)
|
||||
|
||||
return JsonResponse({'status': 'completed'})
|
||||
return redirect('appointments:waiting_queue_detail', pk=queue_entry.queue.pk)
|
||||
|
||||
|
||||
@login_required
|
||||
@ -2263,18 +2272,21 @@ def cancel_telemedicine_session(request, pk):
|
||||
# )
|
||||
|
||||
|
||||
class SchedulingCalendarView(LoginRequiredMixin, TemplateView):
|
||||
class SchedulingCalendarView(LoginRequiredMixin, ListView):
|
||||
"""
|
||||
Calendar view for scheduling appointments.
|
||||
"""
|
||||
model = AppointmentRequest
|
||||
template_name = 'appointments/scheduling_calendar.html'
|
||||
context_object_name = 'appointments'
|
||||
paginate_by = 20
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['appointments'] = AppointmentRequest.objects.filter(
|
||||
tenant=self.request.user.tenant,
|
||||
status='SCHEDULED'
|
||||
)
|
||||
).select_related('patient', 'provider').order_by('-scheduled_datetime')
|
||||
return context
|
||||
|
||||
|
||||
@ -2327,6 +2339,126 @@ class TelemedicineView(LoginRequiredMixin, ListView):
|
||||
|
||||
|
||||
|
||||
@login_required
|
||||
def calendar_view(request):
|
||||
"""Renders the calendar page"""
|
||||
return render(request, "appointments/calendar.html")
|
||||
|
||||
@login_required
|
||||
@require_GET
|
||||
def calendar_events(request):
|
||||
"""
|
||||
FullCalendar event feed (GET /calendar/events?start=..&end=..[&provider_id=&status=...])
|
||||
FullCalendar sends ISO timestamps; we return a list of event dicts.
|
||||
"""
|
||||
STATUS_COLORS = {
|
||||
"PENDING": {"bg": "#f59c1a", "border": "#d08916"},
|
||||
"CONFIRMED": {"bg": "#49b6d6", "border": "#3f9db9"},
|
||||
"CHECKED_IN": {"bg": "#348fe2", "border": "#2c79bf"},
|
||||
"IN_PROGRESS": {"bg": "#00acac", "border": "#009494"},
|
||||
"COMPLETED": {"bg": "#32a932", "border": "#298a29"},
|
||||
"CANCELLED": {"bg": "#ff5b57", "border": "#d64d4a"},
|
||||
"NO_SHOW": {"bg": "#6c757d", "border": "#5a636b"},
|
||||
}
|
||||
|
||||
tenant = request.user.tenant
|
||||
if not tenant:
|
||||
return JsonResponse([], safe=False)
|
||||
|
||||
start = request.GET.get("start")
|
||||
end = request.GET.get("end")
|
||||
provider_id = request.GET.get("provider_id")
|
||||
status = request.GET.get("status")
|
||||
|
||||
if not start or not end:
|
||||
return HttpResponseBadRequest("Missing start/end")
|
||||
|
||||
# Parse (FullCalendar uses ISO 8601)
|
||||
# They can include timezone; parse_datetime handles offsets.
|
||||
start_dt = parse_datetime(start)
|
||||
end_dt = parse_datetime(end)
|
||||
if not start_dt or not end_dt:
|
||||
return HttpResponseBadRequest("Invalid start/end")
|
||||
|
||||
qs = AppointmentRequest.objects.filter(
|
||||
tenant=tenant,
|
||||
scheduled_datetime__gte=start_dt,
|
||||
scheduled_datetime__lt=end_dt,
|
||||
).select_related("patient", "provider")
|
||||
|
||||
if provider_id:
|
||||
qs = qs.filter(provider_id=provider_id)
|
||||
if status:
|
||||
qs = qs.filter(status=status)
|
||||
|
||||
events = []
|
||||
for appt in qs:
|
||||
color = STATUS_COLORS.get(appt.status, {"bg": "#495057", "border": "#3e444a"})
|
||||
title = f"{appt.patient.get_full_name()} • {appt.get_appointment_type_display()}"
|
||||
if appt.is_telemedicine:
|
||||
title = "📹 " + title
|
||||
|
||||
# If you store end time separately, use it; else estimate duration (e.g., 30 min)
|
||||
end_time = getattr(appt, "end_datetime", None)
|
||||
if not end_time:
|
||||
end_time = appt.scheduled_datetime + timedelta(minutes=getattr(appt, "duration_minutes", 30))
|
||||
|
||||
events.append({
|
||||
"id": str(appt.pk),
|
||||
"title": title,
|
||||
"start": appt.scheduled_datetime.isoformat(),
|
||||
"end": end_time.isoformat(),
|
||||
"backgroundColor": color["bg"],
|
||||
"borderColor": color["border"],
|
||||
"textColor": "#fff",
|
||||
"extendedProps": {
|
||||
"status": appt.status,
|
||||
"provider": appt.provider.get_full_name() if appt.provider_id else "",
|
||||
"chief_complaint": (appt.chief_complaint or "")[:120],
|
||||
"telemedicine": appt.is_telemedicine,
|
||||
},
|
||||
})
|
||||
|
||||
return JsonResponse(events, safe=False)
|
||||
|
||||
@login_required
|
||||
def appointment_detail_card(request, pk):
|
||||
tenant = request.user.tenant
|
||||
"""HTMX partial with appointment quick details for the sidebar/modal."""
|
||||
appt = get_object_or_404(AppointmentRequest.objects.select_related("patient","provider"), pk=pk, tenant=tenant)
|
||||
return render(request, "appointments/partials/appointment_detail_card.html", {"appointment": appt})
|
||||
|
||||
@login_required
|
||||
@permission_required("appointments.change_appointment")
|
||||
@require_POST
|
||||
def appointment_reschedule(request, pk):
|
||||
"""
|
||||
Handle drag/drop or resize from FullCalendar.
|
||||
Expect JSON: {"start":"...", "end":"..."} ISO strings (local/offset).
|
||||
"""
|
||||
tenant = request.user.tenant
|
||||
appt = get_object_or_404(AppointmentRequest, pk=pk, tenant=tenant)
|
||||
try:
|
||||
data = request.POST if request.content_type == "application/x-www-form-urlencoded" else request.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
|
||||
start = data.get("start")
|
||||
end = data.get("end")
|
||||
start_dt = parse_datetime(start) if start else None
|
||||
end_dt = parse_datetime(end) if end else None
|
||||
if not start_dt or not end_dt:
|
||||
return HttpResponseBadRequest("Invalid start/end")
|
||||
|
||||
appt.scheduled_datetime = start_dt
|
||||
if hasattr(appt, "end_datetime"):
|
||||
appt.end_datetime = end_dt
|
||||
elif hasattr(appt, "duration_minutes"):
|
||||
appt.duration_minutes = int((end_dt - start_dt).total_seconds() // 60)
|
||||
appt.save(update_fields=["scheduled_datetime"] + (["end_datetime"] if hasattr(appt,"end_datetime") else ["duration_minutes"]))
|
||||
|
||||
return JsonResponse({"ok": True})
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
@ -429,7 +429,7 @@ class InsuranceClaimAdmin(admin.ModelAdmin):
|
||||
else:
|
||||
color = 'red'
|
||||
return format_html(
|
||||
'<span style="color: {};">{:.1f}%</span>',
|
||||
'<span style="color: {};">{}%</span>',
|
||||
color, percentage
|
||||
)
|
||||
payment_percentage_display.short_description = 'Paid %'
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.6 on 2025-09-08 07:28
|
||||
# Generated by Django 5.2.6 on 2025-09-15 14:05
|
||||
|
||||
import billing.utils
|
||||
import django.db.models.deletion
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.6 on 2025-09-08 07:28
|
||||
# Generated by Django 5.2.6 on 2025-09-15 14:05
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -4,12 +4,11 @@
|
||||
{% block title %}Export Bills{% endblock %}
|
||||
|
||||
{% block 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" />
|
||||
<link href="{% static 'plugins/datatables.net-bs5/css/dataTables.bootstrap5.min.css' %}" rel="stylesheet" />
|
||||
<link href="{% static 'plugins/datatables.net-responsive-bs5/css/responsive.bootstrap5.min.css' %}" rel="stylesheet" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content" class="app-content">
|
||||
<div class="container">
|
||||
<ul class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{% url 'core:dashboard' %}">Dashboard</a></li>
|
||||
@ -289,12 +288,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block 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 'plugins/datatables.net/js/dataTables.min.js' %}"></script>
|
||||
<script src="{% static 'plugins/datatables.net-bs5/js/dataTables.bootstrap5.min.js' %}"></script>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
{% block title %}Submit Bill{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content" class="app-content">
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-xl-8">
|
||||
@ -270,7 +269,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
|
||||
@ -199,7 +199,7 @@ def create_saudi_medical_bills():
|
||||
users = list(User.objects.filter(is_active=True))
|
||||
provider_users = list(User.objects.filter(
|
||||
is_active=True,
|
||||
role__in=['PHYSICIAN', 'NURSE_PRACTITIONER', 'PHYSICIAN_ASSISTANT', 'RADIOLOGIST']
|
||||
employee_profile__role__in=['PHYSICIAN', 'NURSE_PRACTITIONER', 'PHYSICIAN_ASSISTANT', 'RADIOLOGIST']
|
||||
))
|
||||
|
||||
if not provider_users:
|
||||
@ -298,7 +298,7 @@ def create_saudi_bill_line_items(medical_bills):
|
||||
|
||||
provider_users = list(User.objects.filter(
|
||||
is_active=True,
|
||||
role__in=['PHYSICIAN', 'NURSE_PRACTITIONER', 'PHYSICIAN_ASSISTANT', 'RADIOLOGIST']
|
||||
employee_profile__role__in=['PHYSICIAN', 'NURSE_PRACTITIONER', 'PHYSICIAN_ASSISTANT', 'RADIOLOGIST']
|
||||
)) or list(User.objects.filter(is_active=True))
|
||||
|
||||
created_line_items = []
|
||||
|
||||
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