Add attendee type management and staff codes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 07:17:47 +00:00
parent dec6446d7d
commit 64ab1d0412
16 changed files with 844 additions and 78 deletions
+7
View File
@@ -3,6 +3,13 @@
{% block title %}{{ 'attendee_dashboard'|t }} - NetEvents{% endblock %}
{% block content %}
<style>
.dashboard {
padding: 30px 20px;
max-width: 1600px;
margin: 0 auto;
}
</style>
<div class="dashboard">
<h1>{{ 'attendee_dashboard'|t }}</h1>
+1 -1
View File
@@ -56,7 +56,7 @@
.personal-page {
max-width: 900px;
margin: 0 auto;
padding: 20px;
padding: 30px 25px;
}
.personal-page h1 {
+2 -31
View File
@@ -5,17 +5,10 @@
{% block content %}
<div class="auth-container">
<div class="auth-box">
<h2>{{ 'login'|t }}</h2>
<div class="user-type-tabs">
<button type="button" class="tab-btn" data-type="breakout_organizer">{{ 'presenter'|t }}</button>
<button type="button" class="tab-btn" data-type="staff">{{ 'staff'|t }}</button>
<button type="button" class="tab-btn" data-type="attendee">{{ 'visitor'|t }}</button>
<button type="button" class="tab-btn" data-type="organizer">{{ 'organizer'|t }}</button>
</div>
<h2>{{ 'organizer_login'|t }}</h2>
<form method="POST" action="{{ url_for('login') }}">
<input type="hidden" name="user_type" id="user_type" value="attendee">
<input type="hidden" name="user_type" value="organizer">
<div class="form-group">
<label for="email">{{ 'email'|t }}</label>
@@ -36,26 +29,4 @@
</p>
</div>
</div>
<script>
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active');
document.getElementById('user_type').value = this.dataset.type;
});
});
// Pre-select tab based on passed default_type
const params = new URLSearchParams(window.location.search);
const type = params.get('type');
if (type) {
const btn = document.querySelector(`.tab-btn[data-type="${type}"]`);
if (btn) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.getElementById('user_type').value = type;
}
}
</script>
{% endblock %}
+1 -1
View File
@@ -31,7 +31,7 @@
</div>
</nav>
<main class="container">
<main>
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="flash-messages">
+2 -3
View File
@@ -124,9 +124,8 @@
</button>
</td>
<td>
<form method="POST" action="{{ url_for('delete_attendee_type', event_id=event.id, type_id=at.id) }}" style="display: inline;"
onsubmit="return confirm('{{ 'confirm_delete_type'|t }}');">
<button type="submit" class="btn btn-sm btn-danger">{{ 'delete'|t }}</button>
<form method="GET" action="{{ url_for('edit_attendee_type', event_id=event.id, type_id=at.id) }}" style="display: inline;">
<button type="submit" class="btn btn-sm" style="background: #2563eb; color: #fff; padding: 4px 10px; margin-right: 5px;">{{ 'edit'|t }}</button>
</form>
</td>
</tr>
+27 -15
View File
@@ -9,6 +9,8 @@
<p>{{ 'attendee_badges'|t }}</p>
<div class="header-actions">
<a href="{{ url_for('event_scan', event_id=event.id) }}" class="btn btn-secondary">📷 {{ 'scan_qr_codes'|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</a>
<button onclick="window.print()" class="btn btn-primary">{{ 'print_badges'|t }}</button>
</div>
</div>
@@ -18,7 +20,7 @@
<div class="badge-card" data-first="{{ attendee.first_name }}" data-last="{{ attendee.last_name }}">
<div class="badge-header">
<h3 class="badge-name">
<span class="first-name">{{ attendee.first_name }}</span><br>
<span class="first-name">{{ attendee.first_name }}</span>
<span class="last-name">{{ attendee.last_name }}</span>
</h3>
</div>
@@ -38,27 +40,32 @@
{% if attendee.checked_in %}✓ {{ 'checked_in'|t }}{% else %}{{ 'pending'|t }}{% endif %}
</span>
</div>
<div class="badge-event-bar">
{{ event.name }} — {{ event.start_time|localized_date if event.start_time else 'TBD' }}
</div>
</div>
{% endfor %}
</div>
<style>
.badge-header h3 { margin: 0; font-size: 1rem; line-height: 1.2; }
.badge-header h3 { margin: 0; font-size: 30pt; line-height: 1.1; }
.badge-header h3 .first-name,
.badge-header h3 .last-name { display: block; line-height: 1.1; }
.badge-header h3 .last-name { font-size: 0.8rem; }
.badge-header h3 .last-name { display: inline; line-height: 1.1; font-size: 30pt; }
.badge-header h3 .last-name:before { content: ' '; }
@media print {
.navbar, .footer, .badges-header .header-actions button, .flash-messages { display: none !important; }
.badges-page { padding: 0; }
.badges-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; }
.badge-card { border: 2px solid #000; padding: 15px; page-break-inside: avoid; }
.badge-header h3 { margin: 0; font-size: 1rem; line-height: 1.2; }
.badge-header h3 { margin: 0; font-size: 30pt; line-height: 1.1; }
.badge-header h3 .first-name,
.badge-header h3 .last-name { display: block !important; line-height: 1.1; }
.badge-header h3 .last-name { font-size: 0.8rem !important; }
.badge-header h3 .last-name { display: inline !important; line-height: 1.1; font-size: 30pt; }
.badge-header h3 .last-name:before { content: ' '; }
.badge-qr img { width: 80px !important; height: 80px !important; }
.badge-body p { margin: 5px 0; }
.badge-footer { display: flex; justify-content: space-between; margin-top: 10px; padding-top: 10px; border-top: 1px solid #ccc; }
.badge-event-bar { background: #000; color: #fff; padding: 8px 15px; margin: 10px -15px -15px; font-size: 12pt; text-align: center; }
.badge-event-bar { background: #000; color: #fff; padding: 8px 15px; margin: 10px -15px -15px; font-size: 12pt; text-align: center; }
}
</style>
<script>
@@ -66,9 +73,10 @@
// Create offscreen canvas for text measurement
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.font = '30pt sans-serif';
// Width limit for last name before it needs font reduction
const LAST_NAME_WIDTH_LIMIT = 120;
// Container width limit (badge width)
const BADGE_WIDTH_LIMIT = 280;
function measureText(text, fontSize, fontFamily) {
ctx.font = fontSize + 'px ' + (fontFamily || 'sans-serif');
@@ -78,16 +86,20 @@
function formatBadgeNames() {
const cards = document.querySelectorAll('.badge-card');
cards.forEach(card => {
const firstName = card.dataset.first || '';
const lastName = card.dataset.last || '';
const firstSpan = card.querySelector('.first-name');
const lastSpan = card.querySelector('.last-name');
// Measure last name at reduced size (0.8rem = 12.8px)
const reducedFontSize = 12.8;
const reducedLastWidth = measureText(lastName, reducedFontSize);
// Measure first + last name at 20pt
const combinedWidth = measureText(firstName + ' ' + lastName, 30);
// If still too wide, reduce further
if (reducedLastWidth > LAST_NAME_WIDTH_LIMIT) {
lastSpan.style.fontSize = '0.7rem';
// If combined width exceeds limit, insert br before last name
if (combinedWidth > BADGE_WIDTH_LIMIT) {
if (!lastSpan.previousElementSibling || lastSpan.previousElementSibling.tagName !== 'BR') {
const br = document.createElement('br');
lastSpan.parentNode.insertBefore(br, lastSpan);
}
}
});
}
@@ -3,6 +3,7 @@
{% block title %}{{ session.name }} - {{ 'breakout_session'|t }}{% endblock %}
{% block content %}
<div style="padding: 20px;">
<div class="breakout-session-detail">
<div class="session-header">
<h1>{{ session.name }}</h1>
@@ -55,4 +56,5 @@
{% endif %}
</section>
</div>
</div>
{% endblock %}
@@ -3,7 +3,7 @@
{% block title %}{{ 'create_breakout_session'|t }} - NetEvents{% endblock %}
{% block content %}
<div class="form-container">
<div class="form-container" style="padding: 20px;">
<h1>{{ 'create_breakout_session'|t }}</h1>
<p class="event-info">{{ 'event'|t }}: {{ event.name }}</p>
@@ -20,12 +20,12 @@
<div class="form-group">
<label for="start_time">{{ 'start_time'|t }}</label>
<input type="datetime-local" id="start_time" name="start_time" required>
<input type="datetime-local" id="start_time" name="start_time" required value="{% if prefilled_date %}{{ prefilled_date }}T09:00{% endif %}">
</div>
<div class="form-group">
<label for="end_time">{{ 'end_time'|t }}</label>
<input type="datetime-local" id="end_time" name="end_time" required>
<input type="datetime-local" id="end_time" name="end_time" required value="{% if prefilled_date %}{{ prefilled_date }}T10:00{% endif %}">
</div>
<div class="form-group">
+88 -5
View File
@@ -4,6 +4,36 @@
{% block content %}
<style>
.dashboard {
width: 100%;
margin: 0 auto;
padding: 30px 20px;
box-sizing: border-box;
max-width: 1600px;
}
.events-list {
display: grid !important;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)) !important;
gap: 1.5rem !important;
width: 100% !important;
}
.event-item {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
box-sizing: border-box !important;
}
.event-item-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 10px;
width: 100%;
}
.event-info {
width: 100%;
}
.progress-bar-container {
background: #e9ecef;
border-radius: 4px;
@@ -89,11 +119,11 @@
background: #dee2e6;
}
.section-toggle::after {
content: '';
content: '';
font-size: 10px;
}
.section-toggle.collapsed::after {
content: '';
content: '';
}
.section-content {
padding: 10px 0;
@@ -126,6 +156,18 @@
display: flex;
gap: 5px;
}
.pagination-controls {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #e9ecef;
}
.pagination-controls button {
padding: 4px 12px;
font-size: 12px;
}
/* Wide screen - spread content */
@media (min-width: 1400px) {
@@ -165,8 +207,46 @@
function toggleSection(id) {
const content = document.getElementById(id);
const toggle = content.previousElementSibling;
const wasCollapsed = content.classList.contains('collapsed');
content.classList.toggle('collapsed');
toggle.classList.toggle('collapsed');
if (wasCollapsed && content.dataset.itemCount) {
const perPage = parseInt(content.dataset.perPage) || 5;
setupPagination(id, parseInt(content.dataset.itemCount), perPage);
}
}
function showPage(sectionId, page, perPage) {
const section = document.getElementById(sectionId);
const items = section.querySelectorAll('.list-item');
items.forEach((item, i) => {
const start = (page - 1) * perPage;
const end = start + perPage;
item.style.display = i >= start && i < end ? 'flex' : 'none';
});
const totalPages = parseInt(section.dataset.totalPages);
section.querySelectorAll('.pagination-controls button').forEach(btn => {
btn.disabled = parseInt(btn.dataset.page) == page;
});
}
function setupPagination(sectionId, totalItems, perPage) {
const section = document.getElementById(sectionId);
const totalPages = Math.ceil(totalItems / perPage);
if (totalPages <= 1) return;
section.dataset.totalPages = totalPages;
const controls = section.querySelector('.pagination-controls');
controls.innerHTML = '';
for (let p = 1; p <= totalPages; p++) {
const btn = document.createElement('button');
btn.className = 'btn btn-sm btn-outline';
btn.dataset.page = p;
btn.textContent = p;
btn.onclick = () => showPage(sectionId, p, perPage);
if (p === 1) btn.disabled = true;
controls.appendChild(btn);
}
showPage(sectionId, 1, perPage);
}
</script>
@@ -215,7 +295,7 @@ function toggleSection(id) {
<div class="section-toggle" onclick="toggleSection('staff-{{ event.id }}')">
<strong>Staff ({{ event.staff|length }})</strong>
</div>
<div id="staff-{{ event.id }}" class="section-content collapsed">
<div id="staff-{{ event.id }}" class="section-content collapsed" data-item-count="{{ event.staff|length }}" data-per-page="5">
<div class="item-list">
{% for s in event.staff %}
<div class="list-item">
@@ -232,6 +312,7 @@ function toggleSection(id) {
</div>
{% endfor %}
</div>
<div class="pagination-controls"></div>
</div>
</div>
{% endif %}
@@ -241,7 +322,7 @@ function toggleSection(id) {
<div class="section-toggle" onclick="toggleSection('sessions-{{ event.id }}')">
<strong>{{ 'breakout_sessions'|t }} ({{ event.breakout_sessions|length }})</strong>
</div>
<div id="sessions-{{ event.id }}" class="section-content collapsed">
<div id="sessions-{{ event.id }}" class="section-content collapsed" data-item-count="{{ event.breakout_sessions|length }}" data-per-page="5">
<div class="item-list">
{% for session in event.breakout_sessions %}
<div class="list-item">
@@ -262,6 +343,7 @@ function toggleSection(id) {
</div>
{% endfor %}
</div>
<div class="pagination-controls"></div>
</div>
</div>
{% endif %}
@@ -271,7 +353,7 @@ function toggleSection(id) {
<div class="section-toggle" onclick="toggleSection('attendees-{{ event.id }}')">
<strong>{{ 'attendees'|t }} ({{ event.attendees|length }})</strong>
</div>
<div id="attendees-{{ event.id }}" class="section-content collapsed">
<div id="attendees-{{ event.id }}" class="section-content collapsed" data-item-count="{{ event.attendees|length }}" data-per-page="5">
<div class="item-list">
{% for att in event.attendees %}
<div class="list-item">
@@ -291,6 +373,7 @@ function toggleSection(id) {
</div>
{% endfor %}
</div>
<div class="pagination-controls"></div>
</div>
</div>
{% endif %}
+10
View File
@@ -37,6 +37,16 @@
<textarea id="introduction" name="introduction" rows="4">{{ attendee.introduction or '' }}</textarea>
</div>
<div class="form-group">
<label for="attendee_type_id">{{ 'attendee_type'|t }}</label>
<select id="attendee_type_id" name="attendee_type_id">
<option value="">{{ 'no_type'|t }}</option>
{% for at in attendee_types %}
<option value="{{ at.id }}" {% if attendee.attendee_type_id == at.id %}selected{% endif %}>{{ at.name }} ({{ at.price|format_currency if at.price > 0 else 'free'|t }})</option>
{% endfor %}
</select>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'save_changes'|t }}</button>
<a href="{{ url_for('organizer_dashboard') }}" class="btn btn-outline">{{ 'cancel'|t }}</a>
@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block title %}{{ 'edit_attendee_type'|t }} - {{ event.name }} - NetEvents{% endblock %}
{% block content %}
<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;
}
.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;
}
.back-link {
margin-bottom: 20px;
display: inline-block;
}
</style>
<div class="edit-attendee-type-page">
<a href="{{ url_for('manage_attendee_types', event_id=event.id) }}" class="back-link">&larr; {{ 'back_to_types'|t }}</a>
<h1>{{ 'edit_attendee_type'|t }} - {{ event.name }}</h1>
<div class="section-box">
<h2>{{ 'type_details'|t }}</h2>
<form method="POST" action="{{ url_for('edit_attendee_type', event_id=event.id, type_id=attendee_type.id) }}">
<div class="form-row">
<div class="form-group">
<label for="name">{{ 'type_name'|t }} *</label>
<input type="text" id="name" name="name" value="{{ attendee_type.name }}" placeholder="{{ 'e_g_vip_speaker_student'|t }}" required>
</div>
<div class="form-group">
<label for="price">{{ 'price'|t }} ({{ 'optional'|t }})</label>
<input type="number" id="price" name="price" step="0.01" min="0" value="{{ attendee_type.price if attendee_type.price else '' }}" placeholder="0.00">
<small style="color: #666;">{{ 'leave_empty_for_free'|t }}</small>
</div>
</div>
<button type="submit" class="btn btn-primary">{{ 'save_changes'|t }}</button>
<a href="{{ url_for('manage_attendee_types', event_id=event.id) }}" class="btn btn-secondary" style="margin-left: 10px; padding: 0.75rem 1.5rem; font-size: 1rem; font-weight: 400; line-height: 1; height: auto; box-sizing: border-box; display: inline-block; text-decoration: none; border-radius: 6px; cursor: pointer; transition: all 0.2s; vertical-align: middle; color: white; background-color: #64748b;">{{ 'cancel'|t }}</a>
{% if attendee_count == 0 %}
<form method="POST" action="{{ url_for('delete_attendee_type', event_id=event.id, type_id=attendee_type.id) }}" style="display: inline; margin-left: 10px;"
onsubmit="return confirm('{{ 'confirm_delete_type'|t }}');">
<button type="submit" class="btn btn-danger">{{ 'delete'|t }}</button>
</form>
{% else %}
<button type="button" class="btn btn-danger" disabled
title="{{ 'cannot_delete_type_with_attendees'|t|format(attendee_count) }}"
style="margin-left: 10px; opacity: 0.5; cursor: not-allowed;">{{ 'delete'|t }}</button>
{% endif %}
</form>
</div>
</div>
{% endblock %}
@@ -3,7 +3,7 @@
{% block title %}{{ 'edit_breakout_session'|t }} - NetEvents{% endblock %}
{% block content %}
<div class="form-container">
<div class="form-container" style="padding: 20px;">
<h1>{{ 'edit_breakout_session'|t }}</h1>
<p class="event-info">{{ 'event'|t }}: {{ event.name }}</p>
+5
View File
@@ -3,6 +3,7 @@
{% block title %}{{ event.name }} - NetEvents{% endblock %}
{% block content %}
<div style="padding: 20px;">
<style>
.section-box {
background: #fff;
@@ -374,6 +375,9 @@ th.sort-desc .sort-icon::before {
<h2>{{ 'registered_attendees'|t }}</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>
</div>
{% if attendees %}
@@ -644,4 +648,5 @@ function fallbackCopy(url) {
// Initial selected count
updateSelectedCount();
</script>
</div>
{% endblock %}