Files
paul a3546b4c01 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>
2026-05-26 20:34:48 +00:00

647 lines
32 KiB
HTML

{% extends "base.html" %}
{% block title %}{{ event.name }} - NetEvents{% endblock %}
{% block content %}
<div class="event-detail">
<div class="event-header">
<h1>{{ event.name }}</h1>
<div class="event-actions">
<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>
{% if event.description %}
<div class="event-description-box">
<h3>{{ 'description'|t }}</h3>
<p>{{ event.description }}</p>
</div>
{% endif %}
<div class="event-meta-box">
<p><strong>{{ 'start'|t }}:</strong> {{ event.start_time|localized_date if event.start_time else 'TBD' }}</p>
{% if event.end_time %}
<p><strong>{{ 'end'|t }}:</strong> {{ event.end_time|localized_date }}</p>
{% endif %}
<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" 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"><i data-lucide="tag"></i> {{ 'manage_types'|t }}</a>
</div>
{% if attendee_types %}
<table class="types-table">
<thead>
<tr>
<th>{{ 'type_name'|t }}</th>
<th>{{ 'price'|t }}</th>
<th>{{ 'registration_link'|t }}</th>
</tr>
</thead>
<tbody>
{% for at in attendee_types %}
<tr>
<td><strong>{{ at.name }}</strong></td>
<td>
{% if at.price and at.price > 0 %}
<span class="text-success font-medium">{{ at.price|format_currency }}</span>
{% else %}
<span class="text-muted">{{ 'free'|t }}</span>
{% endif %}
</td>
<td>
<code class="code">
{{ url_for('register_event', code=event.code, type_code=at.code, _external=True) }}
</code>
<button type="button" class="btn btn-sm btn-ghost"
onclick="copyLink('{{ url_for('register_event', code=event.code, type_code=at.code, _external=True) }}')">
<i data-lucide="copy"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<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>
<section class="staff-section">
<div class="section-box">
<h2>{{ 'staff'|t }}</h2>
<div class="section-actions">
<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" 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">
<label for="staff_first_name">{{ 'first_name'|t }}</label>
<input type="text" id="staff_first_name" name="first_name" required>
</div>
<div class="form-group">
<label for="staff_last_name">{{ 'last_name'|t }}</label>
<input type="text" id="staff_last_name" name="last_name" required>
</div>
<div class="form-group">
<label for="staff_email">{{ 'email'|t }}</label>
<input type="email" id="staff_email" name="email" required>
</div>
</div>
<button type="submit" class="btn btn-primary">{{ 'add_staff_member'|t }}</button>
<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) }}" 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>
{% for other_event in other_events %}
<option value="{{ other_event.id }}">{{ other_event.name }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-secondary">Batch Add</button>
</div>
</form>
{% endif %}
</div>
{% if staff %}
<table class="staff-table" id="event-staff-table">
<thead>
<tr>
<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="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-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 %}
<span class="badge badge-pending">{{ 'invite_pending'|t }}</span>
{% else %}
<span class="badge badge-success">{{ 'active'|t }}</span>
{% endif %}
</td>
<td>
<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">
<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 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" class="d-inline">
<button type="submit" class="btn btn-danger">{{ 'remove'|t }}</button>
</form>
</div>
</div>
</div>
<section class="breakout-sessions-section">
<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"><i data-lucide="plus"></i> {{ 'add_breakout_session'|t }}</a>
</div>
{% if breakout_sessions %}
<table class="sessions-table">
<thead>
<tr>
<th>{{ 'name'|t }}</th>
<th>{{ 'time'|t }}</th>
<th>{{ 'location'|t }}</th>
<th>{{ 'max_attendees'|t }}</th>
<th>{{ 'registered'|t }}</th>
<th>{{ 'actions'|t }}</th>
</tr>
</thead>
<tbody>
{% for session in breakout_sessions %}
<tr>
<td>{{ session.name }}</td>
<td>{{ session.start_time|localized_date }}</td>
<td>{{ session.location or '-' }}</td>
<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"><i data-lucide="eye"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="no-sessions">{{ 'no_breakout_sessions_yet'|t }}</p>
{% endif %}
</div>
</section>
<section class="attendees-section">
<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"><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 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" class="text-muted text-sm"></span>
</div>
</form>
<table class="attendees-table" id="attendees-table">
<thead>
<tr>
<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="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-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 }}</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 badge-info">{{ attendee.attendee_type_name }}</span>
{% else %}
<span class="text-light">-</span>
{% endif %}
</td>
<td>
{% if attendee.checked_in %}
<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 }}"><i data-lucide="user-check"></i></button>
{% else %}
<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 %}
</div>
</section>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
document.getElementById('add-staff-btn').addEventListener('click', function() {
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').classList.remove('shown');
document.getElementById('add-staff-btn').classList.remove('d-none');
});
document.getElementById('staff-inline-form').addEventListener('submit', function(e) {
e.preventDefault();
const form = this;
const formData = new FormData(form);
fetch(form.action, {
method: 'POST',
body: formData
}).then(response => {
if (response.redirected) {
window.location.href = response.url;
} else {
location.reload();
}
}).catch(error => {
showToast('{{ "error_adding_staff"|t }}', 'error');
});
});
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(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) {
showToast('{{ "error_checking_in"|t }}', 'error');
}
});
});
// 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;
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');
loadAttendeesPage(1);
});
});
// 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;
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');
loadStaffPage(1);
});
});
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').classList.add('active');
}
function closeStaffDeleteModal() {
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');
checkboxes.forEach(cb => cb.checked = this.checked);
updateSelectedCount();
});
document.querySelectorAll('.attendee-checkbox').forEach(cb => {
cb.addEventListener('change', updateSelectedCount);
});
function updateSelectedCount() {
const selected = document.querySelectorAll('.attendee-checkbox:checked').length;
document.getElementById('selected-count').textContent = selected > 0 ? `(${selected} selected)` : '';
}
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>
{% endblock %}