A couple more fast email buttons

This commit is contained in:
admin 2025-08-08 17:47:26 -06:00
parent b5cf9b3f8d
commit 35a6d55ffe
14 changed files with 2399 additions and 60 deletions

4
.gitignore vendored
View File

@ -9,4 +9,6 @@
/configs/cloudflare/*.json
/configs/cloudflare/*.yaml
/configs/cloudflare/*.yml
/configs/cloudflare/*.yml
.excalidraw

View File

@ -1,6 +1,8 @@
# NocoDB Map Viewer
A containerized web application that visualizes geographic data from NocoDB on an interactive map using Leaflet.js.
A- <20> **Automated shift email notifications** - Send shift details to volunteers with visual progress tracking
- <20>👨💼 User management panel for admin users (create, delete users)
- <20> **Admin broadcast emailing** - Rich HTML email composer with live preview and delivery trackingntainerized web application that visualizes geographic data from NocoDB on an interactive map using Leaflet.js.
## Features
@ -18,9 +20,13 @@ A containerized web application that visualizes geographic data from NocoDB on a
- 🔗 QR code integration for digital resources
- 📅 Volunteer shift management system with calendar and grid views
- ✋ User shift signup and cancellation with color-coded calendar
- 👥 Admin shift creation and management
- 👨‍💼 User management panel for admin users (create, delete users)
- 🔐 Role-based access control (Admin vs User permissions)
- <20> Calendar integration (Google, Outlook, Apple) for shift export
- <20>👥 Admin shift creation and management with volunteer assignment
- <20> **Automated shift email notifications** - Send shift details to volunteers
- <20>👨💼 User management panel for admin users (create, delete users)
- <20> **Admin broadcast emailing** - Rich HTML email composer with live preview
- <20>🔐 Role-based access control (Admin vs User permissions)
- ⏰ Temporary user accounts with automatic expiration
- 📧 Email notifications and password recovery via SMTP
- 📊 CSV data import with batch geocoding and visual progress tracking
- ✂️ **Cut feature for geographic overlays** - Admin-drawn polygons for map regions
@ -94,7 +100,8 @@ A containerized web application that visualizes geographic data from NocoDB on a
# Allowed Origins
ALLOWED_ORIGINS=https://map.cmlite.org,http://localhost:3000
# Email Configuration (Optional - for password recovery)
# Email Configuration (Required for full functionality)
# Used for password recovery, shift notifications, and admin broadcast emails
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
@ -188,6 +195,9 @@ The build script automatically creates the following table structure:
- `Password` (Single Line Text): User password (required)
- `Name` (Single Line Text): User display name
- `Admin` (Checkbox): Admin privileges
- `UserType` (Single Select): Options: "user" (Blue), "temp" (Orange) - User role type
- `ExpiresAt` (DateTime): Expiration date for temporary users
- `ExpireDays` (Number): Number of days until temp user expires
- `Created At` (DateTime): Account creation timestamp
- `Last Login` (DateTime): Last login timestamp
@ -250,6 +260,40 @@ The build script automatically creates the following table structure:
- `created_at` (DateTime): Creation timestamp
- `updated_at` (DateTime): Last update timestamp
## Email Features
The system includes comprehensive email functionality powered by SMTP configuration:
### 🔧 Admin Email Broadcasting
- **Rich HTML email composer** with live preview and formatting toolbar
- **Mass email sending** to all registered users
- **Professional email templates** with consistent branding
- **Real-time recipient counting** and success/failure tracking
- **Visual progress tracking** with animated progress bars and individual email status
- **Detailed delivery reports** showing successful sends and failure reasons
- Support for rich text content including headers, lists, links, and formatting
### 📅 Shift Email Notifications
- **Automated shift detail emails** sent to all volunteers
- **Professional HTML templates** with complete shift information
- **Shift status tracking** (open, full, cancelled) in emails
- **Volunteer management** - add/remove users from shifts
- **Calendar integration** - Google, Outlook, and Apple calendar export
- **Visual progress indicators** with real-time sending status per volunteer
- **Email status reporting** with success/failure details and error messages
### 👤 User Account Emails
- **Login credential delivery** for new users
- **Password recovery** with secure email notifications
- **Temporary user accounts** with automatic expiration tracking
- **Professional HTML and plain text** email templates
### 📧 Email Template System
- **Responsive HTML templates** with professional design
- **Variable substitution** for personalized content
- **Multi-format support** (HTML and plain text)
- **Consistent branding** across all email communications
## API Endpoints
### Public Endpoints
@ -275,6 +319,9 @@ The build script automatically creates the following table structure:
- `POST /api/shifts/admin` - Create new shift
- `PUT /api/shifts/admin/:id` - Update existing shift
- `DELETE /api/shifts/admin/:id` - Delete shift
- `POST /api/shifts/admin/:shiftId/add-user` - Add user to shift
- `DELETE /api/shifts/admin/:shiftId/remove-user/:userId` - Remove user from shift
- `POST /api/shifts/admin/:shiftId/email-details` - Email shift details to all volunteers
### Authentication Endpoints
@ -289,6 +336,14 @@ 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
### User Management Endpoints (requires admin privileges)
- `GET /api/users` - Get all users
- `POST /api/users` - Create new user
- `DELETE /api/users/:id` - Delete user
- `POST /api/users/:id/send-login-details` - Send login details to user
- `POST /api/users/email-all` - Send broadcast email to all users
### Cuts Endpoints
#### Public Cuts Endpoints (requires authentication)
@ -339,6 +394,7 @@ Administrators have additional capabilities for managing shifts:
- **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
- **Email Notifications**: Send shift details to all volunteers via email
### Shift Status System
@ -407,10 +463,11 @@ The search system operates in two modes:
1. **Documentation Search**: Search through integrated MkDocs documentation
2. **Address Search**: Search for addresses and geographic locations
3. **Database Search**: Search through loaded location records on the map
### Features
- **Mode Toggle**: Switch between docs and address search with dedicated buttons
- **Mode Toggle**: Switch between docs, address, and database search with dedicated buttons
- **Keyboard Shortcuts**:
- `Ctrl+K` or `Cmd+K`: Focus search input from anywhere
- `Escape`: Close search results
@ -440,6 +497,13 @@ For geographic location search:
- **Quick Actions**: Add locations directly from search results
- **Coordinate Display**: Shows precise latitude/longitude coordinates
### Database Search
For searching through loaded location data:
- **Full-text Search**: Search through names, addresses, emails, phone numbers, and notes
- **Smart Matching**: Finds partial matches across multiple fields
- **Result Preview**: See relevant details with search terms highlighted
- **Map Integration**: Click results to pan to location and open marker popup
### Configuration
The unified search system integrates with MkDocs documentation when configured:
@ -459,44 +523,6 @@ Address search is rate-limited to prevent abuse:
## Admin Panel
## Unified Search System
The application features a powerful unified search system accessible via the search bar in the header.
<!-- In the Unified Search System section, update the Search Modes: -->
### Search Modes
The search system operates in three modes:
1. **Documentation Search**: Search through integrated MkDocs documentation
2. **Address Search**: Search for addresses and geographic locations
3. **Database Search**: Search through loaded location records on the map
### Features
- **Mode Toggle**: Switch between docs, address, and database search with dedicated buttons
- **Keyboard Shortcuts**:
- `Ctrl+K` or `Cmd+K`: Focus search input from anywhere
- `Ctrl+Shift+D`: Switch to docs mode
- `Ctrl+Shift+M`: Switch to map mode
- `Ctrl+Shift+B`: Switch to database mode
- `Escape`: Close search results
- Arrow keys: Navigate through search results
- `Enter`: Select highlighted result
### Database Search
For searching through loaded location data:
- **Full-text Search**: Search through names, addresses, emails, phone numbers, and notes
- **Smart Matching**: Finds partial matches across multiple fields
- **Result Preview**: See relevant details with search terms highlighted
- **Map Integration**: Click results to pan to location and open marker popup
- **Marker Highlighting**: Temporarily highlights selected markers on the map
- **Fast Performance**: Searches through already-loaded data for instant results
## Admin Panel
Users with admin privileges can access the admin panel at `/admin.html` to configure system settings.
### Features
@ -606,11 +632,14 @@ All configuration is done via environment variables:
| `TRUST_PROXY` | Trust proxy headers (for Cloudflare) | true |
| `COOKIE_DOMAIN` | Cookie domain setting | .cmlite.org |
| `ALLOWED_ORIGINS` | CORS allowed origins (comma-separated) | Multiple URLs |
| `SMTP_HOST` | SMTP server hostname (optional) | smtp.gmail.com |
| `SMTP_PORT` | SMTP server port (optional) | 587 |
| `SMTP_SECURE` | Use SSL for SMTP (optional) | false |
| `SMTP_USER` | SMTP username (optional) | your-email@gmail.com |
| `SMTP_PASS` | SMTP password (optional) | your-app-password |
| `SMTP_HOST` | SMTP server hostname (required for email features) | smtp.gmail.com |
| `SMTP_PORT` | SMTP server port (required for email features) | 587 |
| `SMTP_SECURE` | Use SSL for SMTP (required for email features) | false |
| `SMTP_USER` | SMTP username (required for email features) | your-email@gmail.com |
| `SMTP_PASS` | SMTP password (required for email features) | your-app-password |
| `EMAIL_FROM_NAME` | Sender name for outgoing emails | CMlite Support |
| `EMAIL_FROM_ADDRESS` | Sender email address for outgoing emails | noreply@cmlite.org |
| `APP_NAME` | Application name used in emails and branding | CMlite Map |
| `EMAIL_FROM_NAME` | Email sender name (optional) | CMlite Support |
| `EMAIL_FROM_ADDRESS` | Email sender address (optional) | noreply@cmlite.org |
| `APP_NAME` | Application name for emails (optional) | CMlite Map |
@ -652,6 +681,36 @@ To run in development mode:
npm run dev
```
## Usage Guide
### Email Features
#### Admin Broadcasting
1. Access the admin panel at `/admin.html`
2. Navigate to the "User Management" section
3. Click "Email All Users" to open the email composer
4. Use the rich text editor to format your message
5. Preview your email before sending
6. Send to all registered users with delivery tracking
#### Shift Email Notifications
1. In the admin panel, go to "Shift Management"
2. Click "Manage Volunteers" for any shift
3. Add or remove volunteers as needed
4. Click "Email Shift Details" to notify all volunteers
5. Professional emails include shift details, location, and calendar links
#### User Management
1. Create new users (regular or temporary with expiration)
2. Send login credentials automatically via email
3. Password recovery through secure email notifications
4. Temporary users receive expiration warnings
#### Calendar Integration
- Users can export shifts to Google Calendar, Outlook, or Apple Calendar
- Calendar invites include all shift details and location information
- One-click calendar integration for better volunteer organization
## Security Considerations
- API tokens are kept server-side only
@ -686,6 +745,30 @@ To run in development mode:
- Verify API token has admin permissions
- Check that the NocoDB database is clean (delete all bases before running)
### Email Issues
**Emails not sending:**
- Verify SMTP configuration in `.env` file
- Check SMTP credentials are correct
- Test SMTP connection with your email provider
- Review application logs for email errors
**Gmail SMTP setup:**
1. Enable 2-factor authentication on your Google account
2. Generate an App Password (not your regular password)
3. Use `smtp.gmail.com` as SMTP_HOST with port 587
4. Set SMTP_SECURE to `false` for STARTTLS
**Email templates not rendering:**
- Check that all template files exist in `app/templates/email/`
- Verify template variable names match controller implementations
- Review logs for template rendering errors
**Shift emails not working:**
- Ensure shift signups table is properly configured
- Verify users have valid email addresses
- Check that email templates include all required variables
## License
MIT License - See LICENSE file for details

View File

@ -494,6 +494,302 @@ class ShiftsController {
});
}
}
// Admin: Add user to shift
async addUserToShift(req, res) {
try {
const { shiftId } = req.params;
const { userEmail } = req.body;
if (!userEmail) {
return res.status(400).json({
success: false,
error: 'User email is required'
});
}
if (!config.nocodb.shiftsSheetId || !config.nocodb.shiftSignupsSheetId) {
return res.status(500).json({
success: false,
error: 'Shifts not properly configured'
});
}
// Get shift details
const shift = await nocodbService.getById(config.nocodb.shiftsSheetId, shiftId);
if (!shift) {
return res.status(404).json({
success: false,
error: 'Shift not found'
});
}
// Check if user exists
const user = await nocodbService.getUserByEmail(userEmail);
if (!user) {
return res.status(404).json({
success: false,
error: 'User not found'
});
}
// Check if user is already signed up
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: 'User is already signed up for this shift'
});
}
// Check capacity
const confirmedSignups = (allSignups.list || []).filter(signup =>
signup['Shift ID'] === parseInt(shiftId) && signup.Status === 'Confirmed'
);
if (confirmedSignups.length >= shift['Max Volunteers']) {
return res.status(400).json({
success: false,
error: 'Shift is at maximum capacity'
});
}
// Create signup
const signup = await nocodbService.create(config.nocodb.shiftSignupsSheetId, {
'Shift ID': parseInt(shiftId),
'Shift Title': shift.Title,
'User Email': userEmail,
'User Name': user.Name || user.name || userEmail,
'Signup Date': new Date().toISOString(),
'Status': 'Confirmed'
});
// Update shift volunteer count
const newCount = confirmedSignups.length + 1;
await nocodbService.update(config.nocodb.shiftsSheetId, shiftId, {
'Current Volunteers': newCount,
'Status': newCount >= shift['Max Volunteers'] ? 'Full' : 'Open'
});
res.json({
success: true,
message: 'User successfully added to shift',
signup: signup
});
} catch (error) {
logger.error('Error adding user to shift:', error);
res.status(500).json({
success: false,
error: 'Failed to add user to shift'
});
}
}
// Admin: Remove user from shift
async removeUserFromShift(req, res) {
try {
const { shiftId, userId } = req.params;
if (!config.nocodb.shiftsSheetId || !config.nocodb.shiftSignupsSheetId) {
return res.status(500).json({
success: false,
error: 'Shifts not properly configured'
});
}
// Find the signup by user ID (signup record ID)
const signup = await nocodbService.getById(config.nocodb.shiftSignupsSheetId, userId);
if (!signup) {
return res.status(404).json({
success: false,
error: 'Signup not found'
});
}
// Verify the signup belongs to the specified shift
if (signup['Shift ID'] !== parseInt(shiftId)) {
return res.status(400).json({
success: false,
error: 'Signup does not belong to this shift'
});
}
// Update signup status to cancelled
await nocodbService.update(config.nocodb.shiftSignupsSheetId, userId, {
'Status': 'Cancelled'
});
// Update shift volunteer count
const allSignups = await nocodbService.getAll(config.nocodb.shiftSignupsSheetId);
const confirmedSignups = (allSignups.list || []).filter(s =>
s['Shift ID'] === parseInt(shiftId) && s.Status === 'Confirmed'
);
const newCount = confirmedSignups.length;
const shift = await nocodbService.getById(config.nocodb.shiftsSheetId, shiftId);
await nocodbService.update(config.nocodb.shiftsSheetId, shiftId, {
'Current Volunteers': newCount,
'Status': newCount >= shift['Max Volunteers'] ? 'Full' : 'Open'
});
res.json({
success: true,
message: 'User successfully removed from shift'
});
} catch (error) {
logger.error('Error removing user from shift:', error);
res.status(500).json({
success: false,
error: 'Failed to remove user from shift'
});
}
}
// Admin: Email shift details to all volunteers
async emailShiftDetails(req, res) {
try {
const { shiftId } = req.params;
if (!config.nocodb.shiftsSheetId || !config.nocodb.shiftSignupsSheetId) {
return res.status(500).json({
success: false,
error: 'Shifts not properly configured'
});
}
// Get shift details
const shift = await nocodbService.getById(config.nocodb.shiftsSheetId, shiftId);
if (!shift) {
return res.status(404).json({
success: false,
error: 'Shift not found'
});
}
// Get all confirmed signups for this shift
const allSignups = await nocodbService.getAll(config.nocodb.shiftSignupsSheetId);
const shiftSignups = (allSignups.list || []).filter(signup =>
signup['Shift ID'] === parseInt(shiftId) && signup.Status === 'Confirmed'
);
if (shiftSignups.length === 0) {
return res.status(400).json({
success: false,
error: 'No volunteers signed up for this shift'
});
}
// Import email service
const { sendEmail } = require('../services/email');
const emailTemplates = require('../services/emailTemplates');
const config_app = require('../config');
// Prepare email template variables
const shiftDate = new Date(shift.Date);
const baseUrl = config_app.isProduction ?
`https://map.${config_app.domain}` :
`http://localhost:${config_app.port}`;
const hasDescription = shift.Description && shift.Description.trim().length > 0;
const templateVariables = {
APP_NAME: 'Volunteer Shift Manager',
SHIFT_TITLE: shift.Title,
SHIFT_DATE: shiftDate.toLocaleDateString(),
SHIFT_START_TIME: shift['Start Time'],
SHIFT_END_TIME: shift['End Time'],
SHIFT_LOCATION: shift.Location || 'TBD',
CURRENT_VOLUNTEERS: shiftSignups.length,
MAX_VOLUNTEERS: shift['Max Volunteers'],
SHIFT_STATUS: shift.Status || 'Open',
SHIFT_STATUS_CLASS: (shift.Status || 'Open').toLowerCase(),
SHIFT_DESCRIPTION: shift.Description || '',
SHIFT_DESCRIPTION_SECTION: hasDescription ? `ADDITIONAL INFORMATION:\n======================\n${shift.Description}` : '',
DESCRIPTION_DISPLAY: hasDescription ? 'block' : 'none',
TIMESTAMP: new Date().toLocaleString()
};
// Send emails to all volunteers
const emailResults = [];
const failedEmails = [];
for (const signup of shiftSignups) {
try {
const userVariables = {
...templateVariables,
USER_NAME: signup['User Name'] || signup['User Email'],
USER_EMAIL: signup['User Email']
};
const emailContent = await emailTemplates.render('shift-details', userVariables);
await sendEmail({
to: signup['User Email'],
subject: `Shift Details: ${shift.Title} - ${shiftDate.toLocaleDateString()}`,
text: emailContent.text,
html: emailContent.html
});
emailResults.push({
email: signup['User Email'],
name: signup['User Name'],
success: true
});
logger.info(`Sent shift details email to: ${signup['User Email']}`);
} catch (emailError) {
logger.error(`Failed to send shift details email to ${signup['User Email']}:`, emailError);
failedEmails.push({
email: signup['User Email'],
name: signup['User Name'],
error: emailError.message
});
}
}
const successCount = emailResults.length;
const failCount = failedEmails.length;
if (successCount === 0) {
return res.status(500).json({
success: false,
error: 'Failed to send any emails',
details: failedEmails
});
}
res.json({
success: true,
message: `Sent shift details to ${successCount} volunteer${successCount !== 1 ? 's' : ''}${failCount > 0 ? `, ${failCount} failed` : ''}`,
results: {
successful: emailResults,
failed: failedEmails,
shift: {
id: shiftId,
title: shift.Title,
date: shiftDate.toLocaleDateString(),
volunteers: shiftSignups.length
}
}
});
} catch (error) {
logger.error('Error sending shift details emails:', error);
res.status(500).json({
success: false,
error: 'Failed to send shift details emails'
});
}
}
}
module.exports = new ShiftsController();

View File

@ -211,6 +211,127 @@ class UsersController {
});
}
}
async emailAllUsers(req, res) {
try {
const { subject, content } = req.body;
if (!subject || !content) {
return res.status(400).json({
success: false,
error: 'Subject and content are required'
});
}
if (!config.nocodb.loginSheetId) {
return res.status(500).json({
success: false,
error: 'Login sheet not configured'
});
}
// Get all users
const response = await nocodbService.getAll(config.nocodb.loginSheetId, {
limit: 1000
});
const users = response.list || [];
if (users.length === 0) {
return res.status(400).json({
success: false,
error: 'No users found to email'
});
}
// Import email service
const { sendEmail } = require('../services/email');
const emailTemplates = require('../services/emailTemplates');
const config_app = require('../config');
// Convert rich text content to plain text for the text version
const stripHtmlTags = (html) => {
return html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
};
// Prepare base template variables
const baseTemplateVariables = {
APP_NAME: 'CMlite Map - User Broadcast',
EMAIL_SUBJECT: subject,
EMAIL_CONTENT: content,
EMAIL_CONTENT_TEXT: stripHtmlTags(content),
SENDER_NAME: req.session.userName || req.session.userEmail || 'Administrator',
TIMESTAMP: new Date().toLocaleString()
};
// Send emails to all users
const emailResults = [];
const failedEmails = [];
for (const user of users) {
try {
const userVariables = {
...baseTemplateVariables,
USER_NAME: user.Name || user.name || user.Email || user.email || 'User',
USER_EMAIL: user.Email || user.email
};
const emailContent = await emailTemplates.render('user-broadcast', userVariables);
await sendEmail({
to: user.Email || user.email,
subject: subject,
text: emailContent.text,
html: emailContent.html
});
emailResults.push({
email: user.Email || user.email,
name: user.Name || user.name || user.Email || user.email,
success: true
});
logger.info(`Sent broadcast email to: ${user.Email || user.email}`);
} catch (emailError) {
logger.error(`Failed to send broadcast email to ${user.Email || user.email}:`, emailError);
failedEmails.push({
email: user.Email || user.email,
name: user.Name || user.name || user.Email || user.email,
error: emailError.message
});
}
}
const successCount = emailResults.length;
const failCount = failedEmails.length;
if (successCount === 0) {
return res.status(500).json({
success: false,
error: 'Failed to send any emails',
details: failedEmails
});
}
res.json({
success: true,
message: `Sent email to ${successCount} user${successCount !== 1 ? 's' : ''}${failCount > 0 ? `, ${failCount} failed` : ''}`,
results: {
successful: emailResults,
failed: failedEmails,
total: users.length,
subject: subject
}
});
} catch (error) {
logger.error('Error sending broadcast email:', error);
res.status(500).json({
success: false,
error: 'Failed to send broadcast email'
});
}
}
}
module.exports = new UsersController();

View File

@ -403,6 +403,86 @@
</div>
</div>
</div>
<!-- Shift User Management Modal -->
<div id="shift-user-modal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3>Manage Volunteers</h3>
<button class="btn btn-secondary btn-sm" id="close-user-modal">×</button>
</div>
<div class="modal-body">
<div class="shift-info">
<h4 id="modal-shift-title"></h4>
<p id="modal-shift-details"></p>
</div>
<div class="add-user-section">
<h4>Add Volunteer</h4>
<div class="form-row">
<div class="form-group">
<label for="user-select">Select User:</label>
<select id="user-select" style="width: 100%;">
<option value="">Select a user...</option>
</select>
</div>
<div class="form-group">
<button type="button" class="btn btn-primary" id="add-user-btn">Add User</button>
</div>
</div>
</div>
<div class="current-volunteers-section">
<h4>Current Volunteers</h4>
<div class="volunteer-actions-header">
<button type="button" class="btn btn-primary btn-sm" id="email-shift-details-btn">
📧 Email Shift Details
</button>
</div>
<div id="current-volunteers-list">
<!-- Current volunteers will be loaded here -->
</div>
<!-- Shift Email Progress Container -->
<div id="shift-email-progress-container" class="email-progress-container">
<div class="progress-header">
<div class="progress-title">Sending Shift Details...</div>
<div class="progress-stats">
<div class="progress-stat success">
<span></span>
<span id="shift-success-count">0</span> sent
</div>
<div class="progress-stat error">
<span></span>
<span id="shift-error-count">0</span> failed
</div>
<div class="progress-stat pending">
<span></span>
<span id="shift-pending-count">0</span> pending
</div>
</div>
</div>
<div class="progress-bar-container">
<div class="progress-bar" id="shift-email-progress-bar">
<div class="progress-text" id="shift-progress-text">0%</div>
</div>
</div>
<div class="email-status-list" id="shift-email-status-list">
<!-- Status items will be added dynamically -->
</div>
<div class="progress-actions">
<button type="button" class="progress-close-btn" id="close-shift-progress-btn" style="display: none;">
Close
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Users Section -->
@ -453,10 +533,136 @@
</div>
<div class="users-list">
<div class="users-list-header">
<h3>All Users</h3>
<button type="button" class="btn btn-primary btn-sm" id="email-all-users-btn">
📧 Email All Users
</button>
</div>
<!-- User table will be dynamically inserted here -->
<p id="users-loading" class="loading-message">Loading users...</p>
</div>
</div>
<!-- Email All Users Modal -->
<div id="email-users-modal" class="modal" style="display: none;">
<div class="modal-content modal-large">
<div class="modal-header">
<h3>Email All Users</h3>
<button class="btn btn-secondary btn-sm" id="close-email-modal">×</button>
</div>
<div class="modal-body">
<div class="email-form">
<form id="email-users-form">
<div class="form-group">
<label for="email-subject">Subject</label>
<input type="text" id="email-subject" required placeholder="Enter email subject">
</div>
<div class="form-group">
<label for="email-content">Message</label>
<div class="rich-text-toolbar">
<button type="button" class="toolbar-btn" data-command="bold" title="Bold">
<strong>B</strong>
</button>
<button type="button" class="toolbar-btn" data-command="italic" title="Italic">
<em>I</em>
</button>
<button type="button" class="toolbar-btn" data-command="underline" title="Underline">
<u>U</u>
</button>
<button type="button" class="toolbar-btn" data-command="insertUnorderedList" title="Bullet List">
• List
</button>
<button type="button" class="toolbar-btn" data-command="insertOrderedList" title="Numbered List">
1. List
</button>
<button type="button" class="toolbar-btn" data-command="createLink" title="Insert Link">
🔗 Link
</button>
</div>
<div id="email-content" class="rich-text-editor" contenteditable="true"
placeholder="Type your message here..."
style="min-height: 200px; border: 1px solid #ddd; padding: 15px; border-radius: 4px; background: white;">
</div>
<small class="help-text">Use the toolbar above to format your message</small>
</div>
<div class="email-preview-section">
<div class="form-group">
<label>
<input type="checkbox" id="show-preview" />
Show email preview
</label>
</div>
<div id="email-preview" class="email-preview" style="display: none;">
<h4>Email Preview:</h4>
<div class="email-preview-content">
<div class="preview-header">
<strong>Subject:</strong> <span id="preview-subject">Your subject will appear here</span>
</div>
<div class="preview-body" id="preview-body">
Your message will appear here
</div>
</div>
</div>
</div>
<div class="email-recipients-info">
<p><strong>Recipients:</strong> <span id="recipients-count">Loading...</span> users will receive this email</p>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" id="send-email-btn">
📧 Send Email to All Users
</button>
<button type="button" class="btn btn-secondary" id="cancel-email-btn">
Cancel
</button>
</div>
</form>
<!-- Email Progress Container -->
<div id="email-progress-container" class="email-progress-container">
<div class="progress-header">
<div class="progress-title">Sending Emails...</div>
<div class="progress-stats">
<div class="progress-stat success">
<span></span>
<span id="success-count">0</span> sent
</div>
<div class="progress-stat error">
<span></span>
<span id="error-count">0</span> failed
</div>
<div class="progress-stat pending">
<span></span>
<span id="pending-count">0</span> pending
</div>
</div>
</div>
<div class="progress-bar-container">
<div class="progress-bar" id="email-progress-bar">
<div class="progress-text" id="progress-text">0%</div>
</div>
</div>
<div class="email-status-list" id="email-status-list">
<!-- Status items will be added dynamically -->
</div>
<div class="progress-actions">
<button type="button" class="progress-close-btn" id="close-progress-btn" style="display: none;">
Close
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Map Cuts Section -->

View File

@ -2539,3 +2539,472 @@
border-color: #ddd;
box-shadow: none;
}
/* Shift User Management Modal */
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 10000;
}
.modal-content {
background-color: white;
border-radius: var(--border-radius);
padding: 0;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 25px;
border-bottom: 1px solid #e0e0e0;
background-color: #f8f9fa;
}
.modal-header h3 {
margin: 0;
color: var(--dark-color);
}
.modal-body {
padding: 25px;
overflow-y: auto;
flex: 1;
}
.shift-info {
background-color: #f8f9fa;
padding: 15px;
border-radius: var(--border-radius);
margin-bottom: 20px;
}
.shift-info h4 {
margin: 0 0 8px 0;
color: var(--dark-color);
}
.shift-info p {
margin: 0;
color: #666;
font-size: 14px;
}
.add-user-section {
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 1px solid #e0e0e0;
}
.add-user-section h4 {
margin: 0 0 15px 0;
color: var(--dark-color);
}
.current-volunteers-section h4 {
margin: 0 0 15px 0;
color: var(--dark-color);
}
.volunteer-actions-header {
display: flex;
justify-content: flex-end;
align-items: center;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
}
/* Users list header styling */
.users-list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #eee;
}
.users-list-header h3 {
margin: 0;
color: var(--dark-color);
}
/* Email modal styling */
.modal-large {
max-width: 800px;
width: 90%;
}
.email-form {
max-width: 100%;
}
.rich-text-toolbar {
display: flex;
gap: 5px;
margin-bottom: 10px;
padding: 10px;
background: #f8f9fa;
border: 1px solid #ddd;
border-radius: 4px 4px 0 0;
border-bottom: none;
}
.toolbar-btn {
padding: 8px 12px;
border: 1px solid #ccc;
background: white;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
}
.toolbar-btn:hover {
background: #e9ecef;
border-color: #adb5bd;
}
.toolbar-btn:active, .toolbar-btn.active {
background: #007bff;
color: white;
border-color: #007bff;
}
.rich-text-editor {
min-height: 200px;
border: 1px solid #ddd;
padding: 15px;
border-radius: 0 0 4px 4px;
background: white;
font-family: inherit;
font-size: 14px;
line-height: 1.5;
outline: none;
}
.rich-text-editor:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(160, 44, 141, 0.2);
}
.rich-text-editor:empty:before {
content: attr(placeholder);
color: #999;
font-style: italic;
}
.email-preview {
margin-top: 20px;
padding: 15px;
background: #f8f9fa;
border: 1px solid #ddd;
border-radius: 4px;
}
.email-preview-content {
background: white;
padding: 20px;
border: 1px solid #ccc;
border-radius: 4px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.preview-header {
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
}
.preview-body {
line-height: 1.6;
}
.email-recipients-info {
background: #e3f2fd;
padding: 15px;
border-radius: 4px;
margin: 20px 0;
border-left: 4px solid #2196f3;
}
.email-recipients-info p {
margin: 0;
color: #1565c0;
}
.volunteer-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 15px;
background-color: #f8f9fa;
border-radius: var(--border-radius);
margin-bottom: 8px;
}
.volunteer-info {
flex: 1;
}
.volunteer-name {
font-weight: bold;
color: var(--dark-color);
}
.volunteer-email {
font-size: 13px;
color: #666;
margin-top: 2px;
}
.volunteer-actions {
display: flex;
gap: 8px;
}
.no-volunteers {
text-align: center;
color: #666;
font-style: italic;
padding: 20px;
background-color: #f8f9fa;
border-radius: var(--border-radius);
}
/* Enhance existing shift admin items */
.shift-admin-item {
position: relative;
}
.manage-volunteers-btn {
margin-left: 8px;
}
/* User select dropdown */
#user-select {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: var(--border-radius);
font-size: 14px;
background-color: white;
}
#user-select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.1);
}
/* Responsive modal */
@media (max-width: 768px) {
.modal-content {
width: 95%;
max-height: 90vh;
}
.modal-body {
padding: 20px;
}
.form-row {
flex-direction: column;
gap: 10px;
}
.volunteer-item {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.volunteer-actions {
align-self: flex-end;
}
}
/* Email Progress Indicators */
.email-progress-container {
display: none;
margin-top: 20px;
padding: 20px;
background-color: #f8f9fa;
border-radius: var(--border-radius);
border: 1px solid #dee2e6;
}
.email-progress-container.show {
display: block;
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.progress-title {
font-weight: 600;
color: var(--dark-color);
font-size: 16px;
}
.progress-stats {
display: flex;
gap: 15px;
font-size: 14px;
}
.progress-stat {
display: flex;
align-items: center;
gap: 5px;
}
.progress-stat.success {
color: var(--success-color);
}
.progress-stat.error {
color: var(--error-color);
}
.progress-stat.pending {
color: #6c757d;
}
.progress-bar-container {
background-color: #e9ecef;
border-radius: 10px;
height: 20px;
overflow: hidden;
margin-bottom: 15px;
position: relative;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--primary-color) 0%, #28a745 100%);
width: 0%;
transition: width 0.3s ease;
position: relative;
}
.progress-bar.complete {
background: linear-gradient(90deg, #28a745 0%, #20c997 100%);
}
.progress-bar.error {
background: linear-gradient(90deg, #dc3545 0%, #e74c3c 100%);
}
.progress-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 12px;
font-weight: 600;
text-shadow: 1px 1px 2px rgba(0,0,0,0.5);
}
.email-status-list {
max-height: 200px;
overflow-y: auto;
border: 1px solid #dee2e6;
border-radius: var(--border-radius);
background-color: white;
}
.email-status-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 15px;
border-bottom: 1px solid #f8f9fa;
font-size: 14px;
}
.email-status-item:last-child {
border-bottom: none;
}
.email-status-recipient {
font-weight: 500;
color: var(--dark-color);
}
.email-status-result {
display: flex;
align-items: center;
gap: 5px;
font-size: 12px;
font-weight: 600;
}
.email-status-result.success {
color: var(--success-color);
}
.email-status-result.error {
color: var(--error-color);
}
.email-status-result.pending {
color: #6c757d;
}
.progress-spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid #f3f3f3;
border-top: 2px solid #6c757d;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.progress-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 15px;
}
.progress-close-btn {
background: var(--secondary-color);
color: white;
border: none;
padding: 8px 16px;
border-radius: var(--border-radius);
cursor: pointer;
font-size: 14px;
transition: var(--transition);
}
.progress-close-btn:hover {
background: #5a6268;
}

View File

@ -500,6 +500,12 @@ function showSection(sectionId) {
}
}, 100);
}
// Special handling for shifts section
if (sectionId === 'shifts') {
console.log('Loading shifts for admin panel...');
loadAdminShifts();
}
}
// Update map from input fields
@ -1113,17 +1119,31 @@ function debounce(func, wait) {
// Add shift management functions
async function loadAdminShifts() {
const list = document.getElementById('admin-shifts-list');
if (list) {
list.innerHTML = '<p>Loading shifts...</p>';
}
try {
console.log('Loading admin shifts...');
const response = await fetch('/api/shifts/admin');
const data = await response.json();
if (data.success) {
console.log('Successfully loaded', data.shifts.length, 'shifts');
displayAdminShifts(data.shifts);
} else {
console.error('Failed to load shifts:', data.error);
if (list) {
list.innerHTML = '<p>Failed to load shifts</p>';
}
showStatus('Failed to load shifts', 'error');
}
} catch (error) {
console.error('Error loading admin shifts:', error);
if (list) {
list.innerHTML = '<p>Error loading shifts</p>';
}
showStatus('Failed to load shifts', 'error');
}
}
@ -1145,6 +1165,8 @@ function displayAdminShifts(shifts) {
const shiftDate = new Date(shift.Date);
const signupCount = shift.signups ? shift.signups.length : 0;
console.log(`Shift "${shift.Title}" (ID: ${shift.ID}) has ${signupCount} volunteers:`, shift.signups?.map(s => s['User Email']) || []);
return `
<div class="shift-admin-item">
<div>
@ -1155,6 +1177,7 @@ function displayAdminShifts(shifts) {
<p class="status-${(shift.Status || 'open').toLowerCase()}">${shift.Status || 'Open'}</p>
</div>
<div class="shift-actions">
<button class="btn btn-primary btn-sm manage-volunteers-btn" data-shift-id="${shift.ID}" data-shift='${JSON.stringify(shift).replace(/'/g, "&#39;")}'>Manage Volunteers</button>
<button class="btn btn-secondary btn-sm edit-shift-btn" data-shift-id="${shift.ID}">Edit</button>
<button class="btn btn-danger btn-sm delete-shift-btn" data-shift-id="${shift.ID}">Delete</button>
</div>
@ -1187,6 +1210,11 @@ function setupShiftActionListeners() {
const shiftId = e.target.getAttribute('data-shift-id');
console.log('Edit button clicked for shift:', shiftId);
editShift(shiftId);
} else if (e.target.classList.contains('manage-volunteers-btn')) {
const shiftId = e.target.getAttribute('data-shift-id');
const shiftData = JSON.parse(e.target.getAttribute('data-shift').replace(/&#39;/g, "'"));
console.log('Manage volunteers clicked for shift:', shiftId);
showShiftUserModal(shiftId, shiftData);
}
});
}
@ -1207,6 +1235,7 @@ async function deleteShift(shiftId) {
if (data.success) {
showStatus('Shift deleted successfully', 'success');
await loadAdminShifts();
console.log('Refreshed shifts list after deleting shift');
} else {
showStatus(data.error || 'Failed to delete shift', 'error');
}
@ -1250,6 +1279,7 @@ async function createShift(e) {
showStatus('Shift created successfully', 'success');
document.getElementById('shift-form').reset();
await loadAdminShifts();
console.log('Refreshed shifts list after creating new shift');
} else {
showStatus(data.error || 'Failed to create shift', 'error');
}
@ -1304,13 +1334,29 @@ function displayUsers(users) {
const container = document.querySelector('.users-list');
if (!container) return;
// Find or create the users table container, preserving the header
let usersTableContainer = container.querySelector('.users-table-container');
if (!usersTableContainer) {
// If container doesn't exist, create it after the header
const header = container.querySelector('.users-list-header');
usersTableContainer = document.createElement('div');
usersTableContainer.className = 'users-table-container';
if (header && header.nextSibling) {
container.insertBefore(usersTableContainer, header.nextSibling);
} else if (header) {
container.appendChild(usersTableContainer);
} else {
container.appendChild(usersTableContainer);
}
}
if (!users || users.length === 0) {
container.innerHTML = '<h3>Existing Users</h3><p class="empty-message">No users found.</p>';
usersTableContainer.innerHTML = '<p class="empty-message">No users found.</p>';
return;
}
const tableHtml = `
<h3>Existing Users</h3>
<div class="users-table-wrapper">
<table class="users-table">
<thead>
@ -1322,7 +1368,7 @@ function displayUsers(users) {
<th>Actions</th>
</tr>
</thead>
<tbody>
<tbody id="users-table-body">
${users.map(user => {
const createdDate = user.created_at || user['Created At'] || user.createdAt;
const formattedDate = createdDate ? new Date(createdDate).toLocaleDateString() : 'N/A';
@ -1376,7 +1422,7 @@ function displayUsers(users) {
<p id="users-loading" class="loading-message" style="display: none;">Loading...</p>
`;
container.innerHTML = tableHtml;
usersTableContainer.innerHTML = tableHtml;
setupUserActionListeners();
}
@ -1402,6 +1448,9 @@ function setupUserActionListeners() {
const userEmail = e.target.getAttribute('data-user-email');
console.log('Send login details button clicked for user:', userId);
sendLoginDetailsToUser(userId, userEmail);
} else if (e.target.id === 'email-all-users-btn') {
console.log('Email All Users button clicked');
showEmailUsersModal();
}
});
}
@ -1545,6 +1594,318 @@ function clearUserForm() {
}
}
// Email All Users Functions
let allUsersData = [];
async function showEmailUsersModal() {
// Load current users data
try {
const response = await fetch('/api/users');
const data = await response.json();
if (data.success && data.users) {
allUsersData = data.users;
// Update recipients count
const recipientsCount = document.getElementById('recipients-count');
if (recipientsCount) {
recipientsCount.textContent = `${allUsersData.length}`;
}
}
} catch (error) {
console.error('Error loading users for email:', error);
showStatus('Failed to load user data', 'error');
return;
}
// Show modal
const modal = document.getElementById('email-users-modal');
if (modal) {
modal.style.display = 'flex';
// Clear previous content
document.getElementById('email-subject').value = '';
document.getElementById('email-content').innerHTML = '';
document.getElementById('show-preview').checked = false;
document.getElementById('email-preview').style.display = 'none';
}
}
function closeEmailUsersModal() {
const modal = document.getElementById('email-users-modal');
if (modal) {
modal.style.display = 'none';
}
}
function setupRichTextEditor() {
const toolbar = document.querySelector('.rich-text-toolbar');
const editor = document.getElementById('email-content');
if (!toolbar || !editor) return;
// Handle toolbar button clicks
toolbar.addEventListener('click', (e) => {
if (e.target.classList.contains('toolbar-btn')) {
e.preventDefault();
const command = e.target.getAttribute('data-command');
if (command === 'createLink') {
const url = prompt('Enter the URL:');
if (url) {
document.execCommand(command, false, url);
}
} else {
document.execCommand(command, false, null);
}
// Update preview if visible
updateEmailPreview();
}
});
// Update preview on content change
editor.addEventListener('input', updateEmailPreview);
// Handle preview toggle
const showPreviewCheckbox = document.getElementById('show-preview');
if (showPreviewCheckbox) {
showPreviewCheckbox.addEventListener('change', togglePreview);
}
// Update preview when subject changes
const subjectInput = document.getElementById('email-subject');
if (subjectInput) {
subjectInput.addEventListener('input', updateEmailPreview);
}
}
function togglePreview() {
const preview = document.getElementById('email-preview');
const checkbox = document.getElementById('show-preview');
if (preview && checkbox) {
if (checkbox.checked) {
preview.style.display = 'block';
updateEmailPreview();
} else {
preview.style.display = 'none';
}
}
}
function updateEmailPreview() {
const previewSubject = document.getElementById('preview-subject');
const previewBody = document.getElementById('preview-body');
const subjectInput = document.getElementById('email-subject');
const contentEditor = document.getElementById('email-content');
if (previewSubject && subjectInput) {
previewSubject.textContent = subjectInput.value || 'Your subject will appear here';
}
if (previewBody && contentEditor) {
const content = contentEditor.innerHTML || 'Your message will appear here';
previewBody.innerHTML = content;
}
}
async function sendEmailToAllUsers(e) {
e.preventDefault();
const subject = document.getElementById('email-subject').value.trim();
const content = document.getElementById('email-content').innerHTML.trim();
if (!subject) {
showStatus('Please enter an email subject', 'error');
return;
}
if (!content || content === '<br>' || content === '') {
showStatus('Please enter email content', 'error');
return;
}
if (allUsersData.length === 0) {
showStatus('No users found to email', 'error');
return;
}
const confirmMessage = `Send this email to all ${allUsersData.length} users?`;
if (!confirm(confirmMessage)) {
return;
}
// Initialize progress tracking
initializeEmailProgress(allUsersData.length);
try {
const response = await fetch('/api/users/email-all', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
subject: subject,
content: content
})
});
const data = await response.json();
if (data.success) {
// Display detailed results
updateEmailProgress(data.results);
showStatus(data.message, 'success');
console.log('Email results:', data.results);
} else {
showEmailError(data.error || 'Failed to send emails');
if (data.details) {
console.error('Failed email details:', data.details);
}
}
} catch (error) {
console.error('Error sending emails to all users:', error);
showEmailError('Failed to send emails - Network error');
}
}
// Initialize email progress display
function initializeEmailProgress(totalCount) {
const progressContainer = document.getElementById('email-progress-container');
const statusList = document.getElementById('email-status-list');
const pendingCountEl = document.getElementById('pending-count');
const successCountEl = document.getElementById('success-count');
const errorCountEl = document.getElementById('error-count');
const progressBar = document.getElementById('email-progress-bar');
const progressText = document.getElementById('progress-text');
const closeBtn = document.getElementById('close-progress-btn');
// Show progress container
progressContainer.classList.add('show');
// Reset counters
pendingCountEl.textContent = totalCount;
successCountEl.textContent = '0';
errorCountEl.textContent = '0';
// Reset progress bar
progressBar.style.width = '0%';
progressBar.classList.remove('complete', 'error');
progressText.textContent = '0%';
// Clear status list
statusList.innerHTML = '';
// Hide close button initially
closeBtn.style.display = 'none';
// Add status items for each user
allUsersData.forEach(user => {
const statusItem = document.createElement('div');
statusItem.className = 'email-status-item';
statusItem.innerHTML = `
<div class="email-status-recipient">${user.Name || user.Email}</div>
<div class="email-status-result pending">
<div class="progress-spinner"></div>
<span>Sending...</span>
</div>
`;
statusList.appendChild(statusItem);
});
}
// Update progress with results
function updateEmailProgress(results) {
const statusList = document.getElementById('email-status-list');
const pendingCountEl = document.getElementById('pending-count');
const successCountEl = document.getElementById('success-count');
const errorCountEl = document.getElementById('error-count');
const progressBar = document.getElementById('email-progress-bar');
const progressText = document.getElementById('progress-text');
const closeBtn = document.getElementById('close-progress-btn');
const successful = results.successful || [];
const failed = results.failed || [];
const total = results.total || (successful.length + failed.length);
// Update counters
successCountEl.textContent = successful.length;
errorCountEl.textContent = failed.length;
pendingCountEl.textContent = '0';
// Update progress bar
const percentage = ((successful.length + failed.length) / total * 100).toFixed(1);
progressBar.style.width = percentage + '%';
progressText.textContent = percentage + '%';
if (failed.length > 0) {
progressBar.classList.add('error');
} else {
progressBar.classList.add('complete');
}
// Update individual status items
const statusItems = statusList.children;
// Update successful emails
successful.forEach(result => {
const statusItem = Array.from(statusItems).find(item =>
item.querySelector('.email-status-recipient').textContent.includes(result.email) ||
item.querySelector('.email-status-recipient').textContent.includes(result.name)
);
if (statusItem) {
statusItem.querySelector('.email-status-result').innerHTML = `
<span class="email-status-result success"> Sent</span>
`;
}
});
// Update failed emails
failed.forEach(result => {
const statusItem = Array.from(statusItems).find(item =>
item.querySelector('.email-status-recipient').textContent.includes(result.email) ||
item.querySelector('.email-status-recipient').textContent.includes(result.name)
);
if (statusItem) {
statusItem.querySelector('.email-status-result').innerHTML = `
<span class="email-status-result error" title="${result.error || 'Unknown error'}"> Failed</span>
`;
}
});
// Show close button
closeBtn.style.display = 'block';
closeBtn.onclick = () => {
document.getElementById('email-progress-container').classList.remove('show');
closeEmailUsersModal();
};
}
// Show email error
function showEmailError(message) {
const progressContainer = document.getElementById('email-progress-container');
const progressBar = document.getElementById('email-progress-bar');
const progressText = document.getElementById('progress-text');
const closeBtn = document.getElementById('close-progress-btn');
// Show progress container if not visible
progressContainer.classList.add('show');
// Update progress bar to show error
progressBar.style.width = '100%';
progressBar.classList.add('error');
progressText.textContent = 'Error';
// Show close button
closeBtn.style.display = 'block';
closeBtn.onclick = () => {
progressContainer.classList.remove('show');
};
showStatus(message, 'error');
}
// Initialize NocoDB links in admin panel
async function initializeNocodbLinks() {
console.log('Starting NocoDB links initialization...');
@ -1626,3 +1987,470 @@ function setAdminNocodbLink(elementId, url) {
console.error(`✗ Element not found: ${elementId}`);
}
}
// Shift User Management Functions
let currentShiftData = null;
let allUsers = [];
// Load all users for the dropdown
async function loadAllUsers() {
try {
const response = await fetch('/api/users');
const data = await response.json();
if (data.success) {
allUsers = data.users;
populateUserSelect();
} else {
console.error('Failed to load users:', data.error);
}
} catch (error) {
console.error('Error loading users:', error);
}
}
// Populate user select dropdown
function populateUserSelect() {
const select = document.getElementById('user-select');
if (!select) return;
// Clear existing options except the first one
select.innerHTML = '<option value="">Select a user...</option>';
allUsers.forEach(user => {
const option = document.createElement('option');
option.value = user.email || user.Email;
option.textContent = `${user.name || user.Name || ''} (${user.email || user.Email})`;
select.appendChild(option);
});
}
// Show the shift user management modal
async function showShiftUserModal(shiftId, shiftData) {
currentShiftData = { ...shiftData, ID: shiftId };
// Update modal title and info
document.getElementById('modal-shift-title').textContent = shiftData.Title;
const shiftDate = new Date(shiftData.Date);
document.getElementById('modal-shift-details').textContent =
`${shiftDate.toLocaleDateString()} | ${shiftData['Start Time']} - ${shiftData['End Time']} | ${shiftData.Location || 'TBD'}`;
// Load users if not already loaded
if (allUsers.length === 0) {
await loadAllUsers();
}
// Display current volunteers
displayCurrentVolunteers(shiftData.signups || []);
// Show modal
document.getElementById('shift-user-modal').style.display = 'flex';
}
// Display current volunteers in the modal
function displayCurrentVolunteers(volunteers) {
const container = document.getElementById('current-volunteers-list');
if (!volunteers || volunteers.length === 0) {
container.innerHTML = '<div class="no-volunteers">No volunteers signed up yet.</div>';
return;
}
container.innerHTML = volunteers.map(volunteer => `
<div class="volunteer-item">
<div class="volunteer-info">
<div class="volunteer-name">${escapeHtml(volunteer['User Name'] || volunteer['User Email'] || 'Unknown')}</div>
<div class="volunteer-email">${escapeHtml(volunteer['User Email'])}</div>
</div>
<div class="volunteer-actions">
<button class="btn btn-danger btn-sm remove-volunteer-btn"
data-volunteer-id="${volunteer.ID || volunteer.id}"
data-volunteer-email="${volunteer['User Email']}">
Remove
</button>
</div>
</div>
`).join('');
// Add event listeners for remove buttons
setupVolunteerActionListeners();
}
// Setup event listeners for volunteer actions
function setupVolunteerActionListeners() {
const container = document.getElementById('current-volunteers-list');
container.addEventListener('click', function(e) {
if (e.target.classList.contains('remove-volunteer-btn')) {
const volunteerId = e.target.getAttribute('data-volunteer-id');
const volunteerEmail = e.target.getAttribute('data-volunteer-email');
removeVolunteerFromShift(volunteerId, volunteerEmail);
}
});
}
// Add user to shift
async function addUserToShift() {
const userSelect = document.getElementById('user-select');
const userEmail = userSelect.value;
if (!userEmail) {
showStatus('Please select a user to add', 'error');
return;
}
if (!currentShiftData) {
showStatus('No shift selected', 'error');
return;
}
try {
const response = await fetch(`/api/shifts/admin/${currentShiftData.ID}/add-user`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ userEmail })
});
const data = await response.json();
if (data.success) {
showStatus('User successfully added to shift', 'success');
userSelect.value = ''; // Clear selection
// Refresh the shift data and reload volunteers
await refreshCurrentShiftData();
console.log('Refreshed shift data after adding user');
} else {
showStatus(data.error || 'Failed to add user to shift', 'error');
}
} catch (error) {
console.error('Error adding user to shift:', error);
showStatus('Failed to add user to shift', 'error');
}
}
// Remove volunteer from shift
async function removeVolunteerFromShift(volunteerId, volunteerEmail) {
if (!confirm(`Are you sure you want to remove ${volunteerEmail} from this shift?`)) {
return;
}
if (!currentShiftData) {
showStatus('No shift selected', 'error');
return;
}
try {
const response = await fetch(`/api/shifts/admin/${currentShiftData.ID}/remove-user/${volunteerId}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
showStatus('Volunteer successfully removed from shift', 'success');
// Refresh the shift data and reload volunteers
await refreshCurrentShiftData();
console.log('Refreshed shift data after removing volunteer');
} else {
showStatus(data.error || 'Failed to remove volunteer from shift', 'error');
}
} catch (error) {
console.error('Error removing volunteer from shift:', error);
showStatus('Failed to remove volunteer from shift', 'error');
}
}
// Refresh current shift data
async function refreshCurrentShiftData() {
if (!currentShiftData) return;
try {
console.log('Refreshing shift data for shift ID:', currentShiftData.ID);
// Reload admin shifts to get updated data
const response = await fetch('/api/shifts/admin');
const data = await response.json();
if (data.success) {
const updatedShift = data.shifts.find(s => s.ID === currentShiftData.ID);
if (updatedShift) {
console.log('Found updated shift with', updatedShift.signups?.length || 0, 'volunteers');
currentShiftData = updatedShift;
displayCurrentVolunteers(updatedShift.signups || []);
// Immediately refresh the main shifts list to show updated counts
console.log('Refreshing main shifts list with', data.shifts.length, 'shifts');
displayAdminShifts(data.shifts);
} else {
console.warn('Could not find updated shift with ID:', currentShiftData.ID);
}
} else {
console.error('Failed to refresh shift data:', data.error);
}
} catch (error) {
console.error('Error refreshing shift data:', error);
}
}
// Close modal
function closeShiftUserModal() {
document.getElementById('shift-user-modal').style.display = 'none';
currentShiftData = null;
// Refresh the main shifts list one more time when closing the modal
// to ensure any changes are reflected
console.log('Refreshing shifts list on modal close');
loadAdminShifts();
}
// Email shift details to all volunteers
async function emailShiftDetails() {
if (!currentShiftData) {
showStatus('No shift selected', 'error');
return;
}
// Check if there are volunteers to email
const volunteers = currentShiftData.signups || [];
if (volunteers.length === 0) {
showStatus('No volunteers signed up for this shift', 'error');
return;
}
// Confirm action
const confirmMessage = `Send shift details email to ${volunteers.length} volunteer${volunteers.length !== 1 ? 's' : ''}?`;
if (!confirm(confirmMessage)) {
return;
}
// Initialize progress tracking for shift emails
initializeShiftEmailProgress(volunteers.length);
try {
const response = await fetch(`/api/shifts/admin/${currentShiftData.ID}/email-details`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (data.success) {
// Display detailed results
updateShiftEmailProgress(data.results);
showStatus(data.message, 'success');
console.log('Email results:', data.results);
} else {
showShiftEmailError(data.error || 'Failed to send emails');
if (data.details) {
console.error('Failed email details:', data.details);
}
}
} catch (error) {
console.error('Error sending shift details emails:', error);
showShiftEmailError('Failed to send emails - Network error');
}
}
// Initialize shift email progress display
function initializeShiftEmailProgress(totalCount) {
const progressContainer = document.getElementById('shift-email-progress-container');
const statusList = document.getElementById('shift-email-status-list');
const pendingCountEl = document.getElementById('shift-pending-count');
const successCountEl = document.getElementById('shift-success-count');
const errorCountEl = document.getElementById('shift-error-count');
const progressBar = document.getElementById('shift-email-progress-bar');
const progressText = document.getElementById('shift-progress-text');
const closeBtn = document.getElementById('close-shift-progress-btn');
// Show progress container
progressContainer.classList.add('show');
// Reset counters
pendingCountEl.textContent = totalCount;
successCountEl.textContent = '0';
errorCountEl.textContent = '0';
// Reset progress bar
progressBar.style.width = '0%';
progressBar.classList.remove('complete', 'error');
progressText.textContent = '0%';
// Clear status list
statusList.innerHTML = '';
// Hide close button initially
closeBtn.style.display = 'none';
// Add status items for each volunteer
const volunteers = currentShiftData.signups || [];
volunteers.forEach(volunteer => {
const statusItem = document.createElement('div');
statusItem.className = 'email-status-item';
statusItem.innerHTML = `
<div class="email-status-recipient">${volunteer['User Name'] || volunteer['User Email']}</div>
<div class="email-status-result pending">
<div class="progress-spinner"></div>
<span>Sending...</span>
</div>
`;
statusList.appendChild(statusItem);
});
}
// Update shift email progress with results
function updateShiftEmailProgress(results) {
const statusList = document.getElementById('shift-email-status-list');
const pendingCountEl = document.getElementById('shift-pending-count');
const successCountEl = document.getElementById('shift-success-count');
const errorCountEl = document.getElementById('shift-error-count');
const progressBar = document.getElementById('shift-email-progress-bar');
const progressText = document.getElementById('shift-progress-text');
const closeBtn = document.getElementById('close-shift-progress-btn');
const successful = results.successful || [];
const failed = results.failed || [];
const total = results.total || (successful.length + failed.length);
// Update counters
successCountEl.textContent = successful.length;
errorCountEl.textContent = failed.length;
pendingCountEl.textContent = '0';
// Update progress bar
const percentage = ((successful.length + failed.length) / total * 100).toFixed(1);
progressBar.style.width = percentage + '%';
progressText.textContent = percentage + '%';
if (failed.length > 0) {
progressBar.classList.add('error');
} else {
progressBar.classList.add('complete');
}
// Update individual status items
const statusItems = statusList.children;
// Update successful emails
successful.forEach(result => {
const statusItem = Array.from(statusItems).find(item =>
item.querySelector('.email-status-recipient').textContent.includes(result.email) ||
item.querySelector('.email-status-recipient').textContent.includes(result.name)
);
if (statusItem) {
statusItem.querySelector('.email-status-result').innerHTML = `
<span class="email-status-result success"> Sent</span>
`;
}
});
// Update failed emails
failed.forEach(result => {
const statusItem = Array.from(statusItems).find(item =>
item.querySelector('.email-status-recipient').textContent.includes(result.email) ||
item.querySelector('.email-status-recipient').textContent.includes(result.name)
);
if (statusItem) {
statusItem.querySelector('.email-status-result').innerHTML = `
<span class="email-status-result error" title="${result.error || 'Unknown error'}"> Failed</span>
`;
}
});
// Show close button
closeBtn.style.display = 'block';
closeBtn.onclick = () => {
document.getElementById('shift-email-progress-container').classList.remove('show');
};
}
// Show shift email error
function showShiftEmailError(message) {
const progressContainer = document.getElementById('shift-email-progress-container');
const progressBar = document.getElementById('shift-email-progress-bar');
const progressText = document.getElementById('shift-progress-text');
const closeBtn = document.getElementById('close-shift-progress-btn');
// Show progress container if not visible
progressContainer.classList.add('show');
// Update progress bar to show error
progressBar.style.width = '100%';
progressBar.classList.add('error');
progressText.textContent = 'Error';
// Show close button
closeBtn.style.display = 'block';
closeBtn.onclick = () => {
progressContainer.classList.remove('show');
};
showStatus(message, 'error');
}
// Setup modal event listeners when DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
const closeModalBtn = document.getElementById('close-user-modal');
const addUserBtn = document.getElementById('add-user-btn');
const emailShiftDetailsBtn = document.getElementById('email-shift-details-btn');
const modal = document.getElementById('shift-user-modal');
if (closeModalBtn) {
closeModalBtn.addEventListener('click', closeShiftUserModal);
}
if (addUserBtn) {
addUserBtn.addEventListener('click', addUserToShift);
}
if (emailShiftDetailsBtn) {
emailShiftDetailsBtn.addEventListener('click', emailShiftDetails);
}
// Close modal when clicking outside
if (modal) {
modal.addEventListener('click', function(e) {
if (e.target === modal) {
closeShiftUserModal();
}
});
}
});
// Setup email users modal event listeners when DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
// Email all users functionality
const closeEmailModalBtn = document.getElementById('close-email-modal');
const cancelEmailBtn = document.getElementById('cancel-email-btn');
const emailUsersForm = document.getElementById('email-users-form');
const emailModal = document.getElementById('email-users-modal');
if (closeEmailModalBtn) {
closeEmailModalBtn.addEventListener('click', closeEmailUsersModal);
}
if (cancelEmailBtn) {
cancelEmailBtn.addEventListener('click', closeEmailUsersModal);
}
if (emailUsersForm) {
emailUsersForm.addEventListener('submit', sendEmailToAllUsers);
}
// Close modal when clicking outside
if (emailModal) {
emailModal.addEventListener('click', function(e) {
if (e.target === emailModal) {
closeEmailUsersModal();
}
});
}
// Setup rich text editor functionality
setupRichTextEditor();
});

View File

@ -15,4 +15,11 @@ router.post('/admin', requireAdmin, shiftsController.create);
router.put('/admin/:id', requireAdmin, shiftsController.update);
router.delete('/admin/:id', requireAdmin, shiftsController.delete);
// Admin user management for shifts
router.post('/admin/:shiftId/add-user', requireAdmin, shiftsController.addUserToShift);
router.delete('/admin/:shiftId/remove-user/:userId', requireAdmin, shiftsController.removeUserFromShift);
// Admin email functionality
router.post('/admin/:shiftId/email-details', requireAdmin, shiftsController.emailShiftDetails);
module.exports = router;

View File

@ -1,6 +1,10 @@
const express = require('express');
const router = express.Router();
const usersController = require('../controllers/usersController');
const { requireAdmin } = require('../middleware/auth');
// All user routes require admin access
router.use(requireAdmin);
// Get all users
router.get('/', usersController.getAll);
@ -11,6 +15,9 @@ router.post('/', usersController.create);
// Send login details to user
router.post('/:id/send-login-details', usersController.sendLoginDetails);
// Email all users
router.post('/email-all', usersController.emailAllUsers);
// Delete user
router.delete('/:id', usersController.delete);

View File

@ -0,0 +1,156 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Shift Details - {{SHIFT_TITLE}}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.container {
background-color: #f9f9f9;
border-radius: 8px;
padding: 30px;
border: 1px solid #e0e0e0;
}
.header {
text-align: center;
margin-bottom: 30px;
}
.logo {
color: #a02c8d;
font-size: 24px;
font-weight: bold;
}
.content {
background-color: white;
padding: 20px;
border-radius: 6px;
margin-bottom: 20px;
}
.shift-details-box {
background-color: #f8f9fa;
padding: 20px;
border-radius: 4px;
margin: 20px 0;
border: 1px solid #ddd;
}
.detail-row {
margin: 12px 0;
display: flex;
align-items: flex-start;
}
.detail-icon {
width: 30px;
font-size: 16px;
margin-right: 10px;
text-align: center;
}
.detail-label {
font-weight: bold;
min-width: 80px;
margin-right: 10px;
}
.detail-value {
color: #2c3e50;
flex: 1;
}
.description-section {
margin-top: 20px;
padding-top: 15px;
border-top: 1px solid #eee;
}
.footer {
text-align: center;
font-size: 12px;
color: #666;
margin-top: 30px;
}
.highlight {
background-color: #fff3cd;
padding: 15px;
border-left: 4px solid #ffc107;
margin: 15px 0;
}
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
}
.status-open {
background-color: #d4edda;
color: #155724;
}
.status-full {
background-color: #fff3cd;
color: #856404;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">{{APP_NAME}}</div>
</div>
<div class="content">
<h2>Shift Details: {{SHIFT_TITLE}}</h2>
<p>Hello {{USER_NAME}},</p>
<p>Here are the details for your upcoming volunteer shift:</p>
<div class="shift-details-box">
<div class="detail-row">
<span class="detail-icon">📅</span>
<span class="detail-label">Date:</span>
<span class="detail-value">{{SHIFT_DATE}}</span>
</div>
<div class="detail-row">
<span class="detail-icon"></span>
<span class="detail-label">Time:</span>
<span class="detail-value">{{SHIFT_START_TIME}} - {{SHIFT_END_TIME}}</span>
</div>
<div class="detail-row">
<span class="detail-icon">📍</span>
<span class="detail-label">Location:</span>
<span class="detail-value">{{SHIFT_LOCATION}}</span>
</div>
<div class="detail-row">
<span class="detail-icon">👥</span>
<span class="detail-label">Volunteers:</span>
<span class="detail-value">{{CURRENT_VOLUNTEERS}}/{{MAX_VOLUNTEERS}} signed up</span>
</div>
<div class="detail-row">
<span class="detail-icon">📊</span>
<span class="detail-label">Status:</span>
<span class="detail-value">
<span class="status-badge status-{{SHIFT_STATUS_CLASS}}">{{SHIFT_STATUS}}</span>
</span>
</div>
</div>
<div class="description-section" id="description-section"{{DESCRIPTION_DISPLAY_STYLE}}>
<h3>Additional Information:</h3>
<p>{{SHIFT_DESCRIPTION}}</p>
</div>
<div class="highlight">
<p><strong>Important:</strong> Please arrive 10-15 minutes early and wear weather appropriate wear. If you need to cancel, please do so as early as possible to allow other volunteers to sign up.</p>
</div>
<p>Questions? Contact your shift coordinator or administrator.</p>
<p>Thank you for volunteering!</p>
</div>
<div class="footer">
<p>This email was sent from {{APP_NAME}} at {{TIMESTAMP}}</p>
<p>Volunteer shift management system</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,30 @@
Shift Details - {{SHIFT_TITLE}}
Hello {{USER_NAME}},
Here are the details for your upcoming volunteer shift:
SHIFT INFORMATION:
==================
Title: {{SHIFT_TITLE}}
Date: {{SHIFT_DATE}}
Time: {{SHIFT_START_TIME}} - {{SHIFT_END_TIME}}
Location: {{SHIFT_LOCATION}}
Volunteers: {{CURRENT_VOLUNTEERS}}/{{MAX_VOLUNTEERS}} signed up
Status: {{SHIFT_STATUS}}
{{SHIFT_DESCRIPTION_SECTION}}
IMPORTANT NOTES:
===============
- Please arrive 10-15 minutes early
- Bring photo ID
- If you need to cancel, please do so as early as possible to allow other volunteers to sign up
Questions? Contact your shift coordinator or administrator.
Thank you for volunteering!
---
This email was sent from {{APP_NAME}} at {{TIMESTAMP}}
Volunteer shift management system

View File

@ -0,0 +1,109 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{{EMAIL_SUBJECT}}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.container {
background-color: #f9f9f9;
border-radius: 8px;
padding: 30px;
border: 1px solid #e0e0e0;
}
.header {
text-align: center;
margin-bottom: 30px;
}
.logo {
color: #a02c8d;
font-size: 24px;
font-weight: bold;
}
.content {
background-color: white;
padding: 20px;
border-radius: 6px;
margin-bottom: 20px;
}
.message-content {
line-height: 1.6;
}
.message-content h1,
.message-content h2,
.message-content h3 {
color: #2c3e50;
margin-top: 25px;
margin-bottom: 15px;
}
.message-content h1 {
font-size: 24px;
border-bottom: 2px solid #e9ecef;
padding-bottom: 10px;
}
.message-content h2 {
font-size: 20px;
}
.message-content h3 {
font-size: 18px;
}
.message-content ul,
.message-content ol {
margin: 15px 0;
padding-left: 25px;
}
.message-content li {
margin: 8px 0;
}
.message-content a {
color: #a02c8d;
text-decoration: none;
}
.message-content a:hover {
text-decoration: underline;
}
.message-content strong {
font-weight: 600;
color: #2c3e50;
}
.message-content blockquote {
border-left: 4px solid #a02c8d;
margin: 20px 0;
padding: 10px 20px;
background-color: #f8f9fa;
font-style: italic;
}
.footer {
text-align: center;
font-size: 12px;
color: #666;
margin-top: 30px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">{{APP_NAME}}</div>
</div>
<div class="content">
<p>Hello {{USER_NAME}},</p>
<div class="message-content">
{{EMAIL_CONTENT}}
</div>
</div>
<div class="footer">
<p>This email was sent from {{APP_NAME}} at {{TIMESTAMP}}</p>
<p>{{SENDER_NAME}} - System Administrator</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,9 @@
{{EMAIL_SUBJECT}}
Hello {{USER_NAME}},
{{EMAIL_CONTENT_TEXT}}
---
This email was sent from {{APP_NAME}} at {{TIMESTAMP}}
{{SENDER_NAME}} - System Administrator

View File

@ -70,7 +70,7 @@ Controller for application settings, start location, and walk sheet config.
# app/controllers/shiftsController.js
Controller for handling shift management, signup/cancellation, and admin operations on volunteer shifts.
Controller for handling shift management, signup/cancellation, and admin operations on volunteer shifts. Includes comprehensive email functionality for sending shift details to all volunteers with professional HTML templates, user management within shifts, and calendar integration features.
# app/controllers/dataConvertController.js
@ -82,7 +82,7 @@ Controller for aggregating and calculating dashboard statistics from locations a
# app/controllers/usersController.js
Controller for user management (list, create, delete users, send login details via email).
Controller for user management (list, create, delete users, send login details via email). Features rich HTML email broadcasting to all users with live preview, temporary user support with expiration tracking, and comprehensive user role management.
# app/controllers/cutsController.js
@ -148,6 +148,22 @@ Plain text email template for sending login credentials to users. Contains email
HTML email template for sending login credentials to users. Features responsive design with styled credentials display and login button for better user experience.
# app/templates/email/shift-details.html
Professional HTML email template for sending shift details to volunteers. Features responsive design with structured shift information display, status badges, important notes section, and branded layout for enhanced communication.
# app/templates/email/shift-details.txt
Plain text email template for shift detail notifications sent to volunteers. Contains comprehensive shift information including date, time, location, volunteer count, status, and important instructions with professional formatting.
# app/templates/email/user-broadcast.html
HTML email template for admin broadcasting messages to all users. Features rich text content support, responsive design, professional styling with typography, lists, links, and blockquotes for effective mass communication.
# app/templates/email/user-broadcast.txt
Plain text email template for admin broadcast messages to all users. Provides clean, accessible format for mass communication with proper formatting and sender information.
# app/utils/helpers.js
Utility functions for geographic data, validation, and helpers used across the backend.
@ -158,7 +174,7 @@ Winston logger configuration for backend logging.
# app/public/admin.html
Admin panel HTML page for managing start location, walk sheet, shift management, and user management.
Admin panel HTML page for managing start location, walk sheet, shift management, user management, and email broadcasting. Features rich text editor with live preview for composing broadcast emails, shift volunteer management modals, and comprehensive admin interface with user role controls.
# app/public/css/admin.css
@ -286,7 +302,7 @@ CSS styles for the user profile page and user management components in the admin
# app/public/js/admin.js
JavaScript for admin panel functionality (map, start location, walk sheet, shift management, and user management).
JavaScript for admin panel functionality (map, start location, walk sheet, shift management, user management, and email broadcasting). Includes rich text editor for composing emails with live preview, shift volunteer management with modal interface, mass email broadcasting to all users, shift detail emailing to volunteers, and comprehensive admin controls.
# app/public/js/dashboard.js
@ -334,7 +350,7 @@ Backup or legacy version of the main map JavaScript logic.
# app/public/js/shifts.js
JavaScript for volunteer shift signup, management, and UI logic with both grid and calendar view functionality. Features include view toggling, calendar navigation, shift color-coding, and interactive shift popups. Updated to use shift titles directly from signup records.
JavaScript for volunteer shift signup, management, and UI logic with both grid and calendar view functionality. Features include view toggling, calendar navigation, shift color-coding, interactive shift popups, calendar integration (Google, Outlook, Apple), and enhanced user experience with dropdown actions. Updated to use shift titles directly from signup records with improved event handling.
# app/public/js/ui-controls.js
@ -362,7 +378,7 @@ JavaScript for the admin cut management interface. Provides complete CRUD functi
# app/public/js/auth.js
JavaScript for authentication logic and user session management.
JavaScript for authentication logic and user session management. Includes temporary user restrictions, role-based UI visibility controls, and dynamic user interface updates based on user type and admin status.
# app/public/js/cache-manager.js