Fixed bug for displaying sign ups for shifts

This commit is contained in:
admin 2025-07-16 09:47:59 -06:00
parent 9e5b3193f7
commit a5bd0e9939
5 changed files with 487 additions and 30 deletions

10
map/Instuctions.md Normal file
View File

@ -0,0 +1,10 @@
# Instructions
The following are instructions for developing this project. The project is called Map and is a canvasing application for political campaigns.
It uses nocodb as a backend database.
## Rules
- Do not use inline event handlers. Use properly attached event listeners instead.
- Always update the README.md, or instruct the user to update the README, when developing new features.

View File

@ -15,7 +15,10 @@ A containerized web application that visualizes geographic data from NocoDB on a
- 🎯 Configurable map start location
- 📋 Walk Sheet generator for door-to-door canvassing
- 🔗 QR code integration for digital resources
- 🐳 Docker containerization for easy deployment
- <20> Volunteer shift management system
- ✋ User shift signup and cancellation
- 👥 Admin shift creation and management
- <20>🐳 Docker containerization for easy deployment
- 🆓 100% open source (no proprietary dependencies)
## Quick Start
@ -40,13 +43,15 @@ A containerized web application that visualizes geographic data from NocoDB on a
Edit the `.env` file with your NocoDB API and API Url:
```env
# NocoDB API Configuration
NOCODB_API_URL=https://your-nocodb-instance.com/api/v1
NOCODB_API_URL=https://db.cmlite.org/api/v1
NOCODB_API_TOKEN=your-api-token-here
# These will be populated after running build-nocodb.sh
NOCODB_VIEW_URL=
NOCODB_LOGIN_SHEET=
NOCODB_SETTINGS_SHEET=
NOCODB_SHIFTS_SHEET=
NOCODB_SHIFT_SIGNUPS_SHEET=
# Server Configuration
PORT=3000
@ -57,6 +62,13 @@ A containerized web application that visualizes geographic data from NocoDB on a
DEFAULT_LAT=53.5461
DEFAULT_LNG=-113.4938
DEFAULT_ZOOM=11
# Cloudflare Settings
TRUST_PROXY=true
COOKIE_DOMAIN=.cmlite.org
# Allowed Origins
ALLOWED_ORIGINS=https://map.cmlite.org,http://localhost:3000
```
3. **Auto-Create Database Structure**
@ -67,26 +79,30 @@ A containerized web application that visualizes geographic data from NocoDB on a
./build-nocodb.sh
```
This creates three tables:
This creates five tables:
- **Locations** - Main map data with geo-location, contact info, support levels
- **Login** - User authentication (email, name, admin flag)
- **Settings** - Admin configuration and QR codes
- **Shifts** - Shift scheduling and management
- **Shift Signups** - User shift registrations
4. **Get Table URLs**
After the script completes:
1. Login to your NocoDB instance
2. Navigate to your project ("Map Viewer Project")
1. Login to your NocoDB instance at https://db.cmlite.org
2. Navigate to your project ("Map Viewer Project - TIMESTAMP")
3. Copy the view URLs for each table from your browser address bar
4. URLs should look like: `https://your-nocodb.com/dashboard/#/nc/project-id/table-id`
4. URLs should look like: `https://db.cmlite.org/dashboard/#/nc/project-id/table-id`
5. **Update Environment with URLs**
Edit your `.env` file and add the table URLs:
```env
NOCODB_VIEW_URL=https://your-nocodb.com/dashboard/#/nc/project-id/locations-table-id
NOCODB_LOGIN_SHEET=https://your-nocodb.com/dashboard/#/nc/project-id/login-table-id
NOCODB_SETTINGS_SHEET=https://your-nocodb.com/dashboard/#/nc/project-id/settings-table-id
NOCODB_VIEW_URL=https://db.cmlite.org/dashboard/#/nc/pnsalzrup2zqvz8/m6g7bkzv7s1w2ur
NOCODB_LOGIN_SHEET=https://db.cmlite.org/dashboard/#/nc/pnsalzrup2zqvz8/mizyc64e4r7ppzh
NOCODB_SETTINGS_SHEET=https://db.cmlite.org/dashboard/#/nc/pnsalzrup2zqvz8/mix06f2mlep7gqb
NOCODB_SHIFTS_SHEET=https://db.cmlite.org/dashboard/#/nc/pnsalzrup2zqvz8/mkx0tex0iquus1u
NOCODB_SHIFT_SIGNUPS_SHEET=https://db.cmlite.org/dashboard/#/nc/pnsalzrup2zqvz8/mi8jg1tn26mu8fj
```
6. **Build and Deploy**
@ -105,6 +121,8 @@ A containerized web application that visualizes geographic data from NocoDB on a
- Check container status: `docker-compose ps`
- View logs: `docker-compose logs -f map-viewer`
- Access the application at: http://localhost:3000
- Access shift management at: http://localhost:3000/shifts.html
- Access admin panel at: http://localhost:3000/admin.html (admin users only)
## Database Schema
@ -147,6 +165,18 @@ The build script automatically creates the following table structure:
- `qr_code_2_image` (Attachment): QR code 2 image
- `qr_code_3_image` (Attachment): QR code 3 image
### Shifts Table
- Standard NocoDB fields for shift scheduling and management
- Contains shift dates, times, locations, capacity limits
- Status tracking (Active, Cancelled, Full)
- Created automatically by build script with basic structure
### Shift Signups Table
- Links users to shifts they've signed up for
- Tracks signup timestamps and user information
- Handles cancellations and waitlist management
- Created automatically by build script with basic structure
## API Endpoints
### Public Endpoints
@ -159,6 +189,20 @@ The build script automatically creates the following table structure:
- `GET /api/config/start-location` - Get map start location
- `GET /health` - Health check
### Shifts Endpoints (requires authentication)
- `GET /api/shifts` - Get all available shifts
- `GET /api/shifts/my-signups` - Get current user's shift signups
- `POST /api/shifts/:shiftId/signup` - Sign up for a shift
- `POST /api/shifts/:shiftId/cancel` - Cancel shift signup
### Shifts Admin Endpoints (requires admin privileges)
- `GET /api/shifts/admin` - Get all shifts including cancelled ones
- `POST /api/shifts/admin` - Create new shift
- `PUT /api/shifts/admin/:id` - Update existing shift
- `DELETE /api/shifts/admin/:id` - Delete shift
### Authentication Endpoints
- `POST /api/auth/login` - User login
@ -172,6 +216,35 @@ The build script automatically creates the following table structure:
- `GET /api/admin/walk-sheet-config` - Get walk sheet configuration
- `POST /api/admin/walk-sheet-config` - Save walk sheet configuration
## Shifts Management
The application includes a comprehensive volunteer shift management system accessible at `/shifts.html`.
### User Features
- **View Available Shifts**: See all upcoming shifts with date, time, and capacity information
- **Sign Up for Shifts**: One-click signup for available shifts
- **My Shifts Dashboard**: View all your current shift signups
- **Cancel Signups**: Cancel your shift signups when needed
- **Date Filtering**: Filter shifts by specific dates
- **Real-time Updates**: Shift availability updates dynamically
### Admin Features
Administrators have additional capabilities for managing shifts:
- **Create New Shifts**: Add new volunteer shifts with date, time, location, and capacity
- **Edit Existing Shifts**: Modify shift details, times, or capacity
- **Cancel Shifts**: Mark shifts as cancelled (they remain in system but hidden from users)
- **View All Signups**: See who has signed up for each shift
- **Manage Capacity**: Set maximum number of volunteers per shift
### Shift Status System
- **Active**: Available for signups
- **Full**: Capacity reached, no more signups accepted
- **Cancelled**: Hidden from public view but retained in database
## Admin Panel
Users with admin privileges can access the admin panel at `/admin.html` to configure system settings.
@ -216,13 +289,20 @@ All configuration is done via environment variables:
|----------|-------------|---------|
| `NOCODB_API_URL` | NocoDB API base URL | Required |
| `NOCODB_API_TOKEN` | API authentication token | Required |
| `NOCODB_VIEW_URL` | Full NocoDB view URL | Required |
| `NOCODB_VIEW_URL` | Full NocoDB view URL for locations table | Required |
| `NOCODB_LOGIN_SHEET` | Login table URL for authentication | Required |
| `NOCODB_SETTINGS_SHEET` | Settings table URL for admin config | Optional |
| `NOCODB_SETTINGS_SHEET` | Settings table URL for admin config | Required |
| `NOCODB_SHIFTS_SHEET` | Shifts table URL for shift management | Required |
| `NOCODB_SHIFT_SIGNUPS_SHEET` | Shift signups table URL for user registrations | Required |
| `PORT` | Server port | 3000 |
| `NODE_ENV` | Environment mode | production |
| `SESSION_SECRET` | Session encryption secret | Required |
| `DEFAULT_LAT` | Default map latitude | 53.5461 |
| `DEFAULT_LNG` | Default map longitude | -113.4938 |
| `DEFAULT_ZOOM` | Default map zoom level | 11 |
| `TRUST_PROXY` | Trust proxy headers (for Cloudflare) | true |
| `COOKIE_DOMAIN` | Cookie domain setting | .cmlite.org |
| `ALLOWED_ORIGINS` | CORS allowed origins | Multiple URLs |
## Maintenance Commands

View File

@ -16,18 +16,40 @@ class ShiftsController {
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 =>
let shifts = (response.list || []).filter(shift =>
shift.Status !== 'Cancelled'
);
// If signups sheet is configured, calculate current volunteer counts
if (config.nocodb.shiftSignupsSheetId) {
try {
const signupsResponse = await nocodbService.getAll(config.nocodb.shiftSignupsSheetId);
const allSignups = signupsResponse.list || [];
// Update each shift with calculated volunteer count
shifts = shifts.map(shift => {
const confirmedSignups = allSignups.filter(signup =>
signup['Shift ID'] === shift.ID && signup.Status === 'Confirmed'
);
const currentVolunteers = confirmedSignups.length;
const maxVolunteers = shift['Max Volunteers'] || 0;
return {
...shift,
'Current Volunteers': currentVolunteers,
'Status': currentVolunteers >= maxVolunteers ? 'Full' : 'Open'
};
});
} catch (signupError) {
logger.warn('Could not load signups for volunteer count calculation:', signupError);
}
}
res.json({
success: true,
shifts: shifts

View File

@ -109,6 +109,10 @@
.shift-actions {
margin-top: 15px;
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.no-shifts {
@ -118,14 +122,67 @@
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);
}
/* Calendar dropdown styles */
.calendar-dropdown {
position: relative;
display: inline-block;
}
/* Mobile: 1 column */
.calendar-toggle {
cursor: pointer;
position: relative;
}
.calendar-options {
position: absolute;
top: 100%;
left: 0;
min-width: 180px;
background: white;
border: 1px solid #e0e0e0;
border-radius: var(--border-radius);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 1000;
margin-top: 4px;
}
.calendar-option {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 15px;
text-decoration: none;
color: var(--dark-color);
transition: var(--transition);
border-bottom: 1px solid #f0f0f0;
}
.calendar-option:last-child {
border-bottom: none;
}
.calendar-option:hover {
background-color: #f5f5f5;
}
.calendar-option img {
width: 16px;
height: 16px;
}
/* Ensure dropdowns appear above other elements */
.shift-card {
position: relative;
}
/* Update signup actions for My Shifts section */
.signup-actions {
display: flex;
gap: 10px;
align-items: center;
}
/* Mobile adjustments */
@media (max-width: 768px) {
.shifts-container {
padding: 15px;
@ -142,8 +199,30 @@
gap: 10px;
}
.shift-card {
padding: 15px;
.signup-actions {
width: 100%;
justify-content: flex-start;
}
.shift-actions {
flex-direction: column;
width: 100%;
}
.shift-actions .btn,
.calendar-dropdown {
width: 100%;
}
.calendar-toggle {
width: 100%;
justify-content: center;
}
.calendar-options {
left: 0;
right: 0;
width: 100%;
}
.my-signups {
@ -158,4 +237,13 @@
.filter-group {
flex-wrap: wrap;
}
}
/* Prevent dropdown from being cut off */
.shifts-grid {
overflow: visible;
}
.shift-card {
overflow: visible;
}

View File

@ -105,7 +105,8 @@ function displayShifts(shifts) {
${shift.Description ? `<div class="shift-description">${escapeHtml(shift.Description)}</div>` : ''}
<div class="shift-actions">
${isSignedUp
? `<button class="btn btn-danger btn-sm cancel-signup-btn" data-shift-id="${shift.ID}">Cancel Signup</button>`
? `<button class="btn btn-danger btn-sm cancel-signup-btn" data-shift-id="${shift.ID}">Cancel Signup</button>
${generateCalendarDropdown(shift)}`
: isFull
? '<button class="btn btn-secondary btn-sm" disabled>Shift Full</button>'
: `<button class="btn btn-primary btn-sm signup-btn" data-shift-id="${shift.ID}">Sign Up</button>`
@ -141,7 +142,10 @@ function displayMySignups() {
<h4>${escapeHtml(signup.shift.Title)}</h4>
<p>📅 ${shiftDate.toLocaleDateString()} ${signup.shift['Start Time']} - ${signup.shift['End Time']}</p>
</div>
<button class="btn btn-danger btn-sm cancel-signup-btn" data-shift-id="${signup.shift.ID}">Cancel</button>
<div class="signup-actions">
${generateCalendarDropdown(signup.shift)}
<button class="btn btn-danger btn-sm cancel-signup-btn" data-shift-id="${signup.shift.ID}">Cancel</button>
</div>
</div>
`;
}).join('');
@ -159,15 +163,40 @@ function setupShiftCardListeners() {
const newGrid = grid.cloneNode(true);
grid.parentNode.replaceChild(newGrid, grid);
// Add click listener for signup buttons
// Add click listener for all buttons
newGrid.addEventListener('click', async (e) => {
// Handle signup buttons
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')) {
}
// Handle cancel buttons
else if (e.target.classList.contains('cancel-signup-btn')) {
const shiftId = e.target.getAttribute('data-shift-id');
await cancelSignup(shiftId);
}
// Handle calendar toggle buttons
else if (e.target.classList.contains('calendar-toggle')) {
e.stopPropagation();
const dropdown = e.target.closest('.calendar-dropdown');
const options = dropdown.querySelector('.calendar-options');
const isOpen = options.style.display !== 'none';
// Close all other dropdowns
document.querySelectorAll('.calendar-options').forEach(opt => {
opt.style.display = 'none';
});
// Toggle this dropdown
options.style.display = isOpen ? 'none' : 'block';
}
// Handle calendar option clicks
else if (e.target.closest('.calendar-option')) {
e.stopPropagation();
const dropdown = e.target.closest('.calendar-dropdown');
const options = dropdown.querySelector('.calendar-options');
options.style.display = 'none';
}
});
}
@ -180,12 +209,35 @@ function setupMySignupsListeners() {
const newList = list.cloneNode(true);
list.parentNode.replaceChild(newList, list);
// Add click listener for cancel buttons
// Add click listener for all interactions
newList.addEventListener('click', async (e) => {
// Handle cancel buttons
if (e.target.classList.contains('cancel-signup-btn')) {
const shiftId = e.target.getAttribute('data-shift-id');
await cancelSignup(shiftId);
}
// Handle calendar toggle buttons
else if (e.target.classList.contains('calendar-toggle')) {
e.stopPropagation();
const dropdown = e.target.closest('.calendar-dropdown');
const options = dropdown.querySelector('.calendar-options');
const isOpen = options.style.display !== 'none';
// Close all other dropdowns
document.querySelectorAll('.calendar-options').forEach(opt => {
opt.style.display = 'none';
});
// Toggle this dropdown
options.style.display = isOpen ? 'none' : 'block';
}
// Handle calendar option clicks
else if (e.target.closest('.calendar-option')) {
e.stopPropagation();
const dropdown = e.target.closest('.calendar-dropdown');
const options = dropdown.querySelector('.calendar-options');
options.style.display = 'none';
}
});
}
@ -290,4 +342,209 @@ function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML;
}
}
// Add these calendar URL generation functions after the existing functions
function generateCalendarUrls(shift) {
const shiftDate = new Date(shift.Date);
// Parse start and end times
const [startHour, startMinute] = shift['Start Time'].split(':').map(n => parseInt(n));
const [endHour, endMinute] = shift['End Time'].split(':').map(n => parseInt(n));
// Create start and end datetime objects
const startDate = new Date(shiftDate);
startDate.setHours(startHour, startMinute, 0, 0);
const endDate = new Date(shiftDate);
endDate.setHours(endHour, endMinute, 0, 0);
// Format dates for different calendar formats
const formatGoogleDate = (date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}${month}${day}T${hours}${minutes}00`;
};
const formatISODate = (date) => {
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
};
// Event details
const title = shift.Title;
const description = shift.Description || 'Volunteer shift';
const location = shift.Location || '';
// Google Calendar URL
const googleStartStr = formatGoogleDate(startDate);
const googleEndStr = formatGoogleDate(endDate);
const googleParams = new URLSearchParams({
action: 'TEMPLATE',
text: title,
dates: `${googleStartStr}/${googleEndStr}`,
details: description,
location: location
});
const googleUrl = `https://calendar.google.com/calendar/render?${googleParams.toString()}`;
// Outlook Web Calendar URL
const outlookStartStr = startDate.toISOString();
const outlookEndStr = endDate.toISOString();
const outlookParams = new URLSearchParams({
path: '/calendar/action/compose',
rru: 'addevent',
subject: title,
startdt: outlookStartStr,
enddt: outlookEndStr,
body: description,
location: location
});
const outlookUrl = `https://outlook.live.com/calendar/0/deeplink/compose?${outlookParams.toString()}`;
// Apple Calendar (.ics file) - we'll generate this dynamically
const icsContent = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//BNKops//Volunteer Shifts//EN',
'BEGIN:VEVENT',
`UID:${shift.ID}-${Date.now()}@bnkops.com`,
`DTSTART:${formatISODate(startDate)}`,
`DTEND:${formatISODate(endDate)}`,
`SUMMARY:${title}`,
`DESCRIPTION:${description.replace(/\n/g, '\\n')}`,
`LOCATION:${location}`,
'STATUS:CONFIRMED',
'END:VEVENT',
'END:VCALENDAR'
].join('\r\n');
// Create a data URL for the .ics file
const icsDataUrl = 'data:text/calendar;charset=utf-8,' + encodeURIComponent(icsContent);
return {
google: googleUrl,
outlook: outlookUrl,
apple: icsDataUrl,
icsFilename: `${title.replace(/[^a-z0-9]/gi, '_')}_${shift.ID}.ics`
};
}
// Update calendar dropdown HTML generator (remove onclick handlers)
function generateCalendarDropdown(shift) {
const urls = generateCalendarUrls(shift);
return `
<div class="calendar-dropdown">
<button class="btn btn-secondary btn-sm calendar-toggle" data-shift-id="${shift.ID}">
📅 Add to Calendar
</button>
<div class="calendar-options" style="display: none;">
<a href="${urls.google}" target="_blank" class="calendar-option" data-calendar-type="google">
<img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1zbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEyLjUgMkgxMy41QzE0LjMyODQgMiAxNSAyLjY3MTU3IDE1IDMuNVYxMi41QzE1IDEzLjMyODQgMTQuMzI4NCAxNCAxMy41IDE0SDIuNUMxLjY3MTU3IDE0IDEgMTMuMzI4NCAxIDEyLjVWMy41QzEgMi42NzE1NyAxLjY3MTU3IDIgMi41IDJIMy41IiBzdHJva2U9IiM0Mjg1RjQiIHN0cm9rZS13aWR0aD0iMS41Ii8+CjxwYXRoIGQ9Ik00IDFWM00xMiAxVjMiIHN0cm9rZT0iIzQyODVGNCIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8cGF0aCBkPSJNMSA1SDE1IiBzdHJva2U9IiM0Mjg1RjQiIHN0cm9rZS13aWR0aD0iMS41Ii8+Cjwvc3ZnPg==" alt="Google"> Google Calendar
</a>
<a href="${urls.outlook}" target="_blank" class="calendar-option" data-calendar-type="outlook">
<img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1zbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0IDJIMlYxNEgxNFYyWiIgZmlsbD0iIzAwNzhENCIvPgo8cGF0aCBkPSJNOCA4VjE0SDE0VjhIOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik04IDJWOEgxNFYySDhaIiBmaWxsPSIjMDA3OEQ0Ii8+Cjwvc3ZnPg==" alt="Outlook"> Outlook
</a>
<a href="${urls.apple}" download="${urls.icsFilename}" class="calendar-option" data-calendar-type="apple">
<img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1zbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEzIDJIMy41QzIuNjcxNTcgMiAyIDIuNjcxNTcgMiAzLjVWMTIuNUMyIDEzLjMyODQgMi42NzE1NyAxNCAzLjUgMTRIMTIuNUMxMy4zMjg0IDE0IDE0IDEzLjMyODQgMTQgMTIuNVYzQzE0IDIuNDQ3NzIgMTMuNTUyMyAyIDEzIDJaIiBmaWxsPSIjRkY1NzMzIi8+CjxwYXRoIGQ9Ik00IDFWM00xMiAxVjMiIHN0cm9rZT0iI0ZGNTczMyIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiLz4KPHBhdGggZD0iTTIgNUgxNCIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIxLjUiLz4KPHRleHQgeD0iOCIgeT0iMTEiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSI2IiBmaWxsPSJ3aGl0ZSIgdGV4dC1hbmNob3I9Im1pZGRsZSI+MzE8L3RleHQ+Cjwvc3ZnPg==" alt="Apple"> Apple Calendar
</a>
</div>
</div>
`;
}
// Update setupShiftCardListeners to handle calendar dropdowns
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 all buttons
newGrid.addEventListener('click', async (e) => {
// Handle signup buttons
if (e.target.classList.contains('signup-btn')) {
const shiftId = e.target.getAttribute('data-shift-id');
await signupForShift(shiftId);
}
// Handle cancel buttons
else if (e.target.classList.contains('cancel-signup-btn')) {
const shiftId = e.target.getAttribute('data-shift-id');
await cancelSignup(shiftId);
}
// Handle calendar toggle buttons
else if (e.target.classList.contains('calendar-toggle')) {
e.stopPropagation();
const dropdown = e.target.closest('.calendar-dropdown');
const options = dropdown.querySelector('.calendar-options');
const isOpen = options.style.display !== 'none';
// Close all other dropdowns
document.querySelectorAll('.calendar-options').forEach(opt => {
opt.style.display = 'none';
});
// Toggle this dropdown
options.style.display = isOpen ? 'none' : 'block';
}
// Handle calendar option clicks
else if (e.target.closest('.calendar-option')) {
e.stopPropagation();
const dropdown = e.target.closest('.calendar-dropdown');
const options = dropdown.querySelector('.calendar-options');
options.style.display = 'none';
}
});
}
// Update setupMySignupsListeners similarly
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 all interactions
newList.addEventListener('click', async (e) => {
// Handle cancel buttons
if (e.target.classList.contains('cancel-signup-btn')) {
const shiftId = e.target.getAttribute('data-shift-id');
await cancelSignup(shiftId);
}
// Handle calendar toggle buttons
else if (e.target.classList.contains('calendar-toggle')) {
e.stopPropagation();
const dropdown = e.target.closest('.calendar-dropdown');
const options = dropdown.querySelector('.calendar-options');
const isOpen = options.style.display !== 'none';
// Close all other dropdowns
document.querySelectorAll('.calendar-options').forEach(opt => {
opt.style.display = 'none';
});
// Toggle this dropdown
options.style.display = isOpen ? 'none' : 'block';
}
// Handle calendar option clicks
else if (e.target.closest('.calendar-option')) {
e.stopPropagation();
const dropdown = e.target.closest('.calendar-dropdown');
const options = dropdown.querySelector('.calendar-options');
options.style.display = 'none';
}
});
}
// Keep the document click handler to close dropdowns when clicking outside
document.addEventListener('click', function() {
document.querySelectorAll('.calendar-options').forEach(opt => {
opt.style.display = 'none';
});
});