feat: token-secured guest invites, product freeze lock during active events, Outlook ics export and template, realistic test data

This commit is contained in:
2026-09-21 14:12:59 +02:00
parent 913aa646ea
commit 2001ffb831
5 changed files with 328 additions and 55 deletions
+119 -7
View File
@@ -93,7 +93,9 @@
<div class="admin-actions-top">
{% if event %}
<button onclick="copyShoppingList()" class="btn-primary">📋 Einkaufsliste kopieren</button>
<button onclick="copyGuestPassLink('{{ event.id }}')" class="btn-secondary" title="Gast-Link für Dienstleister">🔗 Dienstleister-Gastlink</button>
<button onclick="copyGuestPassLink('{{ event.id }}', '{{ event.guest_token or '' }}')" class="btn-secondary" title="Gast-Link für Dienstleister">🔗 Dienstleister-Gastlink</button>
<a href="/events/{{ event.id }}/calendar.ics" class="btn-secondary" title="Termin als Outlook/Kalenderdatei (.ics) herunterladen" style="text-decoration: none; display: inline-flex; align-items: center;">📅 Outlook (.ics)</a>
<button onclick="openOutlookModal()" class="btn-secondary" title="Outlook Einladungsvorlage anzeigen">✉️ Outlook-Vorlage</button>
<button onclick="window.print()" class="btn-secondary">🖨️ Liste drucken (A4)</button>
{% endif %}
</div>
@@ -105,6 +107,12 @@
</div>
{% endif %}
{% if error %}
<div class="alert-warning-card" style="margin-bottom: 20px; border-color: #ef4444; background: rgba(239, 68, 68, 0.15); color: #fca5a5;">
⚠️ {{ error }}
</div>
{% endif %}
{% if bulk_count %}
<div class="alert-success" style="margin-bottom: 20px;">
✓ Erfolgreich <strong>{{ bulk_count }}</strong> Einladungen generiert! Die Links sind unten aufgeführt.
@@ -201,8 +209,18 @@
<div class="stat-card">
<div class="stat-label">Teilnehmer</div>
<div class="stat-value">{{ orders|length }} Personen</div>
</div>
</div>
{% if event and event.status == 'open' %}
<div style="display: flex; justify-content: flex-end; margin-bottom: 16px;">
<form action="/setup/seed-test-data" method="POST" style="margin: 0;" onsubmit="return confirm('Möchtest du 5 realistische Demo-Bestellungen für Kollegen und Gäste für diese Runde einfügen?');">
<input type="hidden" name="event_id" value="{{ event.id }}">
<button type="submit" class="btn-secondary" style="font-size: 0.85rem;" title="Erstellt Beispielbestellungen für Anna Huber, Stefan Meier, Michael Schmidt etc.">
🧪 Realistische Demo-Bestellungen einfügen (5 Kollegen)
</button>
</form>
</div>
{% endif %}
<div class="admin-columns">
<!-- Aggregated Shopping List -->
@@ -442,7 +460,12 @@
<tbody>
{% for prod in products %}
<tr class="{% if not prod.is_active %}row-inactive{% endif %}">
<td><strong>{{ prod.name }}</strong></td>
<td>
<strong>{{ prod.name }}</strong>
{% if prod.id in locked_product_ids %}
<span class="badge-role" style="background: rgba(245, 158, 11, 0.15); color: #f59e0b; margin-left: 6px;" title="Für die offene Runde bestellt. Preis und Deaktivierung gesichert.">🔒 Gesichert</span>
{% endif %}
</td>
<td><small class="text-muted">{{ prod.description }}</small></td>
<td><strong>{{ "%.2f"|format(prod.price) }} €</strong></td>
<td>{{ prod.unit }}</td>
@@ -455,12 +478,16 @@
</td>
<td>
<div style="display: flex; gap: 6px;">
<button type="button" class="btn-copy-small" onclick="openEditProduct('{{ prod.id }}', '{{ prod.name|escape }}', '{{ prod.description|escape }}', '{{ '%.2f'|format(prod.price) }}', '{{ prod.unit }}', '{{ prod.sort_order }}')">✏️ Bearbeiten</button>
<button type="button" class="btn-copy-small" onclick="openEditProduct('{{ prod.id }}', '{{ prod.name|escape }}', '{{ prod.description|escape }}', '{{ '%.2f'|format(prod.price) }}', '{{ prod.unit }}', '{{ prod.sort_order }}', {{ 'true' if prod.id in locked_product_ids else 'false' }})">✏️ Bearbeiten</button>
{% if prod.id in locked_product_ids and prod.is_active %}
<button type="button" class="btn-revoke-small" style="opacity: 0.5; cursor: not-allowed; color: #fca5a5;" title="In der laufenden Runde bereits bestellt – Deaktivierung gesperrt" onclick="alert('Dieser Artikel wurde in der laufenden Runde bereits bestellt und kann daher erst nach Abschluss des Events deaktiviert werden.')">Deaktivieren</button>
{% else %}
<form action="/admin/products/{{ prod.id }}/toggle" method="POST" style="margin: 0; display: inline;">
<button type="submit" class="btn-revoke-small" style="color: {% if prod.is_active %}#fca5a5{% else %}#86efac{% endif %};">
{% if prod.is_active %}Deaktivieren{% else %}Aktivieren{% endif %}
</button>
</form>
{% endif %}
</div>
</td>
</tr>
@@ -509,6 +536,9 @@
<div id="editProductModal" class="modal-backdrop" style="display: none;">
<div class="modal-card">
<h3>✏️ Produkt bearbeiten</h3>
<div id="productLockedNotice" class="alert-warning-card" style="display: none; padding: 10px 14px; margin-top: 10px; margin-bottom: 10px; font-size: 0.85rem; border-color: rgba(245, 158, 11, 0.4);">
🔒 <strong>Artikel gesichert:</strong> Dieses Produkt wurde für eine offene Runde bereits bestellt. Der Preis ist eingefroren und kann erst nach Abschluss des Events geändert werden.
</div>
<form id="editProductForm" method="POST" class="admin-form" style="margin-top: 14px;">
<div class="input-group">
<label for="m_prod_name">Artikelname</label>
@@ -729,6 +759,52 @@
</div>
{% endif %}
<!-- Outlook Template Modal (Global für alle Tabs verfügbar) -->
<div id="outlookModal" class="modal-backdrop" style="display: none;">
<div class="modal-card" style="max-width: 640px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<h3 style="margin: 0;">✉️ Outlook-Einladungsvorlage</h3>
<button type="button" class="btn-revoke-small" onclick="closeOutlookModal()" style="font-size: 1rem; padding: 2px 8px;">✕</button>
</div>
<p class="card-hint">Kopiere diese Vorlage direkt in Outlook oder öffne dein Mailprogramm per Klick:</p>
<div class="input-group" style="margin-bottom: 12px;">
<label for="outlookSubject">Betreff</label>
<input type="text" id="outlookSubject" readonly value="🥨 Einladung: {{ event.title if event else 'Brotzeitrunde' }} ({{ event.event_date.strftime('%d.%m.%Y') if event else '' }})" style="width: 100%;">
</div>
<div class="input-group" style="margin-bottom: 16px;">
<label for="outlookBody">E-Mail-Text (inkl. Bestelllinks)</label>
<textarea id="outlookBody" rows="11" readonly style="width: 100%; padding: 12px; font-family: inherit; font-size: 0.85rem; line-height: 1.5; background: rgba(11, 15, 25, 0.8); border: 1px solid var(--border-color); border-radius: var(--radius-md); color: #fff; resize: vertical;">{% if event %}Hallo zusammen,
wir machen wieder eine gemeinsame Brotzeitrunde am {{ event.event_date.strftime('%d.%m.%Y') }}!
🥨 Bitte tragt eure Bestellung bis spätestens {{ event.deadline.strftime('%d.%m.%Y um %H:%M Uhr') }} im Brotzeit-Portal ein:
👉 https://demo.dev.waiswuaschd.de/?event_id={{ event.id }}
{% if event.allow_guests and event.guest_token %}
Für externe Dienstleister & Gäste ohne Firmenkonto:
🔗 Gast-Bestelllink: https://demo.dev.waiswuaschd.de/?event_id={{ event.id }}&token={{ event.guest_token }}
{% endif %}
{% if event.sponsor_name %}
🎉 Gute Nachrichten: Die Runde wird heute von {{ event.sponsor_name }} spendiert!
{% endif %}
Viele Grüße,
{{ current_user.first_name }} {{ current_user.last_name }}{% endif %}</textarea>
</div>
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<button type="button" class="btn-primary" onclick="copyOutlookTemplate()">📋 Text kopieren</button>
<button type="button" class="btn-secondary" onclick="openMailtoClient()">✉️ In Outlook öffnen</button>
{% if event %}
<a href="/events/{{ event.id }}/calendar.ics" class="btn-secondary" style="text-decoration: none;">📅 .ics Kalenderdatei</a>
{% endif %}
<button type="button" class="btn-secondary" onclick="closeOutlookModal()" style="margin-left: auto;">Schließen</button>
</div>
</div>
</div>
</div>
</div>
@@ -753,14 +829,35 @@
});
}
function copyGuestPassLink(eventId) {
function copyGuestPassLink(eventId, guestToken) {
const origin = window.location.origin;
const url = origin + '/?event_id=' + eventId + '&guest=1';
const url = origin + '/?event_id=' + eventId + (guestToken ? '&token=' + guestToken : '');
navigator.clipboard.writeText(url).then(() => {
alert("Dienstleister-Gastlink kopiert!\n\n" + url + "\n\nExterne können damit für diesen Termin mitbestellen, ohne ein Konto anzulegen.");
});
}
function openOutlookModal() {
document.getElementById('outlookModal').style.display = 'flex';
}
function closeOutlookModal() {
document.getElementById('outlookModal').style.display = 'none';
}
function copyOutlookTemplate() {
const body = document.getElementById('outlookBody').value;
navigator.clipboard.writeText(body).then(() => {
alert("Outlook-Einladungstext in die Zwischenablage kopiert! Kann direkt in Outlook eingefügt werden.");
});
}
function openMailtoClient() {
const subject = encodeURIComponent(document.getElementById('outlookSubject').value);
const body = encodeURIComponent(document.getElementById('outlookBody').value);
window.location.href = `mailto:?subject=${subject}&body=${body}`;
}
function copyAllInviteLinks() {
const origin = window.location.origin;
const items = document.querySelectorAll('#invitationsListContainer .invite-item');
@@ -798,7 +895,7 @@
document.getElementById('new_pw_input').value = pw;
}
function openEditProduct(id, name, desc, price, unit, sort) {
function openEditProduct(id, name, desc, price, unit, sort, isLocked) {
const modal = document.getElementById('editProductModal');
const form = document.getElementById('editProductForm');
form.action = '/admin/products/' + id + '/edit';
@@ -807,6 +904,21 @@
document.getElementById('m_prod_price').value = price;
document.getElementById('m_prod_unit').value = unit;
document.getElementById('m_prod_sort').value = sort;
const notice = document.getElementById('productLockedNotice');
const priceInput = document.getElementById('m_prod_price');
if (isLocked) {
if (notice) notice.style.display = 'block';
priceInput.readOnly = true;
priceInput.title = 'Preis ist gesichert (bereits bestellt)';
priceInput.style.opacity = '0.7';
} else {
if (notice) notice.style.display = 'none';
priceInput.readOnly = false;
priceInput.title = '';
priceInput.style.opacity = '1';
}
modal.style.display = 'flex';
}