1366 lines
43 KiB
HTML
1366 lines
43 KiB
HTML
{% extends 'base.html' %}
|
|
{% load static %}
|
|
|
|
{% block title %}Slot Availability - Appointment Management{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<link href="{% static 'assets/plugins/fullcalendar/main.min.css' %}" rel="stylesheet" />
|
|
<link href="{% static 'assets/plugins/select2/css/select2.min.css' %}" rel="stylesheet" />
|
|
<style>
|
|
.availability-header {
|
|
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
|
border-radius: 0.5rem;
|
|
color: white;
|
|
margin-bottom: 2rem;
|
|
padding: 2rem;
|
|
}
|
|
|
|
.filter-panel {
|
|
background: white;
|
|
border-radius: 0.5rem;
|
|
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
|
margin-bottom: 2rem;
|
|
padding: 1.5rem;
|
|
}
|
|
|
|
.filter-grid {
|
|
display: grid;
|
|
gap: 1rem;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
}
|
|
|
|
.availability-grid {
|
|
display: grid;
|
|
gap: 2rem;
|
|
grid-template-columns: 1fr 300px;
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.calendar-container {
|
|
background: white;
|
|
border-radius: 0.5rem;
|
|
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
|
padding: 1.5rem;
|
|
}
|
|
|
|
.sidebar {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1.5rem;
|
|
}
|
|
|
|
.stats-card {
|
|
background: white;
|
|
border-radius: 0.5rem;
|
|
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
|
padding: 1.5rem;
|
|
}
|
|
|
|
.stats-header {
|
|
align-items: center;
|
|
border-bottom: 2px solid #f8f9fa;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
margin-bottom: 1rem;
|
|
padding-bottom: 1rem;
|
|
}
|
|
|
|
.stats-title {
|
|
color: #495057;
|
|
font-size: 1.1rem;
|
|
font-weight: 600;
|
|
margin: 0;
|
|
}
|
|
|
|
.stats-grid {
|
|
display: grid;
|
|
gap: 1rem;
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.stat-item {
|
|
align-items: center;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
padding: 0.75rem;
|
|
border-radius: 0.375rem;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.stat-item:hover {
|
|
background: #f8f9fa;
|
|
}
|
|
|
|
.stat-label {
|
|
color: #6c757d;
|
|
font-size: 0.9rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.stat-value {
|
|
color: #495057;
|
|
font-size: 1.25rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.stat-value.available {
|
|
color: #28a745;
|
|
}
|
|
|
|
.stat-value.booked {
|
|
color: #007bff;
|
|
}
|
|
|
|
.stat-value.blocked {
|
|
color: #dc3545;
|
|
}
|
|
|
|
.provider-list {
|
|
background: white;
|
|
border-radius: 0.5rem;
|
|
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
|
max-height: 400px;
|
|
overflow-y: auto;
|
|
padding: 1rem;
|
|
}
|
|
|
|
.provider-item {
|
|
align-items: center;
|
|
border-radius: 0.375rem;
|
|
cursor: pointer;
|
|
display: flex;
|
|
gap: 1rem;
|
|
margin-bottom: 0.75rem;
|
|
padding: 1rem;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.provider-item:hover {
|
|
background: #f8f9fa;
|
|
}
|
|
|
|
.provider-item.selected {
|
|
background: #e7f3ff;
|
|
border: 1px solid #007bff;
|
|
}
|
|
|
|
.provider-avatar {
|
|
align-items: center;
|
|
background: #007bff;
|
|
border-radius: 50%;
|
|
color: white;
|
|
display: flex;
|
|
font-size: 0.9rem;
|
|
font-weight: 600;
|
|
height: 40px;
|
|
justify-content: center;
|
|
width: 40px;
|
|
}
|
|
|
|
.provider-info {
|
|
flex: 1;
|
|
}
|
|
|
|
.provider-name {
|
|
color: #495057;
|
|
font-size: 0.95rem;
|
|
font-weight: 600;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.provider-specialty {
|
|
color: #6c757d;
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.availability-indicator {
|
|
align-items: center;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
}
|
|
|
|
.availability-count {
|
|
color: #495057;
|
|
font-size: 0.8rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.availability-bar {
|
|
background: #e9ecef;
|
|
border-radius: 2px;
|
|
height: 4px;
|
|
overflow: hidden;
|
|
width: 40px;
|
|
}
|
|
|
|
.availability-fill {
|
|
height: 100%;
|
|
transition: width 0.3s ease;
|
|
}
|
|
|
|
.availability-fill.high {
|
|
background: #28a745;
|
|
}
|
|
|
|
.availability-fill.medium {
|
|
background: #ffc107;
|
|
}
|
|
|
|
.availability-fill.low {
|
|
background: #dc3545;
|
|
}
|
|
|
|
.time-slots-view {
|
|
background: white;
|
|
border-radius: 0.5rem;
|
|
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
|
margin-bottom: 2rem;
|
|
padding: 1.5rem;
|
|
}
|
|
|
|
.time-grid {
|
|
display: grid;
|
|
gap: 0.5rem;
|
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
|
}
|
|
|
|
.time-slot {
|
|
align-items: center;
|
|
border: 2px solid #e9ecef;
|
|
border-radius: 0.375rem;
|
|
cursor: pointer;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
padding: 0.75rem;
|
|
text-align: center;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.time-slot:hover {
|
|
border-color: #007bff;
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.time-slot.available {
|
|
background: #d4edda;
|
|
border-color: #28a745;
|
|
color: #155724;
|
|
}
|
|
|
|
.time-slot.booked {
|
|
background: #cce5ff;
|
|
border-color: #007bff;
|
|
color: #004085;
|
|
}
|
|
|
|
.time-slot.blocked {
|
|
background: #f8d7da;
|
|
border-color: #dc3545;
|
|
color: #721c24;
|
|
}
|
|
|
|
.time-slot.partially-booked {
|
|
background: #fff3cd;
|
|
border-color: #ffc107;
|
|
color: #856404;
|
|
}
|
|
|
|
.slot-time {
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.slot-status {
|
|
font-size: 0.7rem;
|
|
font-weight: 500;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.slot-count {
|
|
font-size: 0.7rem;
|
|
opacity: 0.8;
|
|
}
|
|
|
|
.legend {
|
|
align-items: center;
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 1.5rem;
|
|
justify-content: center;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.legend-item {
|
|
align-items: center;
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.legend-color {
|
|
border-radius: 0.25rem;
|
|
height: 16px;
|
|
width: 16px;
|
|
}
|
|
|
|
.legend-color.available {
|
|
background: #d4edda;
|
|
border: 1px solid #28a745;
|
|
}
|
|
|
|
.legend-color.booked {
|
|
background: #cce5ff;
|
|
border: 1px solid #007bff;
|
|
}
|
|
|
|
.legend-color.blocked {
|
|
background: #f8d7da;
|
|
border: 1px solid #dc3545;
|
|
}
|
|
|
|
.legend-color.partially-booked {
|
|
background: #fff3cd;
|
|
border: 1px solid #ffc107;
|
|
}
|
|
|
|
.legend-label {
|
|
color: #495057;
|
|
font-size: 0.85rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.view-toggle {
|
|
align-items: center;
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.btn-toggle {
|
|
background: #e9ecef;
|
|
border: 1px solid #dee2e6;
|
|
color: #495057;
|
|
padding: 0.5rem 1rem;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.btn-toggle.active {
|
|
background: #007bff;
|
|
border-color: #007bff;
|
|
color: white;
|
|
}
|
|
|
|
.quick-actions {
|
|
background: #f8f9fa;
|
|
border-radius: 0.375rem;
|
|
margin-bottom: 1.5rem;
|
|
padding: 1rem;
|
|
}
|
|
|
|
.action-buttons {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.btn-action {
|
|
align-items: center;
|
|
display: flex;
|
|
font-size: 0.85rem;
|
|
gap: 0.5rem;
|
|
padding: 0.5rem 1rem;
|
|
}
|
|
|
|
.utilization-chart {
|
|
background: white;
|
|
border-radius: 0.5rem;
|
|
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
|
margin-bottom: 2rem;
|
|
padding: 1.5rem;
|
|
}
|
|
|
|
.chart-container {
|
|
height: 300px;
|
|
position: relative;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.availability-header {
|
|
padding: 1.5rem;
|
|
text-align: center;
|
|
}
|
|
|
|
.availability-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.filter-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.time-grid {
|
|
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
|
}
|
|
|
|
.legend {
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.view-toggle {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.action-buttons {
|
|
flex-direction: column;
|
|
}
|
|
}
|
|
|
|
.fade-in {
|
|
animation: fadeIn 0.6s ease-in;
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; transform: translateY(20px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
|
|
.loading-overlay {
|
|
align-items: center;
|
|
background: rgba(255, 255, 255, 0.9);
|
|
display: none;
|
|
height: 100%;
|
|
justify-content: center;
|
|
left: 0;
|
|
position: absolute;
|
|
top: 0;
|
|
width: 100%;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.loading-spinner {
|
|
align-items: center;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.spinner {
|
|
animation: spin 1s linear infinite;
|
|
border: 3px solid #f3f3f3;
|
|
border-radius: 50%;
|
|
border-top: 3px solid #007bff;
|
|
height: 40px;
|
|
width: 40px;
|
|
}
|
|
|
|
@keyframes spin {
|
|
0% { transform: rotate(0deg); }
|
|
100% { transform: rotate(360deg); }
|
|
}
|
|
|
|
.empty-state {
|
|
align-items: center;
|
|
color: #6c757d;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
padding: 3rem;
|
|
text-align: center;
|
|
}
|
|
|
|
.empty-icon {
|
|
font-size: 4rem;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.refresh-indicator {
|
|
align-items: center;
|
|
background: #e7f3ff;
|
|
border: 1px solid #b3d9ff;
|
|
border-radius: 0.375rem;
|
|
color: #0066cc;
|
|
display: none;
|
|
gap: 0.5rem;
|
|
margin-bottom: 1rem;
|
|
padding: 0.75rem;
|
|
}
|
|
|
|
.auto-refresh-toggle {
|
|
align-items: center;
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
margin-left: auto;
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container-fluid">
|
|
<!-- Page Header -->
|
|
<div class="availability-header fade-in">
|
|
<div class="row align-items-center">
|
|
<div class="col-lg-8">
|
|
<h1 class="mb-2">
|
|
<i class="fas fa-calendar-check me-3"></i>
|
|
Slot Availability Overview
|
|
</h1>
|
|
<p class="mb-3 opacity-90">
|
|
Real-time view of appointment slot availability across all providers and departments
|
|
</p>
|
|
<div class="d-flex flex-wrap gap-3">
|
|
<div class="d-flex align-items-center gap-2">
|
|
<i class="fas fa-calendar"></i>
|
|
<span id="currentDate">{{ today|date:"F d, Y" }}</span>
|
|
</div>
|
|
<div class="d-flex align-items-center gap-2">
|
|
<i class="fas fa-clock"></i>
|
|
<span id="currentTime">{{ now|time:"g:i A" }}</span>
|
|
</div>
|
|
<div class="d-flex align-items-center gap-2">
|
|
<i class="fas fa-users"></i>
|
|
<span>{{ total_providers }} Providers</span>
|
|
</div>
|
|
<div class="d-flex align-items-center gap-2">
|
|
<i class="fas fa-calendar-alt"></i>
|
|
<span>{{ total_slots }} Total Slots</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-lg-4 text-lg-end">
|
|
<div class="d-flex flex-column gap-2">
|
|
<div class="auto-refresh-toggle">
|
|
<label class="form-check-label" for="autoRefresh">Auto Refresh</label>
|
|
<div class="form-check form-switch">
|
|
<input class="form-check-input" type="checkbox" id="autoRefresh" checked>
|
|
</div>
|
|
</div>
|
|
<button class="btn btn-light" onclick="refreshData()">
|
|
<i class="fas fa-sync-alt me-2"></i>Refresh Now
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Refresh Indicator -->
|
|
<div class="refresh-indicator" id="refreshIndicator">
|
|
<i class="fas fa-sync-alt fa-spin"></i>
|
|
<span>Updating availability data...</span>
|
|
</div>
|
|
|
|
<!-- Filter Panel -->
|
|
<div class="filter-panel fade-in">
|
|
<div class="row align-items-end">
|
|
<div class="col-lg-10">
|
|
<div class="filter-grid">
|
|
<div class="form-group">
|
|
<label class="form-label">Date Range</label>
|
|
<select class="form-select" id="dateRange">
|
|
<option value="today">Today</option>
|
|
<option value="tomorrow">Tomorrow</option>
|
|
<option value="week" selected>This Week</option>
|
|
<option value="month">This Month</option>
|
|
<option value="custom">Custom Range</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">Department</label>
|
|
<select class="form-select" id="departmentFilter">
|
|
<option value="">All Departments</option>
|
|
{% for department in departments %}
|
|
<option value="{{ department.id }}">{{ department.name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">Provider</label>
|
|
<select class="form-select" id="providerFilter">
|
|
<option value="">All Providers</option>
|
|
{% for provider in providers %}
|
|
<option value="{{ provider.id }}">{{ provider.get_full_name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">Appointment Type</label>
|
|
<select class="form-select" id="typeFilter">
|
|
<option value="">All Types</option>
|
|
<option value="consultation">Consultation</option>
|
|
<option value="follow_up">Follow-up</option>
|
|
<option value="procedure">Procedure</option>
|
|
<option value="emergency">Emergency</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">Status</label>
|
|
<select class="form-select" id="statusFilter">
|
|
<option value="">All Statuses</option>
|
|
<option value="available">Available</option>
|
|
<option value="booked">Booked</option>
|
|
<option value="blocked">Blocked</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-lg-2">
|
|
<button class="btn btn-primary w-100" onclick="applyFilters()">
|
|
<i class="fas fa-filter me-2"></i>Apply Filters
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- View Toggle -->
|
|
<div class="view-toggle fade-in">
|
|
<label class="form-label me-3">View:</label>
|
|
<button class="btn btn-toggle active" data-view="calendar">
|
|
<i class="fas fa-calendar me-1"></i>Calendar
|
|
</button>
|
|
<button class="btn btn-toggle" data-view="grid">
|
|
<i class="fas fa-th me-1"></i>Grid
|
|
</button>
|
|
<button class="btn btn-toggle" data-view="list">
|
|
<i class="fas fa-list me-1"></i>List
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Main Content -->
|
|
<div class="availability-grid fade-in">
|
|
<!-- Calendar/Grid View -->
|
|
<div class="main-view">
|
|
<!-- Calendar View -->
|
|
<div class="calendar-container" id="calendarView">
|
|
<div class="loading-overlay" id="loadingOverlay">
|
|
<div class="loading-spinner">
|
|
<div class="spinner"></div>
|
|
<span>Loading availability data...</span>
|
|
</div>
|
|
</div>
|
|
<div id="calendar"></div>
|
|
</div>
|
|
|
|
<!-- Grid View -->
|
|
<div class="time-slots-view" id="gridView" style="display: none;">
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h5 class="mb-0">
|
|
<i class="fas fa-th me-2"></i>
|
|
Time Slots Grid
|
|
</h5>
|
|
<div class="d-flex gap-2">
|
|
<button class="btn btn-sm btn-outline-primary" onclick="previousDay()">
|
|
<i class="fas fa-chevron-left"></i>
|
|
</button>
|
|
<span id="gridDate" class="align-self-center px-3">{{ today|date:"M d, Y" }}</span>
|
|
<button class="btn btn-sm btn-outline-primary" onclick="nextDay()">
|
|
<i class="fas fa-chevron-right"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Legend -->
|
|
<div class="legend">
|
|
<div class="legend-item">
|
|
<div class="legend-color available"></div>
|
|
<span class="legend-label">Available</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="legend-color booked"></div>
|
|
<span class="legend-label">Booked</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="legend-color blocked"></div>
|
|
<span class="legend-label">Blocked</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="legend-color partially-booked"></div>
|
|
<span class="legend-label">Partially Booked</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="time-grid" id="timeGrid">
|
|
<!-- Time slots will be populated by JavaScript -->
|
|
</div>
|
|
</div>
|
|
|
|
<!-- List View -->
|
|
<div class="time-slots-view" id="listView" style="display: none;">
|
|
<h5 class="mb-3">
|
|
<i class="fas fa-list me-2"></i>
|
|
Detailed Slot List
|
|
</h5>
|
|
<div class="table-responsive">
|
|
<table class="table table-hover" id="slotsTable">
|
|
<thead>
|
|
<tr>
|
|
<th>Date</th>
|
|
<th>Time</th>
|
|
<th>Provider</th>
|
|
<th>Type</th>
|
|
<th>Status</th>
|
|
<th>Patient</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<!-- Table rows will be populated by JavaScript -->
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sidebar -->
|
|
<div class="sidebar">
|
|
<!-- Statistics Card -->
|
|
<div class="stats-card">
|
|
<div class="stats-header">
|
|
<h5 class="stats-title">
|
|
<i class="fas fa-chart-pie text-primary me-2"></i>
|
|
Availability Stats
|
|
</h5>
|
|
<button class="btn btn-sm btn-outline-secondary" onclick="refreshStats()">
|
|
<i class="fas fa-sync-alt"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="stats-grid">
|
|
<div class="stat-item">
|
|
<span class="stat-label">Available Slots</span>
|
|
<span class="stat-value available" id="availableCount">{{ stats.available|default:0 }}</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
<span class="stat-label">Booked Slots</span>
|
|
<span class="stat-value booked" id="bookedCount">{{ stats.booked|default:0 }}</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
<span class="stat-label">Blocked Slots</span>
|
|
<span class="stat-value blocked" id="blockedCount">{{ stats.blocked|default:0 }}</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
<span class="stat-label">Utilization Rate</span>
|
|
<span class="stat-value" id="utilizationRate">{{ stats.utilization|default:0 }}%</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
<span class="stat-label">No-Show Rate</span>
|
|
<span class="stat-value" id="noShowRate">{{ stats.no_show_rate|default:0 }}%</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
<span class="stat-label">Avg. Duration</span>
|
|
<span class="stat-value" id="avgDuration">{{ stats.avg_duration|default:30 }} min</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Provider List -->
|
|
<div class="stats-card">
|
|
<div class="stats-header">
|
|
<h5 class="stats-title">
|
|
<i class="fas fa-user-md text-success me-2"></i>
|
|
Providers
|
|
</h5>
|
|
<span class="badge bg-secondary">{{ providers|length }}</span>
|
|
</div>
|
|
|
|
<div class="provider-list">
|
|
{% for provider in providers %}
|
|
<div class="provider-item" data-provider-id="{{ provider.id }}" onclick="selectProvider({{ provider.id }})">
|
|
{% if provider.profile_picture %}
|
|
<img src="{{ provider.profile_picture.url }}"
|
|
class="provider-avatar" alt="{{ provider.get_full_name }}">
|
|
{% else %}
|
|
<div class="provider-avatar">
|
|
{{ provider.first_name.0 }}{{ provider.last_name.0 }}
|
|
</div>
|
|
{% endif %}
|
|
<div class="provider-info">
|
|
<div class="provider-name">{{ provider.get_full_name }}</div>
|
|
<div class="provider-specialty">{{ provider.specialty|default:"General Practice" }}</div>
|
|
</div>
|
|
<div class="availability-indicator">
|
|
<div class="availability-count">{{ provider.available_slots|default:0 }}</div>
|
|
<div class="availability-bar">
|
|
<div class="availability-fill {{ provider.availability_level|default:'low' }}"
|
|
style="width: {{ provider.availability_percentage|default:0 }}%"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% empty %}
|
|
<div class="empty-state">
|
|
<i class="fas fa-user-md empty-icon"></i>
|
|
<h6>No Providers Found</h6>
|
|
<p>No providers match the current filters</p>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Quick Actions -->
|
|
<div class="stats-card">
|
|
<div class="stats-header">
|
|
<h5 class="stats-title">
|
|
<i class="fas fa-bolt text-warning me-2"></i>
|
|
Quick Actions
|
|
</h5>
|
|
</div>
|
|
|
|
<div class="action-buttons">
|
|
<a href="{% url 'appointments:slot_create' %}" class="btn btn-primary btn-action">
|
|
<i class="fas fa-plus"></i>Create Slot
|
|
</a>
|
|
<button class="btn btn-success btn-action" onclick="bulkCreateSlots()">
|
|
<i class="fas fa-layer-group"></i>Bulk Create
|
|
</button>
|
|
<button class="btn btn-info btn-action" onclick="exportAvailability()">
|
|
<i class="fas fa-download"></i>Export
|
|
</button>
|
|
<button class="btn btn-warning btn-action" onclick="manageBlocks()">
|
|
<i class="fas fa-ban"></i>Manage Blocks
|
|
</button>
|
|
<a href="{% url 'appointments:slot_list' %}" class="btn btn-secondary btn-action">
|
|
<i class="fas fa-list"></i>All Slots
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Utilization Chart -->
|
|
<div class="utilization-chart fade-in">
|
|
<h5 class="mb-3">
|
|
<i class="fas fa-chart-line text-info me-2"></i>
|
|
Utilization Trends
|
|
</h5>
|
|
<div class="chart-container">
|
|
<canvas id="utilizationChart"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<!-- Bulk Create Modal -->
|
|
<div class="modal fade" id="bulkCreateModal" tabindex="-1">
|
|
<div class="modal-dialog modal-lg">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">
|
|
<i class="fas fa-layer-group me-2"></i>Bulk Create Slots
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<form id="bulkCreateForm">
|
|
<div class="row">
|
|
<div class="col-md-6">
|
|
<div class="mb-3">
|
|
<label class="form-label">Provider *</label>
|
|
<select class="form-select" id="bulkProvider" required>
|
|
<option value="">Select Provider</option>
|
|
{% for provider in providers %}
|
|
<option value="{{ provider.id }}">{{ provider.get_full_name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<div class="mb-3">
|
|
<label class="form-label">Date Range *</label>
|
|
<div class="input-group">
|
|
<input type="date" class="form-control" id="bulkStartDate" required>
|
|
<span class="input-group-text">to</span>
|
|
<input type="date" class="form-control" id="bulkEndDate" required>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<div class="col-md-6">
|
|
<div class="mb-3">
|
|
<label class="form-label">Start Time *</label>
|
|
<input type="time" class="form-control" id="bulkStartTime" value="09:00" required>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<div class="mb-3">
|
|
<label class="form-label">End Time *</label>
|
|
<input type="time" class="form-control" id="bulkEndTime" value="17:00" required>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<div class="col-md-6">
|
|
<div class="mb-3">
|
|
<label class="form-label">Slot Duration (minutes) *</label>
|
|
<select class="form-select" id="bulkDuration" required>
|
|
<option value="15">15 minutes</option>
|
|
<option value="30" selected>30 minutes</option>
|
|
<option value="45">45 minutes</option>
|
|
<option value="60">60 minutes</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<div class="mb-3">
|
|
<label class="form-label">Days of Week</label>
|
|
<div class="d-flex flex-wrap gap-2">
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" value="1" id="mon" checked>
|
|
<label class="form-check-label" for="mon">Mon</label>
|
|
</div>
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" value="2" id="tue" checked>
|
|
<label class="form-check-label" for="tue">Tue</label>
|
|
</div>
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" value="3" id="wed" checked>
|
|
<label class="form-check-label" for="wed">Wed</label>
|
|
</div>
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" value="4" id="thu" checked>
|
|
<label class="form-check-label" for="thu">Thu</label>
|
|
</div>
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" value="5" id="fri" checked>
|
|
<label class="form-check-label" for="fri">Fri</label>
|
|
</div>
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" value="6" id="sat">
|
|
<label class="form-check-label" for="sat">Sat</label>
|
|
</div>
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" value="0" id="sun">
|
|
<label class="form-check-label" for="sun">Sun</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-success" onclick="createBulkSlots()">
|
|
<i class="fas fa-check me-1"></i>Create Slots
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script src="{% static 'assets/plugins/fullcalendar/main.min.js' %}"></script>
|
|
<script src="{% static 'assets/plugins/select2/js/select2.min.js' %}"></script>
|
|
<script src="{% static 'assets/plugins/chart.js/chart.min.js' %}"></script>
|
|
<script>
|
|
let calendar;
|
|
let autoRefreshInterval;
|
|
let currentView = 'calendar';
|
|
let selectedProvider = null;
|
|
|
|
$(document).ready(function() {
|
|
initializeCalendar();
|
|
initializeChart();
|
|
setupEventListeners();
|
|
startAutoRefresh();
|
|
|
|
// Initialize Select2
|
|
$('#providerFilter, #departmentFilter').select2({
|
|
placeholder: 'Select...',
|
|
allowClear: true
|
|
});
|
|
});
|
|
|
|
function initializeCalendar() {
|
|
const calendarEl = document.getElementById('calendar');
|
|
calendar = new FullCalendar.Calendar(calendarEl, {
|
|
initialView: 'dayGridWeek',
|
|
headerToolbar: {
|
|
left: 'prev,next today',
|
|
center: 'title',
|
|
right: 'dayGridMonth,timeGridWeek,timeGridDay'
|
|
},
|
|
events: '/api/slots/calendar-events/',
|
|
eventClick: function(info) {
|
|
window.location.href = `/appointments/slots/${info.event.id}/`;
|
|
},
|
|
eventClassNames: function(arg) {
|
|
return ['slot-' + arg.event.extendedProps.status];
|
|
},
|
|
loading: function(bool) {
|
|
$('#loadingOverlay').toggle(bool);
|
|
}
|
|
});
|
|
calendar.render();
|
|
}
|
|
|
|
function initializeChart() {
|
|
const ctx = document.getElementById('utilizationChart').getContext('2d');
|
|
new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
|
|
datasets: [{
|
|
label: 'Utilization %',
|
|
data: [75, 82, 68, 90, 85, 45, 30],
|
|
borderColor: '#007bff',
|
|
backgroundColor: 'rgba(0, 123, 255, 0.1)',
|
|
tension: 0.4
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
scales: {
|
|
y: {
|
|
beginAtZero: true,
|
|
max: 100
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function setupEventListeners() {
|
|
// View toggle
|
|
$('.btn-toggle').on('click', function() {
|
|
const view = $(this).data('view');
|
|
switchView(view);
|
|
});
|
|
|
|
// Auto refresh toggle
|
|
$('#autoRefresh').on('change', function() {
|
|
if ($(this).is(':checked')) {
|
|
startAutoRefresh();
|
|
} else {
|
|
stopAutoRefresh();
|
|
}
|
|
});
|
|
|
|
// Filter changes
|
|
$('#dateRange, #departmentFilter, #providerFilter, #typeFilter, #statusFilter').on('change', function() {
|
|
applyFilters();
|
|
});
|
|
}
|
|
|
|
function switchView(view) {
|
|
currentView = view;
|
|
|
|
// Update toggle buttons
|
|
$('.btn-toggle').removeClass('active');
|
|
$(`.btn-toggle[data-view="${view}"]`).addClass('active');
|
|
|
|
// Show/hide views
|
|
$('#calendarView, #gridView, #listView').hide();
|
|
$(`#${view}View`).show();
|
|
|
|
// Load data for the view
|
|
switch(view) {
|
|
case 'calendar':
|
|
calendar.render();
|
|
break;
|
|
case 'grid':
|
|
loadGridView();
|
|
break;
|
|
case 'list':
|
|
loadListView();
|
|
break;
|
|
}
|
|
}
|
|
|
|
function loadGridView() {
|
|
const date = $('#gridDate').text();
|
|
$.ajax({
|
|
url: '/api/slots/grid-data/',
|
|
data: { date: date },
|
|
success: function(data) {
|
|
renderTimeGrid(data.slots);
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderTimeGrid(slots) {
|
|
const grid = $('#timeGrid');
|
|
grid.empty();
|
|
|
|
if (slots.length === 0) {
|
|
grid.append(`
|
|
<div class="empty-state">
|
|
<i class="fas fa-calendar-times empty-icon"></i>
|
|
<h6>No Slots Found</h6>
|
|
<p>No slots available for the selected date and filters</p>
|
|
</div>
|
|
`);
|
|
return;
|
|
}
|
|
|
|
slots.forEach(slot => {
|
|
const slotEl = $(`
|
|
<div class="time-slot ${slot.status}" onclick="viewSlot(${slot.id})">
|
|
<div class="slot-time">${slot.start_time} - ${slot.end_time}</div>
|
|
<div class="slot-status">${slot.status}</div>
|
|
${slot.patient ? `<div class="slot-count">${slot.patient}</div>` : ''}
|
|
</div>
|
|
`);
|
|
grid.append(slotEl);
|
|
});
|
|
}
|
|
|
|
function loadListView() {
|
|
$.ajax({
|
|
url: '/api/slots/list-data/',
|
|
data: getFilterData(),
|
|
success: function(data) {
|
|
renderSlotTable(data.slots);
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderSlotTable(slots) {
|
|
const tbody = $('#slotsTable tbody');
|
|
tbody.empty();
|
|
|
|
if (slots.length === 0) {
|
|
tbody.append(`
|
|
<tr>
|
|
<td colspan="7" class="text-center py-4">
|
|
<i class="fas fa-calendar-times text-muted fa-2x mb-2"></i>
|
|
<br>No slots found matching the current filters
|
|
</td>
|
|
</tr>
|
|
`);
|
|
return;
|
|
}
|
|
|
|
slots.forEach(slot => {
|
|
const row = $(`
|
|
<tr>
|
|
<td>${slot.date}</td>
|
|
<td>${slot.start_time} - ${slot.end_time}</td>
|
|
<td>${slot.provider}</td>
|
|
<td>${slot.appointment_type}</td>
|
|
<td><span class="badge bg-${getStatusColor(slot.status)}">${slot.status}</span></td>
|
|
<td>${slot.patient || '-'}</td>
|
|
<td>
|
|
<div class="btn-group btn-group-sm">
|
|
<a href="/appointments/slots/${slot.id}/" class="btn btn-outline-primary">
|
|
<i class="fas fa-eye"></i>
|
|
</a>
|
|
<a href="/appointments/slots/${slot.id}/edit/" class="btn btn-outline-secondary">
|
|
<i class="fas fa-edit"></i>
|
|
</a>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`);
|
|
tbody.append(row);
|
|
});
|
|
}
|
|
|
|
function getStatusColor(status) {
|
|
switch(status.toLowerCase()) {
|
|
case 'available': return 'success';
|
|
case 'booked': return 'primary';
|
|
case 'blocked': return 'danger';
|
|
default: return 'secondary';
|
|
}
|
|
}
|
|
|
|
function applyFilters() {
|
|
const filterData = getFilterData();
|
|
|
|
// Update calendar
|
|
if (currentView === 'calendar') {
|
|
calendar.refetchEvents();
|
|
}
|
|
|
|
// Update other views
|
|
if (currentView === 'grid') {
|
|
loadGridView();
|
|
} else if (currentView === 'list') {
|
|
loadListView();
|
|
}
|
|
|
|
// Update stats
|
|
refreshStats();
|
|
}
|
|
|
|
function getFilterData() {
|
|
return {
|
|
date_range: $('#dateRange').val(),
|
|
department: $('#departmentFilter').val(),
|
|
provider: $('#providerFilter').val(),
|
|
appointment_type: $('#typeFilter').val(),
|
|
status: $('#statusFilter').val()
|
|
};
|
|
}
|
|
|
|
function refreshData() {
|
|
$('#refreshIndicator').show();
|
|
|
|
// Refresh current view
|
|
switch(currentView) {
|
|
case 'calendar':
|
|
calendar.refetchEvents();
|
|
break;
|
|
case 'grid':
|
|
loadGridView();
|
|
break;
|
|
case 'list':
|
|
loadListView();
|
|
break;
|
|
}
|
|
|
|
// Refresh stats
|
|
refreshStats();
|
|
|
|
setTimeout(() => {
|
|
$('#refreshIndicator').hide();
|
|
}, 1000);
|
|
}
|
|
|
|
function refreshStats() {
|
|
$.ajax({
|
|
url: '/api/slots/stats/',
|
|
data: getFilterData(),
|
|
success: function(data) {
|
|
$('#availableCount').text(data.available);
|
|
$('#bookedCount').text(data.booked);
|
|
$('#blockedCount').text(data.blocked);
|
|
$('#utilizationRate').text(data.utilization + '%');
|
|
$('#noShowRate').text(data.no_show_rate + '%');
|
|
$('#avgDuration').text(data.avg_duration + ' min');
|
|
}
|
|
});
|
|
}
|
|
|
|
function startAutoRefresh() {
|
|
stopAutoRefresh();
|
|
autoRefreshInterval = setInterval(refreshData, 30000); // 30 seconds
|
|
}
|
|
|
|
function stopAutoRefresh() {
|
|
if (autoRefreshInterval) {
|
|
clearInterval(autoRefreshInterval);
|
|
}
|
|
}
|
|
|
|
function selectProvider(providerId) {
|
|
selectedProvider = providerId;
|
|
|
|
// Update visual selection
|
|
$('.provider-item').removeClass('selected');
|
|
$(`.provider-item[data-provider-id="${providerId}"]`).addClass('selected');
|
|
|
|
// Update filter
|
|
$('#providerFilter').val(providerId).trigger('change');
|
|
}
|
|
|
|
function previousDay() {
|
|
const currentDate = new Date($('#gridDate').text());
|
|
currentDate.setDate(currentDate.getDate() - 1);
|
|
$('#gridDate').text(currentDate.toLocaleDateString());
|
|
loadGridView();
|
|
}
|
|
|
|
function nextDay() {
|
|
const currentDate = new Date($('#gridDate').text());
|
|
currentDate.setDate(currentDate.getDate() + 1);
|
|
$('#gridDate').text(currentDate.toLocaleDateString());
|
|
loadGridView();
|
|
}
|
|
|
|
function viewSlot(slotId) {
|
|
window.location.href = `/appointments/slots/${slotId}/`;
|
|
}
|
|
|
|
function bulkCreateSlots() {
|
|
$('#bulkCreateModal').modal('show');
|
|
}
|
|
|
|
function createBulkSlots() {
|
|
const formData = {
|
|
provider: $('#bulkProvider').val(),
|
|
start_date: $('#bulkStartDate').val(),
|
|
end_date: $('#bulkEndDate').val(),
|
|
start_time: $('#bulkStartTime').val(),
|
|
end_time: $('#bulkEndTime').val(),
|
|
duration: $('#bulkDuration').val(),
|
|
days: []
|
|
};
|
|
|
|
// Get selected days
|
|
$('input[type="checkbox"]:checked').each(function() {
|
|
formData.days.push($(this).val());
|
|
});
|
|
|
|
if (!formData.provider || !formData.start_date || !formData.end_date) {
|
|
alert('Please fill in all required fields');
|
|
return;
|
|
}
|
|
|
|
$.ajax({
|
|
url: '/api/slots/bulk-create/',
|
|
method: 'POST',
|
|
data: formData,
|
|
headers: {
|
|
'X-CSRFToken': $('[name=csrfmiddlewaretoken]').val()
|
|
},
|
|
success: function(response) {
|
|
$('#bulkCreateModal').modal('hide');
|
|
showAlert(`Created ${response.created_count} slots successfully!`, 'success');
|
|
refreshData();
|
|
},
|
|
error: function() {
|
|
showAlert('Error creating slots', 'error');
|
|
}
|
|
});
|
|
}
|
|
|
|
function exportAvailability() {
|
|
const filterData = getFilterData();
|
|
const params = new URLSearchParams(filterData);
|
|
window.open(`/api/slots/export/?${params}`, '_blank');
|
|
}
|
|
|
|
function manageBlocks() {
|
|
window.location.href = '/appointments/blocks/';
|
|
}
|
|
|
|
function showAlert(message, type) {
|
|
const alertClass = type === 'success' ? 'alert-success' :
|
|
type === 'warning' ? 'alert-warning' :
|
|
type === 'info' ? 'alert-info' : '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>
|
|
`;
|
|
|
|
$('.container-fluid').prepend(alertHtml);
|
|
setTimeout(() => $('.alert').fadeOut(), 5000);
|
|
}
|
|
|
|
// Update current time every minute
|
|
setInterval(function() {
|
|
const now = new Date();
|
|
$('#currentTime').text(now.toLocaleTimeString('en-US', {
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
hour12: true
|
|
}));
|
|
}, 60000);
|
|
</script>
|
|
{% endblock %}
|
|
|