A couple more fast email buttons
This commit is contained in:
parent
b5cf9b3f8d
commit
35a6d55ffe
2
.gitignore
vendored
2
.gitignore
vendored
@ -10,3 +10,5 @@
|
||||
/configs/cloudflare/*.json
|
||||
/configs/cloudflare/*.yaml
|
||||
/configs/cloudflare/*.yml
|
||||
|
||||
.excalidraw
|
||||
181
map/README.md
181
map/README.md
@ -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
|
||||
|
||||
@ -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();
|
||||
@ -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();
|
||||
@ -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 -->
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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, "'")}'>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(/'/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();
|
||||
});
|
||||
|
||||
@ -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;
|
||||
@ -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);
|
||||
|
||||
|
||||
156
map/app/templates/email/shift-details.html
Normal file
156
map/app/templates/email/shift-details.html
Normal 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>
|
||||
30
map/app/templates/email/shift-details.txt
Normal file
30
map/app/templates/email/shift-details.txt
Normal 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
|
||||
109
map/app/templates/email/user-broadcast.html
Normal file
109
map/app/templates/email/user-broadcast.html
Normal 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>
|
||||
9
map/app/templates/email/user-broadcast.txt
Normal file
9
map/app/templates/email/user-broadcast.txt
Normal 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
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user