dt / dashboard.html
SakibAhmed's picture
Upload dashboard.html
7a6fc06 verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Enhanced Dental Admin Dashboard</title>
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.2.0"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0"></script>
<script src="https://cdn.jsdelivr.net/npm/hammerjs@2.0.8"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
:root {
--primary-bg: #F8F8F8; --sidebar-bg: #FFFFFF; --card-bg: #FFFFFF; --text-dark: #333333; --text-medium: #666666; --text-light: #AAAAAA; --border-color: #DDDDDD; --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;
}
html.dark-mode {
--primary-bg: #121212; --sidebar-bg: #1E1E1E; --card-bg: #1E1E1E; --text-dark: #E0E0E0; --text-medium: #A0A0A0; --text-light: #707070; --border-color: #333333;
}
* { margin: 0; padding: 0; box-sizing: border-box; transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease; }
body { font-family: 'Inter', sans-serif; background-color: var(--primary-bg); min-height: 100vh; display: flex; color: var(--text-dark); }
.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); }
.logo { display: flex; align-items: center; gap: 10px; margin-bottom: 30px; font-weight: 700; font-size: 1.2rem; }
.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; }
.user-profile { display: flex; align-items: center; gap: 15px; margin-bottom: 20px; padding-bottom: 20px; border-bottom: 1px solid var(--border-color); }
.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; }
.user-info div { font-weight: 600; font-size: 1rem; }
.user-info span { font-size: 0.85rem; color: var(--text-medium); }
.nav-menu { list-style: none; flex-grow: 1; }
.nav-item { margin-bottom: 8px; }
.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; }
.nav-link:hover, .nav-link.active { background: #2195f320; color: var(--accent-blue); }
.nav-icon { font-size: 1.2rem; width: 24px; text-align: center; }
.logout-link { color: var(--accent-red) !important; margin-top: auto; }
.logout-link:hover { background: #f4433620 !important; }
.main-content { flex: 1; padding: 30px; }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; }
.header h1 { font-size: 2rem; font-weight: 700; }
.header-controls { display: flex; align-items: center; gap: 10px; }
.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;}
.icon-button:hover { background-color: #80808030; color: var(--text-dark); }
.icon-button:disabled { cursor: not-allowed; opacity: 0.5; }
.icon-button svg { width: 24px; height: 24px; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.spinning { animation: spin 1s linear infinite; }
.content-view { display: none; animation: fadeIn 0.5s; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.content-view.active { display: block; }
.dashboard-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 25px; margin-bottom: 30px; }
.charts-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); gap: 25px; }
.card { background: var(--card-bg); border-radius: var(--border-radius-lg); padding: 25px; box-shadow: var(--shadow); display: flex; flex-direction: column; }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; flex-wrap: wrap; gap: 10px;}
.chart-title-section { display: flex; flex-direction: column; }
.chart-title { font-size: 1.1rem; font-weight: 600; }
.chart-subtitle { font-size: 0.8rem; color: var(--text-medium); margin-top: 4px; }
.card-content { font-size: 2.2rem; font-weight: 700; margin-bottom: 5px; }
.card-footer { font-size: 0.85rem; color: var(--text-medium); }
.table-container { background: var(--card-bg); padding: 20px; border-radius: 20px; box-shadow: var(--shadow); overflow-x: auto; }
.table-controls { margin-bottom: 15px; display: flex; gap: 15px; align-items: center; justify-content: space-between; flex-wrap: wrap; }
#filter-input, #calls-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); }
.column-controls { position: relative; }
.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; }
.column-menu label { display: block; padding: 5px 0; cursor: pointer; font-size: 0.9rem; color: var(--text-dark); }
.column-menu label:hover { background: var(--primary-bg); padding-left: 5px; padding-right: 5px; border-radius: 4px; }
.column-menu input[type="checkbox"] { margin-right: 8px; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 12px 15px; text-align: left; border: 1px solid var(--border-color); position: relative; vertical-align: middle; }
th { font-weight: 600; font-size: 0.9rem; color: var(--text-medium); background-color: var(--primary-bg); }
th.sortable { cursor: pointer; user-select: none; }
th.sortable:hover { color: var(--text-dark); }
th .sort-icon { margin-left: 5px; opacity: 0.5; }
td { font-size: 0.95rem; }
td.summary-cell { white-space: normal; min-width: 250px; }
th.resizable { position: relative; }
.resizer { position: absolute; top: 0; right: -2px; width: 5px; cursor: col-resize; user-select: none; height: 100%; z-index: 10; }
.resizer:hover, .resizing { border-right: 2px solid var(--accent-blue); }
/* Enhanced inline editing styles */
.editable { cursor: pointer; position: relative; border-radius: 4px; padding: 2px 4px; transition: all 0.2s ease; }
.editable:hover { background-color: rgba(33, 150, 243, 0.1); }
.editable.editing { background-color: white; border: 2px solid var(--accent-blue); box-shadow: 0 0 0 3px rgba(33, 150, 243, 0.1); }
.editable input, .editable select, .editable textarea { width: 100%; border: none; outline: none; background: transparent; font-family: inherit; font-size: inherit; color: inherit; padding: 2px; }
.editable textarea { resize: vertical; min-height: 60px; }
.edit-indicator { position: absolute; top: -2px; right: -2px; width: 8px; height: 8px; background: var(--accent-blue); border-radius: 50%; opacity: 0; transition: opacity 0.2s; }
.editable:hover .edit-indicator { opacity: 1; }
.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);}
.status-dropdown.status-complete { background-color: #4caf5020; color: #4CAF50; }
.status-dropdown.status-incomplete { background-color: #f4433620; color: #F44336; }
.status-dropdown.status-missed { background-color: #ffc10740; color: #FFC107; }
.loading { text-align: center; padding: 50px; font-size: 1.2rem; color: var(--text-medium); }
.time-filter-group { display: flex; align-items: center; gap: 10px; }
.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); }
.time-filter button.active, .time-filter button:hover { background-color: var(--accent-blue); color: white; border-color: var(--accent-blue); }
.chart-canvas-container { position: relative; height: 100%; flex-grow: 1; min-height: 300px; }
.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; }
.upcoming-appointments-container { margin-top: 30px; }
.upcoming-appointments-list { display: flex; flex-direction: column; gap: 15px; min-height: 200px; }
.appointment-item { display: flex; align-items: center; gap: 15px; padding: 10px; border-radius: var(--border-radius-md); background-color: var(--primary-bg); }
.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; }
.appointment-time span:first-child { font-size: 0.75rem; }
.appointment-time span:last-child { font-size: 1.2rem; line-height: 1; }
.appointment-details { display: flex; flex-direction: column; overflow: hidden; }
.appointment-details strong { font-weight: 600; color: var(--text-dark); }
.appointment-details span { font-size: 0.9rem; color: var(--text-medium); }
.appointment-description { font-size: 0.85rem; color: var(--text-medium); margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#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%; }
.pagination-controls { display: flex; justify-content: space-between; align-items: center; margin-top: 20px; padding-top: 15px; border-top: 1px solid var(--border-color); }
.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); }
.pagination-controls button:hover:not(:disabled) { background-color: var(--accent-blue); color: white; border-color: var(--accent-blue); }
.pagination-controls button:disabled { opacity: 0.5; cursor: not-allowed; }
#upcoming-page-info { font-size: 0.9rem; font-weight: 500; color: var(--text-medium); }
.card-controls-group { display: flex; align-items: center; gap: 20px; flex-wrap: wrap; }
.items-per-page-group { display: flex; align-items: center; gap: 8px; }
.items-per-page-group label { font-size: 0.85rem; color: var(--text-medium); font-weight: 500; }
.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; }
.toggle-button { border: none; padding: 6px 12px; border-radius: 8px; cursor: pointer; font-weight: 500; font-size: 0.85rem; }
.toggle-button.cancelled-toggle-btn { background-color: var(--accent-red); color: white; }
.toggle-button.cancelled-toggle-btn:hover { background-color: #d32f2f; }
.toggle-button.past-toggle-btn { background-color: var(--accent-yellow); color: black; }
.toggle-button.past-toggle-btn:hover { background-color: #ffb300; }
.toggle-button.cancelled-toggle-btn.showing-cancelled,
.toggle-button.past-toggle-btn.showing-past {
background-color: #E0E0E0;
color: black;
}
.toggle-button.cancelled-toggle-btn.showing-cancelled:hover,
.toggle-button.past-toggle-btn.showing-past:hover {
background-color: #BDBDBD;
color: black;
}
.cancelled-row { background-color: rgba(244, 67, 54, 0.1); opacity: 0.6; }
.past-row { background-color: rgba(255, 193, 7, 0.1); }
.hidden { display: none; }
/* Change log styles */
.change-log-container { max-height: 400px; overflow-y: auto; }
.change-log-item { padding: 10px; border-bottom: 1px solid var(--border-color); font-size: 0.85rem; }
.change-log-item:last-child { border-bottom: none; }
.change-log-timestamp { font-weight: 600; color: var(--accent-blue); }
.change-log-user { font-weight: 500; color: var(--accent-green); }
.change-log-details { color: var(--text-medium); margin-top: 4px; }
/* Toast notifications */
.toast { position: fixed; top: 20px; right: 20px; background: var(--accent-green); color: white; padding: 12px 20px; border-radius: 8px; box-shadow: var(--shadow); z-index: 1000; transform: translateX(400px); transition: transform 0.3s ease; }
.toast.show { transform: translateX(0); }
.toast.error { background: var(--accent-red); }
.toast.warning { background: var(--accent-yellow); color: var(--text-dark); }
/* Serial number column for calls */
.serial-number { font-weight: 600; color: var(--text-medium); }
/* Column resizing cursor */
.col-resize { cursor: col-resize; }
/* Make appointment datetime column wider */
.datetime-column { min-width: 250px; }
/* Duration styling */
.duration-text { font-size: 0.85rem; color: var(--text-medium); font-style: italic; }
/* DOB column styling */
.dob-column { min-width: 140px; }
</style>
</head>
<body>
<div class="sidebar">
<div class="logo"><div class="logo-icon">🦷</div><span>DentalAdmin Pro</span></div>
<div class="user-profile">
<div class="avatar" id="user-avatar"></div>
<div class="user-info">
<div id="username-display"></div>
<span>Administrator</span>
</div>
</div>
<nav>
<ul class="nav-menu">
<li class="nav-item"><a class="nav-link active" data-view="dashboard-view"><span class="nav-icon">📊</span> Dashboard</a></li>
<li class="nav-item"><a class="nav-link" data-view="appointments-view"><span class="nav-icon">🗓️</span> Appointments</a></li>
<li class="nav-item"><a class="nav-link" data-view="calls-view"><span class="nav-icon">📞</span> Call Logs</a></li>
<li class="nav-item"><a class="nav-link" data-view="changelog-view"><span class="nav-icon">📝</span> Change Log</a></li>
<li class="nav-item"><a class="nav-link logout-link" href="/logout"><span class="nav-icon">🚪</span> Logout</a></li>
</ul>
</nav>
</div>
<div class="main-content">
<div class="header">
<h1 id="header-title">Dashboard</h1>
<div class="header-controls">
<button id="refresh-data" class="icon-button" title="Refresh data">
<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>
</button>
<button id="theme-toggle" class="icon-button" title="Toggle dark mode">
<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>
<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>
</button>
</div>
</div>
<div id="loading" class="loading">Loading data from Airtable... Please wait.</div>
<div id="dashboard-view" class="content-view">
<div id="dashboard-summary-grid" class="dashboard-grid"></div>
<div id="dashboard-charts-grid" class="charts-grid"></div>
<div class="upcoming-appointments-container card">
<div class="card-header">
<h2 class="chart-title">Upcoming Appointments</h2>
<div class="card-controls-group">
<div class="time-filter-group">
<div class="time-filter" id="upcoming-appointments-filter">
<button data-period="this-week" class="active">This Week</button>
<button data-period="this-month">This Month</button>
<button data-period="this-year">This Year</button>
</div>
</div>
<div class="items-per-page-group">
<label for="max-items-input">Items/Page:</label>
<input type="number" id="max-items-input" min="1" max="100" value="50">
</div>
</div>
</div>
<div id="upcoming-appointments-list" class="upcoming-appointments-list"></div>
<div class="pagination-controls" id="upcoming-pagination-controls">
<button id="upcoming-prev-btn" disabled>&lt; Previous</button>
<span id="upcoming-page-info"></span>
<button id="upcoming-next-btn">Next &gt;</button>
</div>
</div>
</div>
<div id="appointments-view" class="content-view">
<div class="table-container">
<div class="table-controls">
<input type="text" id="filter-input" placeholder="Filter appointments...">
<div style="display: flex; gap: 10px; align-items: center;">
<button id="toggle-past-btn" class="toggle-button past-toggle-btn">Show Past</button>
<button id="toggle-cancelled-btn" class="toggle-button cancelled-toggle-btn">Show Cancelled</button>
<div class="column-controls">
<button id="toggle-columns-btn" class="icon-button" title="Show/Hide Columns">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M3 4h18v2H3V4zm0 7h12v2H3v-2zm0 7h18v2H3v-2z"/></svg>
</button>
<div id="column-visibility-menu" class="column-menu" style="display: none;">
<label><input type="checkbox" data-column="sl" checked> Sl</label>
<label><input type="checkbox" data-column="Name" checked> Patient Name</label>
<label><input type="checkbox" data-column="dob" checked> Date of Birth</label>
<label><input type="checkbox" data-column="start_time" checked> Appointment Date & Time</label>
<label><input type="checkbox" data-column="notes" checked> Notes</label>
<label><input type="checkbox" data-column="reason" checked> Reason</label>
<label><input type="checkbox" data-column="Insurance" checked> Insurance</label>
<label><input type="checkbox" data-column="Booking Status" checked> Booking Status</label>
<label><input type="checkbox" data-column="Phone Number" checked> Phone</label>
<label><input type="checkbox" data-column="Email"> Email</label>
<label><input type="checkbox" data-column="Caller Phone Number"> Caller Phone Number</label>
<label><input type="checkbox" data-column="eventId" checked> Event ID</label>
<label><input type="checkbox" data-column="Created"> Booked on</label>
<label><input type="checkbox" data-column="Last Modified"> Last Modified</label>
<label><input type="checkbox" data-column="Status" checked> Status</label>
</div>
</div>
</div>
</div>
<table id="appointments-table">
<thead>
<tr>
<th class="sortable resizable" data-column="sl" style="width: 60px;">Sl <span class="sort-icon"></span><div class="resizer"></div></th>
<th class="sortable resizable" data-column="Name" style="width: 150px;">Patient Name <span class="sort-icon"></span><div class="resizer"></div></th>
<th class="sortable resizable dob-column" data-column="dob" style="width: 140px;">Date of Birth <span class="sort-icon"></span><div class="resizer"></div></th>
<th class="sortable resizable datetime-column" data-column="start_time">Appointment Date & Time <span class="sort-icon"></span><div class="resizer"></div></th>
<th class="sortable resizable" data-column="notes" style="width: 200px;">Notes <span class="sort-icon"></span><div class="resizer"></div></th>
<th class="sortable resizable" data-column="reason" style="width: 150px;">Reason <span class="sort-icon"></span><div class="resizer"></div></th>
<th class="sortable resizable" data-column="Insurance" style="width: 120px;">Insurance <span class="sort-icon"></span><div class="resizer"></div></th>
<th class="sortable resizable" data-column="Booking Status" style="width: 130px;">Booking Status <span class="sort-icon"></span><div class="resizer"></div></th>
<th class="sortable resizable" data-column="Phone Number" style="width: 140px;">Phone <span class="sort-icon"></span><div class="resizer"></div></th>
<th class="sortable resizable" data-column="Email" style="display: none; width: 180px;">Email <span class="sort-icon"></span><div class="resizer"></div></th>
<th class="sortable resizable" data-column="Caller Phone Number" style="display: none; width: 160px;">Caller Phone Number <span class="sort-icon"></span><div class="resizer"></div></th>
<th class="sortable resizable" data-column="eventId" style="width: 100px;">Event ID <span class="sort-icon"></span><div class="resizer"></div></th>
<th class="sortable resizable" data-column="Created" style="display: none; width: 180px;">Booked on<span class="sort-icon"></span><div class="resizer"></div></th>
<th class="sortable resizable" data-column="Last Modified" style="display: none; width: 180px;">Last Modified<span class="sort-icon"></span><div class="resizer"></div></th>
<th class="resizable" style="width: 100px;">Status<div class="resizer"></div></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<div id="calls-view" class="content-view">
<div class="table-container">
<div class="table-controls">
<input type="text" id="calls-filter-input" placeholder="Filter call logs...">
</div>
<table id="calls-table">
<thead>
<tr>
<th class="sortable resizable" data-column="serial" style="width:80px;">Sl <span class="sort-icon"></span><div class="resizer"></div></th>
<th class="sortable resizable" data-column="customer_Number" style="width:200px;">Caller Number <span class="sort-icon"></span><div class="resizer"></div></th>
<th class="sortable resizable" data-column="startedAt" style="width:220px;">Started At <span class="sort-icon"></span><div class="resizer"></div></th>
<th class="sortable resizable" data-column="endedAt" style="width:180px;">Ended At <span class="sort-icon"></span><div class="resizer"></div></th>
<th class="resizable">Summary<div class="resizer"></div></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<div id="changelog-view" class="content-view">
<div class="card">
<div class="card-header">
<h2 class="chart-title">Recent Changes</h2>
<button id="refresh-changelog" class="icon-button" title="Refresh change log">
<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" /></svg>
</button>
</div>
<div class="change-log-container" id="change-log-container">
<div class="loading">Loading change log...</div>
</div>
</div>
</div>
</div>
<div id="toast-container"></div>
<script>
// Register Chart.js plugins globally
Chart.register(ChartDataLabels, ChartZoom);
document.addEventListener('DOMContentLoaded', function() {
// --- State Management ---
let allData = { appointments: [], calls: [] };
let charts = {};
let currentPieChartFilter = 'today';
let scheduleTimelineView = 'week';
let upcomingAppointmentsFilter = 'this-week';
let upcomingAppointmentsPage = 1;
let appointmentsSort = { column: 'start_time', direction: 'asc' };
let callsSort = { column: 'startedAt', direction: 'desc' };
let appointmentsFilter = '';
let callsFilter = '';
let currentResultsPerPage = 50;
let showCancelled = false;
let showPast = false;
let userPreferences = {};
let socket;
// --- DOM Elements ---
const loadingDiv = document.getElementById('loading');
const headerTitle = document.getElementById('header-title');
const username = "{{ username }}";
const contentViews = document.querySelectorAll('.content-view');
const navLinks = document.querySelectorAll('.nav-link');
const maxItemsInput = document.getElementById('max-items-input');
// --- Initialization ---
function initialize() {
setupTheme();
setupNavigation();
setupEventListeners();
loadUserPreferences();
fetchData();
}
// --- User Preferences ---
async function loadUserPreferences() {
try {
const response = await fetch('/api/preferences');
if (response.ok) {
userPreferences = await response.json();
applyUserPreferences();
}
} catch (error) {
console.error('Error loading preferences:', error);
}
}
async function saveUserPreferences() {
try {
await fetch('/api/preferences', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userPreferences)
});
} catch (error) {
console.error('Error saving preferences:', error);
}
}
function applyUserPreferences() {
if (userPreferences.appointmentsSort) appointmentsSort = userPreferences.appointmentsSort;
if (userPreferences.callsSort) callsSort = userPreferences.callsSort;
if (userPreferences.showCancelled !== undefined) {
showCancelled = userPreferences.showCancelled;
updateButtonState('toggle-cancelled-btn', showCancelled, 'Hide Cancelled', 'Show Cancelled', 'showing-cancelled');
}
if (userPreferences.showPast !== undefined) {
showPast = userPreferences.showPast;
updateButtonState('toggle-past-btn', showPast, 'Hide Past', 'Show Past', 'showing-past');
}
}
function updatePreference(key, value) {
userPreferences[key] = value;
saveUserPreferences();
}
// --- Toast Notifications ---
function showToast(message, type = 'success') {
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
const container = document.getElementById('toast-container');
container.appendChild(toast);
setTimeout(() => toast.classList.add('show'), 100);
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => container.removeChild(toast), 300);
}, 3000);
}
// --- Real-time Communication ---
function setupSocketIO() {
if (socket) return;
socket = io();
socket.on('connect', () => {
console.log('Connected to real-time server.');
});
socket.on('refresh_data', (data) => {
console.log('Refresh event received:', data.message);
fetchData(true);
});
socket.on('disconnect', () => {
console.log('Disconnected from real-time server.');
});
}
// --- Theme Management ---
function setupTheme() {
const themeToggle = document.getElementById('theme-toggle');
const sunIcon = document.getElementById('theme-icon-sun');
const moonIcon = document.getElementById('theme-icon-moon');
document.getElementById('username-display').textContent = username;
document.getElementById('user-avatar').textContent = username.charAt(0);
const applyTheme = (theme) => {
if (theme === 'dark') {
document.documentElement.classList.add('dark-mode');
sunIcon.style.display = 'block';
moonIcon.style.display = 'none';
} else {
document.documentElement.classList.remove('dark-mode');
sunIcon.style.display = 'none';
moonIcon.style.display = 'block';
}
};
let currentTheme = localStorage.getItem('theme') || 'light';
applyTheme(currentTheme);
themeToggle.addEventListener('click', () => {
currentTheme = document.documentElement.classList.contains('dark-mode') ? 'light' : 'dark';
localStorage.setItem('theme', currentTheme);
applyTheme(currentTheme);
});
}
// --- Navigation ---
function setupNavigation() {
navLinks.forEach(link => {
if (!link.href.includes('/logout')) {
link.addEventListener('click', (e) => {
e.preventDefault();
const viewId = link.getAttribute('data-view');
navLinks.forEach(l => l.classList.remove('active'));
link.classList.add('active');
contentViews.forEach(view => view.classList.remove('active'));
const viewEl = document.getElementById(viewId);
if(viewEl) {
viewEl.classList.add('active');
if(viewId === 'calls-view' || viewId === 'appointments-view') {
makeTableResizable(document.querySelector(`#${viewId} table`));
} else if(viewId === 'changelog-view') {
loadChangeLog();
}
}
headerTitle.textContent = link.textContent.trim();
});
}
});
}
// --- Utility Functions ---
function calculateDuration(start_time, end_time) {
if (!start_time || !end_time) return '';
const start = new Date(start_time);
const end = new Date(end_time);
const diffMs = end - start;
const diffMins = Math.round(diffMs / (1000 * 60));
const hours = Math.floor(diffMins / 60);
const minutes = diffMins % 60;
if (hours > 0) {
return `(${hours}h ${minutes}m)`;
} else {
return `(${minutes}m)`;
}
}
function formatAppointmentDateTime(start_time, end_time) {
if (!start_time) return 'N/A';
const start = new Date(start_time);
const startFormatted = start.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true
});
if (!end_time) return startFormatted;
const end = new Date(end_time);
const endTimeFormatted = end.toLocaleString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
const duration = calculateDuration(start_time, end_time);
return `${startFormatted} - ${endTimeFormatted}<br><span class="duration-text">${duration}</span>`;
}
function formatDateOfBirth(dob) {
if (!dob) return '';
try {
const date = new Date(dob);
if (isNaN(date.getTime())) return '';
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
} catch (error) {
return '';
}
}
// --- Inline Editing Functions ---
function makeEditable(element, recordId, fieldName, currentValue, inputType = 'text') {
if (element.classList.contains('editing')) return;
element.classList.add('editing');
const originalContent = element.innerHTML;
let input;
if (inputType === 'textarea') {
input = document.createElement('textarea');
input.rows = 3;
} else if (inputType === 'select') {
input = document.createElement('select');
if (fieldName === 'Status') {
['incomplete', 'complete', 'missed'].forEach(option => {
const opt = document.createElement('option');
opt.value = option;
opt.textContent = option.charAt(0).toUpperCase() + option.slice(1);
if (option === currentValue.toLowerCase()) opt.selected = true;
input.appendChild(opt);
});
}
} else {
input = document.createElement('input');
input.type = inputType;
// Handle date input special formatting
if (inputType === 'date' && currentValue) {
try {
const date = new Date(currentValue);
if (!isNaN(date.getTime())) {
input.value = date.toISOString().split('T')[0];
}
} catch (error) {
console.error('Date parsing error:', error);
}
}
}
if (inputType !== 'date') {
input.value = currentValue || '';
}
element.innerHTML = '';
element.appendChild(input);
input.focus();
const saveEdit = async () => {
let newValue = input.value.trim();
// Special handling for date fields
if (inputType === 'date' && newValue) {
try {
const dateObj = new Date(newValue);
if (!isNaN(dateObj.getTime())) {
newValue = dateObj.toISOString().split('T')[0];
}
} catch (error) {
console.error('Date formatting error:', error);
}
}
if (newValue === currentValue) {
cancelEdit();
return;
}
try {
const response = await fetch(`/api/appointments/update/${recordId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
field_name: fieldName,
new_value: newValue,
old_value: currentValue
})
});
const result = await response.json();
if (result.success) {
showToast(`Updated ${fieldName} successfully`);
const appointment = allData.appointments.find(a => a.id === recordId);
if (appointment) {
appointment[fieldName] = newValue;
}
if (fieldName === 'start_time' || fieldName === 'end_time') {
element.innerHTML = formatAppointmentDateTime(appointment.start_time, appointment.end_time);
element.dataset.currentValue = newValue;
} else if (fieldName === 'dob') {
element.textContent = formatDateOfBirth(newValue);
element.dataset.currentValue = newValue;
} else {
element.textContent = newValue;
}
element.classList.remove('editing');
if (!element.querySelector('.edit-indicator')) {
const indicator = document.createElement('div');
indicator.className = 'edit-indicator';
element.prepend(indicator);
}
} else {
throw new Error(result.error);
}
} catch (error) {
showToast(`Failed to update ${fieldName}: ${error.message}`, 'error');
cancelEdit();
}
};
const cancelEdit = () => {
element.innerHTML = originalContent;
element.classList.remove('editing');
};
input.addEventListener('blur', saveEdit);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && inputType !== 'textarea') {
e.preventDefault();
saveEdit();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelEdit();
}
});
}
// --- Data Fetching ---
async function fetchData(isManualRefresh = false) {
const refreshButton = document.getElementById('refresh-data');
if (isManualRefresh) {
refreshButton.classList.add('spinning');
refreshButton.disabled = true;
} else {
loadingDiv.style.display = 'block';
contentViews.forEach(view => view.classList.remove('active'));
}
try {
const response = await fetch('/api/data');
if (!response.ok) throw new Error(`Network response was not ok: ${response.statusText}`);
allData = await response.json();
if(allData.error) throw new Error(allData.error);
if (!isManualRefresh) {
loadingDiv.style.display = 'none';
document.querySelector('.nav-link.active')?.click();
setupSocketIO();
}
renderAll();
if (isManualRefresh) {
showToast('Data refreshed successfully');
}
} catch (error) {
const message = `Failed to load data. ${error.message}`;
if (isManualRefresh) {
showToast(message, 'error');
} else {
loadingDiv.textContent = message;
}
console.error('Fetch error:', error);
} finally {
if (isManualRefresh) {
refreshButton.classList.remove('spinning');
refreshButton.disabled = false;
}
}
}
// --- Change Log ---
async function loadChangeLog() {
try {
const response = await fetch('/api/change_log');
const data = await response.json();
renderChangeLog(data.changes || []);
} catch (error) {
console.error('Error loading change log:', error);
document.getElementById('change-log-container').innerHTML = '<div class="loading">Error loading change log.</div>';
}
}
function renderChangeLog(changes) {
const container = document.getElementById('change-log-container');
if (changes.length === 0) {
container.innerHTML = '<div class="loading">No changes recorded yet.</div>';
return;
}
container.innerHTML = changes.map(change => {
const timestamp = new Date(change.timestamp).toLocaleString();
return `
<div class="change-log-item">
<div class="change-log-timestamp">${timestamp}</div>
<div class="change-log-details">
<span class="change-log-user">${change.username}</span> updated
<strong>${change.table_name}</strong> record
<strong>${change.field_name}</strong>
from "<em>${change.old_value}</em>" to "<em>${change.new_value}</em>"
</div>
</div>
`;
}).join('');
}
// --- Event Listeners ---
function setupEventListeners() {
document.getElementById('refresh-data').addEventListener('click', () => fetchData(true));
document.getElementById('refresh-changelog').addEventListener('click', loadChangeLog);
document.getElementById('upcoming-appointments-filter').addEventListener('click', (e) => {
if (e.target.tagName === 'BUTTON') {
document.querySelectorAll('#upcoming-appointments-filter button').forEach(btn => btn.classList.remove('active'));
e.target.classList.add('active');
upcomingAppointmentsFilter = e.target.dataset.period;
upcomingAppointmentsPage = 1;
renderUpcomingAppointments();
}
});
maxItemsInput.addEventListener('change', () => {
const newValue = parseInt(maxItemsInput.value, 10);
if (newValue && newValue > 0) {
currentResultsPerPage = newValue;
upcomingAppointmentsPage = 1;
renderUpcomingAppointments();
}
});
document.getElementById('upcoming-prev-btn').addEventListener('click', () => {
if (upcomingAppointmentsPage > 1) {
upcomingAppointmentsPage--;
renderUpcomingAppointments();
}
});
document.getElementById('upcoming-next-btn').addEventListener('click', () => {
upcomingAppointmentsPage++;
renderUpcomingAppointments();
});
document.getElementById('filter-input').addEventListener('input', (e) => {
appointmentsFilter = e.target.value.toLowerCase();
renderAppointmentsTable();
});
document.getElementById('calls-filter-input').addEventListener('input', (e) => {
callsFilter = e.target.value.toLowerCase();
renderCallsTable();
});
// Toggle cancelled appointments
document.getElementById('toggle-cancelled-btn').addEventListener('click', () => {
showCancelled = !showCancelled;
updatePreference('showCancelled', showCancelled);
updateButtonState('toggle-cancelled-btn', showCancelled, 'Hide Cancelled', 'Show Cancelled', 'showing-cancelled');
renderAppointmentsTable();
});
// Toggle past appointments
document.getElementById('toggle-past-btn').addEventListener('click', () => {
showPast = !showPast;
updatePreference('showPast', showPast);
updateButtonState('toggle-past-btn', showPast, 'Hide Past', 'Show Past', 'showing-past');
renderAppointmentsTable();
});
// Column visibility toggle
document.getElementById('toggle-columns-btn').addEventListener('click', () => {
const menu = document.getElementById('column-visibility-menu');
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
});
document.getElementById('column-visibility-menu').addEventListener('change', (e) => {
if (e.target.type === 'checkbox') {
const column = e.target.dataset.column;
const isVisible = e.target.checked;
if (!userPreferences.columnVisibility) userPreferences.columnVisibility = {};
userPreferences.columnVisibility[column] = isVisible;
updatePreference('columnVisibility', userPreferences.columnVisibility);
toggleColumnVisibility(column, isVisible);
}
});
document.addEventListener('click', (e) => {
const menu = document.getElementById('column-visibility-menu');
const toggleBtn = document.getElementById('toggle-columns-btn');
if (menu && toggleBtn && !menu.contains(e.target) && !toggleBtn.contains(e.target)) {
menu.style.display = 'none';
}
});
// Appointments table sorting
document.querySelector('#appointments-table thead').addEventListener('click', (e) => {
const header = e.target.closest('th.sortable');
if (!header) return;
const column = header.dataset.column;
if (appointmentsSort.column === column) {
appointmentsSort.direction = appointmentsSort.direction === 'asc' ? 'desc' : 'asc';
} else {
appointmentsSort.column = column;
appointmentsSort.direction = 'asc';
}
updatePreference('appointmentsSort', appointmentsSort);
renderAppointmentsTable();
});
// Calls table sorting
document.querySelector('#calls-table thead').addEventListener('click', (e) => {
const header = e.target.closest('th.sortable');
if (!header) return;
const column = header.dataset.column;
if (callsSort.column === column) {
callsSort.direction = callsSort.direction === 'asc' ? 'desc' : 'asc';
} else {
callsSort.column = column;
callsSort.direction = 'asc';
}
updatePreference('callsSort', callsSort);
renderCallsTable();
});
// Appointments inline editing
document.querySelector('#appointments-table tbody').addEventListener('click', (e) => {
const cell = e.target.closest('td.editable');
if (!cell) return;
const row = cell.closest('tr');
const recordId = row.dataset.recordId;
const fieldName = cell.dataset.fieldName;
const currentValue = cell.dataset.currentValue !== undefined ? cell.dataset.currentValue : cell.textContent.trim();
const inputType = cell.dataset.inputType || 'text';
makeEditable(cell, recordId, fieldName, currentValue, inputType);
});
document.querySelector('#appointments-table tbody').addEventListener('change', async (e) => {
if (e.target.classList.contains('status-dropdown')) {
const dropdown = e.target;
const recordId = dropdown.dataset.id;
const newStatus = dropdown.value;
const oldClass = dropdown.className;
const oldValue = oldClass.match(/status-(\w+)/)?.[1] || '';
try {
dropdown.className = `status-dropdown status-${newStatus}`;
const response = await fetch(`/api/appointments/update/${recordId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
field_name: 'Status',
new_value: newStatus,
old_value: oldValue
})
});
const result = await response.json();
if (!result.success) throw new Error(result.error);
const appointment = allData.appointments.find(a => a.id === recordId);
if (appointment) appointment.Status = newStatus;
showToast('Status updated successfully');
} catch (error) {
console.error('Update error:', error);
dropdown.className = oldClass;
dropdown.value = oldValue;
showToast('Could not update status', 'error');
}
}
});
}
function updateButtonState(buttonId, isActive, activeText, inactiveText, activeClass) {
const btn = document.getElementById(buttonId);
if (!btn) return;
if (isActive) {
btn.textContent = activeText;
btn.classList.add(activeClass);
} else {
btn.textContent = inactiveText;
btn.classList.remove(activeClass);
}
}
// --- Column Management ---
function toggleColumnVisibility(columnName, isVisible) {
const table = document.getElementById('appointments-table');
const headers = table.querySelectorAll('th');
const rows = table.querySelectorAll('tbody tr');
let columnIndex = -1;
headers.forEach((header, index) => {
if (header.dataset && header.dataset.column === columnName) {
columnIndex = index;
header.style.display = isVisible ? '' : 'none';
}
});
if (columnIndex !== -1) {
rows.forEach(row => {
const cell = row.cells[columnIndex];
if (cell) cell.style.display = isVisible ? '' : 'none';
});
}
}
function syncColumnVisibility() {
const menu = document.getElementById('column-visibility-menu');
if (!menu) return;
const checkboxes = menu.querySelectorAll('input[type="checkbox"]');
const savedVisibility = userPreferences.columnVisibility;
if (!savedVisibility && !userPreferences.hasOwnProperty('columnVisibility')) {
userPreferences.columnVisibility = {};
checkboxes.forEach(cb => {
userPreferences.columnVisibility[cb.dataset.column] = cb.checked;
});
}
checkboxes.forEach(checkbox => {
const column = checkbox.dataset.column;
const isVisible = userPreferences.columnVisibility?.[column] ?? checkbox.checked;
checkbox.checked = isVisible;
toggleColumnVisibility(column, isVisible);
});
}
function applyColumnWidths(tableId) {
const savedWidths = userPreferences.columnWidths?.[tableId];
if (!savedWidths) return;
const table = document.getElementById(tableId);
if (!table) return;
for (const columnKey in savedWidths) {
const header = table.querySelector(`th[data-column="${columnKey}"]`);
if (header) {
header.style.width = savedWidths[columnKey];
}
}
}
// --- Rendering ---
function renderAll() {
renderUpcomingAppointments();
renderDashboard();
renderAppointmentsTable();
renderCallsTable();
}
function renderUpcomingAppointments() {
const listContainer = document.getElementById('upcoming-appointments-list');
const pageInfo = document.getElementById('upcoming-page-info');
const prevBtn = document.getElementById('upcoming-prev-btn');
const nextBtn = document.getElementById('upcoming-next-btn');
const paginationControls = document.getElementById('upcoming-pagination-controls');
const now = new Date();
let allUpcoming = allData.appointments
.filter(a => {
const bookingStatus = (a['Booking Status'] || '').toLowerCase();
return a.start_time &&
new Date(a.start_time) > now &&
bookingStatus !== 'cancelled';
})
.sort((a, b) => new Date(a.start_time) - new Date(b.start_time));
const { end } = getPeriodRange(upcomingAppointmentsFilter);
let filteredUpcoming = allUpcoming.filter(a => new Date(a.start_time) <= end);
const totalPages = Math.ceil(filteredUpcoming.length / currentResultsPerPage);
if (upcomingAppointmentsPage > totalPages) upcomingAppointmentsPage = totalPages || 1;
const startIndex = (upcomingAppointmentsPage - 1) * currentResultsPerPage;
const endIndex = startIndex + currentResultsPerPage;
const paginatedItems = filteredUpcoming.slice(startIndex, endIndex);
listContainer.innerHTML = '';
paginatedItems.forEach(a => {
const appDate = new Date(a.start_time);
const month = appDate.toLocaleString('en-US', { month: 'short' }).toUpperCase();
const day = appDate.getDate();
const details = appDate.toLocaleString('en-US', { weekday: 'long', hour: 'numeric', minute: '2-digit', hour12: true });
const item = document.createElement('div');
item.className = 'appointment-item';
item.innerHTML = `
<div class="appointment-time">
<span>${month}</span>
<span>${day}</span>
</div>
<div class="appointment-details">
<strong>${a.Name || 'N/A'}</strong>
<span>${details}</span>
<span class="appointment-description">${a.reason || ''}</span>
</div>
`;
listContainer.appendChild(item);
});
if (totalPages > 1) {
paginationControls.style.display = 'flex';
pageInfo.textContent = `Page ${upcomingAppointmentsPage} of ${totalPages}`;
prevBtn.disabled = upcomingAppointmentsPage === 1;
nextBtn.disabled = upcomingAppointmentsPage === totalPages;
} else {
paginationControls.style.display = 'none';
}
}
function renderDashboard() {
const summaryGrid = document.getElementById('dashboard-summary-grid');
const chartsGrid = document.getElementById('dashboard-charts-grid');
const today = new Date().toISOString().split('T')[0];
const todaysAppointments = allData.appointments.filter(a => {
const bookingStatus = (a['Booking Status'] || '').toLowerCase();
return a.start_time &&
a.start_time.startsWith(today) &&
bookingStatus !== 'cancelled';
});
const uniquePatients = [...new Set(allData.appointments.map(a => a.Name).filter(Boolean))];
summaryGrid.innerHTML = `
<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>
<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>
<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>
`;
chartsGrid.innerHTML = `
<div class="card">
<div class="card-header">
<div class="chart-title-section">
<span class="chart-title">Booking Status Breakdown</span>
<span class="chart-subtitle" id="booking-status-subtitle"></span>
</div>
<div class="time-filter-group">
<div class="time-filter" id="booking-status-filter">
<button data-period="today" class="active">Today</button>
<button data-period="this-week">This Week</button>
<button data-period="this-month">This Month</button>
<button data-period="this-year">This Year</button>
</div>
</div>
</div>
<div class="chart-canvas-container"><canvas id="bookingStatusChart"></canvas></div>
</div>
<div class="card">
<div class="card-header">
<span class="chart-title">Schedule</span>
<div class="time-filter-group">
<div class="time-filter" id="schedule-timeline-filter">
<button data-view="week" class="active">Week</button>
<button data-view="month">Month</button>
<button data-view="year">Year</button>
</div>
<button id="reset-schedule-view" class="icon-button" title="Reset view">
<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>
</button>
</div>
</div>
<div class="chart-canvas-container"><canvas id="scheduleTimelineChart"></canvas></div>
</div>
`;
renderBookingStatusChart();
renderScheduleTimelineChart();
document.getElementById('booking-status-filter').addEventListener('click', e => {
if (e.target.tagName === 'BUTTON') {
document.querySelectorAll('#booking-status-filter button').forEach(btn => btn.classList.remove('active'));
e.target.classList.add('active');
currentPieChartFilter = e.target.dataset.period;
renderBookingStatusChart();
}
});
document.getElementById('schedule-timeline-filter').addEventListener('click', e => {
if (e.target.tagName === 'BUTTON') {
document.querySelectorAll('#schedule-timeline-filter button').forEach(btn => btn.classList.remove('active'));
e.target.classList.add('active');
scheduleTimelineView = e.target.dataset.view;
renderScheduleTimelineChart();
}
});
document.getElementById('reset-schedule-view').addEventListener('click', () => {
renderScheduleTimelineChart(true);
});
}
function renderBookingStatusChart() {
const subtitleEl = document.getElementById('booking-status-subtitle');
const chartContainer = document.getElementById('bookingStatusChart').parentElement;
const {start, end} = getPeriodRange(currentPieChartFilter);
const options = { month: 'short', day: 'numeric', year: 'numeric' };
const formattedStart = start.toLocaleDateString('en-US', options);
const formattedEnd = end.toLocaleDateString('en-US', options);
subtitleEl.textContent = formattedStart === formattedEnd ? formattedStart : `${formattedStart} - ${formattedEnd}`;
const filteredData = allData.appointments.filter(a => {
if (!a.start_time) return false;
const appTime = new Date(a.start_time);
return appTime >= start && appTime <= end;
});
if (charts.bookingStatus) charts.bookingStatus.destroy();
const existingMsg = chartContainer.querySelector('.no-data-message');
if (existingMsg) existingMsg.remove();
if (filteredData.length === 0) {
const noDataMsg = document.createElement('div');
noDataMsg.className = 'no-data-message';
noDataMsg.textContent = 'No appointments found for this period.';
chartContainer.appendChild(noDataMsg);
return;
}
const statusCounts = filteredData.reduce((acc, a) => {
const status = (a['Booking Status'] || 'Unknown').toLowerCase();
acc[status] = (acc[status] || 0) + 1;
return acc;
}, {});
const chartData = {
labels: Object.keys(statusCounts).map(s => s.charAt(0).toUpperCase() + s.slice(1)),
datasets: [{
data: Object.values(statusCounts),
backgroundColor: Object.keys(statusCounts).map(status => {
switch(status.toLowerCase()) {
case 'confirmed': return '#4CAF50';
case 'cancelled': return '#F44336';
default: return '#9E9E9E';
}
})
}]
};
const chartOptions = {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { position: 'top', labels: { color: getComputedStyle(document.body).getPropertyValue('--text-medium') } },
datalabels: {
formatter: (value, ctx) => {
const sum = ctx.chart.data.datasets[0].data.reduce((a, b) => a + b, 0);
const percentage = sum > 0 ? (value * 100 / sum).toFixed(0) + '%' : '0%';
return percentage;
},
color: '#fff', font: { weight: 'bold' }
}
}
};
charts.bookingStatus = new Chart(document.getElementById('bookingStatusChart'), { type: 'pie', data: chartData, options: chartOptions });
}
function renderScheduleTimelineChart(isReset = false) {
if (isReset) {
scheduleTimelineView = 'week';
document.querySelectorAll('#schedule-timeline-filter button').forEach(btn => {
btn.classList.toggle('active', btn.dataset.view === 'week');
});
}
const now = new Date();
let minDate, maxDate, timeUnit;
if (scheduleTimelineView === 'week') {
const startView = new Date();
startView.setDate(startView.getDate() - 3);
startView.setHours(0, 0, 0, 0);
const endView = new Date(startView);
endView.setDate(startView.getDate() + 6);
endView.setHours(23, 59, 59, 999);
minDate = startView;
maxDate = endView;
timeUnit = 'day';
} else if (scheduleTimelineView === 'month') {
minDate = new Date(now.getFullYear(), now.getMonth(), 1);
maxDate = new Date(now.getFullYear(), now.getMonth() + 1, 0);
maxDate.setHours(23, 59, 59, 999);
timeUnit = 'week';
} else if (scheduleTimelineView === 'year') {
minDate = new Date(now.getFullYear(), 0, 1);
maxDate = new Date(now.getFullYear(), 11, 31);
maxDate.setHours(23, 59, 59, 999);
timeUnit = 'month';
}
const chartData = allData.appointments
.filter(a => {
const bookingStatus = (a['Booking Status'] || '').toLowerCase();
return a.start_time && a.end_time && bookingStatus !== 'cancelled';
})
.sort((a, b) => new Date(a.start_time) - new Date(b.start_time));
const data = {
labels: chartData.map(a => a.Name),
datasets: [{
label: "Appointment",
data: chartData.map(a => [new Date(a.start_time).getTime(), new Date(a.end_time).getTime()]),
backgroundColor: '#667eea'
}]
};
const options = {
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
scales: {
x: {
type: 'time', time: { unit: timeUnit }, min: minDate.getTime(), max: maxDate.getTime(),
ticks: { color: getComputedStyle(document.body).getPropertyValue('--text-medium') },
grid: { color: getComputedStyle(document.body).getPropertyValue('--border-color') }
},
y: {
ticks: { color: getComputedStyle(document.body).getPropertyValue('--text-medium') },
grid: { color: getComputedStyle(document.body).getPropertyValue('--border-color') }
}
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (context) => {
const start = new Date(context.raw[0]).toLocaleString([], {dateStyle:'short', timeStyle: 'short'});
const end = new Date(context.raw[1]).toLocaleString([], {timeStyle: 'short'});
return `${context.label}: ${start} - ${end}`;
}
}
},
datalabels: { display: false },
zoom: {
pan: { enabled: true, mode: 'x' },
zoom: { wheel: { enabled: true }, pinch: { enabled: true }, mode: 'x' }
}
}
};
if(charts.schedule) charts.schedule.destroy();
charts.schedule = new Chart(document.getElementById('scheduleTimelineChart'), { type: 'bar', data: data, options: options });
}
function renderAppointmentsTable() {
const now = new Date();
let filteredAppointments = allData.appointments.filter(a => {
const bookingStatus = (a['Booking Status'] || '').toLowerCase();
if (!showCancelled && bookingStatus === 'cancelled') {
return false;
}
const appDate = a.start_time ? new Date(a.start_time) : null;
const isPast = appDate && appDate < now;
if (!showPast && isPast) {
return false;
}
if (appointmentsFilter) {
return Object.values(a).some(val => String(val).toLowerCase().includes(appointmentsFilter));
}
return true;
});
filteredAppointments.sort((a, b) => {
let valA, valB;
if (appointmentsSort.column === 'sl') {
valA = allData.appointments.indexOf(a) + 1;
valB = allData.appointments.indexOf(b) + 1;
} else {
valA = a[appointmentsSort.column] || '';
valB = b[appointmentsSort.column] || '';
if (['start_time', 'Created', 'Last Modified', 'dob'].includes(appointmentsSort.column)) {
valA = valA ? new Date(valA).getTime() : 0;
valB = valB ? new Date(valB).getTime() : 0;
}
}
if (valA < valB) return appointmentsSort.direction === 'asc' ? -1 : 1;
if (valA > valB) return appointmentsSort.direction === 'asc' ? 1 : -1;
return 0;
});
const tbody = document.querySelector('#appointments-table tbody');
const headers = document.querySelectorAll('#appointments-table th.sortable');
headers.forEach(th => {
const icon = th.querySelector('.sort-icon');
if (th.dataset.column === appointmentsSort.column) {
icon.innerHTML = appointmentsSort.direction === 'asc' ? '▲' : '▼';
} else { icon.innerHTML = ''; }
});
tbody.innerHTML = '';
filteredAppointments.forEach((a, index) => {
const row = tbody.insertRow();
row.dataset.recordId = a.id;
const appDate = a.start_time ? new Date(a.start_time) : null;
const isPastUnfinished = appDate && appDate < now && a.Status !== 'complete';
const displayStatus = isPastUnfinished ? 'missed' : (a.Status || 'incomplete').toLowerCase();
const options = ['incomplete', 'complete', 'missed'];
const optionsHtml = options.map(opt => `<option value="${opt}" ${displayStatus === opt ? 'selected' : ''}>${opt.charAt(0).toUpperCase() + opt.slice(1)}</option>`).join('');
if ((a['Booking Status'] || '').toLowerCase() === 'cancelled') row.classList.add('cancelled-row');
if (isPastUnfinished) row.classList.add('past-row');
row.innerHTML = `
<td>${index + 1}</td>
<td class="editable" data-field-name="Name" data-input-type="text"><div class="edit-indicator"></div>${a.Name || ''}</td>
<td class="editable" data-field-name="dob" data-input-type="date" data-current-value="${a.dob || ''}"><div class="edit-indicator"></div>${formatDateOfBirth(a.dob)}</td>
<td class="editable" data-field-name="start_time" data-input-type="datetime-local" data-current-value="${a.start_time || ''}"><div class="edit-indicator"></div>${formatAppointmentDateTime(a.start_time, a.end_time)}</td>
<td class="editable" data-field-name="notes" data-input-type="textarea"><div class="edit-indicator"></div>${a.notes || ''}</td>
<td class="editable" data-field-name="reason" data-input-type="textarea"><div class="edit-indicator"></div>${a.reason || ''}</td>
<td class="editable" data-field-name="Insurance" data-input-type="text"><div class="edit-indicator"></div>${a.Insurance || ''}</td>
<td class="editable" data-field-name="Booking Status" data-input-type="text"><div class="edit-indicator"></div>${a['Booking Status'] || ''}</td>
<td class="editable" data-field-name="Phone Number" data-input-type="tel"><div class="edit-indicator"></div>${a['Phone Number'] || ''}</td>
<td class="editable" data-field-name="Email" data-input-type="email"><div class="edit-indicator"></div>${a.Email || ''}</td>
<td class="editable" data-field-name="Caller Phone Number" data-input-type="tel"><div class="edit-indicator"></div>${a['Caller Phone Number'] || ''}</td>
<td class="editable" data-field-name="eventId" data-input-type="text"><div class="edit-indicator"></div>${a.eventId || ''}</td>
<td>${a.Created ? new Date(a.Created).toLocaleString() : ''}</td>
<td>${a['Last Modified'] ? new Date(a['Last Modified']).toLocaleString() : ''}</td>
<td><select class="status-dropdown status-${displayStatus}" data-id="${a.id}">${optionsHtml}</select></td>`;
});
syncColumnVisibility();
applyColumnWidths('appointments-table');
}
function renderCallsTable() {
let filteredCalls = allData.calls.filter(c => {
if (callsFilter) {
return Object.values(c).some(val => String(val).toLowerCase().includes(callsFilter));
}
return true;
});
filteredCalls.sort((a, b) => {
let valA, valB;
if (callsSort.column === 'serial') {
valA = a.startedAt ? new Date(a.startedAt).getTime() : 0;
valB = b.startedAt ? new Date(b.startedAt).getTime() : 0;
} else {
valA = a[callsSort.column] || '';
valB = b[callsSort.column] || '';
if (callsSort.column === 'startedAt' || callsSort.column === 'endedAt') {
valA = valA ? new Date(valA).getTime() : 0;
valB = valB ? new Date(valB).getTime() : 0;
}
}
if (valA < valB) return callsSort.direction === 'asc' ? -1 : 1;
if (valA > valB) return callsSort.direction === 'asc' ? 1 : -1;
return 0;
});
const headers = document.querySelectorAll('#calls-table th.sortable');
headers.forEach(th => {
const icon = th.querySelector('.sort-icon');
if (th.dataset.column === callsSort.column) {
icon.innerHTML = callsSort.direction === 'asc' ? '▲' : '▼';
} else {
icon.innerHTML = '';
}
});
const tbody = document.querySelector('#calls-table tbody');
tbody.innerHTML = '';
filteredCalls.forEach((c, index) => {
const row = tbody.insertRow();
const serialNumber = index + 1;
row.innerHTML = `
<td class="serial-number">${serialNumber}</td>
<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>
`;
});
applyColumnWidths('calls-table');
}
// --- Utility Functions ---
function getPeriodRange(period) {
const now = new Date();
let start, end;
switch (period) {
case 'today':
start = new Date(); start.setHours(0, 0, 0, 0);
end = new Date(); end.setHours(23, 59, 59, 999);
break;
case 'this-week':
const todayForWeek = new Date();
const dayOfWeek = todayForWeek.getDay();
const diffToMonday = todayForWeek.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1);
start = new Date(todayForWeek.setDate(diffToMonday));
start.setHours(0, 0, 0, 0);
end = new Date(start);
end.setDate(start.getDate() + 6);
end.setHours(23, 59, 59, 999);
break;
case 'this-month':
start = new Date(now.getFullYear(), now.getMonth(), 1);
end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
end.setHours(23, 59, 59, 999);
break;
case 'this-year':
start = new Date(now.getFullYear(), 0, 1);
end = new Date(now.getFullYear(), 11, 31);
end.setHours(23, 59, 59, 999);
break;
}
return { start, end };
}
function makeTableResizable(table) {
if (table.dataset.resizable) return; // Prevents re-adding listeners
table.dataset.resizable = 'true';
const headers = table.querySelectorAll('th.resizable');
headers.forEach(header => {
const resizer = header.querySelector('.resizer');
if (!resizer) return;
let startX, startWidth;
const onMouseMove = (e) => {
const newWidth = startWidth + (e.pageX - startX);
if (newWidth > 50) { header.style.width = `${newWidth}px`; }
};
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
resizer.classList.remove('resizing');
const tableId = table.id;
const columnKey = header.dataset.column;
if(!userPreferences.columnWidths) userPreferences.columnWidths = {};
if(!userPreferences.columnWidths[tableId]) userPreferences.columnWidths[tableId] = {};
userPreferences.columnWidths[tableId][columnKey] = header.style.width;
updatePreference('columnWidths', userPreferences.columnWidths);
};
resizer.addEventListener('mousedown', (e) => {
e.preventDefault();
startX = e.pageX;
startWidth = header.offsetWidth;
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
resizer.classList.add('resizing');
});
});
}
initialize();
});
</script>
</body>
</html>