Security hardening: env-based config, CSRF protection, secure code generation, plus presenter management and email communications system

- Move all credentials from hardcoded to .env with python-dotenv (add .env.example template and .gitignore)
- Add CSRF token generation and validation on all state-changing requests
- Replace random.choices with secrets.choice for all secure code generation
- Add session cookie security headers (HttpOnly, Secure, SameSite)
- Add presenter management: assign/remove presenters to breakout sessions, presenter QR scanning
- Add email communications system: templates per event, custom email sending, sent email tracking
- Add staff/organizer profile pages with staff code display
- New DB tables: event_email_templates, sent_emails, user_communications
- New DB columns: staff.reminder_count/reminder_dates, breakout_session_organizers.presenter_code
- Expand English translation strings across all new features

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 20:34:48 +00:00
parent 934848b7f5
commit a3546b4c01
45 changed files with 8296 additions and 3207 deletions
+307 -312
View File
@@ -3,170 +3,12 @@
{% block title %}{{ event.name }} - NetEvents{% endblock %}
{% block content %}
<div style="padding: 20px;">
<style>
.section-box {
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.section-box h2 {
margin-top: 0;
margin-bottom: 15px;
color: #2c3e50;
}
.section-actions {
margin-bottom: 15px;
}
.staff-table,
.sessions-table,
.attendees-table {
width: 100%;
}
.form-row {
display: flex;
gap: 15px;
margin-bottom: 15px;
}
.form-group {
flex: 1;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
}
th.sortable {
cursor: pointer;
user-select: none;
}
th.sortable:hover {
background: #f0f0f0;
}
.sort-icon::before {
content: '\2195';
margin-left: 5px;
opacity: 0.4;
}
th.sort-asc .sort-icon::before {
content: '\2191';
opacity: 1;
}
th.sort-desc .sort-icon::before {
content: '\2193';
opacity: 1;
}
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal-content {
background: #fff;
border-radius: 8px;
padding: 24px;
max-width: 400px;
width: 90%;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.modal-header h2 {
margin-top: 0;
color: #dc3545;
}
.modal-actions {
margin-top: 20px;
display: flex;
gap: 10px;
justify-content: flex-end;
}
/* Wide screen - spread content */
@media (min-width: 1400px) {
.event-detail {
max-width: 100%;
}
.event-detail .section-box {
padding: 30px 40px;
}
.event-meta-box {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 15px;
}
.staff-section .section-box,
.breakout-sessions-section .section-box,
.attendees-section .section-box {
padding: 25px 30px;
}
}
/* Responsive styles */
@media (max-width: 768px) {
.event-header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.event-meta-box p {
margin: 8px 0;
}
.form-row {
flex-direction: column;
gap: 10px;
}
.staff-table,
.sessions-table,
.attendees-table {
display: block;
overflow-x: auto;
white-space: nowrap;
width: 100%;
}
.section-box {
padding: 15px;
}
.modal-content {
padding: 16px;
margin: 10px;
}
.btn {
padding: 8px 12px;
font-size: 14px;
}
}
</style>
<div class="event-detail">
<div class="event-header">
<h1>{{ event.name }}</h1>
<div class="event-actions">
<a href="{{ url_for('edit_event', event_id=event.id) }}" class="btn btn-outline">{{ 'edit_event'|t }}</a>
<a href="{{ url_for('event_communications', event_id=event.id) }}" class="btn btn-outline"><i data-lucide="mail"></i> {{ 'communications'|t }}</a>
<a href="{{ url_for('edit_event', event_id=event.id) }}" class="btn btn-outline"><i data-lucide="pencil"></i> {{ 'edit_event'|t }}</a>
</div>
</div>
@@ -185,14 +27,14 @@ th.sort-desc .sort-icon::before {
<p><strong>{{ 'location'|t }}:</strong> {{ event.location }}</p>
<p><strong>{{ 'max_attendees'|t }}:</strong> {{ event.max_attendees or ('unlimited'|t) }}</p>
<p><strong>{{ 'registered_attendees'|t }}:</strong> <span id="attendee-count">{{ attendees|length }}</span></p>
<p><strong>{{ 'registration_link'|t }}:</strong> <span id="reg-link">{{ url_for('register_event', code=event.code, _external=True) }}</span> <button type="button" style="padding: 2px 6px; font-size: 11px;" onclick="navigator.clipboard.writeText('{{ url_for('register_event', code=event.code, _external=True) }}').then(() => this.textContent = 'Copied!')">Copy</button></p>
<p><strong>{{ 'registration_link'|t }}:</strong> <span id="reg-link">{{ url_for('register_event', code=event.code, _external=True) }}</span> <button type="button" class="btn btn-sm btn-ghost" onclick="copyLink('{{ url_for('register_event', code=event.code, _external=True) }}')"><i data-lucide="copy"></i></button></p>
</div>
<section class="attendee-types-section">
<div class="section-box">
<h2>{{ 'attendee_types'|t }}</h2>
<div class="section-actions">
<a href="{{ url_for('manage_attendee_types', event_id=event.id) }}" class="btn btn-primary">{{ 'manage_types'|t }}</a>
<a href="{{ url_for('manage_attendee_types', event_id=event.id) }}" class="btn btn-primary"><i data-lucide="tag"></i> {{ 'manage_types'|t }}</a>
</div>
{% if attendee_types %}
@@ -210,18 +52,18 @@ th.sort-desc .sort-icon::before {
<td><strong>{{ at.name }}</strong></td>
<td>
{% if at.price and at.price > 0 %}
<span style="color: #27ae60; font-weight: 500;">{{ at.price|format_currency }}</span>
<span class="text-success font-medium">{{ at.price|format_currency }}</span>
{% else %}
<span style="color: #7f8c8d;">{{ 'free'|t }}</span>
<span class="text-muted">{{ 'free'|t }}</span>
{% endif %}
</td>
<td>
<code style="background: #f0f0f0; padding: 4px 8px; border-radius: 4px; font-size: 12px;">
<code class="code">
{{ url_for('register_event', code=event.code, type_code=at.code, _external=True) }}
</code>
<button type="button" class="btn btn-sm" style="padding: 2px 8px; margin-left: 8px;"
<button type="button" class="btn btn-sm btn-ghost"
onclick="copyLink('{{ url_for('register_event', code=event.code, type_code=at.code, _external=True) }}')">
{{ 'copy'|t }}
<i data-lucide="copy"></i>
</button>
</td>
</tr>
@@ -229,7 +71,7 @@ th.sort-desc .sort-icon::before {
</tbody>
</table>
{% else %}
<p class="no-types" style="color: #888;">{{ 'no_attendee_types_defined'|t }} <a href="{{ url_for('manage_attendee_types', event_id=event.id) }}">{{ 'create_first_type'|t }}</a></p>
<p class="no-types">{{ 'no_attendee_types_defined'|t }} <a href="{{ url_for('manage_attendee_types', event_id=event.id) }}">{{ 'create_first_type'|t }}</a></p>
{% endif %}
</div>
</section>
@@ -238,10 +80,10 @@ th.sort-desc .sort-icon::before {
<div class="section-box">
<h2>{{ 'staff'|t }}</h2>
<div class="section-actions">
<button type="button" class="btn btn-primary" id="add-staff-btn">{{ 'add_staff'|t }}</button>
<button type="button" class="btn btn-primary" id="add-staff-btn"><i data-lucide="user-plus"></i> {{ 'add_staff'|t }}</button>
</div>
<div id="add-staff-form" style="display: none; margin-bottom: 20px; padding: 15px; background: #f8f9fa; border-radius: 5px;">
<div id="add-staff-form" class="add-staff-form d-none">
<form method="POST" action="{{ url_for('manage_event_staff', event_id=event.id) }}" id="staff-inline-form">
<div class="form-row">
<div class="form-group">
@@ -261,9 +103,9 @@ th.sort-desc .sort-icon::before {
<button type="button" class="btn btn-outline" id="cancel-staff-btn">{{ 'cancel'|t }}</button>
</form>
{% if other_events %}
<form method="POST" action="{{ url_for('batch_add_staff', event_id=event.id) }}" style="margin-top: 15px; padding-top: 15px; border-top: 1px solid #ddd;">
<div class="form-row" style="align-items: flex-end;">
<div class="form-group" style="flex: 1;">
<form method="POST" action="{{ url_for('batch_add_staff', event_id=event.id) }}" class="border-top pt-4 mt-4">
<div class="form-row align-items-end">
<div class="form-group">
<label for="batch_source_event">Or add staff from another event:</label>
<select name="source_event_id" id="batch_source_event" required>
<option value="">Select event...</option>
@@ -282,16 +124,18 @@ th.sort-desc .sort-icon::before {
<table class="staff-table" id="event-staff-table">
<thead>
<tr>
<th data-sort="name" class="sortable">{{ 'name'|t }} <span class="sort-icon"></span></th>
<th data-sort="first_name" class="sortable">{{ 'first_name'|t }} <span class="sort-icon"></span></th>
<th data-sort="last_name" class="sortable">{{ 'last_name'|t }} <span class="sort-icon"></span></th>
<th data-sort="email" class="sortable">{{ 'email'|t }} <span class="sort-icon"></span></th>
<th data-sort="status" class="sortable">{{ 'status'|t }} <span class="sort-icon"></span></th>
<th data-sort="invite_token" class="sortable">{{ 'status'|t }} <span class="sort-icon"></span></th>
<th>{{ 'actions'|t }}</th>
</tr>
</thead>
<tbody id="event-staff-tbody">
{% for member in staff %}
<tr data-name="{{ member.first_name }} {{ member.last_name }}" data-email="{{ member.email }}" data-status="{{ member.invite_token }}">
<td>{{ member.first_name }} {{ member.last_name }}</td>
<tr data-first-name="{{ member.first_name }}" data-last-name="{{ member.last_name }}" data-email="{{ member.email }}" data-status="{{ member.invite_token }}">
<td>{{ member.first_name }}</td>
<td>{{ member.last_name }}</td>
<td>{{ member.email }}</td>
<td>
{% if member.invite_token %}
@@ -301,29 +145,45 @@ th.sort-desc .sort-icon::before {
{% endif %}
</td>
<td>
<a href="{{ url_for('edit_staff', event_id=event.id, staff_id=member.id) }}" class="btn btn-sm btn-success">{{ 'view'|t }}</a>
<button type="button" class="btn btn-sm btn-danger" onclick="showDeleteModal({{ member.id }}, '{{ member.first_name }} {{ member.last_name }}', '{{ member.email }}')">{{ 'remove'|t }}</button>
<a href="{{ url_for('edit_staff', event_id=event.id, staff_id=member.id) }}" class="btn btn-sm btn-success"><i data-lucide="eye"></i></a>
<button type="button" class="btn btn-sm btn-danger" onclick="showDeleteModal({{ member.id }}, '{{ member.first_name }} {{ member.last_name }}', '{{ member.email }}')"><i data-lucide="trash-2"></i></button>
{% if member.invite_token %}
<button type="button" class="btn btn-sm btn-outline remind-btn" data-staff-id="{{ member.id }}" data-reminder-dates="{{ member.reminder_dates|tojson }}" title="{{ 'send_reminder'|t }}">
<i data-lucide="bell"></i> <span class="reminder-count">{{ member.reminder_count|default(0) }}</span>
</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if total_staff_pages > 1 %}
<div class="pagination mt-4" id="staff-pagination">
<button type="button" class="btn btn-sm btn-outline" onclick="loadStaffPage({{ staff_page - 1 }})" {% if staff_page <= 1 %}disabled{% endif %}>
<i data-lucide="chevron-left"></i>
</button>
<span class="pagination-info">{{ 'page'|t }} <span id="staff-current-page">{{ staff_page }}</span> {{ 'of'|t }} {{ total_staff_pages }}</span>
<button type="button" class="btn btn-sm btn-outline" onclick="loadStaffPage({{ staff_page + 1 }})" {% if staff_page >= total_staff_pages %}disabled{% endif %}>
<i data-lucide="chevron-right"></i>
</button>
</div>
{% endif %}
{% else %}
<p class="no-staff">{{ 'no_staff_yet'|t }}</p>
{% endif %}
</div>
</section>
<div id="staffDeleteModal" class="modal-overlay" style="display: none;">
<div id="staffDeleteModal" class="modal-overlay">
<div class="modal-content">
<div class="modal-header">
<h2>{{ 'remove_staff_member'|t }}</h2>
<p>{{ 'confirm_remove_staff'|t }} <strong id="staffName"></strong> {{ 'from_event'|t }}?</p>
<p style="color: #666; font-size: 14px;">{{ 'email'|t }}: <span id="staffEmail"></span></p>
<p class="text-muted text-sm">{{ 'email'|t }}: <span id="staffEmail"></span></p>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-outline" onclick="closeStaffDeleteModal()">{{ 'cancel'|t }}</button>
<form id="staffDeleteForm" method="POST" style="display: inline;">
<form id="staffDeleteForm" method="POST" class="d-inline">
<button type="submit" class="btn btn-danger">{{ 'remove'|t }}</button>
</form>
</div>
@@ -334,7 +194,7 @@ th.sort-desc .sort-icon::before {
<div class="section-box">
<h2>{{ 'breakout_sessions'|t }}</h2>
<div class="section-actions">
<a href="{{ url_for('create_breakout_session', event_id=event.id) }}" class="btn btn-primary">{{ 'add_breakout_session'|t }}</a>
<a href="{{ url_for('create_breakout_session', event_id=event.id) }}" class="btn btn-primary"><i data-lucide="plus"></i> {{ 'add_breakout_session'|t }}</a>
</div>
{% if breakout_sessions %}
@@ -358,7 +218,7 @@ th.sort-desc .sort-icon::before {
<td>{{ session.max_attendees or ('unlimited'|t) }}</td>
<td>{{ session.registered_count|default(0) }}</td>
<td>
<a href="{{ url_for('view_breakout_session', code=session.code) }}" class="btn btn-sm btn-success">{{ 'view'|t }}</a>
<a href="{{ url_for('view_breakout_session', code=session.code) }}" class="btn btn-sm btn-success"><i data-lucide="eye"></i></a>
</td>
</tr>
{% endfor %}
@@ -374,75 +234,88 @@ th.sort-desc .sort-icon::before {
<div class="section-box">
<h2>{{ 'registered_attendees'|t }} ({{ attendees|length }})</h2>
<div class="section-actions">
<a href="{{ url_for('register_attendee', code=event.code) }}" class="btn btn-primary">{{ 'add_attendee'|t }}</a>
<a href="{{ url_for('event_badges', event_id=event.id) }}" class="btn btn-secondary">🖨️ {{ 'print_badges'|t }}</a>
<a href="{{ url_for('download_rectangular_badges', event_id=event.id) }}" class="btn btn-secondary">🏷️ Rectangular Labels (80x50mm)</a>
<a href="{{ url_for('download_attendees_excel', event_id=event.id) }}" class="btn btn-secondary">📥 {{ 'download_excel'|t }}</a>
<a href="{{ url_for('register_attendee', code=event.code) }}" class="btn btn-primary"><i data-lucide="user-plus"></i> {{ 'add_attendee'|t }}</a>
<a href="{{ url_for('event_badges', event_id=event.id) }}" class="btn btn-secondary"><i data-lucide="printer"></i> {{ 'print_badges'|t }}</a>
<a href="{{ url_for('download_rectangular_badges', event_id=event.id) }}" class="btn btn-secondary"><i data-lucide="tag"></i> Rectangular Labels</a>
<a href="{{ url_for('download_attendees_excel', event_id=event.id) }}" class="btn btn-secondary"><i data-lucide="download"></i> {{ 'download_excel'|t }}</a>
</div>
{% if attendees %}
<form method="POST" action="{{ url_for('batch_assign_attendee_type', event_id=event.id) }}" id="batch-assign-form">
<div style="margin-bottom: 15px; padding: 10px; background: #f8f9fa; border-radius: 5px; display: flex; gap: 10px; align-items: center;">
<input type="checkbox" id="select-all-attendees" style="width: auto;">
<label for="select-all-attendees" style="margin: 0; font-weight: 500;">{{ 'select_all'|t }}</label>
<span style="margin-left: 20px;">{{ 'assign_type'|t }}:</span>
<select name="attendee_type_id" id="batch-type-select" style="padding: 5px 10px;">
<div class="batch-select-bar">
<input type="checkbox" id="select-all-attendees">
<label for="select-all-attendees" class="m-0 font-semibold">{{ 'select_all'|t }}</label>
<span class="ml-4">{{ 'assign_type'|t }}:</span>
<select name="attendee_type_id" id="batch-type-select" class="batch-type-select">
<option value="">{{ 'no_type'|t }}</option>
{% for at in attendee_types %}
<option value="{{ at.id }}">{{ at.name }} {% if at.price and at.price > 0 %}({{ at.price|format_currency }}){% endif %}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-sm btn-primary">{{ 'apply'|t }}</button>
<span id="selected-count" style="color: #888; font-size: 12px;"></span>
<span id="selected-count" class="text-muted text-sm"></span>
</div>
</form>
<table class="attendees-table" id="attendees-table">
<thead>
<tr>
<th style="width: 40px;"></th>
<th data-sort="name" class="sortable">{{ 'name'|t }} <span class="sort-icon"></span></th>
<th class="checkbox-col"></th>
<th data-sort="first_name" class="sortable">{{ 'first_name'|t }} <span class="sort-icon"></span></th>
<th data-sort="last_name" class="sortable">{{ 'last_name'|t }} <span class="sort-icon"></span></th>
<th data-sort="organisation" class="sortable">{{ 'organisation'|t }} <span class="sort-icon"></span></th>
<th data-sort="role" class="sortable">{{ 'role'|t }} <span class="sort-icon"></span></th>
<th data-sort="type" class="sortable">{{ 'type'|t }} <span class="sort-icon"></span></th>
<th data-sort="status" class="sortable">{{ 'status'|t }} <span class="sort-icon"></span></th>
<th data-sort="attendee_type_id" class="sortable">{{ 'type'|t }} <span class="sort-icon"></span></th>
<th data-sort="checked_in" class="sortable">{{ 'status'|t }} <span class="sort-icon"></span></th>
<th>{{ 'actions'|t }}</th>
</tr>
</thead>
<tbody id="attendees-tbody">
{% for attendee in attendees %}
<tr data-name="{{ attendee.first_name }} {{ attendee.last_name }}" data-organisation="{{ attendee.organisation or '' }}" data-role="{{ attendee.role or '' }}" data-status="{{ attendee.checked_in }}" data-type="{{ attendee.attendee_type_name or '' }}">
<tr data-first-name="{{ attendee.first_name }}" data-last-name="{{ attendee.last_name }}" data-organisation="{{ attendee.organisation or '' }}" data-role="{{ attendee.role or '' }}" data-status="{{ attendee.checked_in }}" data-type="{{ attendee.attendee_type_name or '' }}">
<td>
<input type="checkbox" name="attendee_ids" value="{{ attendee.id }}" class="attendee-checkbox" form="batch-assign-form">
</td>
<td>{{ attendee.first_name }} {{ attendee.last_name }}</td>
<td>{{ attendee.first_name }}</td>
<td>{{ attendee.last_name }}</td>
<td>{{ attendee.organisation if attendee.organisation else '-' }}</td>
<td>{{ attendee.role if attendee.role else '-' }}</td>
<td>
{% if attendee.attendee_type_name %}
<span class="badge" style="background: #3498db; color: white;">{{ attendee.attendee_type_name }}</span>
<span class="badge badge-info">{{ attendee.attendee_type_name }}</span>
{% else %}
<span style="color: #aaa;">-</span>
<span class="text-light">-</span>
{% endif %}
</td>
<td>
{% if attendee.checked_in %}
<span class="badge badge-success">{{ 'checked_in'|t }}</span>
<span class="badge badge-success"><i data-lucide="check"></i> {{ 'checked_in'|t }}</span>
{% else %}
<span class="badge badge-pending">{{ 'not_checked_in'|t }}</span>
{% endif %}
</td>
<td>
{% if not attendee.checked_in %}
<button class="btn btn-sm btn-success checkin-btn" data-attendee-id="{{ attendee.id }}">{{ 'check_in'|t }}</button>
<button class="btn btn-sm btn-success checkin-btn" data-attendee-id="{{ attendee.id }}"><i data-lucide="user-check"></i></button>
{% else %}
<span class="text-muted">{{ 'checked_in'|t }}</span>
<span class="text-muted"><i data-lucide="check"></i></span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if total_pages > 1 %}
<div class="pagination mt-4" id="attendees-pagination">
<button type="button" class="btn btn-sm btn-outline" onclick="loadAttendeesPage({{ page - 1 }})" {% if page <= 1 %}disabled{% endif %}>
<i data-lucide="chevron-left"></i>
</button>
<span class="pagination-info">{{ 'page'|t }} <span id="attendees-current-page">{{ page }}</span> {{ 'of'|t }} {{ total_pages }}</span>
<button type="button" class="btn btn-sm btn-outline" onclick="loadAttendeesPage({{ page + 1 }})" {% if page >= total_pages %}disabled{% endif %}>
<i data-lucide="chevron-right"></i>
</button>
</div>
{% endif %}
{% else %}
<p class="no-attendees">{{ 'no_attendees_yet'|t }}</p>
{% endif %}
@@ -451,14 +324,18 @@ th.sort-desc .sort-icon::before {
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
document.getElementById('add-staff-btn').addEventListener('click', function() {
document.getElementById('add-staff-form').style.display = 'block';
this.style.display = 'none';
document.getElementById('add-staff-form').classList.add('shown');
this.classList.add('d-none');
});
document.getElementById('cancel-staff-btn').addEventListener('click', function() {
document.getElementById('add-staff-form').style.display = 'none';
document.getElementById('add-staff-btn').style.display = 'inline-block';
document.getElementById('add-staff-form').classList.remove('shown');
document.getElementById('add-staff-btn').classList.remove('d-none');
});
document.getElementById('staff-inline-form').addEventListener('submit', function(e) {
@@ -476,13 +353,10 @@ document.getElementById('staff-inline-form').addEventListener('submit', function
location.reload();
}
}).catch(error => {
alert('{{ "error_adding_staff"|t }}');
showToast('{{ "error_adding_staff"|t }}', 'error');
});
});
</script>
</div>
<script>
document.querySelectorAll('.checkin-btn').forEach(btn => {
btn.addEventListener('click', async function() {
const attendeeId = this.dataset.attendeeId;
@@ -494,89 +368,51 @@ document.querySelectorAll('.checkin-btn').forEach(btn => {
});
if (response.ok) {
// Update status cell (6th td) and remove checkin button
const row = this.closest('tr');
row.querySelector('td:nth-child(6)').innerHTML = '<span class="badge badge-success">{{ "checked_in"|t }}</span>';
row.querySelector('td:nth-child(6)').innerHTML = '<span class="badge badge-success"><i data-lucide="check"></i> {{ "checked_in"|t }}</span>';
this.remove();
document.getElementById('attendee-count').textContent = parseInt(document.getElementById('attendee-count').textContent) + 1;
lucide.createIcons();
}
} catch (error) {
alert('{{ "error_checking_in"|t }}');
showToast('{{ "error_checking_in"|t }}', 'error');
}
});
});
// Attendee table sorting
const sortableHeaders = document.querySelectorAll('#attendees-table th.sortable');
let currentSort = { column: null, direction: 'asc' };
// Attendee table sorting - AJAX-based with backend sorting
let currentAttendeeSort = { column: 'first_name', direction: 'asc' };
const sortableHeaders = document.querySelectorAll('#attendees-table th.sortable');
sortableHeaders.forEach(th => {
th.addEventListener('click', () => {
const column = th.dataset.sort;
const direction = currentSort.column === column && currentSort.direction === 'asc' ? 'desc' : 'asc';
currentSort = { column, direction };
if (!column) return;
const direction = currentAttendeeSort.column === column && currentAttendeeSort.direction === 'asc' ? 'desc' : 'asc';
currentAttendeeSort = { column, direction };
sortableHeaders.forEach(h => h.classList.remove('sort-asc', 'sort-desc'));
th.classList.add(direction === 'asc' ? 'sort-asc' : 'sort-desc');
const tbody = document.getElementById('attendees-tbody');
const rows = Array.from(tbody.querySelectorAll('tr'));
rows.sort((a, b) => {
let valA = a.dataset[column] || '';
let valB = b.dataset[column] || '';
if (column === 'status') {
valA = valA === 'True' ? 1 : 0;
valB = valB === 'True' ? 1 : 0;
} else {
valA = valA.toLowerCase();
valB = valB.toLowerCase();
}
if (valA < valB) return direction === 'asc' ? -1 : 1;
if (valA > valB) return direction === 'asc' ? 1 : -1;
return 0;
});
rows.forEach(row => tbody.appendChild(row));
loadAttendeesPage(1);
});
});
// Staff table sorting
const staffSortableHeaders = document.querySelectorAll('#event-staff-table th.sortable');
let staffCurrentSort = { column: null, direction: 'asc' };
// Staff table sorting - AJAX-based with backend sorting
let currentStaffSort = { column: 'first_name', direction: 'asc' };
const staffSortableHeaders = document.querySelectorAll('#event-staff-table th.sortable');
staffSortableHeaders.forEach(th => {
th.addEventListener('click', () => {
const column = th.dataset.sort;
const direction = staffCurrentSort.column === column && staffCurrentSort.direction === 'asc' ? 'desc' : 'asc';
staffCurrentSort = { column, direction };
if (!column) return;
const direction = currentStaffSort.column === column && currentStaffSort.direction === 'asc' ? 'desc' : 'asc';
currentStaffSort = { column, direction };
staffSortableHeaders.forEach(h => h.classList.remove('sort-asc', 'sort-desc'));
th.classList.add(direction === 'asc' ? 'sort-asc' : 'sort-desc');
const tbody = document.getElementById('event-staff-tbody');
const rows = Array.from(tbody.querySelectorAll('tr'));
rows.sort((a, b) => {
let valA = a.dataset[column] || '';
let valB = b.dataset[column] || '';
if (column === 'status') {
valA = valA === '' ? 1 : 0;
valB = valB === '' ? 1 : 0;
} else {
valA = valA.toLowerCase();
valB = valB.toLowerCase();
}
if (valA < valB) return direction === 'asc' ? -1 : 1;
if (valA > valB) return direction === 'asc' ? 1 : -1;
return 0;
});
rows.forEach(row => tbody.appendChild(row));
loadStaffPage(1);
});
});
@@ -584,13 +420,46 @@ function showDeleteModal(staffId, staffName, staffEmail) {
document.getElementById('staffName').textContent = staffName;
document.getElementById('staffEmail').textContent = staffEmail;
document.getElementById('staffDeleteForm').action = '{{ url_for("delete_staff", event_id=event.id, staff_id=0) }}'.replace('0', staffId);
document.getElementById('staffDeleteModal').style.display = 'flex';
document.getElementById('staffDeleteModal').classList.add('active');
}
function closeStaffDeleteModal() {
document.getElementById('staffDeleteModal').style.display = 'none';
document.getElementById('staffDeleteModal').classList.remove('active');
}
document.getElementById('staffDeleteModal').addEventListener('click', function(e) {
if (e.target === this) {
closeStaffDeleteModal();
}
});
// Reminder button handler
document.querySelectorAll('.remind-btn').forEach(btn => {
btn.addEventListener('click', async function() {
const staffId = this.dataset.staffId;
const countSpan = this.querySelector('.reminder-count');
try {
const response = await fetch(`{{ url_for('send_staff_reminder', event_id=event.id, staff_id=0) }}`.replace('0', staffId), {
method: 'POST'
});
const data = await response.json();
if (data.success) {
countSpan.textContent = data.reminder_count;
// Update the data attribute with new dates
this.dataset.reminderDates = JSON.stringify(data.reminder_dates);
showToast('{{ "reminder_sent"|t }}', 'success');
} else {
showToast(data.error || '{{ "error_sending_reminder"|t }}', 'error');
}
} catch (error) {
showToast('{{ "error_sending_reminder"|t }}', 'error');
}
});
});
// Select all attendees checkbox
document.getElementById('select-all-attendees').addEventListener('change', function() {
const checkboxes = document.querySelectorAll('.attendee-checkbox');
@@ -604,49 +473,175 @@ document.querySelectorAll('.attendee-checkbox').forEach(cb => {
function updateSelectedCount() {
const selected = document.querySelectorAll('.attendee-checkbox:checked').length;
document.getElementById('selected-count').textContent = selected > 0 ? `(${selected} {{ 'selected'|t }})` : '';
document.getElementById('selected-count').textContent = selected > 0 ? `(${selected} selected)` : '';
}
// Copy link function
function copyLink(url) {
// Try modern clipboard API first
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(function() {
alert('{{ 'link_copied'|t }}');
}, function(err) {
// Fallback for HTTP sites
fallbackCopy(url);
});
} else {
// Fallback for older browsers or HTTP sites
fallbackCopy(url);
}
}
function fallbackCopy(url) {
var textArea = document.createElement('textarea');
textArea.value = url;
textArea.style.position = 'fixed';
textArea.style.left = '-9999px';
textArea.style.top = '-9999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
var successful = document.execCommand('copy');
if (successful) {
alert('{{ 'link_copied'|t }}');
} else {
alert('{{ 'copy_failed'|t }}');
}
} catch (err) {
alert('{{ 'copy_failed'|t }}');
}
document.body.removeChild(textArea);
}
// Initial selected count
updateSelectedCount();
// Staff pagination via AJAX
function loadStaffPage(pageNum) {
const currentUrl = new URL(window.location.href);
currentUrl.searchParams.set('staff_page', pageNum);
currentUrl.searchParams.set('staff_sort_by', currentStaffSort.column);
currentUrl.searchParams.set('staff_sort_order', currentStaffSort.direction);
fetch(currentUrl.pathname + '?' + currentUrl.searchParams.toString(), {
headers: { 'Accept': 'text/html' }
})
.then(response => response.text())
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const staffSection = doc.querySelector('.staff-section .section-box');
const staffPagination = doc.querySelector('#staff-pagination');
if (staffSection && staffPagination) {
const currentStaffSection = document.querySelector('.staff-section .section-box');
currentStaffSection.innerHTML = staffSection.innerHTML;
const currentPagination = document.querySelector('#staff-pagination');
if (currentPagination) {
currentPagination.innerHTML = staffPagination.innerHTML;
}
lucide.createIcons();
// Re-attach staff sort handlers
const newStaffSortableHeaders = document.querySelectorAll('#event-staff-table th.sortable');
newStaffSortableHeaders.forEach(th => {
th.addEventListener('click', () => {
const column = th.dataset.sort;
if (!column) return;
const direction = currentStaffSort.column === column && currentStaffSort.direction === 'asc' ? 'desc' : 'asc';
currentStaffSort = { column, direction };
newStaffSortableHeaders.forEach(h => h.classList.remove('sort-asc', 'sort-desc'));
th.classList.add(direction === 'asc' ? 'sort-asc' : 'sort-desc');
loadStaffPage(1);
});
});
// Re-attach reminder handlers
document.querySelectorAll('.remind-btn').forEach(btn => {
btn.addEventListener('click', async function() {
const staffId = this.dataset.staffId;
const countSpan = this.querySelector('.reminder-count');
try {
const response = await fetch(`{{ url_for('send_staff_reminder', event_id=event.id, staff_id=0) }}`.replace('0', staffId), {
method: 'POST'
});
const data = await response.json();
if (data.success) {
countSpan.textContent = data.reminder_count;
this.dataset.reminderDates = JSON.stringify(data.reminder_dates);
showToast('{{ "reminder_sent"|t }}', 'success');
} else {
showToast(data.error || '{{ "error_sending_reminder"|t }}', 'error');
}
} catch (error) {
showToast('{{ "error_sending_reminder"|t }}', 'error');
}
});
});
}
})
.catch(error => {
window.location.href = currentUrl.toString();
});
}
// Attendees pagination via AJAX
function loadAttendeesPage(pageNum) {
const currentUrl = new URL(window.location.href);
currentUrl.searchParams.set('page', pageNum);
currentUrl.searchParams.set('sort_by', currentAttendeeSort.column);
currentUrl.searchParams.set('sort_order', currentAttendeeSort.direction);
fetch(currentUrl.pathname + '?' + currentUrl.searchParams.toString(), {
headers: { 'Accept': 'text/html' }
})
.then(response => response.text())
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const attendeesSection = doc.querySelector('.attendees-section .section-box');
const attendeesPagination = doc.querySelector('#attendees-pagination');
if (attendeesSection && attendeesPagination) {
const currentAttendeesSection = document.querySelector('.attendees-section .section-box');
currentAttendeesSection.innerHTML = attendeesSection.innerHTML;
const currentPagination = document.querySelector('#attendees-pagination');
if (currentPagination) {
currentPagination.innerHTML = attendeesPagination.innerHTML;
}
lucide.createIcons();
// Re-attach attendee sort handlers
const newSortableHeaders = document.querySelectorAll('#attendees-table th.sortable');
newSortableHeaders.forEach(th => {
th.addEventListener('click', () => {
const column = th.dataset.sort;
if (!column) return;
const direction = currentAttendeeSort.column === column && currentAttendeeSort.direction === 'asc' ? 'desc' : 'asc';
currentAttendeeSort = { column, direction };
newSortableHeaders.forEach(h => h.classList.remove('sort-asc', 'sort-desc'));
th.classList.add(direction === 'asc' ? 'sort-asc' : 'sort-desc');
loadAttendeesPage(1);
});
});
// Re-attach checkin handlers
document.querySelectorAll('.checkin-btn').forEach(btn => {
btn.addEventListener('click', async function() {
const attendeeId = this.dataset.attendeeId;
const eventId = {{ event.id }};
try {
const response = await fetch(`/organizer/event/${eventId}/checkin/${attendeeId}`, {
method: 'POST'
});
if (response.ok) {
const row = this.closest('tr');
row.querySelector('td:nth-child(7)').innerHTML = '<span class="badge badge-success"><i data-lucide="check"></i> {{ "checked_in"|t }}</span>';
this.remove();
document.getElementById('attendee-count').textContent = parseInt(document.getElementById('attendee-count').textContent) + 1;
lucide.createIcons();
}
} catch (error) {
showToast('{{ "error_checking_in"|t }}', 'error');
}
});
});
// Re-attach select all handler
const selectAllCheckbox = document.getElementById('select-all-attendees');
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', function() {
const checkboxes = document.querySelectorAll('.attendee-checkbox');
checkboxes.forEach(cb => cb.checked = this.checked);
updateSelectedCount();
});
}
document.querySelectorAll('.attendee-checkbox').forEach(cb => {
cb.addEventListener('change', updateSelectedCount);
});
}
})
.catch(error => {
window.location.href = currentUrl.toString();
});
}
</script>
</div>
{% endblock %}
{% endblock %}