Spaces:
Running
Running
Upload 7 files
Browse files- .env +19 -0
- Dockerfile +45 -0
- app.py +260 -0
- data/users.csv +4 -0
- requirements.txt +4 -0
- templates/dashboard.html +1003 -0
- templates/login.html +150 -0
.env
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Airtable Configuration
|
| 2 |
+
# Replace these values with your actual Airtable credentials
|
| 3 |
+
|
| 4 |
+
# Your Airtable Base ID (found in your Airtable API documentation)
|
| 5 |
+
AIRTABLE_BASE_ID=appY1hTlYJdRIT7WK
|
| 6 |
+
|
| 7 |
+
# Your Airtable API Key (Personal Access Token)
|
| 8 |
+
# Generate one at: https://airtable.com/create/tokens
|
| 9 |
+
AIRTABLE_API_KEY=patufvuN6E4aLRdWT.16c88f6ae1fd7df77568673ebc0a9b62671b73df4a98ac313870b47008be7ce5
|
| 10 |
+
|
| 11 |
+
# Table names in your Airtable base
|
| 12 |
+
AIRTABLE_APPOINTMENTS_TABLE=Appointments
|
| 13 |
+
AIRTABLE_CALLS_TABLE=Call Recording
|
| 14 |
+
|
| 15 |
+
# Optional: CORS origin for development
|
| 16 |
+
# CORS_ORIGIN=http://localhost:3000
|
| 17 |
+
|
| 18 |
+
FLASK_SECRET_KEY="a-very-secret-key-change-in-production"
|
| 19 |
+
RESULT_LIMIT=50
|
Dockerfile
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use an official Python runtime as a parent image
|
| 2 |
+
FROM python:3.10-slim
|
| 3 |
+
|
| 4 |
+
# Set the working directory in the container
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# Install system dependencies
|
| 8 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 9 |
+
libgl1 \
|
| 10 |
+
libglib2.0-0 \
|
| 11 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 12 |
+
|
| 13 |
+
# Copy the requirements file
|
| 14 |
+
COPY requirements.txt requirements.txt
|
| 15 |
+
|
| 16 |
+
# Install Python packages with timeout increase
|
| 17 |
+
RUN pip install --no-cache-dir --timeout=1000 -r requirements.txt
|
| 18 |
+
|
| 19 |
+
# Copy application code
|
| 20 |
+
COPY . /app
|
| 21 |
+
|
| 22 |
+
# Create a non-root user
|
| 23 |
+
RUN useradd -m -u 1000 user
|
| 24 |
+
|
| 25 |
+
# Change ownership
|
| 26 |
+
RUN chown -R user:user /app
|
| 27 |
+
|
| 28 |
+
# Switch to the non-root user
|
| 29 |
+
USER user
|
| 30 |
+
|
| 31 |
+
# Expose the port
|
| 32 |
+
EXPOSE 7860
|
| 33 |
+
|
| 34 |
+
# Set environment variables
|
| 35 |
+
ENV FLASK_HOST=0.0.0.0
|
| 36 |
+
ENV FLASK_PORT=7860
|
| 37 |
+
ENV FLASK_DEBUG=False
|
| 38 |
+
|
| 39 |
+
# CRITICAL: Set HF-specific env vars
|
| 40 |
+
ENV TRANSFORMERS_CACHE=/tmp/transformers_cache
|
| 41 |
+
ENV HF_HOME=/tmp/hf_home
|
| 42 |
+
ENV TORCH_HOME=/tmp/torch_home
|
| 43 |
+
|
| 44 |
+
# Command to run the app
|
| 45 |
+
CMD ["python", "app.py"]
|
app.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import requests
|
| 3 |
+
import csv
|
| 4 |
+
from flask import Flask, jsonify, render_template, request, redirect, url_for, session
|
| 5 |
+
from flask_socketio import SocketIO, emit
|
| 6 |
+
from dotenv import load_dotenv
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
import logging
|
| 9 |
+
|
| 10 |
+
# --- App Configuration ---
|
| 11 |
+
load_dotenv()
|
| 12 |
+
app = Flask(__name__)
|
| 13 |
+
app.secret_key = os.getenv('FLASK_SECRET_KEY', 'a-very-secret-key-change-in-production')
|
| 14 |
+
socketio = SocketIO(app)
|
| 15 |
+
|
| 16 |
+
# Configure logging
|
| 17 |
+
logging.basicConfig(level=logging.INFO)
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
# --- File & Airtable Configuration ---
|
| 21 |
+
DATA_DIR = 'data'
|
| 22 |
+
USERS_FILE = os.path.join(DATA_DIR, 'users.csv')
|
| 23 |
+
AIRTABLE_API_KEY = os.getenv('AIRTABLE_API_KEY')
|
| 24 |
+
AIRTABLE_BASE_ID = os.getenv('AIRTABLE_BASE_ID')
|
| 25 |
+
APPOINTMENTS_TABLE = os.getenv('AIRTABLE_APPOINTMENTS_TABLE', 'Appointments')
|
| 26 |
+
CALLS_TABLE = os.getenv('AIRTABLE_CALLS_TABLE', 'CallRecording')
|
| 27 |
+
AIRTABLE_API_URL = f"https://api.airtable.com/v0/{AIRTABLE_BASE_ID}"
|
| 28 |
+
RESULTS_PER_PAGE = int(os.getenv('RESULT_LIMIT', 50))
|
| 29 |
+
N8N_WEBHOOK_SECRET = os.getenv('N8N_WEBHOOK_SECRET') # Optional secret for webhook security
|
| 30 |
+
|
| 31 |
+
# --- Helper Functions ---
|
| 32 |
+
def airtable_request(table_name, record_id=None, method='GET', params=None, json_data=None):
|
| 33 |
+
"""Generic function to make requests to the Airtable API."""
|
| 34 |
+
url = f"{AIRTABLE_API_URL}/{table_name}"
|
| 35 |
+
if record_id:
|
| 36 |
+
url += f"/{record_id}"
|
| 37 |
+
|
| 38 |
+
if not AIRTABLE_API_KEY or not AIRTABLE_BASE_ID:
|
| 39 |
+
logger.error("Airtable credentials are not set in the .env file.")
|
| 40 |
+
return None
|
| 41 |
+
|
| 42 |
+
headers = {
|
| 43 |
+
'Authorization': f'Bearer {AIRTABLE_API_KEY}',
|
| 44 |
+
'Content-Type': 'application/json'
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
try:
|
| 48 |
+
response = requests.request(method, url, headers=headers, params=params, json=json_data, timeout=10)
|
| 49 |
+
response.raise_for_status()
|
| 50 |
+
return response.json()
|
| 51 |
+
except requests.exceptions.Timeout:
|
| 52 |
+
logger.error(f"Airtable API timeout for {table_name}")
|
| 53 |
+
return None
|
| 54 |
+
except requests.exceptions.RequestException as e:
|
| 55 |
+
logger.error(f"Airtable API Error: {e}")
|
| 56 |
+
if hasattr(e, 'response') and e.response is not None:
|
| 57 |
+
logger.error(f"Response content: {e.response.text}")
|
| 58 |
+
return None
|
| 59 |
+
|
| 60 |
+
def flatten_airtable_records(records):
|
| 61 |
+
"""Flatten Airtable records for easier use in frontend."""
|
| 62 |
+
if not records:
|
| 63 |
+
return []
|
| 64 |
+
return [dict(id=rec.get('id'), **rec.get('fields', {})) for rec in records]
|
| 65 |
+
|
| 66 |
+
def get_all_records(table_name):
|
| 67 |
+
"""Fetch all records from an Airtable table, handling pagination."""
|
| 68 |
+
all_records = []
|
| 69 |
+
params = {'pageSize': 100}
|
| 70 |
+
|
| 71 |
+
while True:
|
| 72 |
+
data = airtable_request(table_name, params=params)
|
| 73 |
+
if not data:
|
| 74 |
+
break
|
| 75 |
+
|
| 76 |
+
records = data.get('records', [])
|
| 77 |
+
all_records.extend(records)
|
| 78 |
+
|
| 79 |
+
# Check for more pages
|
| 80 |
+
offset = data.get('offset')
|
| 81 |
+
if not offset:
|
| 82 |
+
break
|
| 83 |
+
params['offset'] = offset
|
| 84 |
+
|
| 85 |
+
return all_records
|
| 86 |
+
|
| 87 |
+
def validate_user_credentials(username, password):
|
| 88 |
+
"""Validate user credentials from CSV file."""
|
| 89 |
+
try:
|
| 90 |
+
if not os.path.exists(USERS_FILE):
|
| 91 |
+
logger.error(f"Users file not found: {USERS_FILE}")
|
| 92 |
+
return False
|
| 93 |
+
|
| 94 |
+
with open(USERS_FILE, mode='r', encoding='utf-8') as infile:
|
| 95 |
+
reader = csv.DictReader(infile)
|
| 96 |
+
for row in reader:
|
| 97 |
+
if row.get('username') == username and row.get('password') == password:
|
| 98 |
+
return True
|
| 99 |
+
return False
|
| 100 |
+
except Exception as e:
|
| 101 |
+
logger.error(f"Error validating credentials: {e}")
|
| 102 |
+
return False
|
| 103 |
+
|
| 104 |
+
def create_default_users_file():
|
| 105 |
+
"""Create a default users.csv file if it doesn't exist."""
|
| 106 |
+
if not os.path.exists(USERS_FILE):
|
| 107 |
+
os.makedirs(DATA_DIR, exist_ok=True)
|
| 108 |
+
with open(USERS_FILE, 'w', newline='', encoding='utf-8') as f:
|
| 109 |
+
writer = csv.writer(f)
|
| 110 |
+
writer.writerow(['username', 'password'])
|
| 111 |
+
writer.writerow(['admin', 'admin123']) # Default credentials
|
| 112 |
+
logger.info(f"Created default users file at {USERS_FILE}")
|
| 113 |
+
|
| 114 |
+
# --- Authentication Routes ---
|
| 115 |
+
@app.route('/login', methods=['GET', 'POST'])
|
| 116 |
+
def login():
|
| 117 |
+
error = None
|
| 118 |
+
if request.method == 'POST':
|
| 119 |
+
username = request.form.get('username', '').strip()
|
| 120 |
+
password = request.form.get('password', '').strip()
|
| 121 |
+
|
| 122 |
+
if not username or not password:
|
| 123 |
+
error = 'Please enter both username and password.'
|
| 124 |
+
elif validate_user_credentials(username, password):
|
| 125 |
+
session['logged_in'] = True
|
| 126 |
+
session['username'] = username
|
| 127 |
+
session.permanent = True # Make session persistent
|
| 128 |
+
logger.info(f"Successful login for user: {username}")
|
| 129 |
+
return redirect(url_for('dashboard'))
|
| 130 |
+
else:
|
| 131 |
+
error = 'Invalid username or password.'
|
| 132 |
+
logger.warning(f"Failed login attempt for user: {username}")
|
| 133 |
+
|
| 134 |
+
return render_template('login.html', error=error)
|
| 135 |
+
|
| 136 |
+
@app.route('/logout')
|
| 137 |
+
def logout():
|
| 138 |
+
username = session.get('username', 'Unknown')
|
| 139 |
+
session.clear()
|
| 140 |
+
logger.info(f"User logged out: {username}")
|
| 141 |
+
return redirect(url_for('login'))
|
| 142 |
+
|
| 143 |
+
# --- Main Application Routes ---
|
| 144 |
+
@app.route('/')
|
| 145 |
+
def dashboard():
|
| 146 |
+
if not session.get('logged_in'):
|
| 147 |
+
return redirect(url_for('login'))
|
| 148 |
+
return render_template(
|
| 149 |
+
'dashboard.html',
|
| 150 |
+
username=session.get('username', 'User'),
|
| 151 |
+
results_per_page=RESULTS_PER_PAGE
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
# --- API Endpoints ---
|
| 155 |
+
@app.route('/api/data')
|
| 156 |
+
def get_data():
|
| 157 |
+
"""Fetch all data from Airtable tables."""
|
| 158 |
+
if not session.get('logged_in'):
|
| 159 |
+
return jsonify({'error': 'Unauthorized'}), 401
|
| 160 |
+
|
| 161 |
+
try:
|
| 162 |
+
if not AIRTABLE_API_KEY or not AIRTABLE_BASE_ID:
|
| 163 |
+
logger.warning("Airtable not configured, returning empty data")
|
| 164 |
+
return jsonify({
|
| 165 |
+
'appointments': [],
|
| 166 |
+
'calls': [],
|
| 167 |
+
'error': 'Airtable not configured. Please check your .env file.'
|
| 168 |
+
})
|
| 169 |
+
|
| 170 |
+
appointments_records = get_all_records(APPOINTMENTS_TABLE)
|
| 171 |
+
calls_records = get_all_records(CALLS_TABLE)
|
| 172 |
+
|
| 173 |
+
return jsonify({
|
| 174 |
+
'appointments': flatten_airtable_records(appointments_records),
|
| 175 |
+
'calls': flatten_airtable_records(calls_records)
|
| 176 |
+
})
|
| 177 |
+
except Exception as e:
|
| 178 |
+
logger.error(f"Error fetching data: {e}")
|
| 179 |
+
return jsonify({'error': str(e)}), 500
|
| 180 |
+
|
| 181 |
+
@app.route('/api/webhook/refresh', methods=['POST'])
|
| 182 |
+
def webhook_refresh():
|
| 183 |
+
"""Webhook endpoint for n8n to trigger a refresh."""
|
| 184 |
+
# Optional: Secure webhook with a secret key
|
| 185 |
+
if N8N_WEBHOOK_SECRET:
|
| 186 |
+
webhook_secret = request.headers.get('X-N8N-Webhook-Secret')
|
| 187 |
+
if webhook_secret != N8N_WEBHOOK_SECRET:
|
| 188 |
+
logger.warning("Unauthorized webhook attempt.")
|
| 189 |
+
return jsonify({'error': 'Unauthorized'}), 401
|
| 190 |
+
|
| 191 |
+
logger.info("Webhook received. Triggering client refresh via Socket.IO.")
|
| 192 |
+
socketio.emit('refresh_data', {'message': 'Airtable data has changed.'})
|
| 193 |
+
return jsonify({'success': True, 'message': 'Refresh event emitted to all clients.'}), 200
|
| 194 |
+
|
| 195 |
+
@app.route('/api/appointments/update_status/<record_id>', methods=['PATCH'])
|
| 196 |
+
def update_appointment_status(record_id):
|
| 197 |
+
"""Update the status of an appointment."""
|
| 198 |
+
if not session.get('logged_in'):
|
| 199 |
+
return jsonify({'error': 'Unauthorized'}), 401
|
| 200 |
+
|
| 201 |
+
try:
|
| 202 |
+
data = request.json
|
| 203 |
+
new_status = data.get('Status')
|
| 204 |
+
|
| 205 |
+
if not new_status:
|
| 206 |
+
return jsonify({'error': 'Status not provided'}), 400
|
| 207 |
+
|
| 208 |
+
valid_statuses = ['incomplete', 'complete', 'missed']
|
| 209 |
+
if new_status.lower() not in valid_statuses:
|
| 210 |
+
return jsonify({'error': f'Invalid status. Must be one of: {", ".join(valid_statuses)}'}), 400
|
| 211 |
+
|
| 212 |
+
payload = {'fields': {'Status': new_status}}
|
| 213 |
+
response = airtable_request(APPOINTMENTS_TABLE, record_id=record_id, method='PATCH', json_data=payload)
|
| 214 |
+
|
| 215 |
+
if response:
|
| 216 |
+
logger.info(f"Updated appointment {record_id} status to {new_status}")
|
| 217 |
+
return jsonify({
|
| 218 |
+
'success': True,
|
| 219 |
+
'record': flatten_airtable_records([response])[0]
|
| 220 |
+
}), 200
|
| 221 |
+
else:
|
| 222 |
+
return jsonify({'error': 'Failed to update status in Airtable'}), 500
|
| 223 |
+
|
| 224 |
+
except Exception as e:
|
| 225 |
+
logger.error(f"Error updating appointment status: {e}")
|
| 226 |
+
return jsonify({'error': str(e)}), 500
|
| 227 |
+
|
| 228 |
+
# --- Error Handlers ---
|
| 229 |
+
@app.errorhandler(404)
|
| 230 |
+
def not_found(error):
|
| 231 |
+
if request.path.startswith('/api/'):
|
| 232 |
+
return jsonify({'error': 'Endpoint not found'}), 404
|
| 233 |
+
return render_template('404.html'), 404
|
| 234 |
+
|
| 235 |
+
@app.errorhandler(500)
|
| 236 |
+
def internal_error(error):
|
| 237 |
+
logger.error(f"Internal server error: {error}")
|
| 238 |
+
if request.path.startswith('/api/'):
|
| 239 |
+
return jsonify({'error': 'Internal server error'}), 500
|
| 240 |
+
return render_template('500.html'), 500
|
| 241 |
+
|
| 242 |
+
# --- Main Execution ---
|
| 243 |
+
if __name__ == '__main__':
|
| 244 |
+
os.makedirs(DATA_DIR, exist_ok=True)
|
| 245 |
+
create_default_users_file()
|
| 246 |
+
|
| 247 |
+
print("=" * 50)
|
| 248 |
+
print(" DENTAL ADMIN APPLICATION")
|
| 249 |
+
print("=" * 50)
|
| 250 |
+
|
| 251 |
+
if not (AIRTABLE_API_KEY and AIRTABLE_BASE_ID):
|
| 252 |
+
print("⚠️ WARNING: Airtable credentials not configured!")
|
| 253 |
+
else:
|
| 254 |
+
print("✅ Airtable configuration loaded")
|
| 255 |
+
|
| 256 |
+
print(f"🚀 Starting server on http://localhost:5000")
|
| 257 |
+
print(" Default login: admin / admin123")
|
| 258 |
+
print("=" * 50)
|
| 259 |
+
|
| 260 |
+
socketio.run(app, debug=True, host='0.0.0.0', port=5000)
|
data/users.csv
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
username,password
|
| 2 |
+
admin,password
|
| 3 |
+
jennifer,dentaladmin123
|
| 4 |
+
ali786,1234
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Flask==3.1.2
|
| 2 |
+
Flask_SocketIO==5.5.1
|
| 3 |
+
python-dotenv==1.1.1
|
| 4 |
+
Requests==2.32.5
|
templates/dashboard.html
ADDED
|
@@ -0,0 +1,1003 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Dental Admin Dashboard</title>
|
| 7 |
+
<!-- Libraries -->
|
| 8 |
+
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
|
| 9 |
+
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
| 10 |
+
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.2.0"></script>
|
| 11 |
+
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0"></script>
|
| 12 |
+
<script src="https://cdn.jsdelivr.net/npm/hammerjs@2.0.8"></script>
|
| 13 |
+
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1"></script>
|
| 14 |
+
|
| 15 |
+
<style>
|
| 16 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
| 17 |
+
|
| 18 |
+
:root {
|
| 19 |
+
--primary-bg: #F8F8F8; --sidebar-bg: #FFFFFF; --card-bg: #FFFFFF; --text-dark: #333333; --text-medium: #666666; --text-light: #AAAAAA; --border-color: #EDEDED; --accent-green: #4CAF50; --accent-blue: #2196F3; --accent-red: #F44336; --accent-yellow: #FFC107; --accent-gray: #9E9E9E; --shadow: 0 4px 20px rgba(0, 0, 0, 0.05); --border-radius-lg: 20px; --border-radius-md: 12px;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
html.dark-mode {
|
| 23 |
+
--primary-bg: #121212; --sidebar-bg: #1E1E1E; --card-bg: #1E1E1E; --text-dark: #E0E0E0; --text-medium: #A0A0A0; --text-light: #707070; --border-color: #333333;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
* { margin: 0; padding: 0; box-sizing: border-box; transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease; }
|
| 27 |
+
|
| 28 |
+
body { font-family: 'Inter', sans-serif; background-color: var(--primary-bg); min-height: 100vh; display: flex; color: var(--text-dark); }
|
| 29 |
+
|
| 30 |
+
.sidebar { width: 280px; background: var(--sidebar-bg); border-right: 1px solid var(--border-color); padding: 25px; display: flex; flex-direction: column; box-shadow: var(--shadow); border-radius: var(--border-radius-lg); margin: 20px; position: sticky; top: 20px; align-self: flex-start; height: calc(100vh - 40px); }
|
| 31 |
+
|
| 32 |
+
.logo { display: flex; align-items: center; gap: 10px; margin-bottom: 30px; font-weight: 700; font-size: 1.2rem; }
|
| 33 |
+
.logo-icon { width: 36px; height: 36px; background: var(--accent-blue); border-radius: 8px; display: flex; align-items: center; justify-content: center; color: white; font-size: 1.2rem; }
|
| 34 |
+
|
| 35 |
+
.user-profile { display: flex; align-items: center; gap: 15px; margin-bottom: 20px; padding-bottom: 20px; border-bottom: 1px solid var(--border-color); }
|
| 36 |
+
.avatar { width: 50px; height: 50px; border-radius: 50%; background: linear-gradient(45deg, #FFD700, #FF69B4); display: flex; align-items: center; justify-content: center; color: white; font-weight: 600; text-transform: uppercase; }
|
| 37 |
+
.user-info div { font-weight: 600; font-size: 1rem; }
|
| 38 |
+
.user-info span { font-size: 0.85rem; color: var(--text-medium); }
|
| 39 |
+
|
| 40 |
+
.nav-menu { list-style: none; flex-grow: 1; }
|
| 41 |
+
.nav-item { margin-bottom: 8px; }
|
| 42 |
+
.nav-link { display: flex; align-items: center; gap: 15px; padding: 12px 18px; color: var(--text-medium); text-decoration: none; border-radius: 12px; font-weight: 500; cursor: pointer; }
|
| 43 |
+
.nav-link:hover, .nav-link.active { background: #2195f320; color: var(--accent-blue); }
|
| 44 |
+
.nav-icon { font-size: 1.2rem; width: 24px; text-align: center; }
|
| 45 |
+
.logout-link { color: var(--accent-red) !important; margin-top: auto; }
|
| 46 |
+
.logout-link:hover { background: #f4433620 !important; }
|
| 47 |
+
|
| 48 |
+
.main-content { flex: 1; padding: 30px; }
|
| 49 |
+
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; }
|
| 50 |
+
.header h1 { font-size: 2rem; font-weight: 700; }
|
| 51 |
+
.header-controls { display: flex; align-items: center; gap: 10px; }
|
| 52 |
+
|
| 53 |
+
.icon-button { background: none; border: 1px solid transparent; cursor: pointer; color: var(--text-medium); padding: 5px; border-radius: 50%; display: flex; align-items: center; justify-content: center;}
|
| 54 |
+
.icon-button:hover { background-color: #80808030; color: var(--text-dark); }
|
| 55 |
+
.icon-button:disabled { cursor: not-allowed; opacity: 0.5; }
|
| 56 |
+
.icon-button svg { width: 24px; height: 24px; }
|
| 57 |
+
|
| 58 |
+
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
| 59 |
+
.spinning { animation: spin 1s linear infinite; }
|
| 60 |
+
|
| 61 |
+
.content-view { display: none; animation: fadeIn 0.5s; }
|
| 62 |
+
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
| 63 |
+
.content-view.active { display: block; }
|
| 64 |
+
|
| 65 |
+
.dashboard-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 25px; margin-bottom: 30px; }
|
| 66 |
+
.charts-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); gap: 25px; }
|
| 67 |
+
|
| 68 |
+
.card { background: var(--card-bg); border-radius: var(--border-radius-lg); padding: 25px; box-shadow: var(--shadow); display: flex; flex-direction: column; }
|
| 69 |
+
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; flex-wrap: wrap; gap: 10px;}
|
| 70 |
+
.chart-title-section { display: flex; flex-direction: column; }
|
| 71 |
+
.chart-title { font-size: 1.1rem; font-weight: 600; }
|
| 72 |
+
.chart-subtitle { font-size: 0.8rem; color: var(--text-medium); margin-top: 4px; }
|
| 73 |
+
.card-content { font-size: 2.2rem; font-weight: 700; margin-bottom: 5px; }
|
| 74 |
+
.card-footer { font-size: 0.85rem; color: var(--text-medium); }
|
| 75 |
+
|
| 76 |
+
.table-container { background: var(--card-bg); padding: 20px; border-radius: 20px; box-shadow: var(--shadow); overflow-x: auto; }
|
| 77 |
+
.table-controls { margin-bottom: 15px; display: flex; gap: 15px; align-items: center; justify-content: space-between; flex-wrap: wrap; }
|
| 78 |
+
#filter-input { width: 100%; max-width: 300px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--border-color); background-color: var(--primary-bg); color: var(--text-dark); }
|
| 79 |
+
|
| 80 |
+
.column-controls { position: relative; }
|
| 81 |
+
.column-menu { position: absolute; top: 100%; right: 0; background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 8px; padding: 10px; box-shadow: var(--shadow); z-index: 1000; min-width: 200px; }
|
| 82 |
+
.column-menu label { display: block; padding: 5px 0; cursor: pointer; font-size: 0.9rem; color: var(--text-dark); }
|
| 83 |
+
.column-menu label:hover { background: var(--primary-bg); padding-left: 5px; padding-right: 5px; border-radius: 4px; }
|
| 84 |
+
.column-menu input[type="checkbox"] { margin-right: 8px; }
|
| 85 |
+
|
| 86 |
+
table { width: 100%; border-collapse: collapse; }
|
| 87 |
+
th, td { padding: 12px 15px; text-align: left; border-bottom: 1px solid var(--border-color); }
|
| 88 |
+
th { font-weight: 600; font-size: 0.9rem; color: var(--text-medium); }
|
| 89 |
+
th.sortable { cursor: pointer; user-select: none; }
|
| 90 |
+
th.sortable:hover { color: var(--text-dark); }
|
| 91 |
+
th .sort-icon { margin-left: 5px; opacity: 0.5; }
|
| 92 |
+
td { font-size: 0.95rem; }
|
| 93 |
+
td.summary-cell { white-space: normal; min-width: 250px; }
|
| 94 |
+
|
| 95 |
+
th.resizable { position: relative; }
|
| 96 |
+
.resizer { position: absolute; top: 0; right: -2px; width: 5px; cursor: col-resize; user-select: none; height: 100%; z-index: 10; }
|
| 97 |
+
.resizer:hover, .resizing { border-right: 2px solid var(--accent-blue); }
|
| 98 |
+
|
| 99 |
+
.status-dropdown { background: none; padding: 5px 8px; border-radius: 8px; border: 1px solid var(--border-color); font-family: 'Inter', sans-serif; font-size: 0.9rem; cursor: pointer; color: var(--text-dark);}
|
| 100 |
+
.status-dropdown.status-complete { background-color: #4caf5020; color: #4CAF50; }
|
| 101 |
+
.status-dropdown.status-incomplete { background-color: #f4433620; color: #F44336; }
|
| 102 |
+
.status-dropdown.status-missed { background-color: #9e9e9e20; color: #9E9E9E; }
|
| 103 |
+
|
| 104 |
+
.loading { text-align: center; padding: 50px; font-size: 1.2rem; color: var(--text-medium); }
|
| 105 |
+
.time-filter-group { display: flex; align-items: center; gap: 10px; }
|
| 106 |
+
.time-filter button { background-color: var(--card-bg); border: 1px solid var(--border-color); padding: 6px 12px; border-radius: 8px; cursor: pointer; font-weight: 500; color: var(--text-medium); }
|
| 107 |
+
.time-filter button.active, .time-filter button:hover { background-color: var(--accent-blue); color: white; border-color: var(--accent-blue); }
|
| 108 |
+
.chart-canvas-container { position: relative; height: 100%; flex-grow: 1; min-height: 300px; }
|
| 109 |
+
.no-data-message { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: var(--text-medium); font-size: 0.9rem; text-align: center; pointer-events: none; }
|
| 110 |
+
|
| 111 |
+
.upcoming-appointments-container { margin-top: 30px; }
|
| 112 |
+
.upcoming-appointments-list { display: flex; flex-direction: column; gap: 15px; min-height: 200px; }
|
| 113 |
+
.appointment-item { display: flex; align-items: center; gap: 15px; padding: 10px; border-radius: var(--border-radius-md); background-color: var(--primary-bg); }
|
| 114 |
+
.appointment-time { display: flex; flex-direction: column; align-items: center; justify-content: center; background-color: var(--accent-blue); color: white; border-radius: 8px; width: 50px; height: 50px; font-weight: 600; flex-shrink: 0; }
|
| 115 |
+
.appointment-time span:first-child { font-size: 0.75rem; }
|
| 116 |
+
.appointment-time span:last-child { font-size: 1.2rem; line-height: 1; }
|
| 117 |
+
.appointment-details { display: flex; flex-direction: column; overflow: hidden; }
|
| 118 |
+
.appointment-details strong { font-weight: 600; color: var(--text-dark); }
|
| 119 |
+
.appointment-details span { font-size: 0.9rem; color: var(--text-medium); }
|
| 120 |
+
.appointment-description { font-size: 0.85rem; color: var(--text-medium); margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
| 121 |
+
#upcoming-appointments-list:empty::after { content: "No upcoming appointments found for this period."; color: var(--text-medium); padding: 20px 0; text-align: center; display: block; width: 100%; }
|
| 122 |
+
|
| 123 |
+
.pagination-controls { display: flex; justify-content: space-between; align-items: center; margin-top: 20px; padding-top: 15px; border-top: 1px solid var(--border-color); }
|
| 124 |
+
.pagination-controls button { background-color: var(--card-bg); border: 1px solid var(--border-color); padding: 8px 16px; border-radius: 8px; cursor: pointer; font-weight: 500; color: var(--text-medium); }
|
| 125 |
+
.pagination-controls button:hover:not(:disabled) { background-color: var(--accent-blue); color: white; border-color: var(--accent-blue); }
|
| 126 |
+
.pagination-controls button:disabled { opacity: 0.5; cursor: not-allowed; }
|
| 127 |
+
#upcoming-page-info { font-size: 0.9rem; font-weight: 500; color: var(--text-medium); }
|
| 128 |
+
|
| 129 |
+
.card-controls-group { display: flex; align-items: center; gap: 20px; flex-wrap: wrap; }
|
| 130 |
+
.items-per-page-group { display: flex; align-items: center; gap: 8px; }
|
| 131 |
+
.items-per-page-group label { font-size: 0.85rem; color: var(--text-medium); font-weight: 500; }
|
| 132 |
+
.items-per-page-group input { width: 60px; padding: 4px 8px; border-radius: 6px; border: 1px solid var(--border-color); background-color: var(--primary-bg); color: var(--text-dark); text-align: center; }
|
| 133 |
+
|
| 134 |
+
.cancelled-toggle-btn { background-color: var(--accent-red); color: white; border: none; padding: 6px 12px; border-radius: 8px; cursor: pointer; font-weight: 500; font-size: 0.85rem; }
|
| 135 |
+
.cancelled-toggle-btn:hover { background-color: #d32f2f; }
|
| 136 |
+
.cancelled-toggle-btn.showing-cancelled { background-color: var(--accent-gray); }
|
| 137 |
+
|
| 138 |
+
.cancelled-row { background-color: rgba(244, 67, 54, 0.1); opacity: 0.6; }
|
| 139 |
+
.cancelled-row.hidden { display: none; }
|
| 140 |
+
|
| 141 |
+
</style>
|
| 142 |
+
</head>
|
| 143 |
+
<body>
|
| 144 |
+
<div class="sidebar">
|
| 145 |
+
<div class="logo"><div class="logo-icon">🦷</div><span>DentalAdmin</span></div>
|
| 146 |
+
<div class="user-profile">
|
| 147 |
+
<div class="avatar" id="user-avatar"></div>
|
| 148 |
+
<div class="user-info">
|
| 149 |
+
<div id="username-display"></div>
|
| 150 |
+
<span>Administrator</span>
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
<nav>
|
| 154 |
+
<ul class="nav-menu">
|
| 155 |
+
<li class="nav-item"><a class="nav-link active" data-view="dashboard-view"><span class="nav-icon">📊</span> Dashboard</a></li>
|
| 156 |
+
<li class="nav-item"><a class="nav-link" data-view="appointments-view"><span class="nav-icon">🗓️</span> Appointments</a></li>
|
| 157 |
+
<li class="nav-item"><a class="nav-link" data-view="calls-view"><span class="nav-icon">📞</span> Call Logs</a></li>
|
| 158 |
+
<li class="nav-item"><a class="nav-link logout-link" href="/logout"><span class="nav-icon">🚪</span> Logout</a></li>
|
| 159 |
+
</ul>
|
| 160 |
+
</nav>
|
| 161 |
+
</div>
|
| 162 |
+
|
| 163 |
+
<div class="main-content">
|
| 164 |
+
<div class="header">
|
| 165 |
+
<h1 id="header-title">Dashboard</h1>
|
| 166 |
+
<div class="header-controls">
|
| 167 |
+
<button id="refresh-data" class="icon-button" title="Refresh data">
|
| 168 |
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path fill-rule="evenodd" d="M4.755 10.059a7.5 7.5 0 0112.548-3.364l1.903 1.903h-4.518a.75.75 0 00-.75.75v.008c0 .414.336.75.75.75h5.25a.75.75 0 00.75-.75v-5.25a.75.75 0 00-.75-.75h-.008a.75.75 0 00-.75.75v4.518l-1.903-1.903a9 9 0 00-15.057 4.042.75.75 0 00.58 1.157 7.5 7.5 0 01.548-2.223z" clip-rule="evenodd" /><path fill-rule="evenodd" d="M19.245 13.941a7.5 7.5 0 01-12.548 3.364l-1.903-1.903h4.518a.75.75 0 00.75-.75v-.008a.75.75 0 00-.75-.75h-5.25a.75.75 0 00-.75.75v5.25a.75.75 0 00.75.75h.008a.75.75 0 00.75-.75v-4.518l1.903 1.903a9 9 0 0015.057-4.042.75.75 0 00-.58-1.157 7.5 7.5 0 01-.548 2.223z" clip-rule="evenodd" /></svg>
|
| 169 |
+
</button>
|
| 170 |
+
<button id="theme-toggle" class="icon-button" title="Toggle dark mode">
|
| 171 |
+
<svg id="theme-icon-sun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="display: none;"><path d="M12 2.25a.75.75 0 01.75.75v2.25a.75.75 0 01-1.5 0V3a.75.75 0 01.75-.75zM7.5 12a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM18.894 6.106a.75.75 0 010 1.06l-1.591 1.59a.75.75 0 11-1.06-1.06l1.59-1.59a.75.75 0 011.06 0zM21.75 12a.75.75 0 01-.75.75h-2.25a.75.75 0 010-1.5h2.25a.75.75 0 01.75.75zM17.894 17.894a.75.75 0 01-1.06 0l-1.59-1.591a.75.75 0 111.06-1.06l1.59 1.59a.75.75 0 010 1.061zM12 18a.75.75 0 01.75.75v2.25a.75.75 0 01-1.5 0v-2.25A.75.75 0 0112 18zM6.106 17.894a.75.75 0 010-1.06l1.59-1.59a.75.75 0 111.06 1.06l-1.59 1.59a.75.75 0 01-1.06 0zM4.5 12a.75.75 0 01-.75.75H1.5a.75.75 0 010-1.5h2.25a.75.75 0 01.75.75zM6.106 6.106a.75.75 0 011.06 0l1.591 1.59a.75.75 0 01-1.06 1.06l-1.59-1.59a.75.75 0 010-1.06z"></path></svg>
|
| 172 |
+
<svg id="theme-icon-moon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path fill-rule="evenodd" d="M9.528 1.718a.75.75 0 01.162.819A8.97 8.97 0 009 6a9 9 0 009 9 8.97 8.97 0 003.463-.69.75.75 0 01.981.98 10.503 10.503 0 01-9.694 6.46c-5.799 0-10.5-4.701-10.5-10.5 0-4.368 2.667-8.112 6.46-9.694a.75.75 0 01.818.162z" clip-rule="evenodd"></path></svg>
|
| 173 |
+
</button>
|
| 174 |
+
</div>
|
| 175 |
+
</div>
|
| 176 |
+
|
| 177 |
+
<div id="loading" class="loading">Loading data from Airtable... Please wait.</div>
|
| 178 |
+
|
| 179 |
+
<!-- Dashboard View -->
|
| 180 |
+
<div id="dashboard-view" class="content-view">
|
| 181 |
+
<div id="dashboard-summary-grid" class="dashboard-grid"></div>
|
| 182 |
+
<div id="dashboard-charts-grid" class="charts-grid"></div>
|
| 183 |
+
<div class="upcoming-appointments-container card">
|
| 184 |
+
<div class="card-header">
|
| 185 |
+
<h2 class="chart-title">Upcoming Appointments</h2>
|
| 186 |
+
<div class="card-controls-group">
|
| 187 |
+
<div class="time-filter-group">
|
| 188 |
+
<div class="time-filter" id="upcoming-appointments-filter">
|
| 189 |
+
<button data-period="this-week" class="active">This Week</button>
|
| 190 |
+
<button data-period="this-month">This Month</button>
|
| 191 |
+
<button data-period="this-year">This Year</button>
|
| 192 |
+
</div>
|
| 193 |
+
</div>
|
| 194 |
+
<div class="items-per-page-group">
|
| 195 |
+
<label for="max-items-input">Items/Page:</label>
|
| 196 |
+
<input type="number" id="max-items-input" min="1" max="100" value="50">
|
| 197 |
+
</div>
|
| 198 |
+
</div>
|
| 199 |
+
</div>
|
| 200 |
+
<div id="upcoming-appointments-list" class="upcoming-appointments-list"></div>
|
| 201 |
+
<div class="pagination-controls" id="upcoming-pagination-controls">
|
| 202 |
+
<button id="upcoming-prev-btn" disabled>< Previous</button>
|
| 203 |
+
<span id="upcoming-page-info"></span>
|
| 204 |
+
<button id="upcoming-next-btn">Next ></button>
|
| 205 |
+
</div>
|
| 206 |
+
</div>
|
| 207 |
+
</div>
|
| 208 |
+
|
| 209 |
+
<!-- All Appointments View -->
|
| 210 |
+
<div id="appointments-view" class="content-view">
|
| 211 |
+
<div class="table-container">
|
| 212 |
+
<div class="table-controls">
|
| 213 |
+
<input type="text" id="filter-input" placeholder="Filter appointments...">
|
| 214 |
+
<div style="display: flex; gap: 10px; align-items: center;">
|
| 215 |
+
<button id="toggle-cancelled-btn" class="cancelled-toggle-btn">Show Cancelled</button>
|
| 216 |
+
<div class="column-controls">
|
| 217 |
+
<button id="toggle-columns-btn" class="icon-button" title="Show/Hide Columns">
|
| 218 |
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M3 4h18v2H3V4zm0 7h12v2H3v-2zm0 7h18v2H3v-2z"/></svg>
|
| 219 |
+
</button>
|
| 220 |
+
<div id="column-visibility-menu" class="column-menu" style="display: none;">
|
| 221 |
+
<label><input type="checkbox" data-column="sl" checked> Sl</label>
|
| 222 |
+
<label><input type="checkbox" data-column="Name" checked> Patient Name</label>
|
| 223 |
+
<label><input type="checkbox" data-column="starttime" checked> Start Time</label>
|
| 224 |
+
<label><input type="checkbox" data-column="meetdescription" checked> Description</label>
|
| 225 |
+
<label><input type="checkbox" data-column="Booking Status" checked> Booking Status</label>
|
| 226 |
+
<label><input type="checkbox" data-column="Phone Number" checked> Phone</label>
|
| 227 |
+
<label><input type="checkbox" data-column="Caller Phone Number"> Caller Phone Number</label>
|
| 228 |
+
<label><input type="checkbox" data-column="eventId" checked> Event ID</label>
|
| 229 |
+
<label><input type="checkbox" data-column="Status" checked> Status</label>
|
| 230 |
+
</div>
|
| 231 |
+
</div>
|
| 232 |
+
</div>
|
| 233 |
+
</div>
|
| 234 |
+
<table id="appointments-table">
|
| 235 |
+
<thead>
|
| 236 |
+
<tr>
|
| 237 |
+
<th class="sortable" data-column="sl">Sl <span class="sort-icon"></span></th>
|
| 238 |
+
<th class="sortable" data-column="Name">Patient Name <span class="sort-icon"></span></th>
|
| 239 |
+
<th class="sortable" data-column="starttime">Start Time <span class="sort-icon"></span></th>
|
| 240 |
+
<th class="sortable" data-column="meetdescription">Description <span class="sort-icon"></span></th>
|
| 241 |
+
<th class="sortable" data-column="Booking Status">Booking Status <span class="sort-icon"></span></th>
|
| 242 |
+
<th class="sortable" data-column="Phone Number">Phone <span class="sort-icon"></span></th>
|
| 243 |
+
<th class="sortable" data-column="Caller Phone Number" style="display: none;">Caller Phone Number <span class="sort-icon"></span></th>
|
| 244 |
+
<th class="sortable" data-column="eventId">Event ID <span class="sort-icon"></span></th>
|
| 245 |
+
<th>Status</th>
|
| 246 |
+
</tr>
|
| 247 |
+
</thead>
|
| 248 |
+
<tbody></tbody>
|
| 249 |
+
</table>
|
| 250 |
+
</div>
|
| 251 |
+
</div>
|
| 252 |
+
|
| 253 |
+
<!-- Call Logs View -->
|
| 254 |
+
<div id="calls-view" class="content-view">
|
| 255 |
+
<div class="table-container">
|
| 256 |
+
<table id="calls-table">
|
| 257 |
+
<thead>
|
| 258 |
+
<tr>
|
| 259 |
+
<th class="resizable" style="width:200px;">Caller Number<div class="resizer"></div></th>
|
| 260 |
+
<th class="resizable" style="width:220px;">Started At<div class="resizer"></div></th>
|
| 261 |
+
<th class="resizable" style="width:180px;">Ended At<div class="resizer"></div></th>
|
| 262 |
+
<th class="resizable">Summary<div class="resizer"></div></th>
|
| 263 |
+
</tr>
|
| 264 |
+
</thead>
|
| 265 |
+
<tbody></tbody>
|
| 266 |
+
</table>
|
| 267 |
+
</div>
|
| 268 |
+
</div>
|
| 269 |
+
</div>
|
| 270 |
+
|
| 271 |
+
<script>
|
| 272 |
+
// Register Chart.js plugins globally
|
| 273 |
+
Chart.register(ChartDataLabels, ChartZoom);
|
| 274 |
+
|
| 275 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 276 |
+
// --- State Management ---
|
| 277 |
+
let allData = { appointments: [], calls: [] };
|
| 278 |
+
let charts = {};
|
| 279 |
+
let currentPieChartFilter = 'today';
|
| 280 |
+
let scheduleTimelineView = 'week';
|
| 281 |
+
let upcomingAppointmentsFilter = 'this-week';
|
| 282 |
+
let upcomingAppointmentsPage = 1;
|
| 283 |
+
let appointmentsSort = { column: 'starttime', direction: 'asc' };
|
| 284 |
+
let appointmentsFilter = '';
|
| 285 |
+
let currentResultsPerPage = 50;
|
| 286 |
+
let showCancelled = false;
|
| 287 |
+
let socket;
|
| 288 |
+
|
| 289 |
+
// --- DOM Elements ---
|
| 290 |
+
const loadingDiv = document.getElementById('loading');
|
| 291 |
+
const headerTitle = document.getElementById('header-title');
|
| 292 |
+
const username = "User";
|
| 293 |
+
const contentViews = document.querySelectorAll('.content-view');
|
| 294 |
+
const navLinks = document.querySelectorAll('.nav-link');
|
| 295 |
+
const maxItemsInput = document.getElementById('max-items-input');
|
| 296 |
+
|
| 297 |
+
// --- Initialization ---
|
| 298 |
+
function initialize() {
|
| 299 |
+
setupTheme();
|
| 300 |
+
setupNavigation();
|
| 301 |
+
setupEventListeners();
|
| 302 |
+
fetchData();
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
// --- Real-time Communication ---
|
| 306 |
+
function setupSocketIO() {
|
| 307 |
+
if (socket) return; // Prevent multiple connections
|
| 308 |
+
socket = io();
|
| 309 |
+
socket.on('connect', () => {
|
| 310 |
+
console.log('Connected to real-time server.');
|
| 311 |
+
});
|
| 312 |
+
socket.on('refresh_data', (data) => {
|
| 313 |
+
console.log('Refresh event received:', data.message);
|
| 314 |
+
fetchData(true); // Trigger a manual-style refresh
|
| 315 |
+
});
|
| 316 |
+
socket.on('disconnect', () => {
|
| 317 |
+
console.log('Disconnected from real-time server.');
|
| 318 |
+
});
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
// --- Theme Management ---
|
| 322 |
+
function setupTheme() {
|
| 323 |
+
const themeToggle = document.getElementById('theme-toggle');
|
| 324 |
+
const sunIcon = document.getElementById('theme-icon-sun');
|
| 325 |
+
const moonIcon = document.getElementById('theme-icon-moon');
|
| 326 |
+
document.getElementById('username-display').textContent = username;
|
| 327 |
+
document.getElementById('user-avatar').textContent = username.charAt(0);
|
| 328 |
+
|
| 329 |
+
const applyTheme = (theme) => {
|
| 330 |
+
if (theme === 'dark') {
|
| 331 |
+
document.documentElement.classList.add('dark-mode');
|
| 332 |
+
sunIcon.style.display = 'block';
|
| 333 |
+
moonIcon.style.display = 'none';
|
| 334 |
+
} else {
|
| 335 |
+
document.documentElement.classList.remove('dark-mode');
|
| 336 |
+
sunIcon.style.display = 'none';
|
| 337 |
+
moonIcon.style.display = 'block';
|
| 338 |
+
}
|
| 339 |
+
};
|
| 340 |
+
|
| 341 |
+
let currentTheme = localStorage.getItem('theme') || 'light';
|
| 342 |
+
applyTheme(currentTheme);
|
| 343 |
+
|
| 344 |
+
themeToggle.addEventListener('click', () => {
|
| 345 |
+
currentTheme = document.documentElement.classList.contains('dark-mode') ? 'light' : 'dark';
|
| 346 |
+
localStorage.setItem('theme', currentTheme);
|
| 347 |
+
applyTheme(currentTheme);
|
| 348 |
+
});
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
// --- Navigation ---
|
| 352 |
+
function setupNavigation() {
|
| 353 |
+
navLinks.forEach(link => {
|
| 354 |
+
if (!link.href.includes('/logout')) {
|
| 355 |
+
link.addEventListener('click', (e) => {
|
| 356 |
+
e.preventDefault();
|
| 357 |
+
const viewId = link.getAttribute('data-view');
|
| 358 |
+
navLinks.forEach(l => l.classList.remove('active'));
|
| 359 |
+
link.classList.add('active');
|
| 360 |
+
contentViews.forEach(view => view.classList.remove('active'));
|
| 361 |
+
const viewEl = document.getElementById(viewId);
|
| 362 |
+
if(viewEl) {
|
| 363 |
+
viewEl.classList.add('active');
|
| 364 |
+
if(viewId === 'calls-view') {
|
| 365 |
+
makeTableResizable(document.getElementById('calls-table'));
|
| 366 |
+
}
|
| 367 |
+
}
|
| 368 |
+
headerTitle.textContent = link.textContent.trim();
|
| 369 |
+
});
|
| 370 |
+
}
|
| 371 |
+
});
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
// --- Data Fetching ---
|
| 375 |
+
async function fetchData(isManualRefresh = false) {
|
| 376 |
+
const refreshButton = document.getElementById('refresh-data');
|
| 377 |
+
if (isManualRefresh) {
|
| 378 |
+
refreshButton.classList.add('spinning');
|
| 379 |
+
refreshButton.disabled = true;
|
| 380 |
+
} else {
|
| 381 |
+
loadingDiv.style.display = 'block';
|
| 382 |
+
contentViews.forEach(view => view.classList.remove('active'));
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
try {
|
| 386 |
+
const response = await fetch('/api/data');
|
| 387 |
+
if (!response.ok) throw new Error(`Network response was not ok: ${response.statusText}`);
|
| 388 |
+
allData = await response.json();
|
| 389 |
+
|
| 390 |
+
if(allData.error) throw new Error(allData.error);
|
| 391 |
+
|
| 392 |
+
if (!isManualRefresh) {
|
| 393 |
+
loadingDiv.style.display = 'none';
|
| 394 |
+
document.querySelector('.nav-link.active')?.click();
|
| 395 |
+
// Connect to WebSocket only after initial data is loaded
|
| 396 |
+
setupSocketIO();
|
| 397 |
+
}
|
| 398 |
+
renderAll();
|
| 399 |
+
} catch (error) {
|
| 400 |
+
loadingDiv.textContent = `Failed to load data. ${error.message}`;
|
| 401 |
+
console.error('Fetch error:', error);
|
| 402 |
+
} finally {
|
| 403 |
+
if (isManualRefresh) {
|
| 404 |
+
refreshButton.classList.remove('spinning');
|
| 405 |
+
refreshButton.disabled = false;
|
| 406 |
+
}
|
| 407 |
+
}
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
// --- Event Listeners ---
|
| 411 |
+
function setupEventListeners() {
|
| 412 |
+
document.getElementById('refresh-data').addEventListener('click', () => fetchData(true));
|
| 413 |
+
|
| 414 |
+
document.getElementById('upcoming-appointments-filter').addEventListener('click', (e) => {
|
| 415 |
+
if (e.target.tagName === 'BUTTON') {
|
| 416 |
+
document.querySelectorAll('#upcoming-appointments-filter button').forEach(btn => btn.classList.remove('active'));
|
| 417 |
+
e.target.classList.add('active');
|
| 418 |
+
upcomingAppointmentsFilter = e.target.dataset.period;
|
| 419 |
+
upcomingAppointmentsPage = 1;
|
| 420 |
+
renderUpcomingAppointments();
|
| 421 |
+
}
|
| 422 |
+
});
|
| 423 |
+
|
| 424 |
+
maxItemsInput.addEventListener('change', () => {
|
| 425 |
+
const newValue = parseInt(maxItemsInput.value, 10);
|
| 426 |
+
if (newValue && newValue > 0) {
|
| 427 |
+
currentResultsPerPage = newValue;
|
| 428 |
+
upcomingAppointmentsPage = 1;
|
| 429 |
+
renderUpcomingAppointments();
|
| 430 |
+
}
|
| 431 |
+
});
|
| 432 |
+
|
| 433 |
+
document.getElementById('upcoming-prev-btn').addEventListener('click', () => {
|
| 434 |
+
if (upcomingAppointmentsPage > 1) {
|
| 435 |
+
upcomingAppointmentsPage--;
|
| 436 |
+
renderUpcomingAppointments();
|
| 437 |
+
}
|
| 438 |
+
});
|
| 439 |
+
document.getElementById('upcoming-next-btn').addEventListener('click', () => {
|
| 440 |
+
upcomingAppointmentsPage++;
|
| 441 |
+
renderUpcomingAppointments();
|
| 442 |
+
});
|
| 443 |
+
|
| 444 |
+
document.getElementById('filter-input').addEventListener('input', (e) => {
|
| 445 |
+
appointmentsFilter = e.target.value.toLowerCase();
|
| 446 |
+
renderAppointmentsTable();
|
| 447 |
+
});
|
| 448 |
+
|
| 449 |
+
// Toggle cancelled appointments
|
| 450 |
+
document.getElementById('toggle-cancelled-btn').addEventListener('click', () => {
|
| 451 |
+
showCancelled = !showCancelled;
|
| 452 |
+
const btn = document.getElementById('toggle-cancelled-btn');
|
| 453 |
+
if (showCancelled) {
|
| 454 |
+
btn.textContent = 'Hide Cancelled';
|
| 455 |
+
btn.classList.add('showing-cancelled');
|
| 456 |
+
} else {
|
| 457 |
+
btn.textContent = 'Show Cancelled';
|
| 458 |
+
btn.classList.remove('showing-cancelled');
|
| 459 |
+
}
|
| 460 |
+
renderAppointmentsTable();
|
| 461 |
+
});
|
| 462 |
+
|
| 463 |
+
// Column visibility toggle
|
| 464 |
+
document.getElementById('toggle-columns-btn').addEventListener('click', () => {
|
| 465 |
+
const menu = document.getElementById('column-visibility-menu');
|
| 466 |
+
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
|
| 467 |
+
});
|
| 468 |
+
|
| 469 |
+
document.getElementById('column-visibility-menu').addEventListener('change', (e) => {
|
| 470 |
+
if (e.target.type === 'checkbox') {
|
| 471 |
+
const column = e.target.dataset.column;
|
| 472 |
+
const isVisible = e.target.checked;
|
| 473 |
+
toggleColumnVisibility(column, isVisible);
|
| 474 |
+
}
|
| 475 |
+
});
|
| 476 |
+
|
| 477 |
+
// Close column menu when clicking outside
|
| 478 |
+
document.addEventListener('click', (e) => {
|
| 479 |
+
const menu = document.getElementById('column-visibility-menu');
|
| 480 |
+
const toggleBtn = document.getElementById('toggle-columns-btn');
|
| 481 |
+
if (!menu.contains(e.target) && !toggleBtn.contains(e.target)) {
|
| 482 |
+
menu.style.display = 'none';
|
| 483 |
+
}
|
| 484 |
+
});
|
| 485 |
+
|
| 486 |
+
document.querySelector('#appointments-table thead').addEventListener('click', (e) => {
|
| 487 |
+
const header = e.target.closest('th.sortable');
|
| 488 |
+
if (!header) return;
|
| 489 |
+
const column = header.dataset.column;
|
| 490 |
+
if (appointmentsSort.column === column) {
|
| 491 |
+
appointmentsSort.direction = appointmentsSort.direction === 'asc' ? 'desc' : 'asc';
|
| 492 |
+
} else {
|
| 493 |
+
appointmentsSort.column = column;
|
| 494 |
+
appointmentsSort.direction = 'asc';
|
| 495 |
+
}
|
| 496 |
+
renderAppointmentsTable();
|
| 497 |
+
});
|
| 498 |
+
|
| 499 |
+
document.querySelector('#appointments-table tbody').addEventListener('change', async (e) => {
|
| 500 |
+
if (e.target.classList.contains('status-dropdown')) {
|
| 501 |
+
const dropdown = e.target;
|
| 502 |
+
const recordId = dropdown.dataset.id;
|
| 503 |
+
const newStatus = dropdown.value;
|
| 504 |
+
const oldClass = dropdown.className;
|
| 505 |
+
try {
|
| 506 |
+
dropdown.className = `status-dropdown status-${newStatus}`;
|
| 507 |
+
const response = await fetch(`/api/appointments/update_status/${recordId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ Status: newStatus }) });
|
| 508 |
+
const result = await response.json();
|
| 509 |
+
if (!result.success) throw new Error(result.error);
|
| 510 |
+
const appointment = allData.appointments.find(a => a.id === recordId);
|
| 511 |
+
if (appointment) appointment.Status = newStatus;
|
| 512 |
+
} catch (error) {
|
| 513 |
+
console.error('Update error:', error);
|
| 514 |
+
dropdown.className = oldClass;
|
| 515 |
+
dropdown.value = oldClass.match(/status-(\w+)/)[1];
|
| 516 |
+
console.error('Could not update status.');
|
| 517 |
+
}
|
| 518 |
+
}
|
| 519 |
+
});
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
// --- Column Visibility Management ---
|
| 523 |
+
function toggleColumnVisibility(columnName, isVisible) {
|
| 524 |
+
const table = document.getElementById('appointments-table');
|
| 525 |
+
const headers = table.querySelectorAll('th');
|
| 526 |
+
const rows = table.querySelectorAll('tbody tr');
|
| 527 |
+
|
| 528 |
+
let columnIndex = -1;
|
| 529 |
+
headers.forEach((header, index) => {
|
| 530 |
+
if (header.dataset && header.dataset.column === columnName) {
|
| 531 |
+
columnIndex = index;
|
| 532 |
+
header.style.display = isVisible ? '' : 'none';
|
| 533 |
+
} else if (header.textContent.includes(columnName)) {
|
| 534 |
+
columnIndex = index;
|
| 535 |
+
header.style.display = isVisible ? '' : 'none';
|
| 536 |
+
}
|
| 537 |
+
});
|
| 538 |
+
|
| 539 |
+
if (columnIndex !== -1) {
|
| 540 |
+
rows.forEach(row => {
|
| 541 |
+
const cell = row.cells[columnIndex];
|
| 542 |
+
if (cell) cell.style.display = isVisible ? '' : 'none';
|
| 543 |
+
});
|
| 544 |
+
}
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
// --- Rendering ---
|
| 548 |
+
function renderAll() {
|
| 549 |
+
renderUpcomingAppointments();
|
| 550 |
+
renderDashboard();
|
| 551 |
+
renderAppointmentsTable();
|
| 552 |
+
renderCallsTable();
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
function renderUpcomingAppointments() {
|
| 556 |
+
const listContainer = document.getElementById('upcoming-appointments-list');
|
| 557 |
+
const pageInfo = document.getElementById('upcoming-page-info');
|
| 558 |
+
const prevBtn = document.getElementById('upcoming-prev-btn');
|
| 559 |
+
const nextBtn = document.getElementById('upcoming-next-btn');
|
| 560 |
+
const paginationControls = document.getElementById('upcoming-pagination-controls');
|
| 561 |
+
|
| 562 |
+
const now = new Date();
|
| 563 |
+
let allUpcoming = allData.appointments
|
| 564 |
+
.filter(a => {
|
| 565 |
+
// Filter out cancelled appointments
|
| 566 |
+
const bookingStatus = (a['Booking Status'] || '').toLowerCase();
|
| 567 |
+
return a.starttime &&
|
| 568 |
+
new Date(a.starttime) > now &&
|
| 569 |
+
bookingStatus !== 'cancelled';
|
| 570 |
+
})
|
| 571 |
+
.sort((a, b) => new Date(a.starttime) - new Date(b.starttime));
|
| 572 |
+
|
| 573 |
+
const { end } = getPeriodRange(upcomingAppointmentsFilter);
|
| 574 |
+
let filteredUpcoming = allUpcoming.filter(a => new Date(a.starttime) <= end);
|
| 575 |
+
|
| 576 |
+
const totalPages = Math.ceil(filteredUpcoming.length / currentResultsPerPage);
|
| 577 |
+
if (upcomingAppointmentsPage > totalPages) upcomingAppointmentsPage = totalPages || 1;
|
| 578 |
+
|
| 579 |
+
const startIndex = (upcomingAppointmentsPage - 1) * currentResultsPerPage;
|
| 580 |
+
const endIndex = startIndex + currentResultsPerPage;
|
| 581 |
+
const paginatedItems = filteredUpcoming.slice(startIndex, endIndex);
|
| 582 |
+
|
| 583 |
+
listContainer.innerHTML = '';
|
| 584 |
+
paginatedItems.forEach(a => {
|
| 585 |
+
const appDate = new Date(a.starttime);
|
| 586 |
+
const month = appDate.toLocaleString('en-US', { month: 'short' }).toUpperCase();
|
| 587 |
+
const day = appDate.getDate();
|
| 588 |
+
const details = appDate.toLocaleString('en-US', { weekday: 'long', hour: 'numeric', minute: '2-digit', hour12: true });
|
| 589 |
+
|
| 590 |
+
const item = document.createElement('div');
|
| 591 |
+
item.className = 'appointment-item';
|
| 592 |
+
item.innerHTML = `
|
| 593 |
+
<div class="appointment-time">
|
| 594 |
+
<span>${month}</span>
|
| 595 |
+
<span>${day}</span>
|
| 596 |
+
</div>
|
| 597 |
+
<div class="appointment-details">
|
| 598 |
+
<strong>${a.Name || 'N/A'}</strong>
|
| 599 |
+
<span>${details}</span>
|
| 600 |
+
<span class="appointment-description">${a.meetdescription || ''}</span>
|
| 601 |
+
</div>
|
| 602 |
+
`;
|
| 603 |
+
listContainer.appendChild(item);
|
| 604 |
+
});
|
| 605 |
+
|
| 606 |
+
if (totalPages > 1) {
|
| 607 |
+
paginationControls.style.display = 'flex';
|
| 608 |
+
pageInfo.textContent = `Page ${upcomingAppointmentsPage} of ${totalPages}`;
|
| 609 |
+
prevBtn.disabled = upcomingAppointmentsPage === 1;
|
| 610 |
+
nextBtn.disabled = upcomingAppointmentsPage === totalPages;
|
| 611 |
+
} else {
|
| 612 |
+
paginationControls.style.display = 'none';
|
| 613 |
+
}
|
| 614 |
+
}
|
| 615 |
+
|
| 616 |
+
function renderDashboard() {
|
| 617 |
+
const summaryGrid = document.getElementById('dashboard-summary-grid');
|
| 618 |
+
const chartsGrid = document.getElementById('dashboard-charts-grid');
|
| 619 |
+
const today = new Date().toISOString().split('T')[0];
|
| 620 |
+
|
| 621 |
+
// Filter out cancelled appointments for dashboard summary
|
| 622 |
+
const todaysAppointments = allData.appointments.filter(a => {
|
| 623 |
+
const bookingStatus = (a['Booking Status'] || '').toLowerCase();
|
| 624 |
+
return a.starttime &&
|
| 625 |
+
a.starttime.startsWith(today) &&
|
| 626 |
+
bookingStatus !== 'cancelled';
|
| 627 |
+
});
|
| 628 |
+
|
| 629 |
+
const uniquePatients = [...new Set(allData.appointments.map(a => a.Name).filter(Boolean))];
|
| 630 |
+
|
| 631 |
+
summaryGrid.innerHTML = `
|
| 632 |
+
<div class="card"><div class="card-header"><div class="chart-title">Today's Appointments</div></div><div class="card-content">${todaysAppointments.length}</div><div class="card-footer">Confirmed appointments only</div></div>
|
| 633 |
+
<div class="card"><div class="card-header"><div class="chart-title">Total Patients</div></div><div class="card-content">${uniquePatients.length}</div><div class="card-footer">All-time records</div></div>
|
| 634 |
+
<div class="card"><div class="card-header"><div class="chart-title">Total Call Logs</div></div><div class="card-content">${allData.calls.length}</div><div class="card-footer">All-time records</div></div>
|
| 635 |
+
`;
|
| 636 |
+
|
| 637 |
+
chartsGrid.innerHTML = `
|
| 638 |
+
<div class="card">
|
| 639 |
+
<div class="card-header">
|
| 640 |
+
<div class="chart-title-section">
|
| 641 |
+
<span class="chart-title">Booking Status Breakdown</span>
|
| 642 |
+
<span class="chart-subtitle" id="booking-status-subtitle"></span>
|
| 643 |
+
</div>
|
| 644 |
+
<div class="time-filter-group">
|
| 645 |
+
<div class="time-filter" id="booking-status-filter">
|
| 646 |
+
<button data-period="today" class="active">Today</button>
|
| 647 |
+
<button data-period="this-week">This Week</button>
|
| 648 |
+
<button data-period="this-month">This Month</button>
|
| 649 |
+
<button data-period="this-year">This Year</button>
|
| 650 |
+
</div>
|
| 651 |
+
</div>
|
| 652 |
+
</div>
|
| 653 |
+
<div class="chart-canvas-container"><canvas id="bookingStatusChart"></canvas></div>
|
| 654 |
+
</div>
|
| 655 |
+
<div class="card">
|
| 656 |
+
<div class="card-header">
|
| 657 |
+
<span class="chart-title">Schedule</span>
|
| 658 |
+
<div class="time-filter-group">
|
| 659 |
+
<div class="time-filter" id="schedule-timeline-filter">
|
| 660 |
+
<button data-view="week" class="active">Week</button>
|
| 661 |
+
<button data-view="month">Month</button>
|
| 662 |
+
<button data-view="year">Year</button>
|
| 663 |
+
</div>
|
| 664 |
+
<button id="reset-schedule-view" class="icon-button" title="Reset view">
|
| 665 |
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path fill-rule="evenodd" d="M4.755 10.059a7.5 7.5 0 0112.548-3.364l1.903 1.903h-4.518a.75.75 0 00-.75.75v.008c0 .414.336.75.75.75h5.25a.75.75 0 00.75-.75v-5.25a.75.75 0 00-.75-.75h-.008a.75.75 0 00-.75.75v4.518l-1.903-1.903a9 9 0 00-15.057 4.042.75.75 0 00.58 1.157 7.5 7.5 0 01.548-2.223z" clip-rule="evenodd" /><path fill-rule="evenodd" d="M19.245 13.941a7.5 7.5 0 01-12.548 3.364l-1.903-1.903h4.518a.75.75 0 00.75-.75v-.008a.75.75 0 00-.75-.75h-5.25a.75.75 0 00-.75.75v5.25a.75.75 0 00.75.75h.008a.75.75 0 00.75-.75v-4.518l1.903 1.903a9 9 0 0015.057-4.042.75.75 0 00-.58-1.157 7.5 7.5 0 01-.548 2.223z" clip-rule="evenodd" /></svg>
|
| 666 |
+
</button>
|
| 667 |
+
</div>
|
| 668 |
+
</div>
|
| 669 |
+
<div class="chart-canvas-container"><canvas id="scheduleTimelineChart"></canvas></div>
|
| 670 |
+
</div>
|
| 671 |
+
`;
|
| 672 |
+
|
| 673 |
+
renderBookingStatusChart();
|
| 674 |
+
renderScheduleTimelineChart();
|
| 675 |
+
|
| 676 |
+
document.getElementById('booking-status-filter').addEventListener('click', e => {
|
| 677 |
+
if (e.target.tagName === 'BUTTON') {
|
| 678 |
+
document.querySelectorAll('#booking-status-filter button').forEach(btn => btn.classList.remove('active'));
|
| 679 |
+
e.target.classList.add('active');
|
| 680 |
+
currentPieChartFilter = e.target.dataset.period;
|
| 681 |
+
renderBookingStatusChart();
|
| 682 |
+
}
|
| 683 |
+
});
|
| 684 |
+
|
| 685 |
+
document.getElementById('schedule-timeline-filter').addEventListener('click', e => {
|
| 686 |
+
if (e.target.tagName === 'BUTTON') {
|
| 687 |
+
document.querySelectorAll('#schedule-timeline-filter button').forEach(btn => btn.classList.remove('active'));
|
| 688 |
+
e.target.classList.add('active');
|
| 689 |
+
scheduleTimelineView = e.target.dataset.view;
|
| 690 |
+
renderScheduleTimelineChart();
|
| 691 |
+
}
|
| 692 |
+
});
|
| 693 |
+
|
| 694 |
+
document.getElementById('reset-schedule-view').addEventListener('click', () => {
|
| 695 |
+
renderScheduleTimelineChart(true);
|
| 696 |
+
});
|
| 697 |
+
}
|
| 698 |
+
|
| 699 |
+
function renderBookingStatusChart() {
|
| 700 |
+
const subtitleEl = document.getElementById('booking-status-subtitle');
|
| 701 |
+
const chartContainer = document.getElementById('bookingStatusChart').parentElement;
|
| 702 |
+
const {start, end} = getPeriodRange(currentPieChartFilter);
|
| 703 |
+
|
| 704 |
+
const options = { month: 'short', day: 'numeric', year: 'numeric' };
|
| 705 |
+
const formattedStart = start.toLocaleDateString('en-US', options);
|
| 706 |
+
const formattedEnd = end.toLocaleDateString('en-US', options);
|
| 707 |
+
subtitleEl.textContent = formattedStart === formattedEnd ? formattedStart : `${formattedStart} - ${formattedEnd}`;
|
| 708 |
+
|
| 709 |
+
const filteredData = allData.appointments.filter(a => {
|
| 710 |
+
if (!a.starttime) return false;
|
| 711 |
+
const appTime = new Date(a.starttime);
|
| 712 |
+
return appTime >= start && appTime <= end;
|
| 713 |
+
});
|
| 714 |
+
|
| 715 |
+
if (charts.bookingStatus) charts.bookingStatus.destroy();
|
| 716 |
+
const existingMsg = chartContainer.querySelector('.no-data-message');
|
| 717 |
+
if (existingMsg) existingMsg.remove();
|
| 718 |
+
|
| 719 |
+
if (filteredData.length === 0) {
|
| 720 |
+
const noDataMsg = document.createElement('div');
|
| 721 |
+
noDataMsg.className = 'no-data-message';
|
| 722 |
+
noDataMsg.textContent = 'No appointments found for this period.';
|
| 723 |
+
chartContainer.appendChild(noDataMsg);
|
| 724 |
+
return;
|
| 725 |
+
}
|
| 726 |
+
|
| 727 |
+
const statusCounts = filteredData.reduce((acc, a) => {
|
| 728 |
+
const status = (a['Booking Status'] || 'Unknown').toLowerCase();
|
| 729 |
+
acc[status] = (acc[status] || 0) + 1;
|
| 730 |
+
return acc;
|
| 731 |
+
}, {});
|
| 732 |
+
|
| 733 |
+
const chartData = {
|
| 734 |
+
labels: Object.keys(statusCounts).map(s => s.charAt(0).toUpperCase() + s.slice(1)),
|
| 735 |
+
datasets: [{
|
| 736 |
+
data: Object.values(statusCounts),
|
| 737 |
+
backgroundColor: Object.keys(statusCounts).map(status => {
|
| 738 |
+
switch(status.toLowerCase()) {
|
| 739 |
+
case 'confirmed': return '#4CAF50';
|
| 740 |
+
case 'cancelled': return '#F44336';
|
| 741 |
+
default: return '#9E9E9E';
|
| 742 |
+
}
|
| 743 |
+
})
|
| 744 |
+
}]
|
| 745 |
+
};
|
| 746 |
+
|
| 747 |
+
const chartOptions = {
|
| 748 |
+
responsive: true, maintainAspectRatio: false,
|
| 749 |
+
plugins: {
|
| 750 |
+
legend: { position: 'top', labels: { color: getComputedStyle(document.body).getPropertyValue('--text-medium') } },
|
| 751 |
+
datalabels: {
|
| 752 |
+
formatter: (value, ctx) => {
|
| 753 |
+
const sum = ctx.chart.data.datasets[0].data.reduce((a, b) => a + b, 0);
|
| 754 |
+
const percentage = sum > 0 ? (value * 100 / sum).toFixed(0) + '%' : '0%';
|
| 755 |
+
return percentage;
|
| 756 |
+
},
|
| 757 |
+
color: '#fff', font: { weight: 'bold' }
|
| 758 |
+
}
|
| 759 |
+
}
|
| 760 |
+
};
|
| 761 |
+
|
| 762 |
+
charts.bookingStatus = new Chart(document.getElementById('bookingStatusChart'), { type: 'pie', data: chartData, options: chartOptions });
|
| 763 |
+
}
|
| 764 |
+
|
| 765 |
+
function renderScheduleTimelineChart(isReset = false) {
|
| 766 |
+
if (isReset) {
|
| 767 |
+
scheduleTimelineView = 'week';
|
| 768 |
+
document.querySelectorAll('#schedule-timeline-filter button').forEach(btn => {
|
| 769 |
+
btn.classList.toggle('active', btn.dataset.view === 'week');
|
| 770 |
+
});
|
| 771 |
+
}
|
| 772 |
+
|
| 773 |
+
const now = new Date();
|
| 774 |
+
let minDate, maxDate, timeUnit;
|
| 775 |
+
|
| 776 |
+
if (scheduleTimelineView === 'week') {
|
| 777 |
+
const startView = new Date();
|
| 778 |
+
startView.setDate(startView.getDate() - 3);
|
| 779 |
+
startView.setHours(0, 0, 0, 0);
|
| 780 |
+
const endView = new Date(startView);
|
| 781 |
+
endView.setDate(startView.getDate() + 6);
|
| 782 |
+
endView.setHours(23, 59, 59, 999);
|
| 783 |
+
minDate = startView;
|
| 784 |
+
maxDate = endView;
|
| 785 |
+
timeUnit = 'day';
|
| 786 |
+
} else if (scheduleTimelineView === 'month') {
|
| 787 |
+
minDate = new Date(now.getFullYear(), now.getMonth(), 1);
|
| 788 |
+
maxDate = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
| 789 |
+
maxDate.setHours(23, 59, 59, 999);
|
| 790 |
+
timeUnit = 'week';
|
| 791 |
+
} else if (scheduleTimelineView === 'year') {
|
| 792 |
+
minDate = new Date(now.getFullYear(), 0, 1);
|
| 793 |
+
maxDate = new Date(now.getFullYear(), 11, 31);
|
| 794 |
+
maxDate.setHours(23, 59, 59, 999);
|
| 795 |
+
timeUnit = 'month';
|
| 796 |
+
}
|
| 797 |
+
|
| 798 |
+
// Filter out cancelled appointments from schedule timeline
|
| 799 |
+
const chartData = allData.appointments
|
| 800 |
+
.filter(a => {
|
| 801 |
+
const bookingStatus = (a['Booking Status'] || '').toLowerCase();
|
| 802 |
+
return a.starttime && a.endtime && bookingStatus !== 'cancelled';
|
| 803 |
+
})
|
| 804 |
+
.sort((a, b) => new Date(a.starttime) - new Date(b.starttime));
|
| 805 |
+
|
| 806 |
+
const data = {
|
| 807 |
+
labels: chartData.map(a => a.Name),
|
| 808 |
+
datasets: [{
|
| 809 |
+
label: "Appointment",
|
| 810 |
+
data: chartData.map(a => [new Date(a.starttime).getTime(), new Date(a.endtime).getTime()]),
|
| 811 |
+
backgroundColor: '#667eea'
|
| 812 |
+
}]
|
| 813 |
+
};
|
| 814 |
+
|
| 815 |
+
const options = {
|
| 816 |
+
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
|
| 817 |
+
scales: {
|
| 818 |
+
x: {
|
| 819 |
+
type: 'time', time: { unit: timeUnit }, min: minDate.getTime(), max: maxDate.getTime(),
|
| 820 |
+
ticks: { color: getComputedStyle(document.body).getPropertyValue('--text-medium') },
|
| 821 |
+
grid: { color: getComputedStyle(document.body).getPropertyValue('--border-color') }
|
| 822 |
+
},
|
| 823 |
+
y: {
|
| 824 |
+
ticks: { color: getComputedStyle(document.body).getPropertyValue('--text-medium') },
|
| 825 |
+
grid: { color: getComputedStyle(document.body).getPropertyValue('--border-color') }
|
| 826 |
+
}
|
| 827 |
+
},
|
| 828 |
+
plugins: {
|
| 829 |
+
legend: { display: false },
|
| 830 |
+
tooltip: {
|
| 831 |
+
callbacks: {
|
| 832 |
+
label: (context) => {
|
| 833 |
+
const start = new Date(context.raw[0]).toLocaleString([], {dateStyle:'short', timeStyle: 'short'});
|
| 834 |
+
const end = new Date(context.raw[1]).toLocaleString([], {timeStyle: 'short'});
|
| 835 |
+
return `${context.label}: ${start} - ${end}`;
|
| 836 |
+
}
|
| 837 |
+
}
|
| 838 |
+
},
|
| 839 |
+
datalabels: { display: false },
|
| 840 |
+
zoom: {
|
| 841 |
+
pan: { enabled: true, mode: 'x' },
|
| 842 |
+
zoom: { wheel: { enabled: true }, pinch: { enabled: true }, mode: 'x' }
|
| 843 |
+
}
|
| 844 |
+
}
|
| 845 |
+
};
|
| 846 |
+
if(charts.schedule) charts.schedule.destroy();
|
| 847 |
+
charts.schedule = new Chart(document.getElementById('scheduleTimelineChart'), { type: 'bar', data: data, options: options });
|
| 848 |
+
}
|
| 849 |
+
|
| 850 |
+
function renderAppointmentsTable() {
|
| 851 |
+
let filteredAppointments = allData.appointments.filter(a => {
|
| 852 |
+
const bookingStatus = (a['Booking Status'] || '').toLowerCase();
|
| 853 |
+
|
| 854 |
+
// Filter out cancelled appointments unless showCancelled is true
|
| 855 |
+
if (!showCancelled && bookingStatus === 'cancelled') {
|
| 856 |
+
return false;
|
| 857 |
+
}
|
| 858 |
+
|
| 859 |
+
// Apply text filter
|
| 860 |
+
if (appointmentsFilter) {
|
| 861 |
+
return Object.values(a).some(val => String(val).toLowerCase().includes(appointmentsFilter));
|
| 862 |
+
}
|
| 863 |
+
|
| 864 |
+
return true;
|
| 865 |
+
});
|
| 866 |
+
|
| 867 |
+
filteredAppointments.sort((a, b) => {
|
| 868 |
+
let valA, valB;
|
| 869 |
+
|
| 870 |
+
if (appointmentsSort.column === 'sl') {
|
| 871 |
+
// For serial number, use the original index
|
| 872 |
+
valA = allData.appointments.indexOf(a) + 1;
|
| 873 |
+
valB = allData.appointments.indexOf(b) + 1;
|
| 874 |
+
} else {
|
| 875 |
+
valA = a[appointmentsSort.column] || '';
|
| 876 |
+
valB = b[appointmentsSort.column] || '';
|
| 877 |
+
|
| 878 |
+
if (appointmentsSort.column === 'starttime') {
|
| 879 |
+
valA = valA ? new Date(valA).getTime() : 0;
|
| 880 |
+
valB = valB ? new Date(valB).getTime() : 0;
|
| 881 |
+
}
|
| 882 |
+
}
|
| 883 |
+
|
| 884 |
+
if (valA < valB) return appointmentsSort.direction === 'asc' ? -1 : 1;
|
| 885 |
+
if (valA > valB) return appointmentsSort.direction === 'asc' ? 1 : -1;
|
| 886 |
+
return 0;
|
| 887 |
+
});
|
| 888 |
+
|
| 889 |
+
const tbody = document.querySelector('#appointments-table tbody');
|
| 890 |
+
const headers = document.querySelectorAll('#appointments-table th.sortable');
|
| 891 |
+
|
| 892 |
+
headers.forEach(th => {
|
| 893 |
+
const icon = th.querySelector('.sort-icon');
|
| 894 |
+
if (th.dataset.column === appointmentsSort.column) {
|
| 895 |
+
icon.innerHTML = appointmentsSort.direction === 'asc' ? '▲' : '▼';
|
| 896 |
+
} else { icon.innerHTML = ''; }
|
| 897 |
+
});
|
| 898 |
+
|
| 899 |
+
tbody.innerHTML = '';
|
| 900 |
+
filteredAppointments.forEach((a, index) => {
|
| 901 |
+
const row = tbody.insertRow();
|
| 902 |
+
const status = (a.Status || 'incomplete').toLowerCase();
|
| 903 |
+
const bookingStatus = (a['Booking Status'] || '').toLowerCase();
|
| 904 |
+
const options = ['incomplete', 'complete', 'missed'];
|
| 905 |
+
const optionsHtml = options.map(opt => `<option value="${opt}" ${status === opt ? 'selected' : ''}>${opt.charAt(0).toUpperCase() + opt.slice(1)}</option>`).join('');
|
| 906 |
+
|
| 907 |
+
// Add cancelled class if booking status is cancelled
|
| 908 |
+
if (bookingStatus === 'cancelled') {
|
| 909 |
+
row.classList.add('cancelled-row');
|
| 910 |
+
}
|
| 911 |
+
|
| 912 |
+
const originalIndex = allData.appointments.indexOf(a) + 1;
|
| 913 |
+
|
| 914 |
+
row.innerHTML = `
|
| 915 |
+
<td>${originalIndex}</td>
|
| 916 |
+
<td>${a.Name || 'N/A'}</td>
|
| 917 |
+
<td>${a.starttime ? new Date(a.starttime).toLocaleString() : 'N/A'}</td>
|
| 918 |
+
<td>${a.meetdescription || ''}</td>
|
| 919 |
+
<td>${a['Booking Status'] || 'Unknown'}</td>
|
| 920 |
+
<td>${a['Phone Number'] || 'N/A'}</td>
|
| 921 |
+
<td style="display: none;">${a['Caller Phone Number'] || 'N/A'}</td>
|
| 922 |
+
<td>${a.eventId || 'N/A'}</td>
|
| 923 |
+
<td><select class="status-dropdown status-${status}" data-id="${a.id}">${optionsHtml}</select></td>`;
|
| 924 |
+
});
|
| 925 |
+
}
|
| 926 |
+
|
| 927 |
+
function renderCallsTable() {
|
| 928 |
+
const tbody = document.querySelector('#calls-table tbody');
|
| 929 |
+
tbody.innerHTML = '';
|
| 930 |
+
allData.calls.forEach(c => {
|
| 931 |
+
const row = tbody.insertRow();
|
| 932 |
+
row.innerHTML = `<td>${c.customer_Number||'N/A'}</td><td>${c.startedAt?new Date(c.startedAt).toLocaleString():'N/A'}</td><td>${c.endedAt?new Date(c.endedAt).toLocaleString():'N/A'}</td><td class="summary-cell">${c.callsummary||'N/A'}</td>`;
|
| 933 |
+
});
|
| 934 |
+
}
|
| 935 |
+
|
| 936 |
+
// --- Utility Functions ---
|
| 937 |
+
function getPeriodRange(period) {
|
| 938 |
+
const now = new Date();
|
| 939 |
+
let start, end;
|
| 940 |
+
|
| 941 |
+
switch (period) {
|
| 942 |
+
case 'today':
|
| 943 |
+
start = new Date(); start.setHours(0, 0, 0, 0);
|
| 944 |
+
end = new Date(); end.setHours(23, 59, 59, 999);
|
| 945 |
+
break;
|
| 946 |
+
case 'this-week':
|
| 947 |
+
const todayForWeek = new Date();
|
| 948 |
+
const dayOfWeek = todayForWeek.getDay();
|
| 949 |
+
const diffToMonday = todayForWeek.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1);
|
| 950 |
+
start = new Date(todayForWeek.setDate(diffToMonday));
|
| 951 |
+
start.setHours(0, 0, 0, 0);
|
| 952 |
+
end = new Date(start);
|
| 953 |
+
end.setDate(start.getDate() + 6);
|
| 954 |
+
end.setHours(23, 59, 59, 999);
|
| 955 |
+
break;
|
| 956 |
+
case 'this-month':
|
| 957 |
+
start = new Date(now.getFullYear(), now.getMonth(), 1);
|
| 958 |
+
end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
| 959 |
+
end.setHours(23, 59, 59, 999);
|
| 960 |
+
break;
|
| 961 |
+
case 'this-year':
|
| 962 |
+
start = new Date(now.getFullYear(), 0, 1);
|
| 963 |
+
end = new Date(now.getFullYear(), 11, 31);
|
| 964 |
+
end.setHours(23, 59, 59, 999);
|
| 965 |
+
break;
|
| 966 |
+
}
|
| 967 |
+
return { start, end };
|
| 968 |
+
}
|
| 969 |
+
|
| 970 |
+
function makeTableResizable(table) {
|
| 971 |
+
const headers = table.querySelectorAll('th.resizable');
|
| 972 |
+
headers.forEach(header => {
|
| 973 |
+
const resizer = header.querySelector('.resizer');
|
| 974 |
+
let startX, startWidth;
|
| 975 |
+
|
| 976 |
+
resizer.addEventListener('mousedown', (e) => {
|
| 977 |
+
e.preventDefault();
|
| 978 |
+
startX = e.pageX;
|
| 979 |
+
startWidth = header.offsetWidth;
|
| 980 |
+
document.addEventListener('mousemove', onMouseMove);
|
| 981 |
+
document.addEventListener('mouseup', onMouseUp);
|
| 982 |
+
resizer.classList.add('resizing');
|
| 983 |
+
});
|
| 984 |
+
|
| 985 |
+
function onMouseMove(e) {
|
| 986 |
+
const newWidth = startWidth + (e.pageX - startX);
|
| 987 |
+
if (newWidth > 50) { header.style.width = `${newWidth}px`; }
|
| 988 |
+
}
|
| 989 |
+
|
| 990 |
+
function onMouseUp() {
|
| 991 |
+
document.removeEventListener('mousemove', onMouseMove);
|
| 992 |
+
document.removeEventListener('mouseup', onMouseUp);
|
| 993 |
+
resizer.classList.remove('resizing');
|
| 994 |
+
}
|
| 995 |
+
});
|
| 996 |
+
}
|
| 997 |
+
|
| 998 |
+
initialize();
|
| 999 |
+
});
|
| 1000 |
+
</script>
|
| 1001 |
+
</body>
|
| 1002 |
+
</html>
|
| 1003 |
+
|
templates/login.html
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Dental Admin Login</title>
|
| 7 |
+
<style>
|
| 8 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
| 9 |
+
:root {
|
| 10 |
+
--primary-bg: #F8F8F8;
|
| 11 |
+
--card-bg: #FFFFFF;
|
| 12 |
+
--text-dark: #333333;
|
| 13 |
+
--text-medium: #666666;
|
| 14 |
+
--accent-blue: #2196F3;
|
| 15 |
+
--shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
|
| 16 |
+
--border-radius-lg: 20px;
|
| 17 |
+
}
|
| 18 |
+
body {
|
| 19 |
+
font-family: 'Inter', sans-serif;
|
| 20 |
+
background-color: var(--primary-bg);
|
| 21 |
+
display: flex;
|
| 22 |
+
align-items: center;
|
| 23 |
+
justify-content: center;
|
| 24 |
+
min-height: 100vh;
|
| 25 |
+
margin: 0;
|
| 26 |
+
color: var(--text-dark);
|
| 27 |
+
}
|
| 28 |
+
.login-container {
|
| 29 |
+
display: flex;
|
| 30 |
+
width: 900px;
|
| 31 |
+
height: 600px;
|
| 32 |
+
background-color: var(--card-bg);
|
| 33 |
+
border-radius: var(--border-radius-lg);
|
| 34 |
+
box-shadow: var(--shadow);
|
| 35 |
+
overflow: hidden;
|
| 36 |
+
}
|
| 37 |
+
.login-form-section {
|
| 38 |
+
flex: 1;
|
| 39 |
+
padding: 60px;
|
| 40 |
+
display: flex;
|
| 41 |
+
flex-direction: column;
|
| 42 |
+
justify-content: center;
|
| 43 |
+
}
|
| 44 |
+
.login-image-section {
|
| 45 |
+
flex: 1;
|
| 46 |
+
background: linear-gradient(rgba(33, 150, 243, 0.8), rgba(33, 150, 243, 0.8)), url('https://images.unsplash.com/photo-1588776814546-1ff20a3a7aa8?q=80&w=1974&auto=format&fit=crop');
|
| 47 |
+
background-size: cover;
|
| 48 |
+
background-position: center;
|
| 49 |
+
}
|
| 50 |
+
.logo {
|
| 51 |
+
display: flex;
|
| 52 |
+
align-items: center;
|
| 53 |
+
gap: 10px;
|
| 54 |
+
font-weight: 700;
|
| 55 |
+
font-size: 1.5rem;
|
| 56 |
+
color: var(--text-dark);
|
| 57 |
+
margin-bottom: 10px;
|
| 58 |
+
}
|
| 59 |
+
.logo-icon {
|
| 60 |
+
font-size: 1.8rem;
|
| 61 |
+
}
|
| 62 |
+
h1 {
|
| 63 |
+
font-size: 2rem;
|
| 64 |
+
font-weight: 700;
|
| 65 |
+
margin-bottom: 10px;
|
| 66 |
+
}
|
| 67 |
+
.subtitle {
|
| 68 |
+
color: var(--text-medium);
|
| 69 |
+
margin-bottom: 30px;
|
| 70 |
+
}
|
| 71 |
+
.form-group {
|
| 72 |
+
margin-bottom: 20px;
|
| 73 |
+
}
|
| 74 |
+
.form-group label {
|
| 75 |
+
display: block;
|
| 76 |
+
font-weight: 500;
|
| 77 |
+
margin-bottom: 8px;
|
| 78 |
+
font-size: 0.9rem;
|
| 79 |
+
}
|
| 80 |
+
.form-group input {
|
| 81 |
+
width: 100%;
|
| 82 |
+
padding: 12px 15px;
|
| 83 |
+
border: 1px solid #E0E0E0;
|
| 84 |
+
border-radius: 8px;
|
| 85 |
+
font-size: 1rem;
|
| 86 |
+
transition: border-color 0.2s ease;
|
| 87 |
+
}
|
| 88 |
+
.form-group input:focus {
|
| 89 |
+
outline: none;
|
| 90 |
+
border-color: var(--accent-blue);
|
| 91 |
+
}
|
| 92 |
+
.login-button {
|
| 93 |
+
width: 100%;
|
| 94 |
+
padding: 15px;
|
| 95 |
+
background-color: var(--accent-blue);
|
| 96 |
+
color: white;
|
| 97 |
+
border: none;
|
| 98 |
+
border-radius: 8px;
|
| 99 |
+
font-size: 1rem;
|
| 100 |
+
font-weight: 600;
|
| 101 |
+
cursor: pointer;
|
| 102 |
+
transition: background-color 0.2s ease;
|
| 103 |
+
}
|
| 104 |
+
.login-button:hover {
|
| 105 |
+
background-color: #1976D2;
|
| 106 |
+
}
|
| 107 |
+
.error-message {
|
| 108 |
+
background-color: #ffebee;
|
| 109 |
+
color: #d32f2f;
|
| 110 |
+
padding: 10px;
|
| 111 |
+
border-radius: 8px;
|
| 112 |
+
text-align: center;
|
| 113 |
+
margin-bottom: 20px;
|
| 114 |
+
font-size: 0.9rem;
|
| 115 |
+
}
|
| 116 |
+
</style>
|
| 117 |
+
</head>
|
| 118 |
+
<body>
|
| 119 |
+
<div class="login-container">
|
| 120 |
+
<div class="login-form-section">
|
| 121 |
+
<div class="logo">
|
| 122 |
+
<span class="logo-icon">🦷</span>
|
| 123 |
+
<span>DentalAdmin</span>
|
| 124 |
+
</div>
|
| 125 |
+
<h1>Welcome back!</h1>
|
| 126 |
+
<p class="subtitle">Please enter your details to sign in.</p>
|
| 127 |
+
|
| 128 |
+
{% if error %}
|
| 129 |
+
<div class="error-message">{{ error }}</div>
|
| 130 |
+
{% endif %}
|
| 131 |
+
|
| 132 |
+
<form action="/login" method="post">
|
| 133 |
+
<div class="form-group">
|
| 134 |
+
<label for="username">Username</label>
|
| 135 |
+
<input type="text" id="username" name="username" required>
|
| 136 |
+
</div>
|
| 137 |
+
<div class="form-group">
|
| 138 |
+
<label for="password">Password</label>
|
| 139 |
+
<input type="password" id="password" name="password" required>
|
| 140 |
+
</div>
|
| 141 |
+
<button type="submit" class="login-button">Sign In</button>
|
| 142 |
+
</form>
|
| 143 |
+
</div>
|
| 144 |
+
<div class="login-image-section">
|
| 145 |
+
<!-- Background image is set via CSS -->
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
</body>
|
| 149 |
+
</html>
|
| 150 |
+
|