// js/main.js // --- IMPORTS --- import { apiCall } from './api.js'; import { showMessage } from './utils.js'; import { renderAuthView, renderAdminDashboard, renderEmployeeDashboard, renderEditModal, renderChangePasswordModal, renderResetPasswordModal, renderRequestHistoryModal, handleViewNotesClick, renderArchiveView, renderTimeOffHistoryView, updatePendingRequestsList, renderEditTimeOffModal, renderCalendarView } from './ui.js'; // --- STATE MANAGEMENT --- let user = null; let authToken = null; let lastAdminTab = 'overview'; let lastEmployeeTab = 'dashboard'; export { lastAdminTab }; // --- NOTIFICATION LOGIC --- // This helper function converts the VAPID public key for the browser function urlBase64ToUint8Array(base64String) { const padding = '='.repeat((4 - base64String.length % 4) % 4); const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/'); const rawData = window.atob(base64); const outputArray = new Uint8Array(rawData.length); for (let i = 0; i < rawData.length; ++i) { outputArray[i] = rawData.charCodeAt(i); } return outputArray; } // This function handles the actual subscription process async function subscribeToNotifications() { try { // This key is not a secret, it's safe to have here. const publicVapidKey = 'BI1mWxe0yAsMw_iDjmb4Te2ByWwKuHhWsLYFilk7prozsnCEbtNHEJfNh_zIiNumLgWFKSvD6pMhnRbjhXVY_pU'; const token = localStorage.getItem('authToken'); if (!token || !publicVapidKey) { return console.error('Auth token or VAPID key is missing.'); } const register = await navigator.serviceWorker.register('/sw.js', { scope: '/' }); const subscription = await register.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicVapidKey) }); const res = await apiCall('/subscribe', 'POST', subscription); // If the subscription is saved successfully, set a flag so we don't do it again if (res.success) { localStorage.setItem('subscriptionSent', 'true'); showMessage('You are now subscribed to notifications!', 'success'); } } catch (error) { console.error('Error subscribing to notifications:', error); showMessage('Failed to subscribe. Please ensure notifications are allowed for this site.', 'error'); } } // NEW: This function creates the pop-up prompt for the user function setupNotifications() { // Don't do anything if service workers aren't supported if (!('serviceWorker' in navigator)) return; // Case 1: Permission is already granted if (Notification.permission === 'granted') { // Only try to subscribe if we haven't already sent the subscription to the server if (!localStorage.getItem('subscriptionSent')) { subscribeToNotifications(); } return; } // Case 2: Permission has been denied, so we don't ask again if (Notification.permission === 'denied') { return; } // Case 3: Permission is 'default' (user hasn't chosen yet) // Only ask if we haven't prompted them before if (!localStorage.getItem('notificationPrompted')) { setTimeout(() => { if (confirm("Enable notifications to receive important updates?")) { subscribeToNotifications(); } // Remember that we've prompted them so we don't ask again on the next login localStorage.setItem('notificationPrompted', 'true'); }, 2000); } } // --- EVENT HANDLERS --- async function handleAuthSubmit(e) { e.preventDefault(); const username = e.target.elements.username.value; const password = e.target.elements.password.value; const res = await apiCall('/login', 'POST', { username, password }); if (res && res.success) { localStorage.setItem('authToken', res.data.token); localStorage.setItem('user', JSON.stringify(res.data.user)); initializeApp(); } else { showMessage(res?.data?.message || 'Login failed. Please check your credentials.', 'error'); } } function handleSignOut(message) { localStorage.clear(); // This already clears everything, which is what we want. authToken = null; user = null; if (message) { showMessage(message, 'success'); } initializeApp(); } const handlePunch = () => { apiCall('/punch', 'POST').then(res => res.success && renderEmployeeDashboard()); }; async function handleChangePassword(e) { e.preventDefault(); const currentPassword = e.target.elements['modal-current-pw'].value; const newPassword = e.target.elements['modal-new-pw'].value; const res = await apiCall('/user/change-password', 'POST', { currentPassword, newPassword }); if (res.success) { showMessage(res.data.message, 'success'); document.getElementById('modal-container').innerHTML = ''; } } async function handleTimeOffRequest(e) { e.preventDefault(); const startDate = e.target.elements['start-date'].value; const endDate = e.target.elements['end-date'].value; const reason = e.target.elements['reason'].value; if (new Date(endDate) < new Date(startDate)) { return showMessage('End date cannot be before start date.', 'error'); } const res = await apiCall('/user/request-time-off', 'POST', { startDate, endDate, reason }); if (res.success) { showMessage(res.data.message, 'success'); e.target.reset(); renderEmployeeDashboard(); } } async function handleViewRequestHistoryClick() { const res = await apiCall('/user/time-off-requests/history'); if (res.success) { renderRequestHistoryModal(res.data); } } async function loadEmployeeCalendar() { const iframe = document.getElementById('employee-calendar-iframe'); const empty = document.getElementById('employee-calendar-empty'); const link = document.getElementById('employee-calendar-link'); if (!iframe || !empty || !link) return; const res = await apiCall('/calendar-url'); if (!res.success) return; const url = res.data?.url || ''; if (!url) { iframe.removeAttribute('src'); empty.classList.remove('hidden'); link.classList.add('hidden'); return; } iframe.src = url; link.href = url; link.classList.remove('hidden'); empty.classList.add('hidden'); } async function handleEditSubmit(e) { e.preventDefault(); const id = e.target.elements['edit-id'].value; const punch_in_time = new Date(e.target.elements['edit-in'].value).toISOString(); const punch_out_value = e.target.elements['edit-out'].value; const punch_out_time = punch_out_value ? new Date(punch_out_value).toISOString() : null; const res = await apiCall(`/admin/logs/${id}`, 'PUT', { punch_in_time, punch_out_time }); if (res.success) { document.getElementById('modal-container').innerHTML = ''; renderAdminDashboard(); showMessage('Entry updated successfully.', 'success'); } } const handleArchive = () => { if (confirm('Are you sure you want to archive all completed time entries? This action cannot be undone.')) { apiCall('/admin/archive', 'POST').then(res => { if (res.success) { showMessage('Records archived successfully.', 'success'); renderAdminDashboard(); } }); } }; async function handleCreateUser(e) { e.preventDefault(); const username = e.target.elements['new-username'].value; const password = e.target.elements['new-password'].value; const role = e.target.elements['new-user-role'].value; const res = await apiCall('/admin/create-user', 'POST', { username, password, role }); if (res.success) { showMessage(res.data.message, 'success'); e.target.reset(); renderAdminDashboard(); } } async function handleAddPunch(e) { e.preventDefault(); const selected = e.target.elements['add-punch-user']; const userId = selected.value; const username = selected.options[selected.selectedIndex].dataset.username; const punchInTime = new Date(e.target.elements['add-punch-in'].value).toISOString(); const punchOutValue = e.target.elements['add-punch-out'].value; const punchOutTime = punchOutValue ? new Date(punchOutValue).toISOString() : null; const res = await apiCall('/admin/add-punch', 'POST', { userId, username, punchInTime, punchOutTime }); if (res.success) { showMessage(res.data.message, 'success'); e.target.reset(); renderAdminDashboard(); } } async function handleAddNote(e) { e.preventDefault(); const userId = e.target.elements['note-user-select'].value; const noteText = e.target.elements['note-text'].value; if (!userId) { return showMessage('Please select an employee.', 'error'); } const res = await apiCall('/admin/notes', 'POST', { userId, noteText }); if (res.success) { showMessage(res.data.message, 'success'); e.target.reset(); } } async function handleCalendarSettingsSubmit(e) { e.preventDefault(); const input = document.getElementById('calendar-embed-url'); if (!input) return; const url = input.value.trim(); const res = await apiCall('/admin/calendar-url', 'POST', { url }); if (res.success) { showMessage(res.data.message || 'Calendar settings updated.', 'success'); await loadCalendarSettings(); } } async function handleCalendarSettingsClear() { const res = await apiCall('/admin/calendar-url', 'POST', { url: '' }); if (res.success) { const input = document.getElementById('calendar-embed-url'); if (input) input.value = ''; updateCalendarPreview(''); showMessage(res.data.message || 'Calendar settings cleared.', 'success'); } } function updateCalendarPreview(url) { const iframe = document.getElementById('calendar-preview-iframe'); const empty = document.getElementById('calendar-preview-empty'); if (!iframe || !empty) return; if (!url) { iframe.removeAttribute('src'); empty.classList.remove('hidden'); return; } iframe.src = url; empty.classList.add('hidden'); } async function loadCalendarSettings() { const input = document.getElementById('calendar-embed-url'); if (!input) return; const res = await apiCall('/admin/calendar-url'); if (res.success) { const url = res.data?.url || ''; input.value = url; updateCalendarPreview(url); } } async function handleResetPassword(e) { e.preventDefault(); const username = e.target.elements['reset-username'].value; const newPassword = e.target.elements['reset-new-pw'].value; const res = await apiCall('/admin/reset-password', 'POST', { username, newPassword }); if (res.success) { showMessage(res.data.message, 'success'); document.getElementById('modal-container').innerHTML = ''; } } function handleAdminDashboardClick(e) { const target = e.target.closest('button'); if (!target) return; const { id, userid, username, role, noteId } = target.dataset; switch (target.id) { case 'view-archives-btn': renderArchiveView(); return; case 'archive-btn': handleArchive(); return; case 'view-time-off-history-btn': renderTimeOffHistoryView(); return; case 'view-notes-btn': handleViewNotesClick(); return; case 'view-calendar-btn': renderCalendarView(); return; } if (target.classList.contains('approve-request-btn') || target.classList.contains('deny-request-btn')) { const status = target.classList.contains('approve-request-btn') ? 'approved' : 'denied'; if (confirm(`Set this request to "${status}"?`)) { apiCall('/admin/update-time-off-status', 'POST', { requestId: id, status: status }) .then(res => { if (res.success) { showMessage(`Request ${status}.`, 'success'); apiCall('/admin/time-off-requests/pending').then(requestsRes => { if (requestsRes.success) { updatePendingRequestsList(requestsRes.data); } }); } }); } return; } if (target.classList.contains('admin-delete-request-btn')) { if (confirm('Are you sure you want to permanently delete this request? This cannot be undone.')) { apiCall(`/admin/time-off-requests/${id}`, 'DELETE') .then(res => { if (res.success) { showMessage('Request deleted.', 'success'); apiCall('/admin/time-off-requests/pending').then(requestsRes => { if (requestsRes.success) { updatePendingRequestsList(requestsRes.data); } }); } }); } return; } if (target.classList.contains('edit-btn')) renderEditModal(id, handleEditSubmit); if (target.classList.contains('delete-btn') && confirm('Delete this time entry?')) apiCall(`/admin/logs/${id}`, 'DELETE').then(res => res.success && renderAdminDashboard()); if (target.classList.contains('archive-log-btn') && confirm('Archive this time entry?')) apiCall(`/admin/logs/archive/${id}`, 'POST').then(res => res.success && renderAdminDashboard()); if (target.classList.contains('force-clock-out-btn') && confirm(`Force clock out ${username}?`)) apiCall('/admin/force-clock-out', 'POST', { userId: userid }).then(res => res.success && renderAdminDashboard()); if (target.classList.contains('reset-pw-btn')) renderResetPasswordModal(username, handleResetPassword); if (target.classList.contains('change-role-btn')) { const newRole = role === 'admin' ? 'employee' : 'admin'; if (confirm(`Change ${username} to ${newRole}?`)) apiCall('/admin/update-role', 'POST', { username, newRole }).then(res => res.success && renderAdminDashboard()); } if (target.classList.contains('delete-user-btn') && confirm(`PERMANENTLY DELETE user '${username}'?`)) apiCall(`/admin/delete-user/${username}`, 'DELETE').then(res => res.success && renderAdminDashboard()); if (target.classList.contains('delete-note-btn')) { if (confirm('Delete this note?')) { apiCall(`/admin/notes/${noteId}`, 'DELETE').then(res => { if (res.success) { showMessage('Note deleted.', 'success'); handleViewNotesClick(); } }); } } } async function handleEditTimeOffSubmit(e) { e.preventDefault(); const id = e.target.elements['edit-request-id'].value; const startDate = e.target.elements['edit-start-date'].value; const endDate = e.target.elements['edit-end-date'].value; const reason = e.target.elements['edit-reason'].value; if (new Date(endDate) < new Date(startDate)) { return showMessage('End date cannot be before start date.', 'error'); } const res = await apiCall(`/user/time-off-requests/${id}`, 'PUT', { startDate, endDate, reason }); if (res.success) { showMessage(res.data.message, 'success'); document.getElementById('modal-container').innerHTML = ''; renderEmployeeDashboard(); } } function handleTimeOffHistoryClick(e) { const target = e.target.closest('button'); if (!target) return; const { id } = target.dataset; if (target.classList.contains('set-pending-btn')) { if (confirm('Are you sure you want to move this request back to pending?')) { apiCall('/admin/update-time-off-status', 'POST', { requestId: id, status: 'pending' }) .then(res => { if (res.success) { showMessage('Request status set to pending.', 'success'); renderTimeOffHistoryView(); } }); } } if (target.classList.contains('admin-delete-request-btn')) { if (confirm('Are you sure you want to permanently delete this request? This cannot be undone.')) { apiCall(`/admin/time-off-requests/${id}`, 'DELETE') .then(res => { if (res.success) { showMessage('Request deleted.', 'success'); renderTimeOffHistoryView(); } }); } } } // --- LISTENER ATTACHMENT FUNCTIONS --- export function attachAuthFormListener() { const form = document.getElementById('auth-form'); if (form) form.addEventListener('submit', handleAuthSubmit); } export function attachEmployeeDashboardListeners() { const dashboard = document.getElementById('employee-dashboard'); if (!dashboard) return; dashboard.addEventListener('click', async (e) => { const target = e.target; if (target.id === 'punch-btn') handlePunch(); if (target.id === 'change-password-btn') renderChangePasswordModal(handleChangePassword); if (target.id === 'view-request-history-btn') handleViewRequestHistoryClick(); if (target.classList.contains('delete-request-btn')) { const id = target.dataset.id; if (confirm('Are you sure you want to delete this time off request?')) { const res = await apiCall(`/user/time-off-requests/${id}`, 'DELETE'); if (res.success) { showMessage(res.data.message, 'success'); renderEmployeeDashboard(); } } } if (target.classList.contains('edit-request-btn')) { const id = target.dataset.id; const res = await apiCall('/user/time-off-requests/history'); if (res.success) { const requestToEdit = res.data.find(r => r.id == id); if (requestToEdit) { renderEditTimeOffModal(requestToEdit, handleEditTimeOffSubmit); } } } }); const timeOffForm = document.getElementById('time-off-form'); if (timeOffForm) { timeOffForm.addEventListener('submit', handleTimeOffRequest); } setupEmployeeTabs(); if (lastEmployeeTab !== 'dashboard') { const tabsContainer = document.getElementById('employee-tabs-nav'); tabsContainer?.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active-tab')); tabsContainer?.querySelector(`.tab-btn[data-tab="${lastEmployeeTab}"]`)?.classList.add('active-tab'); document.getElementById('tab-content-employee-dashboard')?.classList.add('hidden'); document.getElementById(`tab-content-employee-${lastEmployeeTab}`)?.classList.remove('hidden'); } if (lastEmployeeTab === 'calendar') loadEmployeeCalendar(); } export function attachAdminDashboardListeners() { document.getElementById('admin-dashboard')?.addEventListener('click', handleAdminDashboardClick); document.getElementById('create-user-form')?.addEventListener('submit', handleCreateUser); document.getElementById('add-punch-form')?.addEventListener('submit', handleAddPunch); document.getElementById('add-note-form')?.addEventListener('submit', handleAddNote); document.getElementById('calendar-settings-form')?.addEventListener('submit', handleCalendarSettingsSubmit); document.getElementById('calendar-clear-btn')?.addEventListener('click', handleCalendarSettingsClear); document.getElementById('calendar-embed-url')?.addEventListener('input', (e) => updateCalendarPreview(e.target.value.trim())); setupTabbedInterface(); const adminContent = document.getElementById('admin-tabs-content'); if (adminContent) { adminContent.classList.toggle('calendar-full-bleed', lastAdminTab === 'calendar'); } if (lastAdminTab === 'calendar') renderCalendarView(); if (lastAdminTab === 'settings') loadCalendarSettings(); } export function attachTimeOffHistoryListeners() { const historyView = document.getElementById('admin-time-off-history-view'); if (historyView) { historyView.addEventListener('click', handleTimeOffHistoryClick); } } // --- APP INITIALIZER --- function initializeApp() { authToken = localStorage.getItem('authToken'); const userString = localStorage.getItem('user'); user = userString ? JSON.parse(userString) : null; const userControls = document.getElementById('nav-user-controls'); if (authToken && user) { userControls.classList.remove('hidden'); userControls.querySelector('#welcome-message').textContent = `Welcome, ${user.username}`; if (user.role === 'admin') { renderAdminDashboard(); } else { renderEmployeeDashboard(); } // Ask for notification permission after user is logged in setupNotifications(); } else { userControls.classList.add('hidden'); renderAuthView(); } } // --- HELPERS --- function setupTabbedInterface() { const tabsContainer = document.getElementById('admin-tabs-nav'); const contentContainer = document.getElementById('admin-tabs-content'); if (!tabsContainer || !contentContainer) return; tabsContainer.addEventListener('click', (e) => { const clickedTab = e.target.closest('.tab-btn'); if (!clickedTab) return; const tabTarget = clickedTab.dataset.tab; lastAdminTab = tabTarget; tabsContainer.querySelectorAll('.tab-btn').forEach(btn => { btn.classList.remove('active-tab'); }); clickedTab.classList.add('active-tab'); contentContainer.querySelectorAll('[id^="tab-content-"]').forEach(panel => { panel.classList.add('hidden'); }); document.getElementById(`tab-content-${tabTarget}`).classList.remove('hidden'); contentContainer.classList.toggle('calendar-full-bleed', tabTarget === 'calendar'); if (tabTarget === 'calendar') { renderCalendarView(); } if (tabTarget === 'settings') { loadCalendarSettings(); } }); } function setupEmployeeTabs() { const tabsContainer = document.getElementById('employee-tabs-nav'); const contentContainer = document.getElementById('employee-tabs-content'); if (!tabsContainer || !contentContainer) return; tabsContainer.addEventListener('click', (e) => { const clickedTab = e.target.closest('.tab-btn'); if (!clickedTab) return; const tabTarget = clickedTab.dataset.tab; lastEmployeeTab = tabTarget; tabsContainer.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active-tab')); clickedTab.classList.add('active-tab'); contentContainer.querySelectorAll('[id^="tab-content-employee-"]').forEach(panel => { panel.classList.add('hidden'); }); document.getElementById(`tab-content-employee-${tabTarget}`).classList.remove('hidden'); if (tabTarget === 'calendar') loadEmployeeCalendar(); }); } // --- START THE APP --- // Register the service worker when the page loads if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js') .then(registration => console.log('Service Worker registered! Scope:', registration.scope)) .catch(err => console.error('Service Worker registration failed:', err)); }); } document.getElementById('sign-out-btn').addEventListener('click', () => handleSignOut('You have been signed out.')); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && localStorage.getItem('user')) { initializeApp(); } }); initializeApp();