diff --git a/_config.yml b/_config.yml index 12b4277..babba3d 100644 --- a/_config.yml +++ b/_config.yml @@ -10,6 +10,7 @@ admin_email: damien@alphagame.dev # TODO: Add this at launch. # tracking_id: +# force_backend_url: "http://192.168.0.167:8000/api" # Temporary for local testing FIXME: Remove this line permalink: /news/:year/:month/:title description: >- # this means to ignore newlines until "baseurl:" St. Anthony de Padua Catholic Parish Confirmation Program, guiding youth @@ -20,6 +21,7 @@ description: >- # this means to ignore newlines until "baseurl:" instagram_username: stanthonyyouth.novato publisher: "St. Anthony de Padua Catholic Parish" + plugins: - jekyll-feed - jekyll-sitemap diff --git a/_includes/volunteer-hours/card-footer.html b/_includes/volunteer-hours/card-footer.html new file mode 100644 index 0000000..3856e15 --- /dev/null +++ b/_includes/volunteer-hours/card-footer.html @@ -0,0 +1,8 @@ + + diff --git a/_includes/volunteer-hours/create-user-form.html b/_includes/volunteer-hours/create-user-form.html new file mode 100644 index 0000000..c1331eb --- /dev/null +++ b/_includes/volunteer-hours/create-user-form.html @@ -0,0 +1,26 @@ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ Volunteer registered successfully! +
+
+ Error registering volunteer. Please try again. +
diff --git a/_includes/volunteer-hours/log-hours-form.html b/_includes/volunteer-hours/log-hours-form.html new file mode 100644 index 0000000..7b45a43 --- /dev/null +++ b/_includes/volunteer-hours/log-hours-form.html @@ -0,0 +1,33 @@ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ Hours logged successfully! +
+
+ Error logging hours. Please try again. +
diff --git a/_includes/volunteer-hours/tab-card.html b/_includes/volunteer-hours/tab-card.html new file mode 100644 index 0000000..7a41ce7 --- /dev/null +++ b/_includes/volunteer-hours/tab-card.html @@ -0,0 +1,10 @@ + +
+
+
{{ include.title }}
+
+
+ {{ include.content }} +
+ {% include volunteer-hours/card-footer.html id=include.footer_id %} +
diff --git a/_includes/volunteer-hours/tabs-nav.html b/_includes/volunteer-hours/tabs-nav.html new file mode 100644 index 0000000..4108f60 --- /dev/null +++ b/_includes/volunteer-hours/tabs-nav.html @@ -0,0 +1,23 @@ + + diff --git a/_includes/volunteer-hours/user-stats.html b/_includes/volunteer-hours/user-stats.html new file mode 100644 index 0000000..8285c7d --- /dev/null +++ b/_includes/volunteer-hours/user-stats.html @@ -0,0 +1,70 @@ + +
+ +
+ + +
+
+ + +
+
+
+
+
+
Volunteer Name
+

-

+
+
+
+
+
+
+
Total Hours
+

-

+
+
+
+
+ +
Volunteer History
+
+ + + + + + + + + + + +
DateHoursNotes
+
+
+ + +
+ +

Select a volunteer to view their stats

+
+ + +
+
+ Loading... +
+

Loading volunteer stats...

+
+ + +
+ Error retrieving volunteer stats. Please try again. +
diff --git a/_includes/volunteer-hours/view-hours-table.html b/_includes/volunteer-hours/view-hours-table.html new file mode 100644 index 0000000..db2538a --- /dev/null +++ b/_includes/volunteer-hours/view-hours-table.html @@ -0,0 +1,29 @@ + +
+ + + + + + + + + + + + + +
NameDateHoursNotesActions
+
+
+ No volunteer hours have been logged yet. +
+
+ Error retrieving volunteer hours. Please try again. +
+
+ Entry deleted successfully. +
+
+ Error deleting entry. Please try again. +
diff --git a/_layouts/default.html b/_layouts/default.html index 18f80a0..665d6b2 100644 --- a/_layouts/default.html +++ b/_layouts/default.html @@ -10,7 +10,11 @@ {% if site.tracking_id %} diff --git a/_sass/_base.scss b/_sass/_base.scss index 6fcd155..1dc78b0 100644 --- a/_sass/_base.scss +++ b/_sass/_base.scss @@ -8,6 +8,12 @@ @use "sass:color"; // Base Styles +html, body { + overflow-x: hidden; // Prevent horizontal scrolling + width: 100%; + position: relative; +} + body { font-family: $font-secondary; color: #333; diff --git a/_sass/_footer.scss b/_sass/_footer.scss index 78546e3..983cb71 100644 --- a/_sass/_footer.scss +++ b/_sass/_footer.scss @@ -9,6 +9,9 @@ .site-footer { background-color: #1a1a1a; color: rgb(255 255 255 / 80%); + width: 100%; + max-width: 100%; + box-sizing: border-box; } .footer-links { diff --git a/_sass/_volunteer-hours.scss b/_sass/_volunteer-hours.scss new file mode 100644 index 0000000..c6f1734 --- /dev/null +++ b/_sass/_volunteer-hours.scss @@ -0,0 +1,272 @@ +/* + * Styles for Volunteer Hours Tracking Tool + */ + +.volunteer-hours { + width: 100%; + max-width: 100%; + box-sizing: border-box; + overflow-x: hidden; + /* Custom tab transitions */ + .tab-content { + .tab-pane { + /* Override Bootstrap's default fade transition */ + display: block !important; + position: relative; + opacity: 0; + visibility: hidden; + height: auto; + transition: transform 0.35s ease-out, opacity 0.25s ease-out; + transform: translateX(20px); + + /* Position tabs as absolute to prevent layout jumps */ + position: absolute; + width: 100%; + + /* Active tab styling */ + &.active { + opacity: 1; + visibility: visible; + transform: translateX(0); + position: relative; + z-index: 5; + } + + /* Add directional animations based on tab order */ + &.sliding-left { + transform: translateX(-20px); + } + + &.sliding-right { + transform: translateX(20px); + } + } + } + + &-tabs { + .nav-link { + color: #495057; + position: relative; + transition: all 0.2s ease; + + /* Animated underline indicator for tabs */ + &::after { + content: ''; + position: absolute; + bottom: 0; + left: 50%; + width: 0; + height: 2px; + background: currentColor; + transition: all 0.3s ease; + transform: translateX(-50%); + } + + &.active { + font-weight: bold; + + &::after { + width: 80%; + } + } + + &:hover:not(.active)::after { + width: 40%; + } + } + } + + // Action buttons in tables + .action-buttons { + .btn { + margin: 0 3px; + padding: 0.25rem 0.5rem; + + &:hover { + transform: translateY(-2px); + transition: transform 0.2s ease; + } + } + } + + // Modal fixes + .modal-backdrop { + opacity: 0.6; + z-index: 1040; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: #000; + } + + .modal { + z-index: 1050; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + overflow-x: hidden; + overflow-y: auto; + outline: 0; + + &-dialog { + margin: 1.75rem auto; + max-height: calc(100vh - 3.5rem); + overflow-y: auto; + position: relative; + } + + &-content { + position: relative; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + } + + &.fade .modal-dialog { + transition: transform 0.3s ease-out; + transform: translate(0, -50px); + } + + &.show .modal-dialog { + transform: none; + } + } + + &-card { + border-radius: 10px; + overflow: hidden; + transition: transform 0.3s ease, box-shadow 0.3s ease; + + &:hover { + transform: translateY(-3px); + box-shadow: 0 5px 15px rgba(0,0,0,0.08); + } + + .card-header { + border-bottom: 0; + } + } + + #statsContent { + .card { + transition: all 0.3s ease; + + &:hover { + transform: translateY(-3px); + box-shadow: 0 4px 8px rgba(0,0,0,0.1); + } + } + + h5 { + color: #495057; + } + } + + #statsEmptyState { + color: #6c757d; + transition: all 0.3s ease; + + i { + opacity: 0.7; + } + } + + // Handle table overflow + .table-responsive { + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + + // Make sure the table doesn't force the page to be wider + table { + width: 100%; + max-width: 100%; + } + } + + // Inline editing styles + .hours-row { + transition: background-color 0.3s ease; + + &[data-mode="edit"] { + background-color: rgba(0, 123, 255, 0.05); + } + + td { + vertical-align: middle; + } + + .form-control-sm { + padding: 0.25rem 0.5rem; + height: calc(1.5em + 0.5rem + 2px); + font-size: 0.875rem; + } + + .edit-mode-buttons, .view-mode-buttons { + white-space: nowrap; + } + + // Button loading states + .save-row-btn { + min-width: 34px; // Prevent button size changing when spinner shows + + .spinner-border-sm { + width: 1rem; + height: 1rem; + border-width: 0.15em; + } + } + } + + // Mobile optimizations + @media (max-width: 768px) { + .nav-tabs .nav-link { + padding: .5rem .75rem; + font-size: 0.9rem; + } + + .card-body { + padding: 1rem; + } + + #statsContent { + .row > div { + margin-bottom: 1rem; + } + } + } + + // Footer styles (only show on computer horizontal screen) + // Card styles + &-card { + border-radius: 10px; + overflow: hidden; + + .card-header { + border-bottom: 0; + } + + .card-footer { + padding: 0.5rem 1rem; + border-top: 1px solid rgba(0,0,0,0.05); + } + } + + // Footer styles + &-footer { + font-size: 0.75rem; + opacity: 0.7; + font-style: italic; + transition: opacity 0.3s ease; + + .d-block { + line-height: 1.4; + } + + &:hover { + opacity: 1; + } + } +} diff --git a/assets/css/main.scss b/assets/css/main.scss index 0c713de..f0fd972 100644 --- a/assets/css/main.scss +++ b/assets/css/main.scss @@ -18,6 +18,7 @@ @use "people"; @use "healthcheck"; @use "registration"; +@use "volunteer-hours"; // note - for some gosh darn reason, if I include the following blockcomment at the top, // it will repeat the comment a million times in the compiled CSS. diff --git a/assets/js/volunteer-hours.js b/assets/js/volunteer-hours.js new file mode 100644 index 0000000..1c05e55 --- /dev/null +++ b/assets/js/volunteer-hours.js @@ -0,0 +1,842 @@ +/** + * Volunteer Hours Tracking Tool + * + * Handles all functionality for the volunteer hours tracking system including: + * - Logging volunteer hours + * - Creating new volunteer accounts + * - Viewing volunteer hour logs + */ + +var apiRequests = { + total: 0, + success: 0, + failed: 0, + post: 0 +}; + +function cleanVersion(version) { + // remove .dXXXXXXXX, if present + const cleaned = version.replace(/\.d[0-9a-f]+$/, ''); + console.log(`cleanVersion: ${version} -> ${cleaned}`); + return cleaned; +} + +function wrappedFetch(endpoint, options = {}) { + // Ensure headers exist in options + if (!options.headers) { + options.headers = {}; + } + + // Track POST requests + if (options.method === 'POST') { + apiRequests.post++; + } + + return fetch(endpoint, options) + .then(response => { + apiRequests.success++; + apiRequests.total = apiRequests.success + apiRequests.failed; + + // Update all API request counters + updateAllElements('apiRequests', apiRequests.total); + updateAllElements('postRequests', apiRequests.post); + updateAllElements('failedRequests', apiRequests.failed); + return response; + }) + .catch(error => { + // Track failed requests + apiRequests.failed++; + apiRequests.total = apiRequests.success + apiRequests.failed; + + // Update counters + updateAllElements('apiRequests', apiRequests.total); + updateAllElements('failedRequests', apiRequests.failed); + + // Re-throw the error for handling elsewhere + throw error; + }); +} + +// Helper function to update all elements with the same base ID +function updateAllElements(baseId, value) { + // Find all elements that start with the baseId + for (let i = 1; i <= 4; i++) { + const element = document.getElementById(baseId + i); + if (element) { + element.textContent = value; + } + } + // Also update the original element if it exists (for backward compatibility) + const originalElement = document.getElementById(baseId); + if (originalElement) { + originalElement.textContent = value; + } +} + +document.addEventListener('DOMContentLoaded', function() { + const backendURL = window.backendBaseURL + '/volunteer_hours'; + + wrappedwindow.backendBaseURL + "/healthcheck?src=VolunteerHoursTool&c=1") + .then(response => { + return response.json(); + }) + .then(data => { + // Update all version elements + updateAllElements('backendVersion', cleanVersion(data.version) || 'Unknown'); + }) + .catch(error => { + console.error('Error fetching backend version:', error); + }); + + // Track the currently selected users in each dropdown + const selectedUsers = { + userSelect: '', + statsUserSelect: '' + }; + + // Tab management object + const tabManager = { + // Keep track of current and previous tab for direction-aware animations + previousTabId: null, + currentTabId: 'log-hours', + + // Tab order for determining animation direction + tabOrder: ['log-hours', 'create-user', 'view-hours', 'user-stats'], + + // Get the current tab ID from URL hash or default to 'log-hours' + getCurrentTabId: function() { + if (window.location.hash) { + return window.location.hash.substring(1); // Remove the # from the hash + } + return 'log-hours'; // Default tab + }, + + // Update URL hash without scrolling + setTabInUrl: function(tabId) { + history.replaceState(null, null, `#${tabId}`); + }, + + // Determine if we're moving left or right in tab order + getAnimationDirection: function(newTabId) { + const previousIndex = this.tabOrder.indexOf(this.currentTabId); + const newIndex = this.tabOrder.indexOf(newTabId); + + // Update tab tracking + this.previousTabId = this.currentTabId; + this.currentTabId = newTabId; + + return (newIndex > previousIndex) ? 'right' : 'left'; + }, + + // Apply sliding animation classes based on direction + animateTabTransition: function(newTabId) { + const direction = this.getAnimationDirection(newTabId); + const newTab = document.getElementById(newTabId); + const oldTab = document.getElementById(this.previousTabId); + + if (oldTab && newTab) { + // First remove any existing animation classes + document.querySelectorAll('.tab-pane').forEach(pane => { + pane.classList.remove('sliding-left', 'sliding-right'); + }); + + // Set direction classes for nice animation + if (direction === 'right') { + oldTab.classList.add('sliding-left'); + newTab.classList.add('sliding-right'); + } else { + oldTab.classList.add('sliding-right'); + newTab.classList.add('sliding-left'); + } + + // Remove animation classes after transition completes + setTimeout(() => { + document.querySelectorAll('.tab-pane').forEach(pane => { + pane.classList.remove('sliding-left', 'sliding-right'); + }); + }, 400); + } + }, + + // Activate a tab by ID with animation + activateTab: function(tabId) { + const tabToActivate = document.querySelector(`[data-bs-target="#${tabId}"]`); + if (tabToActivate) { + // Set up animation before tab changes + this.animateTabTransition(tabId); + + // Show the tab + const tab = new bootstrap.Tab(tabToActivate); + tab.show(); + } + }, + + // Handle tab content loading based on tab ID + loadTabContent: function(tabId) { + switch(tabId) { + case 'log-hours': + loadUsers(); + break; + case 'view-hours': + loadHoursData(); + break; + case 'user-stats': + populateStatsUserDropdown(); + break; + } + } + }; + + // ========================================== + // Helper Functions + // ========================================== + + /** + * Makes a fetch request to the API with error handling and no caching + * @param {string} endpoint - API endpoint to fetch from + * @param {Object} options - Fetch options (method, headers, body) + * @returns {Promise} - Promise that resolves to the JSON response + */ + function fetchWithErrorHandling(endpoint, options = {}) { + // Ensure headers exist in options + if (!options.headers) { + options.headers = {}; + } + + // Add cache control headers to prevent caching + options.headers = { + ...options.headers, + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0' + }; + + return wrappedFetch(backendURL + endpoint, options) + .then(response => { + if (!response.ok) { + return response.json().then(errorData => { + // Track failed requests in fetchWithErrorHandling too + apiRequests.failed++; + updateAllElements('failedRequests', apiRequests.failed); + throw new Error(errorData.error || `Failed request - ${response.statusText}`); + }).catch(e => { + // If response.json() fails, just use the status text + if (e instanceof SyntaxError) { + throw new Error(`Failed request: ${response.statusText}`); + } + throw e; + }); + } + return response.json(); + }); + } + + /** + * Shows a message to the user and hides it after 5 seconds + * @param {string} elementId - ID of the element to show message in + * @param {string} message - Message to display + */ + function showMessage(elementId, message) { + const element = document.getElementById(elementId); + + if (!element) { + console.error(`Element with ID "${elementId}" not found for showing message: ${message}`); + return; + } + + element.textContent = message; + element.classList.remove('d-none'); + + // Hide message after 5 seconds + setTimeout(() => { + element.classList.add('d-none'); + }, 5000); + } + + /** + * Formats a date string to a readable format + * @param {string} dateString - Date string in YYYY-MM-DD format + * @returns {string} - Formatted date string + */ + function formatDate(dateString) { + const dateObj = new Date(dateString); + return dateObj.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + } + + /** + * Populates a select element with user options + * @param {string} selectId - ID of the select element to populate + * @param {Array} users - Array of user objects + * @param {string} defaultText - Default option text + */ + function populateUserSelect(selectId, users, defaultText = "Select volunteer...") { + const selectElement = document.getElementById(selectId); + // Remember currently selected value if any + const currentValue = selectElement.value; + selectedUsers[selectId] = currentValue; + + // Clear existing options + selectElement.innerHTML = ``; + + // Add users to dropdown + users.forEach(user => { + const option = document.createElement('option'); + option.value = user.id; + option.textContent = user.name; + + // If this was the previously selected value, keep it selected + if (user.id == currentValue) { + option.selected = true; + } + + selectElement.appendChild(option); + }); + + // If there was a previously selected value that's no longer in the list + if (currentValue && selectElement.value !== currentValue) { + console.log(`Previously selected user (${currentValue}) no longer available.`); + } + } + + // ========================================== + // Main Code + // ========================================== + + // Load users for dropdown + loadUsers(); + + // Set default date to today + const today = new Date().toISOString().split('T')[0]; + document.getElementById('dateInput').value = today; + + // Initialize tab from URL hash if present + const initialTabId = tabManager.getCurrentTabId(); + + // Set current tab in tab manager to ensure animations work correctly + tabManager.currentTabId = initialTabId; + + if (initialTabId !== 'log-hours') { // Only if not the default tab + // Use setTimeout to avoid scroll behavior + setTimeout(() => { + window.scrollTo(0, 0); // Ensure no scrolling + + // For initial load, we just activate the tab without animation + const tabToActivate = document.querySelector(`[data-bs-target="#${initialTabId}"]`); + if (tabToActivate) { + const tab = new bootstrap.Tab(tabToActivate); + tab.show(); + tabManager.loadTabContent(initialTabId); + } + }, 0); + } + + // Event listeners for forms + document.getElementById('logHoursForm').addEventListener('submit', logHours); + document.getElementById('createUserForm').addEventListener('submit', createUser); + + // Add tab change event listeners to all tabs + const tabButtons = document.querySelectorAll('[data-bs-toggle="tab"]'); + tabButtons.forEach(button => { + // Listen for the 'show.bs.tab' event which fires BEFORE tab is shown + button.addEventListener('show.bs.tab', function(event) { + // Get the newly activated tab + const activeTab = event.target.getAttribute('data-bs-target'); + const tabId = activeTab.substring(1); // Remove the # from the selector + + // Set up animation before the tab change happens + tabManager.animateTabTransition(tabId); + }); + + // Listen for the 'shown.bs.tab' event which fires AFTER tab is shown + button.addEventListener('shown.bs.tab', function(event) { + // Get the newly activated tab + const activeTab = event.target.getAttribute('data-bs-target'); + const tabId = activeTab.substring(1); // Remove the # from the selector + console.log(`Tab changed to: ${tabId}`); + + // Update URL hash without scrolling + tabManager.setTabInUrl(tabId); + + // Load the appropriate content for this tab + tabManager.loadTabContent(tabId); + }); + }); + + // Remove duplicate event listeners as they're now handled by tabManager + // The "click" handlers aren't needed as we already have "shown.bs.tab" handlers + + // Add event listener for the load stats button + document.getElementById('loadStatsBtn').addEventListener('click', loadUserStats); + + // No need for modal initialization anymore since we're using inline editing + + // Function to load users + function loadUsers() { + fetchWithErrorHandling('/users') + .then(users => { + // Populate the log hours dropdown + populateUserSelect('userSelect', users); + }) + .catch(error => { + console.error('Error loading users:', error); + }); + } + + // Function to log hours + function logHours(event) { + event.preventDefault(); + + const userId = document.getElementById('userSelect').value; + const date = document.getElementById('dateInput').value; + const hours = document.getElementById('hoursInput').value; + const notes = document.getElementById('notesInput').value; + + // Validate form data + if (!userId || !date || !hours) { + showMessage('logHoursError', 'Please fill out all required fields.'); + return; + } + + const hoursData = { + user_id: parseInt(userId), + date: date, + hours: parseFloat(hours), + notes: notes + }; + + fetchWithErrorHandling('/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(hoursData) + }) + .then(data => { + // Show success message + showMessage('logHoursSuccess', 'Hours logged successfully!'); + + // Reset form + document.getElementById('logHoursForm').reset(); + document.getElementById('dateInput').value = today; + }) + .catch(error => { + console.error('Error logging hours:', error); + showMessage('logHoursError', error.message || 'Error logging hours. Please try again.'); + }); + } + + // Function to create user + function createUser(event) { + event.preventDefault(); + + const name = document.getElementById('nameInput').value; + const email = document.getElementById('emailInput').value; + const phone = document.getElementById('phoneInput').value; + + // Validate form data + if (!name || !email) { + showMessage('createUserError', 'Please fill out all required fields.'); + return; + } + + const userData = { + name: name, + email: email, + phone: phone + }; + + fetchWithErrorHandling('/users', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(userData) + }) + .then(data => { + // Show success message + showMessage('createUserSuccess', 'Volunteer registered successfully!'); + + // Reset form + document.getElementById('createUserForm').reset(); + + // Reload users dropdown + loadUsers(); + }) + .catch(error => { + console.error('Error creating user:', error); + showMessage('createUserError', error.message || 'Error registering volunteer. Please try again.'); + }); + } + + // Function to load hours data + function loadHoursData() { + fetchWithErrorHandling('/all') + .then(hoursData => { + const hoursTable = document.getElementById('hoursTable'); + + // Check if the table exists before proceeding + if (!hoursTable) { + console.error('Hours table element not found'); + return; + } + + const tableBody = hoursTable.querySelector('tbody'); + tableBody.innerHTML = ''; + + if (hoursData.length === 0) { + document.getElementById('noHoursData').classList.remove('d-none'); + return; + } + + document.getElementById('noHoursData').classList.add('d-none'); + + // Sort data by date (newest first) + hoursData.sort((a, b) => new Date(b.date) - new Date(a.date)); + + // Add rows to table + hoursData.forEach(entry => { + const row = document.createElement('tr'); + row.setAttribute('data-id', entry.id); + row.setAttribute('data-mode', 'view'); + row.className = 'hours-row'; + row.innerHTML = ` + ${entry.name} + ${formatDate(entry.date)} + ${entry.hours} + ${entry.notes || ''} + +
+ + +
+ + + `; + + tableBody.appendChild(row); + }); + + // Add event listeners to action buttons + attachActionEventListeners(); + }) + .catch(error => { + console.error('Error loading hours data:', error); + document.getElementById('viewHoursError').classList.remove('d-none'); + document.getElementById('viewHoursError').textContent = error.message || 'Error retrieving volunteer hours. Please try again.'; + }); + } + // Function to populate the stats user dropdown + function populateStatsUserDropdown() { + fetchWithErrorHandling('/users') + .then(users => { + populateUserSelect('statsUserSelect', users, 'Choose volunteer...'); + }) + .catch(error => { + console.error('Error loading users for stats:', error); + showMessage('statsError', error.message || 'Error loading volunteers. Please try again.'); + }); + } + + // Function to attach event listeners to action buttons + function attachActionEventListeners() { + // Attach listeners to delete buttons + const deleteButtons = document.querySelectorAll('.delete-hours-btn'); + + if (deleteButtons.length > 0) { + deleteButtons.forEach(button => { + button.addEventListener('click', function(event) { + event.preventDefault(); + const row = this.closest('tr'); + const entryId = row.getAttribute('data-id'); + + // Confirm before deleting + if (confirm('Are you sure you want to delete this volunteer hours entry? This action cannot be undone.')) { + deleteHoursEntry(entryId); + } + }); + }); + } + + // Attach listeners to edit row buttons + const editButtons = document.querySelectorAll('.edit-row-btn'); + + if (editButtons.length > 0) { + editButtons.forEach(button => { + button.addEventListener('click', function(event) { + event.preventDefault(); + const row = this.closest('tr'); + switchToEditMode(row); + }); + }); + } + + // Attach listeners to save row buttons + const saveButtons = document.querySelectorAll('.save-row-btn'); + + if (saveButtons.length > 0) { + saveButtons.forEach(button => { + button.addEventListener('click', function(event) { + event.preventDefault(); + const row = this.closest('tr'); + saveRowChanges(row); + }); + }); + } + + // Attach listeners to cancel edit buttons + const cancelButtons = document.querySelectorAll('.cancel-edit-btn'); + + if (cancelButtons.length > 0) { + cancelButtons.forEach(button => { + button.addEventListener('click', function(event) { + event.preventDefault(); + const row = this.closest('tr'); + cancelRowEdit(row); + }); + }); + } + } + + // Function to delete a volunteer hours entry + function deleteHoursEntry(entryId) { + // Hide any previous messages + const successElement = document.getElementById('deleteHoursSuccess'); + const errorElement = document.getElementById('deleteHoursError'); + + if (successElement) { + successElement.classList.add('d-none'); + } + + if (errorElement) { + errorElement.classList.add('d-none'); + } + + fetchWithErrorHandling(`/delete/${entryId}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }) + .then(data => { + // Show success message + showMessage('deleteHoursSuccess', data.message || 'Entry deleted successfully.'); + + // Reload hours data to refresh the table + loadHoursData(); + }) + .catch(error => { + console.error('Error deleting hours entry:', error); + showMessage('deleteHoursError', error.message || 'Error deleting entry. Please try again.'); + }); + } + + // Function to switch a row to edit mode + function switchToEditMode(row) { + // Set row to edit mode + row.setAttribute('data-mode', 'edit'); + + // Get the current values from the cells + const dateCell = row.querySelector('.date-cell'); + const hoursCell = row.querySelector('.hours-cell'); + const notesCell = row.querySelector('.notes-cell'); + + const date = dateCell.getAttribute('data-date'); + const hours = hoursCell.getAttribute('data-hours'); + const notes = notesCell.getAttribute('data-notes'); + + // Replace content with form fields + dateCell.innerHTML = ``; + hoursCell.innerHTML = ``; + notesCell.innerHTML = ``; + + // Show edit mode buttons, hide view mode buttons + const viewBtns = row.querySelector('.view-mode-buttons'); + const editBtns = row.querySelector('.edit-mode-buttons'); + + viewBtns.style.display = 'none'; + editBtns.style.display = 'block'; + } + + // Function to save changes made to a row + function saveRowChanges(row) { + // Get the entry ID from the row + const entryId = row.getAttribute('data-id'); + + // Get the save button and show the spinner + const saveBtn = row.querySelector('.save-row-btn'); + const saveIcon = saveBtn.querySelector('.save-icon'); + const saveSpinner = saveBtn.querySelector('.save-spinner'); + + // Disable buttons and show spinner + saveBtn.disabled = true; + row.querySelector('.cancel-edit-btn').disabled = true; + saveIcon.style.display = 'none'; + saveSpinner.style.display = 'inline-block'; + + // Get the updated values from the input fields + const dateInput = row.querySelector('.edit-date'); + const hoursInput = row.querySelector('.edit-hours'); + const notesInput = row.querySelector('.edit-notes'); + + const date = dateInput.value; + const hours = parseFloat(hoursInput.value); + const notes = notesInput.value; + + // Validate the input + if (!date || isNaN(hours) || hours <= 0) { + alert('Please enter valid date and hours.'); + + // Reset button states + saveBtn.disabled = false; + row.querySelector('.cancel-edit-btn').disabled = false; + saveIcon.style.display = 'inline-block'; + saveSpinner.style.display = 'none'; + return; + } + + const updatedData = { + date: date, + hours: hours, + notes: notes + }; + + // Make API request to update the entry + fetchWithErrorHandling(`/edit/${entryId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(updatedData) + }) + .then(data => { + // Show success message + showMessage('deleteHoursSuccess', 'Hours updated successfully!'); + + // Reload hours data to refresh the table + loadHoursData(); + }) + .catch(error => { + console.error('Error updating hours entry:', error); + showMessage('deleteHoursError', error.message || 'Error updating hours. Please try again.'); + + // Reset button states + const saveBtn = row.querySelector('.save-row-btn'); + const cancelBtn = row.querySelector('.cancel-edit-btn'); + const saveIcon = saveBtn.querySelector('.save-icon'); + const saveSpinner = saveBtn.querySelector('.save-spinner'); + + saveBtn.disabled = false; + cancelBtn.disabled = false; + saveIcon.style.display = 'inline-block'; + saveSpinner.style.display = 'none'; + + // Switch back to view mode with old values + cancelRowEdit(row); + }); + } + + // Function to cancel editing a row + function cancelRowEdit(row) { + // Set row back to view mode + row.setAttribute('data-mode', 'view'); + + // Get the cells + const dateCell = row.querySelector('.date-cell'); + const hoursCell = row.querySelector('.hours-cell'); + const notesCell = row.querySelector('.notes-cell'); + + // Get the original values + const date = dateCell.getAttribute('data-date'); + const hours = hoursCell.getAttribute('data-hours'); + const notes = notesCell.getAttribute('data-notes'); + + // Restore the original content + dateCell.innerHTML = formatDate(date); + hoursCell.innerHTML = hours; + notesCell.innerHTML = notes || ''; + + // Show view mode buttons, hide edit mode buttons + const viewBtns = row.querySelector('.view-mode-buttons'); + const editBtns = row.querySelector('.edit-mode-buttons'); + + viewBtns.style.display = 'block'; + editBtns.style.display = 'none'; + } + + // Function to load user stats + function loadUserStats() { + const userId = document.getElementById('statsUserSelect').value; + + if (!userId) { + showMessage('statsError', 'Please select a volunteer to view their stats.'); + return; + } + + // Show loading state + document.getElementById('statsContent').classList.add('d-none'); + document.getElementById('statsEmptyState').classList.add('d-none'); + document.getElementById('statsLoading').classList.remove('d-none'); + document.getElementById('statsError').classList.add('d-none'); + + fetchWithErrorHandling('/view/' + userId) + .then(userData => { + // Hide loading, show content + document.getElementById('statsLoading').classList.add('d-none'); + document.getElementById('statsContent').classList.remove('d-none'); + + // Update the stats display + document.getElementById('statsName').textContent = userData.name; + document.getElementById('statsTotalHours').textContent = userData.total_hours + ' hours'; + + // Populate the history table + const tableBody = document.getElementById('userHistoryTable').querySelector('tbody'); + tableBody.innerHTML = ''; + + if (userData.history.length === 0) { + const row = document.createElement('tr'); + row.innerHTML = 'No volunteer hours recorded yet'; + tableBody.appendChild(row); + } else { + // Sort history by date (newest first) + userData.history.sort((a, b) => new Date(b.date) - new Date(a.date)); + + // Add rows to table + userData.history.forEach(entry => { + const row = document.createElement('tr'); + row.setAttribute('title', `ID ${entry.id}`); + row.innerHTML = ` + ${formatDate(entry.date)} + ${entry.hours} + ${entry.notes || ''} + `; + + tableBody.appendChild(row); + }); + } + }) + .catch(error => { + console.error('Error loading volunteer stats:', error); + document.getElementById('statsLoading').classList.add('d-none'); + document.getElementById('statsEmptyState').classList.remove('d-none'); + showMessage('statsError', error.message || 'Error retrieving volunteer stats. Please try again.'); + }); + } +}); diff --git a/pages/hourTool.html b/pages/hourTool.html new file mode 100644 index 0000000..b763731 --- /dev/null +++ b/pages/hourTool.html @@ -0,0 +1,76 @@ +--- +layout: default +title: Volunteer Hour Tracking +permalink: /hours +--- + +
+ + {% include volunteer-hours/tabs-nav.html %} + + +
+ +
+ {% capture log_hours_content %} + {% include volunteer-hours/log-hours-form.html %} + {% endcapture %} + + {% include volunteer-hours/tab-card.html + title="Log Volunteer Hours" + icon_class="fas fa-clock" + header_class="bg-primary text-white" + content=log_hours_content + footer_id="1" + %} +
+ + +
+ {% capture create_user_content %} + {% include volunteer-hours/create-user-form.html %} + {% endcapture %} + + {% include volunteer-hours/tab-card.html + title="Register New Volunteer" + icon_class="fas fa-user-plus" + header_class="bg-success text-white" + content=create_user_content + footer_id="2" + %} +
+ + +
+ {% capture view_hours_content %} + {% include volunteer-hours/view-hours-table.html %} + {% endcapture %} + + {% include volunteer-hours/tab-card.html + title="Volunteer Hours Log" + icon_class="fas fa-table" + header_class="bg-info text-white" + content=view_hours_content + footer_id="3" + %} +
+ + +
+ {% capture user_stats_content %} + {% include volunteer-hours/user-stats.html %} + {% endcapture %} + + {% include volunteer-hours/tab-card.html + title="My Volunteer Stats" + icon_class="fas fa-chart-bar" + header_class="bg-purple text-white" + header_style="background-color: #6f42c1;" + content=user_stats_content + footer_id="4" + %} +
+
+
+ +