Fixed bug for displaying sign ups for shifts
This commit is contained in:
parent
9e5b3193f7
commit
a5bd0e9939
10
map/Instuctions.md
Normal file
10
map/Instuctions.md
Normal 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.
|
||||
102
map/README.md
102
map/README.md
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
@ -159,3 +238,12 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* Prevent dropdown from being cut off */
|
||||
.shifts-grid {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.shift-card {
|
||||
overflow: visible;
|
||||
}
|
||||
@ -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,8 +142,11 @@ function displayMySignups() {
|
||||
<h4>${escapeHtml(signup.shift.Title)}</h4>
|
||||
<p>📅 ${shiftDate.toLocaleDateString()} ⏰ ${signup.shift['Start Time']} - ${signup.shift['End Time']}</p>
|
||||
</div>
|
||||
<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';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -291,3 +343,208 @@ function escapeHtml(text) {
|
||||
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';
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user