SakibAhmed commited on
Commit
929abaf
·
verified ·
1 Parent(s): 1457b80

Upload 7 files

Browse files
Files changed (7) hide show
  1. .env +19 -0
  2. Dockerfile +45 -0
  3. app.py +260 -0
  4. data/users.csv +4 -0
  5. requirements.txt +4 -0
  6. templates/dashboard.html +1003 -0
  7. 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>&lt; Previous</button>
203
+ <span id="upcoming-page-info"></span>
204
+ <button id="upcoming-next-btn">Next &gt;</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
+