Spaces:
Sleeping
Sleeping
| <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) ; margin-top: auto; } | |
| .logout-link:hover { background: #f4433620 ; } | |
| .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>< Previous</button> | |
| <span id="upcoming-page-info"></span> | |
| <button id="upcoming-next-btn">Next ></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> |