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
|
- 🎯 Configurable map start location
|
||||||
- 📋 Walk Sheet generator for door-to-door canvassing
|
- 📋 Walk Sheet generator for door-to-door canvassing
|
||||||
- 🔗 QR code integration for digital resources
|
- 🔗 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)
|
- 🆓 100% open source (no proprietary dependencies)
|
||||||
|
|
||||||
## Quick Start
|
## 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:
|
Edit the `.env` file with your NocoDB API and API Url:
|
||||||
```env
|
```env
|
||||||
# NocoDB API Configuration
|
# 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
|
NOCODB_API_TOKEN=your-api-token-here
|
||||||
|
|
||||||
# These will be populated after running build-nocodb.sh
|
# These will be populated after running build-nocodb.sh
|
||||||
NOCODB_VIEW_URL=
|
NOCODB_VIEW_URL=
|
||||||
NOCODB_LOGIN_SHEET=
|
NOCODB_LOGIN_SHEET=
|
||||||
NOCODB_SETTINGS_SHEET=
|
NOCODB_SETTINGS_SHEET=
|
||||||
|
NOCODB_SHIFTS_SHEET=
|
||||||
|
NOCODB_SHIFT_SIGNUPS_SHEET=
|
||||||
|
|
||||||
# Server Configuration
|
# Server Configuration
|
||||||
PORT=3000
|
PORT=3000
|
||||||
@ -57,6 +62,13 @@ A containerized web application that visualizes geographic data from NocoDB on a
|
|||||||
DEFAULT_LAT=53.5461
|
DEFAULT_LAT=53.5461
|
||||||
DEFAULT_LNG=-113.4938
|
DEFAULT_LNG=-113.4938
|
||||||
DEFAULT_ZOOM=11
|
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**
|
3. **Auto-Create Database Structure**
|
||||||
@ -67,26 +79,30 @@ A containerized web application that visualizes geographic data from NocoDB on a
|
|||||||
./build-nocodb.sh
|
./build-nocodb.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This creates three tables:
|
This creates five tables:
|
||||||
- **Locations** - Main map data with geo-location, contact info, support levels
|
- **Locations** - Main map data with geo-location, contact info, support levels
|
||||||
- **Login** - User authentication (email, name, admin flag)
|
- **Login** - User authentication (email, name, admin flag)
|
||||||
- **Settings** - Admin configuration and QR codes
|
- **Settings** - Admin configuration and QR codes
|
||||||
|
- **Shifts** - Shift scheduling and management
|
||||||
|
- **Shift Signups** - User shift registrations
|
||||||
|
|
||||||
4. **Get Table URLs**
|
4. **Get Table URLs**
|
||||||
|
|
||||||
After the script completes:
|
After the script completes:
|
||||||
1. Login to your NocoDB instance
|
1. Login to your NocoDB instance at https://db.cmlite.org
|
||||||
2. Navigate to your project ("Map Viewer Project")
|
2. Navigate to your project ("Map Viewer Project - TIMESTAMP")
|
||||||
3. Copy the view URLs for each table from your browser address bar
|
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**
|
5. **Update Environment with URLs**
|
||||||
|
|
||||||
Edit your `.env` file and add the table URLs:
|
Edit your `.env` file and add the table URLs:
|
||||||
```env
|
```env
|
||||||
NOCODB_VIEW_URL=https://your-nocodb.com/dashboard/#/nc/project-id/locations-table-id
|
NOCODB_VIEW_URL=https://db.cmlite.org/dashboard/#/nc/pnsalzrup2zqvz8/m6g7bkzv7s1w2ur
|
||||||
NOCODB_LOGIN_SHEET=https://your-nocodb.com/dashboard/#/nc/project-id/login-table-id
|
NOCODB_LOGIN_SHEET=https://db.cmlite.org/dashboard/#/nc/pnsalzrup2zqvz8/mizyc64e4r7ppzh
|
||||||
NOCODB_SETTINGS_SHEET=https://your-nocodb.com/dashboard/#/nc/project-id/settings-table-id
|
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**
|
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`
|
- Check container status: `docker-compose ps`
|
||||||
- View logs: `docker-compose logs -f map-viewer`
|
- View logs: `docker-compose logs -f map-viewer`
|
||||||
- Access the application at: http://localhost:3000
|
- 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
|
## 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_2_image` (Attachment): QR code 2 image
|
||||||
- `qr_code_3_image` (Attachment): QR code 3 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
|
## API Endpoints
|
||||||
|
|
||||||
### Public 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 /api/config/start-location` - Get map start location
|
||||||
- `GET /health` - Health check
|
- `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
|
### Authentication Endpoints
|
||||||
|
|
||||||
- `POST /api/auth/login` - User login
|
- `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
|
- `GET /api/admin/walk-sheet-config` - Get walk sheet configuration
|
||||||
- `POST /api/admin/walk-sheet-config` - Save 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
|
## Admin Panel
|
||||||
|
|
||||||
Users with admin privileges can access the admin panel at `/admin.html` to configure system settings.
|
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_URL` | NocoDB API base URL | Required |
|
||||||
| `NOCODB_API_TOKEN` | API authentication token | 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_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 |
|
| `PORT` | Server port | 3000 |
|
||||||
|
| `NODE_ENV` | Environment mode | production |
|
||||||
|
| `SESSION_SECRET` | Session encryption secret | Required |
|
||||||
| `DEFAULT_LAT` | Default map latitude | 53.5461 |
|
| `DEFAULT_LAT` | Default map latitude | 53.5461 |
|
||||||
| `DEFAULT_LNG` | Default map longitude | -113.4938 |
|
| `DEFAULT_LNG` | Default map longitude | -113.4938 |
|
||||||
| `DEFAULT_ZOOM` | Default map zoom level | 11 |
|
| `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
|
## Maintenance Commands
|
||||||
|
|
||||||
|
|||||||
@ -16,18 +16,40 @@ class ShiftsController {
|
|||||||
|
|
||||||
logger.info('Loading public shifts from:', config.nocodb.shiftsSheetId);
|
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, {
|
const response = await nocodbService.getAll(config.nocodb.shiftsSheetId, {
|
||||||
sort: 'Date,Start Time'
|
sort: 'Date,Start Time'
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('Loaded shifts:', response);
|
let shifts = (response.list || []).filter(shift =>
|
||||||
|
|
||||||
// Filter out cancelled shifts manually
|
|
||||||
const shifts = (response.list || []).filter(shift =>
|
|
||||||
shift.Status !== 'Cancelled'
|
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({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
shifts: shifts
|
shifts: shifts
|
||||||
|
|||||||
@ -109,6 +109,10 @@
|
|||||||
|
|
||||||
.shift-actions {
|
.shift-actions {
|
||||||
margin-top: 15px;
|
margin-top: 15px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-shifts {
|
.no-shifts {
|
||||||
@ -118,14 +122,67 @@
|
|||||||
grid-column: 1 / -1; /* Span all columns */
|
grid-column: 1 / -1; /* Span all columns */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tablet: 2 columns */
|
/* Calendar dropdown styles */
|
||||||
@media (max-width: 1024px) and (min-width: 769px) {
|
.calendar-dropdown {
|
||||||
.shifts-grid {
|
position: relative;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
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) {
|
@media (max-width: 768px) {
|
||||||
.shifts-container {
|
.shifts-container {
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
@ -142,8 +199,30 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shift-card {
|
.signup-actions {
|
||||||
padding: 15px;
|
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 {
|
.my-signups {
|
||||||
@ -158,4 +237,13 @@
|
|||||||
.filter-group {
|
.filter-group {
|
||||||
flex-wrap: wrap;
|
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>` : ''}
|
${shift.Description ? `<div class="shift-description">${escapeHtml(shift.Description)}</div>` : ''}
|
||||||
<div class="shift-actions">
|
<div class="shift-actions">
|
||||||
${isSignedUp
|
${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
|
: isFull
|
||||||
? '<button class="btn btn-secondary btn-sm" disabled>Shift Full</button>'
|
? '<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>`
|
: `<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>
|
<h4>${escapeHtml(signup.shift.Title)}</h4>
|
||||||
<p>📅 ${shiftDate.toLocaleDateString()} ⏰ ${signup.shift['Start Time']} - ${signup.shift['End Time']}</p>
|
<p>📅 ${shiftDate.toLocaleDateString()} ⏰ ${signup.shift['Start Time']} - ${signup.shift['End Time']}</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
@ -159,15 +163,40 @@ function setupShiftCardListeners() {
|
|||||||
const newGrid = grid.cloneNode(true);
|
const newGrid = grid.cloneNode(true);
|
||||||
grid.parentNode.replaceChild(newGrid, grid);
|
grid.parentNode.replaceChild(newGrid, grid);
|
||||||
|
|
||||||
// Add click listener for signup buttons
|
// Add click listener for all buttons
|
||||||
newGrid.addEventListener('click', async (e) => {
|
newGrid.addEventListener('click', async (e) => {
|
||||||
|
// Handle signup buttons
|
||||||
if (e.target.classList.contains('signup-btn')) {
|
if (e.target.classList.contains('signup-btn')) {
|
||||||
const shiftId = e.target.getAttribute('data-shift-id');
|
const shiftId = e.target.getAttribute('data-shift-id');
|
||||||
await signupForShift(shiftId);
|
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');
|
const shiftId = e.target.getAttribute('data-shift-id');
|
||||||
await cancelSignup(shiftId);
|
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);
|
const newList = list.cloneNode(true);
|
||||||
list.parentNode.replaceChild(newList, list);
|
list.parentNode.replaceChild(newList, list);
|
||||||
|
|
||||||
// Add click listener for cancel buttons
|
// Add click listener for all interactions
|
||||||
newList.addEventListener('click', async (e) => {
|
newList.addEventListener('click', async (e) => {
|
||||||
|
// Handle cancel buttons
|
||||||
if (e.target.classList.contains('cancel-signup-btn')) {
|
if (e.target.classList.contains('cancel-signup-btn')) {
|
||||||
const shiftId = e.target.getAttribute('data-shift-id');
|
const shiftId = e.target.getAttribute('data-shift-id');
|
||||||
await cancelSignup(shiftId);
|
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');
|
const div = document.createElement('div');
|
||||||
div.textContent = String(text);
|
div.textContent = String(text);
|
||||||
return div.innerHTML;
|
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