diff --git a/map/app/config/index.js b/map/app/config/index.js index 58a74ef..6c6eb96 100644 --- a/map/app/config/index.js +++ b/map/app/config/index.js @@ -52,6 +52,28 @@ if (process.env.NOCODB_SETTINGS_SHEET) { } } +// Parse shifts sheet ID +let shiftsSheetId = null; +if (process.env.NOCODB_SHIFTS_SHEET) { + if (process.env.NOCODB_SHIFTS_SHEET.startsWith('http')) { + const { tableId } = parseNocoDBUrl(process.env.NOCODB_SHIFTS_SHEET); + shiftsSheetId = tableId; + } else { + shiftsSheetId = process.env.NOCODB_SHIFTS_SHEET; + } +} + +// Parse shift signups sheet ID +let shiftSignupsSheetId = null; +if (process.env.NOCODB_SHIFT_SIGNUPS_SHEET) { + if (process.env.NOCODB_SHIFT_SIGNUPS_SHEET.startsWith('http')) { + const { tableId } = parseNocoDBUrl(process.env.NOCODB_SHIFT_SIGNUPS_SHEET); + shiftSignupsSheetId = tableId; + } else { + shiftSignupsSheetId = process.env.NOCODB_SHIFT_SIGNUPS_SHEET; + } +} + module.exports = { // Server config port: process.env.PORT || 3000, @@ -66,7 +88,9 @@ module.exports = { tableId: process.env.NOCODB_TABLE_ID || parsedIds.tableId, loginSheetId, settingsSheetId, - viewUrl: process.env.NOCODB_VIEW_URL + viewUrl: process.env.NOCODB_VIEW_URL, + shiftsSheetId, + shiftSignupsSheetId }, // Session config diff --git a/map/app/controllers/shiftsController.js b/map/app/controllers/shiftsController.js new file mode 100644 index 0000000..51d37f2 --- /dev/null +++ b/map/app/controllers/shiftsController.js @@ -0,0 +1,461 @@ +const nocodbService = require('../services/nocodb'); +const config = require('../config'); +const logger = require('../utils/logger'); +const { extractId } = require('../utils/helpers'); + +class ShiftsController { + // Get all shifts (public) + async getAll(req, res) { + try { + if (!config.nocodb.shiftsSheetId) { + return res.status(500).json({ + success: false, + error: 'Shifts not configured' + }); + } + + logger.info('Loading public shifts from:', config.nocodb.shiftsSheetId); + + // Load all shifts without filter - we'll filter in JavaScript + const response = await nocodbService.getAll(config.nocodb.shiftsSheetId, { + sort: 'Date,Start Time' + }); + + logger.info('Loaded shifts:', response); + + // Filter out cancelled shifts manually + const shifts = (response.list || []).filter(shift => + shift.Status !== 'Cancelled' + ); + + res.json({ + success: true, + shifts: shifts + }); + } catch (error) { + logger.error('Error fetching shifts:', error); + res.status(500).json({ + success: false, + error: 'Failed to fetch shifts' + }); + } + } + + // Get user's signups + async getUserSignups(req, res) { + try { + const userEmail = req.session.userEmail; + + // Check if shift signups sheet is configured + if (!config.nocodb.shiftSignupsSheetId) { + logger.warn('Shift signups sheet not configured'); + return res.json({ + success: true, + signups: [] + }); + } + + // Load all signups and filter in JavaScript + const allSignups = await nocodbService.getAll(config.nocodb.shiftSignupsSheetId, { + sort: '-Signup Date' + }); + + logger.info('All signups loaded:', allSignups); + logger.info('Filtering for user:', userEmail); + + // Filter for this user's confirmed signups + const userSignups = (allSignups.list || []).filter(signup => { + logger.debug('Checking signup:', signup); + // NocoDB returns fields with title case + const email = signup['User Email']; + const status = signup.Status; + + logger.debug(`Comparing: email="${email}" vs userEmail="${userEmail}", status="${status}"`); + + return email === userEmail && status === 'Confirmed'; + }); + + logger.info('User signups found:', userSignups); + + // Transform to match expected format in frontend + const transformedSignups = userSignups.map(signup => ({ + id: signup.ID || signup.id, + shift_id: signup['Shift ID'], + user_email: signup['User Email'], + user_name: signup['User Name'], + signup_date: signup['Signup Date'], + status: signup.Status + })); + + res.json({ + success: true, + signups: transformedSignups + }); + + } catch (error) { + logger.error('Error fetching user signups:', error); + // Don't fail, just return empty array + res.json({ + success: true, + signups: [] + }); + } + } + + // Sign up for a shift + async signup(req, res) { + try { + if (!config.nocodb.shiftsSheetId) { + return res.status(400).json({ + success: false, + error: 'Shifts sheet not configured' + }); + } + + if (!config.nocodb.shiftSignupsSheetId) { + return res.status(400).json({ + success: false, + error: 'Shift signups sheet not configured' + }); + } + + const { shiftId } = req.params; + const userEmail = req.session.userEmail; + const userName = req.session.userName || userEmail; + + logger.info(`User ${userEmail} attempting to sign up for shift ${shiftId}`); + + // Check if shift exists and is open + const shift = await nocodbService.getById(config.nocodb.shiftsSheetId, shiftId); + + if (!shift || shift.Status === 'Cancelled') { + return res.status(400).json({ + success: false, + error: 'Shift not available' + }); + } + + if (shift['Current Volunteers'] >= shift['Max Volunteers']) { + return res.status(400).json({ + success: false, + error: 'Shift is full' + }); + } + + // Check if already signed up - get all signups and filter + const allSignups = await nocodbService.getAll(config.nocodb.shiftSignupsSheetId); + const existingSignup = (allSignups.list || []).find(signup => { + return signup['Shift ID'] === parseInt(shiftId) && + signup['User Email'] === userEmail && + signup.Status === 'Confirmed'; + }); + + if (existingSignup) { + return res.status(400).json({ + success: false, + error: 'Already signed up for this shift' + }); + } + + // Create signup + const signup = await nocodbService.create(config.nocodb.shiftSignupsSheetId, { + 'Shift ID': parseInt(shiftId), + 'User Email': userEmail, + 'User Name': userName, + 'Signup Date': new Date().toISOString(), + 'Status': 'Confirmed' + }); + + logger.info('Created signup:', signup); + + // Update shift volunteer count + await nocodbService.update(config.nocodb.shiftsSheetId, shiftId, { + 'Current Volunteers': (shift['Current Volunteers'] || 0) + 1, + 'Status': shift['Current Volunteers'] + 1 >= shift['Max Volunteers'] ? 'Full' : 'Open' + }); + + res.json({ + success: true, + message: 'Successfully signed up for shift' + }); + + } catch (error) { + logger.error('Error signing up for shift:', error); + res.status(500).json({ + success: false, + error: 'Failed to sign up for shift' + }); + } + } + + // Cancel shift signup + async cancelSignup(req, res) { + try { + if (!config.nocodb.shiftsSheetId || !config.nocodb.shiftSignupsSheetId) { + return res.status(400).json({ + success: false, + error: 'Shifts not configured' + }); + } + + const { shiftId } = req.params; + const userEmail = req.session.userEmail; + + logger.info(`User ${userEmail} attempting to cancel signup for shift ${shiftId}`); + + // Find the signup + const allSignups = await nocodbService.getAll(config.nocodb.shiftSignupsSheetId); + const signup = (allSignups.list || []).find(s => { + return s['Shift ID'] === parseInt(shiftId) && + s['User Email'] === userEmail && + s.Status === 'Confirmed'; + }); + + if (!signup) { + return res.status(404).json({ + success: false, + error: 'Signup not found' + }); + } + + // Update signup status to cancelled + await nocodbService.update(config.nocodb.shiftSignupsSheetId, signup.ID || signup.id, { + 'Status': 'Cancelled' + }); + + // Update shift volunteer count + const shift = await nocodbService.getById(config.nocodb.shiftsSheetId, shiftId); + const newCount = Math.max(0, (shift['Current Volunteers'] || 0) - 1); + + await nocodbService.update(config.nocodb.shiftsSheetId, shiftId, { + 'Current Volunteers': newCount, + 'Status': newCount >= shift['Max Volunteers'] ? 'Full' : 'Open' + }); + + res.json({ + success: true, + message: 'Successfully cancelled signup' + }); + + } catch (error) { + logger.error('Error cancelling signup:', error); + res.status(500).json({ + success: false, + error: 'Failed to cancel signup' + }); + } + } + + // Admin: Create shift + async create(req, res) { + try { + const { title, description, date, startTime, endTime, location, maxVolunteers } = req.body; + + if (!title || !date || !startTime || !endTime || !location || !maxVolunteers) { + return res.status(400).json({ + success: false, + error: 'Missing required fields' + }); + } + + const shift = await nocodbService.create(config.nocodb.shiftsSheetId, { + Title: title, + Description: description, + Date: date, + 'Start Time': startTime, + 'End Time': endTime, + Location: location, + 'Max Volunteers': parseInt(maxVolunteers), + 'Current Volunteers': 0, + Status: 'Open', + 'Created By': req.session.userEmail, + 'Created At': new Date().toISOString(), + 'Updated At': new Date().toISOString() + }); + + res.json({ + success: true, + shift + }); + + } catch (error) { + logger.error('Error creating shift:', error); + res.status(500).json({ + success: false, + error: 'Failed to create shift' + }); + } + } + + // Admin: Update shift + async update(req, res) { + try { + const { id } = req.params; + const updateData = {}; + + // Map fields that can be updated + const fieldMap = { + title: 'Title', + description: 'Description', + date: 'Date', + startTime: 'Start Time', + endTime: 'End Time', + location: 'Location', + maxVolunteers: 'Max Volunteers', + status: 'Status' + }; + + for (const [key, field] of Object.entries(fieldMap)) { + if (req.body[key] !== undefined) { + updateData[field] = req.body[key]; + } + } + + if (updateData['Max Volunteers']) { + updateData['Max Volunteers'] = parseInt(updateData['Max Volunteers']); + } + + updateData['Updated At'] = new Date().toISOString(); + + const updated = await nocodbService.update(config.nocodb.shiftsSheetId, id, updateData); + + res.json({ + success: true, + shift: updated + }); + + } catch (error) { + logger.error('Error updating shift:', error); + res.status(500).json({ + success: false, + error: 'Failed to update shift' + }); + } + } + + // Admin: Delete shift + async delete(req, res) { + try { + const { id } = req.params; + + // Check if signups sheet is configured + if (config.nocodb.shiftSignupsSheetId) { + try { + // Get all signups and filter in JavaScript to avoid NocoDB query issues + const allSignups = await nocodbService.getAll(config.nocodb.shiftSignupsSheetId); + + // Filter for confirmed signups for this shift + const signupsToCancel = (allSignups.list || []).filter(signup => + signup['Shift ID'] === parseInt(id) && signup.Status === 'Confirmed' + ); + + // Cancel each signup + for (const signup of signupsToCancel) { + await nocodbService.update(config.nocodb.shiftSignupsSheetId, signup.ID || signup.id, { + Status: 'Cancelled' + }); + } + + logger.info(`Cancelled ${signupsToCancel.length} signups for shift ${id}`); + } catch (signupError) { + logger.error('Error cancelling signups:', signupError); + // Continue with shift deletion even if signup cancellation fails + } + } + + // Delete the shift + await nocodbService.delete(config.nocodb.shiftsSheetId, id); + + res.json({ + success: true, + message: 'Shift deleted successfully' + }); + + } catch (error) { + logger.error('Error deleting shift:', error); + res.status(500).json({ + success: false, + error: 'Failed to delete shift' + }); + } + } + + // Admin: Get all shifts with signup details + async getAllAdmin(req, res) { + try { + if (!config.nocodb.shiftsSheetId) { + logger.error('Shifts sheet not configured'); + return res.status(500).json({ + success: false, + error: 'Shifts not configured' + }); + } + + logger.info('Loading admin shifts from:', config.nocodb.shiftsSheetId); + + let shifts; + try { + shifts = await nocodbService.getAll(config.nocodb.shiftsSheetId, { + sort: '-Date,-Start Time' + }); + } catch (apiError) { + logger.error('Error loading shifts from NocoDB:', apiError); + // If it's a 422 error, try without sort parameters + if (apiError.response?.status === 422) { + logger.warn('Retrying without sort parameters due to 422 error'); + try { + shifts = await nocodbService.getAll(config.nocodb.shiftsSheetId); + } catch (retryError) { + logger.error('Retry also failed:', retryError); + return res.status(500).json({ + success: false, + error: 'Failed to load shifts from database' + }); + } + } else { + throw apiError; + } + } + + logger.info('Loaded shifts:', shifts); + + // Only try to get signups if the signups sheet is configured + if (config.nocodb.shiftSignupsSheetId) { + // Get signup counts for each shift + for (const shift of shifts.list || []) { + try { + const signups = await nocodbService.getAll(config.nocodb.shiftSignupsSheetId); + // Filter signups for this shift manually + const shiftSignups = (signups.list || []).filter(signup => + signup['Shift ID'] === shift.ID && signup.Status === 'Confirmed' + ); + shift.signups = shiftSignups; + } catch (signupError) { + logger.error(`Error loading signups for shift ${shift.ID}:`, signupError); + shift.signups = []; + } + } + } else { + logger.warn('Shift signups sheet not configured, skipping signup data'); + // Set empty signups for all shifts + for (const shift of shifts.list || []) { + shift.signups = []; + } + } + + res.json({ + success: true, + shifts: shifts.list || [] + }); + + } catch (error) { + logger.error('Error fetching admin shifts:', error); + res.status(500).json({ + success: false, + error: 'Failed to fetch shifts' + }); + } + } +} + +module.exports = new ShiftsController(); \ No newline at end of file diff --git a/map/app/public/admin.html b/map/app/public/admin.html index 83beaae..bac2651 100644 --- a/map/app/public/admin.html +++ b/map/app/public/admin.html @@ -33,10 +33,11 @@
-

Settings

+

Admin Panel

@@ -174,6 +175,70 @@
+ + + diff --git a/map/app/public/css/admin.css b/map/app/public/css/admin.css index 7878978..4daff93 100644 --- a/map/app/public/css/admin.css +++ b/map/app/public/css/admin.css @@ -747,3 +747,69 @@ .admin-map .leaflet-container { cursor: crosshair; } + +/* Shifts Admin Styles */ +.shifts-admin-container { + display: grid; + grid-template-columns: 400px 1fr; + gap: 30px; +} + +.shift-form { + background: white; + padding: 20px; + border-radius: var(--border-radius); + border: 1px solid #e0e0e0; +} + +.shifts-list { + background: white; + padding: 20px; + border-radius: var(--border-radius); + border: 1px solid #e0e0e0; +} + +.shift-admin-item { + border: 1px solid #e0e0e0; + border-radius: var(--border-radius); + padding: 15px; + margin-bottom: 15px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.shift-admin-item h4 { + margin: 0 0 10px 0; +} + +.shift-admin-item p { + margin: 5px 0; + color: var(--secondary-color); +} + +.status-open { + color: var(--success-color); + font-weight: bold; +} + +.status-full { + color: var(--warning-color); + font-weight: bold; +} + +.status-cancelled { + color: var(--danger-color); + font-weight: bold; +} + +.shift-actions { + display: flex; + gap: 10px; +} + +@media (max-width: 768px) { + .shifts-admin-container { + grid-template-columns: 1fr; + } +} diff --git a/map/app/public/css/shifts.css b/map/app/public/css/shifts.css new file mode 100644 index 0000000..8e8dd88 --- /dev/null +++ b/map/app/public/css/shifts.css @@ -0,0 +1,161 @@ +.shifts-container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +/* My Signups section - now at the top */ +.my-signups { + margin-bottom: 40px; + padding-bottom: 30px; + border-bottom: 1px solid #e0e0e0; +} + +.my-signups h2 { + margin-bottom: 20px; + color: var(--dark-color); +} + +.signup-item { + background: white; + border: 1px solid #e0e0e0; + border-radius: var(--border-radius); + padding: 15px; + margin-bottom: 10px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.signup-item h4 { + margin: 0 0 5px 0; +} + +.signup-item p { + margin: 0; + color: var(--secondary-color); +} + +/* Filters section */ +.shifts-filters { + margin-bottom: 30px; +} + +.shifts-filters h2 { + margin-bottom: 15px; + color: var(--dark-color); +} + +.filter-group { + display: flex; + gap: 10px; + align-items: center; + margin-top: 10px; +} + +.filter-group input { + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: var(--border-radius); +} + +/* Desktop: 3 columns layout */ +.shifts-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + margin-bottom: 40px; +} + +.shift-card { + background: white; + border: 1px solid #e0e0e0; + border-radius: var(--border-radius); + padding: 20px; + transition: var(--transition); +} + +.shift-card:hover { + box-shadow: 0 4px 12px rgba(0,0,0,0.1); +} + +.shift-card.full { + opacity: 0.7; +} + +.shift-card.signed-up { + border-color: var(--success-color); + background-color: #f0f9ff; +} + +.shift-card h3 { + margin: 0 0 15px 0; + color: var(--dark-color); +} + +.shift-details { + margin-bottom: 15px; +} + +.shift-details p { + margin: 5px 0; + color: var(--secondary-color); +} + +.shift-description { + margin: 15px 0; + color: var(--dark-color); +} + +.shift-actions { + margin-top: 15px; +} + +.no-shifts { + text-align: center; + color: var(--secondary-color); + padding: 40px; + grid-column: 1 / -1; /* Span all columns */ +} + +/* Tablet: 2 columns */ +@media (max-width: 1024px) and (min-width: 769px) { + .shifts-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +/* Mobile: 1 column */ +@media (max-width: 768px) { + .shifts-container { + padding: 15px; + } + + .shifts-grid { + grid-template-columns: 1fr; + gap: 15px; + } + + .signup-item { + flex-direction: column; + align-items: flex-start; + gap: 10px; + } + + .shift-card { + padding: 15px; + } + + .my-signups { + margin-bottom: 30px; + padding-bottom: 20px; + } + + .shifts-filters { + margin-bottom: 20px; + } + + .filter-group { + flex-wrap: wrap; + } +} \ No newline at end of file diff --git a/map/app/public/css/style.css b/map/app/public/css/style.css index 0a334a5..3faab31 100644 --- a/map/app/public/css/style.css +++ b/map/app/public/css/style.css @@ -743,6 +743,39 @@ body { transform: scale(0.95); } +/* Desktop styles - show normal layout */ +@media (min-width: 769px) { + .mobile-dropdown { + display: none; + } + + .mobile-sidebar { + display: none; + } + + .header-actions { + display: flex; + } + + .user-info, + .location-count { + display: flex; + } + + .map-controls { + display: flex; + } + + /* Show shifts button on desktop */ + .header-actions a[href="/shifts.html"] { + display: inline-flex !important; + } + + .btn span.btn-icon { + margin-right: 5px; + } +} + /* Hide desktop elements on mobile */ @media (max-width: 768px) { .header h1 { @@ -753,6 +786,11 @@ body { display: none; } + /* Hide any floating shifts button on mobile - but NOT the one in dropdown */ + .header-actions a[href="/shifts.html"] { + display: none !important; + } + .mobile-dropdown { display: block; } @@ -791,34 +829,6 @@ body { } } -/* Desktop styles - show normal layout */ -@media (min-width: 769px) { - .mobile-dropdown { - display: none; - } - - .mobile-sidebar { - display: none; - } - - .header-actions { - display: flex; - } - - .user-info, - .location-count { - display: flex; - } - - .map-controls { - display: flex; - } - - .btn span.btn-icon { - margin-right: 5px; - } -} - /* Fullscreen styles */ .fullscreen #map-container { position: fixed; diff --git a/map/app/public/index.html b/map/app/public/index.html index 54ec30b..8b47722 100644 --- a/map/app/public/index.html +++ b/map/app/public/index.html @@ -20,6 +20,10 @@

Map for CM-lite

+ + 📅 + View Shifts + @@ -32,6 +36,9 @@
+
0 locations diff --git a/map/app/public/js/admin.js b/map/app/public/js/admin.js index e02f135..85edc85 100644 --- a/map/app/public/js/admin.js +++ b/map/app/public/js/admin.js @@ -237,14 +237,29 @@ function setupEventListeners() { let previousUrl = urlInput.value; urlInput.addEventListener('change', () => { - if (urlInput.value !== previousUrl) { - // URL changed, clear stored QR code - delete storedQRCodes[i]; - previousUrl = urlInput.value; + const currentUrl = urlInput.value; + if (currentUrl !== previousUrl) { + console.log(`QR Code ${i} URL changed from "${previousUrl}" to "${currentUrl}"`); + // Remove stored QR code so it gets regenerated + delete storedQRCodes[currentUrl]; + previousUrl = currentUrl; + generateWalkSheetPreview(); } }); } } + + // Shift form submission + const shiftForm = document.getElementById('shift-form'); + if (shiftForm) { + shiftForm.addEventListener('submit', createShift); + } + + // Clear shift form button + const clearShiftBtn = document.getElementById('clear-shift-form'); + if (clearShiftBtn) { + clearShiftBtn.addEventListener('click', clearShiftForm); + } } // Setup navigation between admin sections @@ -276,19 +291,31 @@ function setupNavigation() { }); link.classList.add('active'); - // If switching to walk sheet, load config first then generate preview + // If switching to shifts section, load shifts + if (targetId === 'shifts') { + console.log('Loading admin shifts...'); + loadAdminShifts(); + } + + // If switching to walk sheet section, load config if (targetId === 'walk-sheet') { - console.log('Switching to walk sheet section, loading config...'); - // Always load the latest config when switching to walk sheet loadWalkSheetConfig().then((success) => { if (success) { - console.log('Config loaded, generating preview...'); generateWalkSheetPreview(); } }); } }); }); + + // Also check if we're already on the shifts page (via hash) + const hash = window.location.hash; + if (hash === '#shifts') { + const shiftsLink = document.querySelector('.admin-nav a[href="#shifts"]'); + if (shiftsLink) { + shiftsLink.click(); + } + } } // Update map from input fields @@ -872,3 +899,314 @@ function debounce(func, wait) { timeout = setTimeout(later, wait); }; } + +// Add shift management functions +async function loadAdminShifts() { + try { + const response = await fetch('/api/shifts/admin'); + const data = await response.json(); + + if (data.success) { + displayAdminShifts(data.shifts); + } else { + showStatus('Failed to load shifts', 'error'); + } + } catch (error) { + console.error('Error loading admin shifts:', error); + showStatus('Failed to load shifts', 'error'); + } +} + +function displayAdminShifts(shifts) { + const list = document.getElementById('admin-shifts-list'); + + if (!list) { + console.error('Admin shifts list element not found'); + return; + } + + if (shifts.length === 0) { + list.innerHTML = '

No shifts created yet.

'; + return; + } + + list.innerHTML = shifts.map(shift => { + const shiftDate = new Date(shift.Date); + const signupCount = shift.signups ? shift.signups.length : 0; + + return ` +
+
+

${escapeHtml(shift.Title)}

+

📅 ${shiftDate.toLocaleDateString()} | ⏰ ${shift['Start Time']} - ${shift['End Time']}

+

📍 ${escapeHtml(shift.Location || 'TBD')}

+

👥 ${signupCount}/${shift['Max Volunteers']} volunteers

+

${shift.Status || 'Open'}

+
+
+ + +
+
+ `; + }).join(''); + + // Add event listeners using delegation + setupShiftActionListeners(); +} + +// Fix the setupNavigation function to properly load shifts when switching to shifts section +function setupNavigation() { + const navLinks = document.querySelectorAll('.admin-nav a'); + const sections = document.querySelectorAll('.admin-section'); + + navLinks.forEach(link => { + link.addEventListener('click', (e) => { + e.preventDefault(); + + // Get target section ID + const targetId = link.getAttribute('href').substring(1); + + // Hide all sections + sections.forEach(section => { + section.style.display = 'none'; + }); + + // Show target section + const targetSection = document.getElementById(targetId); + if (targetSection) { + targetSection.style.display = 'block'; + } + + // Update active nav link + navLinks.forEach(navLink => { + navLink.classList.remove('active'); + }); + link.classList.add('active'); + + // If switching to shifts section, load shifts + if (targetId === 'shifts') { + console.log('Loading admin shifts...'); + loadAdminShifts(); + } + + // If switching to walk sheet section, load config + if (targetId === 'walk-sheet') { + loadWalkSheetConfig().then((success) => { + if (success) { + generateWalkSheetPreview(); + } + }); + } + }); + }); + + // Also check if we're already on the shifts page (via hash) + const hash = window.location.hash; + if (hash === '#shifts') { + const shiftsLink = document.querySelector('.admin-nav a[href="#shifts"]'); + if (shiftsLink) { + shiftsLink.click(); + } + } +} + +// Fix the setupShiftActionListeners function +function setupShiftActionListeners() { + const list = document.getElementById('admin-shifts-list'); + if (!list) return; + + // Remove any existing listeners to avoid duplicates + const newList = list.cloneNode(true); + list.parentNode.replaceChild(newList, list); + + // Get the updated reference + const updatedList = document.getElementById('admin-shifts-list'); + + updatedList.addEventListener('click', function(e) { + if (e.target.classList.contains('delete-shift-btn')) { + const shiftId = e.target.getAttribute('data-shift-id'); + console.log('Delete button clicked for shift:', shiftId); + deleteShift(shiftId); + } else if (e.target.classList.contains('edit-shift-btn')) { + const shiftId = e.target.getAttribute('data-shift-id'); + console.log('Edit button clicked for shift:', shiftId); + editShift(shiftId); + } + }); +} + +// Update the deleteShift function (remove window. prefix) +async function deleteShift(shiftId) { + if (!confirm('Are you sure you want to delete this shift? All signups will be cancelled.')) { + return; + } + + try { + const response = await fetch(`/api/shifts/admin/${shiftId}`, { + method: 'DELETE' + }); + + const data = await response.json(); + + if (data.success) { + showStatus('Shift deleted successfully', 'success'); + await loadAdminShifts(); + } else { + showStatus(data.error || 'Failed to delete shift', 'error'); + } + } catch (error) { + console.error('Error deleting shift:', error); + showStatus('Failed to delete shift', 'error'); + } +} + +// Update editShift function (remove window. prefix) +function editShift(shiftId) { + showStatus('Edit functionality coming soon', 'info'); +} + +// Add function to create shift +async function createShift(e) { + e.preventDefault(); + + const formData = { + title: document.getElementById('shift-title').value, + description: document.getElementById('shift-description').value, + date: document.getElementById('shift-date').value, + startTime: document.getElementById('shift-start').value, + endTime: document.getElementById('shift-end').value, + location: document.getElementById('shift-location').value, + maxVolunteers: document.getElementById('shift-max-volunteers').value + }; + + try { + const response = await fetch('/api/shifts/admin', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(formData) + }); + + const data = await response.json(); + + if (data.success) { + showStatus('Shift created successfully', 'success'); + document.getElementById('shift-form').reset(); + await loadAdminShifts(); + } else { + showStatus(data.error || 'Failed to create shift', 'error'); + } + } catch (error) { + console.error('Error creating shift:', error); + showStatus('Failed to create shift', 'error'); + } +} + +function clearShiftForm() { + const form = document.getElementById('shift-form'); + if (form) { + form.reset(); + showStatus('Form cleared', 'info'); + } +} + +// Update setupEventListeners to include shift form and clear button +function setupEventListeners() { + // Use current view button + const useCurrentViewBtn = document.getElementById('use-current-view'); + if (useCurrentViewBtn) { + useCurrentViewBtn.addEventListener('click', () => { + const center = adminMap.getCenter(); + const zoom = adminMap.getZoom(); + + document.getElementById('start-lat').value = center.lat.toFixed(6); + document.getElementById('start-lng').value = center.lng.toFixed(6); + document.getElementById('start-zoom').value = zoom; + + updateStartMarker(center.lat, center.lng); + showStatus('Captured current map view', 'success'); + }); + } + + // Save button + const saveLocationBtn = document.getElementById('save-start-location'); + if (saveLocationBtn) { + saveLocationBtn.addEventListener('click', saveStartLocation); + } + + // Coordinate input changes + const startLatInput = document.getElementById('start-lat'); + const startLngInput = document.getElementById('start-lng'); + const startZoomInput = document.getElementById('start-zoom'); + + if (startLatInput) startLatInput.addEventListener('change', updateMapFromInputs); + if (startLngInput) startLngInput.addEventListener('change', updateMapFromInputs); + if (startZoomInput) startZoomInput.addEventListener('change', updateMapFromInputs); + + // Walk Sheet buttons + const saveWalkSheetBtn = document.getElementById('save-walk-sheet'); + const previewWalkSheetBtn = document.getElementById('preview-walk-sheet'); + const printWalkSheetBtn = document.getElementById('print-walk-sheet'); + const refreshPreviewBtn = document.getElementById('refresh-preview'); + + if (saveWalkSheetBtn) saveWalkSheetBtn.addEventListener('click', saveWalkSheetConfig); + if (previewWalkSheetBtn) previewWalkSheetBtn.addEventListener('click', generateWalkSheetPreview); + if (printWalkSheetBtn) printWalkSheetBtn.addEventListener('click', printWalkSheet); + if (refreshPreviewBtn) refreshPreviewBtn.addEventListener('click', generateWalkSheetPreview); + + // Auto-update preview on input change + const walkSheetInputs = document.querySelectorAll( + '#walk-sheet-title, #walk-sheet-subtitle, #walk-sheet-footer, ' + + '[id^="qr-code-"][id$="-url"], [id^="qr-code-"][id$="-label"]' + ); + + walkSheetInputs.forEach(input => { + if (input) { + input.addEventListener('input', debounce(() => { + generateWalkSheetPreview(); + }, 500)); + } + }); + + // Add URL change listeners to detect when QR codes need regeneration + for (let i = 1; i <= 3; i++) { + const urlInput = document.getElementById(`qr-code-${i}-url`); + if (urlInput) { + let previousUrl = urlInput.value; + + urlInput.addEventListener('change', () => { + const currentUrl = urlInput.value; + if (currentUrl !== previousUrl) { + console.log(`QR Code ${i} URL changed from "${previousUrl}" to "${currentUrl}"`); + // Remove stored QR code so it gets regenerated + delete storedQRCodes[currentUrl]; + previousUrl = currentUrl; + generateWalkSheetPreview(); + } + }); + } + } + + // Shift form submission + const shiftForm = document.getElementById('shift-form'); + if (shiftForm) { + shiftForm.addEventListener('submit', createShift); + } + + // Clear shift form button + const clearShiftBtn = document.getElementById('clear-shift-form'); + if (clearShiftBtn) { + clearShiftBtn.addEventListener('click', clearShiftForm); + } +} + +// Add the missing clearShiftForm function +function clearShiftForm() { + const form = document.getElementById('shift-form'); + if (form) { + form.reset(); + showStatus('Form cleared', 'info'); + } +} diff --git a/map/app/public/js/map-manager.js b/map/app/public/js/map-manager.js index 507cce0..c3429f0 100644 --- a/map/app/public/js/map-manager.js +++ b/map/app/public/js/map-manager.js @@ -9,8 +9,8 @@ export let isStartLocationVisible = true; export async function initializeMap() { try { - // Get start location from server - const response = await fetch('/api/admin/start-location'); + // Get start location from PUBLIC endpoint (not admin endpoint) + const response = await fetch('/api/config/start-location'); const data = await response.json(); let startLat = CONFIG.DEFAULT_LAT; diff --git a/map/app/public/js/shifts.js b/map/app/public/js/shifts.js new file mode 100644 index 0000000..8d802e1 --- /dev/null +++ b/map/app/public/js/shifts.js @@ -0,0 +1,293 @@ +let currentUser = null; +let allShifts = []; +let mySignups = []; + +// Initialize when DOM is loaded +document.addEventListener('DOMContentLoaded', async () => { + await checkAuth(); + await loadShifts(); + await loadMySignups(); + setupEventListeners(); + + // Add clear filters button handler + const clearBtn = document.getElementById('clear-filters-btn'); + if (clearBtn) { + clearBtn.addEventListener('click', clearFilters); + } +}); + +async function checkAuth() { + try { + const response = await fetch('/api/auth/check'); + const data = await response.json(); + + if (!data.authenticated) { + window.location.href = '/login.html'; + return; + } + + currentUser = data.user; + document.getElementById('user-email').textContent = currentUser.email; + + // Add admin link if user is admin + if (currentUser.isAdmin) { + const headerActions = document.querySelector('.header-actions'); + const adminLink = document.createElement('a'); + adminLink.href = '/admin.html#shifts'; + adminLink.className = 'btn btn-secondary'; + adminLink.textContent = '⚙️ Manage Shifts'; + headerActions.insertBefore(adminLink, headerActions.firstChild); + } + + } catch (error) { + console.error('Auth check failed:', error); + window.location.href = '/login.html'; + } +} + +async function loadShifts() { + try { + const response = await fetch('/api/shifts'); + const data = await response.json(); + + if (data.success) { + allShifts = data.shifts; + displayShifts(allShifts); + } + } catch (error) { + showStatus('Failed to load shifts', 'error'); + } +} + +async function loadMySignups() { + try { + const response = await fetch('/api/shifts/my-signups'); + const data = await response.json(); + + if (data.success) { + mySignups = data.signups; + displayMySignups(); + } else { + // Still display empty signups if the endpoint fails + mySignups = []; + displayMySignups(); + } + } catch (error) { + console.error('Failed to load signups:', error); + // Don't show error to user, just display empty signups + mySignups = []; + displayMySignups(); + } +} + +function displayShifts(shifts) { + const grid = document.getElementById('shifts-grid'); + + if (shifts.length === 0) { + grid.innerHTML = '

No shifts available at this time.

'; + return; + } + + grid.innerHTML = shifts.map(shift => { + const shiftDate = new Date(shift.Date); + const isSignedUp = mySignups.some(s => s.shift_id === shift.ID); + const isFull = shift['Current Volunteers'] >= shift['Max Volunteers']; + + return ` +
+

${escapeHtml(shift.Title)}

+
+

📅 ${shiftDate.toLocaleDateString()}

+

⏰ ${shift['Start Time']} - ${shift['End Time']}

+

📍 ${escapeHtml(shift.Location || 'TBD')}

+

👥 ${shift['Current Volunteers']}/${shift['Max Volunteers']} volunteers

+
+ ${shift.Description ? `
${escapeHtml(shift.Description)}
` : ''} +
+ ${isSignedUp + ? `` + : isFull + ? '' + : `` + } +
+
+ `; + }).join(''); + + // Add event listeners after rendering + setupShiftCardListeners(); +} + +function displayMySignups() { + const list = document.getElementById('my-signups-list'); + + if (mySignups.length === 0) { + list.innerHTML = '

You haven\'t signed up for any shifts yet.

'; + return; + } + + // Need to match signups with shift details + const signupsWithDetails = mySignups.map(signup => { + const shift = allShifts.find(s => s.ID === signup.shift_id); + return { ...signup, shift }; + }).filter(s => s.shift); + + list.innerHTML = signupsWithDetails.map(signup => { + const shiftDate = new Date(signup.shift.Date); + return ` + + `; + }).join(''); + + // Add event listeners after rendering + setupMySignupsListeners(); +} + +// New function to setup listeners for shift cards +function setupShiftCardListeners() { + const grid = document.getElementById('shifts-grid'); + if (!grid) return; + + // Remove any existing listeners by cloning + const newGrid = grid.cloneNode(true); + grid.parentNode.replaceChild(newGrid, grid); + + // Add click listener for signup buttons + newGrid.addEventListener('click', async (e) => { + if (e.target.classList.contains('signup-btn')) { + const shiftId = e.target.getAttribute('data-shift-id'); + await signupForShift(shiftId); + } else if (e.target.classList.contains('cancel-signup-btn')) { + const shiftId = e.target.getAttribute('data-shift-id'); + await cancelSignup(shiftId); + } + }); +} + +// New function to setup listeners for my signups +function setupMySignupsListeners() { + const list = document.getElementById('my-signups-list'); + if (!list) return; + + // Remove any existing listeners by cloning + const newList = list.cloneNode(true); + list.parentNode.replaceChild(newList, list); + + // Add click listener for cancel buttons + newList.addEventListener('click', async (e) => { + if (e.target.classList.contains('cancel-signup-btn')) { + const shiftId = e.target.getAttribute('data-shift-id'); + await cancelSignup(shiftId); + } + }); +} + +async function signupForShift(shiftId) { + try { + const response = await fetch(`/api/shifts/${shiftId}/signup`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + + if (data.success) { + showStatus('Successfully signed up for shift!', 'success'); + await loadShifts(); + await loadMySignups(); + } else { + showStatus(data.error || 'Failed to sign up', 'error'); + } + } catch (error) { + console.error('Error signing up:', error); + showStatus('Failed to sign up for shift', 'error'); + } +} + +async function cancelSignup(shiftId) { + if (!confirm('Are you sure you want to cancel your signup for this shift?')) { + return; + } + + try { + const response = await fetch(`/api/shifts/${shiftId}/cancel`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + + if (data.success) { + showStatus('Signup cancelled', 'success'); + await loadShifts(); + await loadMySignups(); + } else { + showStatus(data.error || 'Failed to cancel signup', 'error'); + } + } catch (error) { + console.error('Error cancelling signup:', error); + showStatus('Failed to cancel signup', 'error'); + } +} + +function setupEventListeners() { + const dateFilter = document.getElementById('date-filter'); + if (dateFilter) { + dateFilter.addEventListener('change', filterShifts); + } +} + +function filterShifts() { + const dateFilter = document.getElementById('date-filter').value; + + if (!dateFilter) { + displayShifts(allShifts); + return; + } + + const filtered = allShifts.filter(shift => { + return shift.Date === dateFilter; // Changed from shift.date to shift.Date + }); + + displayShifts(filtered); +} + +function clearFilters() { + document.getElementById('date-filter').value = ''; + loadShifts(); // Reload shifts without filters +} + +function showStatus(message, type = 'info') { + const container = document.getElementById('status-container'); + if (!container) return; + + const messageDiv = document.createElement('div'); + messageDiv.className = `status-message ${type}`; + messageDiv.textContent = message; + + container.appendChild(messageDiv); + + setTimeout(() => { + messageDiv.remove(); + }, 5000); +} + +function escapeHtml(text) { + if (text === null || text === undefined) { + return ''; + } + const div = document.createElement('div'); + div.textContent = String(text); + return div.innerHTML; +} \ No newline at end of file diff --git a/map/app/public/shifts.html b/map/app/public/shifts.html new file mode 100644 index 0000000..f6e43b0 --- /dev/null +++ b/map/app/public/shifts.html @@ -0,0 +1,52 @@ + + + + + + Volunteer Shifts - BNKops Map + + + + + + +
+
+

Volunteer Shifts

+
+ ← Back to Map + +
+
+ +
+ +
+

My Shifts

+
+ +
+
+ +
+

Available Shifts

+
+ + + +
+
+ +
+ +
+
+ +
+
+ + + + \ No newline at end of file diff --git a/map/app/routes/index.js b/map/app/routes/index.js index 6ee055b..51b37a0 100644 --- a/map/app/routes/index.js +++ b/map/app/routes/index.js @@ -11,6 +11,7 @@ const userRoutes = require('./users'); const qrRoutes = require('./qr'); const debugRoutes = require('./debug'); const geocodingRoutes = require('../routes/geocoding'); // Existing geocoding routes +const shiftsRoutes = require('./shifts'); module.exports = (app) => { // Health check (no auth) @@ -45,6 +46,7 @@ module.exports = (app) => { app.use('/api/locations', requireAuth, locationRoutes); app.use('/api/geocode', requireAuth, geocodingRoutes); app.use('/api/settings', requireAuth, settingsRoutes); + app.use('/api/shifts', shiftsRoutes); // Admin routes app.get('/admin.html', requireAdmin, (req, res) => { @@ -95,6 +97,11 @@ module.exports = (app) => { res.sendFile(path.join(__dirname, '../public', 'index.html')); }); + // Protected page route + app.get('/shifts.html', requireAuth, (req, res) => { + res.sendFile(path.join(__dirname, '../public', 'shifts.html')); + }); + // Catch all - redirect to login app.get('*', (req, res) => { res.redirect('/login.html'); diff --git a/map/app/routes/shifts.js b/map/app/routes/shifts.js new file mode 100644 index 0000000..5912f51 --- /dev/null +++ b/map/app/routes/shifts.js @@ -0,0 +1,18 @@ +const express = require('express'); +const router = express.Router(); +const shiftsController = require('../controllers/shiftsController'); +const { requireAuth, requireAdmin } = require('../middleware/auth'); + +// Public routes (authenticated users) +router.get('/', requireAuth, shiftsController.getAll); +router.get('/my-signups', requireAuth, shiftsController.getUserSignups); +router.post('/:shiftId/signup', requireAuth, shiftsController.signup); +router.post('/:shiftId/cancel', requireAuth, shiftsController.cancelSignup); + +// Admin routes +router.get('/admin', requireAdmin, shiftsController.getAllAdmin); +router.post('/admin', requireAdmin, shiftsController.create); +router.put('/admin/:id', requireAdmin, shiftsController.update); +router.delete('/admin/:id', requireAdmin, shiftsController.delete); + +module.exports = router; \ No newline at end of file diff --git a/map/build-nocodb.sh b/map/build-nocodb.sh index e96d37b..ec5507b 100755 --- a/map/build-nocodb.sh +++ b/map/build-nocodb.sh @@ -562,6 +562,163 @@ create_settings_table() { create_table "$base_id" "settings" "$table_data" "System configuration with walk sheet text fields" } +# Function to create the shifts table +create_shifts_table() { + local base_id=$1 + local table_data='{ + "table_name": "shifts", + "title": "Shifts", + "columns": [ + { + "column_name": "id", + "title": "ID", + "uidt": "ID", + "pk": true, + "ai": true, + "rqd": true + }, + { + "column_name": "title", + "title": "Title", + "uidt": "SingleLineText", + "rqd": true + }, + { + "column_name": "description", + "title": "Description", + "uidt": "LongText", + "rqd": false + }, + { + "column_name": "date", + "title": "Date", + "uidt": "Date", + "rqd": true + }, + { + "column_name": "start_time", + "title": "Start Time", + "uidt": "Time", + "rqd": true + }, + { + "column_name": "end_time", + "title": "End Time", + "uidt": "Time", + "rqd": true + }, + { + "column_name": "location", + "title": "Location", + "uidt": "SingleLineText", + "rqd": false + }, + { + "column_name": "max_volunteers", + "title": "Max Volunteers", + "uidt": "Number", + "rqd": true + }, + { + "column_name": "current_volunteers", + "title": "Current Volunteers", + "uidt": "Number", + "rqd": false + }, + { + "column_name": "status", + "title": "Status", + "uidt": "SingleSelect", + "rqd": false, + "colOptions": { + "options": [ + {"title": "Open", "color": "#4CAF50"}, + {"title": "Full", "color": "#FF9800"}, + {"title": "Cancelled", "color": "#F44336"} + ] + } + }, + { + "column_name": "created_by", + "title": "Created By", + "uidt": "SingleLineText", + "rqd": false + }, + { + "column_name": "created_at", + "title": "Created At", + "uidt": "DateTime", + "rqd": false + }, + { + "column_name": "updated_at", + "title": "Updated At", + "uidt": "DateTime", + "rqd": false + } + ] + }' + + create_table "$base_id" "shifts" "$table_data" "shifts table" +} + +# Function to create the shift signups table +create_shift_signups_table() { + local base_id=$1 + local table_data='{ + "table_name": "shift_signups", + "title": "Shift Signups", + "columns": [ + { + "column_name": "id", + "title": "ID", + "uidt": "ID", + "pk": true, + "ai": true, + "rqd": true + }, + { + "column_name": "shift_id", + "title": "Shift ID", + "uidt": "Number", + "rqd": true + }, + { + "column_name": "user_email", + "title": "User Email", + "uidt": "Email", + "rqd": true + }, + { + "column_name": "user_name", + "title": "User Name", + "uidt": "SingleLineText", + "rqd": false + }, + { + "column_name": "signup_date", + "title": "Signup Date", + "uidt": "DateTime", + "rqd": false + }, + { + "column_name": "status", + "title": "Status", + "uidt": "SingleSelect", + "rqd": false, + "colOptions": { + "options": [ + {"title": "Confirmed", "color": "#4CAF50"}, + {"title": "Cancelled", "color": "#F44336"} + ] + } + } + ] + }' + + create_table "$base_id" "shift_signups" "$table_data" "shift signups table" +} + # Function to create default admin user create_default_admin() { @@ -645,6 +802,12 @@ main() { # Create settings table SETTINGS_TABLE_ID=$(create_settings_table "$BASE_ID") + # Create shifts table + SHIFTS_TABLE_ID=$(create_shifts_table "$BASE_ID") + + # Create shift signups table + SHIFT_SIGNUPS_TABLE_ID=$(create_shift_signups_table "$BASE_ID") + # Wait a moment for tables to be fully created sleep 3 @@ -670,6 +833,8 @@ main() { print_status " - NOCODB_VIEW_URL (for locations table)" print_status " - NOCODB_LOGIN_SHEET (for login table)" print_status " - NOCODB_SETTINGS_SHEET (for settings table)" + print_status " - NOCODB_SHIFTS_SHEET (for shifts table)" + print_status " - NOCODB_SHIFT_SIGNUPS_SHEET (for shift signups table)" print_status "4. The default admin user is: admin@thebunkerops.ca with password: admin123" print_status "5. IMPORTANT: Change the default password after first login!" print_status "6. Start adding your location data!"