feat: add a booking event history for admins

Admins get /admin/history: one line per booking created, rescheduled or
released, per series created or ended, and per resource deleted with its
bookings. Sortable by time, kind, resource, booked day and both people;
filterable by period, kinds, who acted and resource type.

Events are snapshots written inside the same transaction as the change,
so a rolled-back booking leaves none and a released booking stays
readable. app:booking-events:prune keeps twelve months and needs a cron
entry on the server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
AdminandClaude Opus 5.5 committed 2026-10-16 16:02:07 +02:00
1 parent d68b6b156c
commit dcdce4d248
18 files changed
+1556 -6

No files matched your search

+119
View File
@@ -0,0 +1,119 @@
{#
The event history. One row per thing that happened -- a series is one row, not one per day --
so a year of standing bookings does not drown out this morning.
"Für" and "Durchgeführt von" are both shown on every row; when they differ, somebody acted on
another person's booking, and the row says so with a badge rather than leaving it to be
noticed.
#}
{% extends 'layout.html.twig' %}
{% block title %}Ereignishistorie · E&P Platz{% endblock %}
{% block content %}
<a class="text-sm text-gray-500 hover:text-gray-900" href="{{ path('app_dashboard') }}">← Übersicht</a>
<h1 class="pt-2">Ereignishistorie</h1>
<p class="subline">Angelegte, geänderte und freigegebene Buchungen der letzten zwölf Monate.</p>
{{ form_start(form, { attr: { class: 'mt-6 rounded-md border border-gray-200 p-4' } }) }}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{{ form_row(form.from) }}
{{ form_row(form.to) }}
{{ form_row(form.actor) }}
{{ form_row(form.resourceType) }}
</div>
<fieldset class="pt-4">
<legend class="block font-bold pb-2">{{ form.types.vars.label }}</legend>
<div class="flex flex-wrap gap-x-6 gap-y-2 text-sm">
{% for choice in form.types %}
<label class="inline-flex items-center gap-2">
{{ form_widget(choice) }}
{{ choice.vars.label }}
</label>
{% endfor %}
</div>
</fieldset>
<div class="flex justify-end gap-2 pt-2">
<a class="button button--small button--neutral" href="{{ path('app_admin_booking_event') }}">Zurücksetzen</a>
<button type="submit" class="button button--small button--primary">Filtern</button>
</div>
{{ form_end(form) }}
<p class="subline pt-8 pb-4">
{{ filter.fromDate|format_datetime(pattern: 'd. MMMM y') }} bis
{{ filter.toDate|format_datetime(pattern: 'd. MMMM y') }}
· {{ pagination.totalItemCount }} {{ pagination.totalItemCount == 1 ? 'Ereignis' : 'Ereignisse' }}
</p>
<div class="data-table-wrapper">
<div class="data-table-wrapper__inner text-sm">
<table class="data-table">
<thead>
<tr>
<th>{{ knp_pagination_sortable(pagination, 'Zeitpunkt', 'e.occurredAt') }}</th>
<th>{{ knp_pagination_sortable(pagination, 'Ereignis', 'e.type') }}</th>
<th>{{ knp_pagination_sortable(pagination, 'Ressource', 'e.resourceLabel') }}</th>
<th>{{ knp_pagination_sortable(pagination, 'Buchung', 'e.date') }}</th>
<th>{{ knp_pagination_sortable(pagination, 'Für', 'e.holderName') }}</th>
<th>{{ knp_pagination_sortable(pagination, 'Durchgeführt von', 'e.actorName') }}</th>
</tr>
</thead>
<tbody>
{% for event in pagination %}
{% set type = event.type.value %}
<tr>
<td class="whitespace-nowrap">{{ event.occurredAt|format_datetime(pattern: 'dd.MM.y, HH:mm') }}</td>
<td class="whitespace-nowrap">
<span class="badge {{ event.type.badgeClass }}">{{ event.type.label }}</span>
</td>
<td>
{{ event.resourceLabel }}
<span class="block text-xs text-gray-500">{{ event.resourceType.label }}</span>
</td>
<td>
{% if type in ['series_created', 'series_ended'] %}
{{ event.rule ? event.rule.label }}{% if event.timeRange %}, {{ event.timeRange }}{% endif %}
<span class="block text-xs text-gray-500">
{% if type == 'series_created' %}
{{ event.date|format_datetime(pattern: 'd. MMM y') }} – {{ event.endDate|format_datetime(pattern: 'd. MMM y') }}
· {{ event.details.created }} {{ event.details.created == 1 ? 'Termin' : 'Termine' }}
{%- if event.details.skipped is not empty %}, {{ event.details.skipped|length }} übersprungen{% endif %}
{% else %}
ab {{ event.date|format_datetime(pattern: 'd. MMM y') }}
· {{ event.details.removed }} {{ event.details.removed == 1 ? 'Termin' : 'Termine' }} entfernt
{% endif %}
</span>
{% elseif type == 'resource_deleted' %}
{{ event.details.removed }} {{ event.details.removed == 1 ? 'Buchung' : 'Buchungen' }} gelöscht
{% else %}
<span class="whitespace-nowrap">{{ event.date|format_datetime(pattern: 'EEE, d. MMM y') }}</span>
<span class="block text-xs text-gray-500">
{% if event.previousTimeRange %}
{{ event.previousTimeRange }} → {{ event.timeRange }}
{% else %}
{{ event.timeRange }}
{% endif %}
{%- if event.seriesId %} · wiederkehrend{% endif %}
</span>
{% endif %}
</td>
<td>{{ event.holderName ?? '–' }}</td>
<td>
{{ event.actorName }}
{% if event.onBehalf %}
<span class="badge bg-gray-100 text-gray-600">stellvertretend</span>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="text-gray-500">Keine Ereignisse in diesem Zeitraum.</td>
</tr>
{% endfor %}
</tbody>
</table>
{{ knp_pagination_render(pagination) }}
</div>
</div>
{% endblock %}
+11
View File
@@ -87,6 +87,17 @@
Buchbare Ressourcen und ihre Standorte verwalten.
</p>
</a>
<a href="{{ path('app_admin_booking_event') }}"
class="group flex flex-col rounded-md border border-gray-200 p-6 hover:border-primary hover:bg-gray-50">
<div class="flex items-center gap-3 pb-2">
{{ ux_icon('lucide:history', { class: 'h-6 w-6 shrink-0 text-primary' }) }}
<span class="text-lg font-bold">Ereignishistorie</span>
</div>
<p class="text-sm text-gray-500 pb-4">
Nachvollziehen, wer welche Buchung angelegt, geändert oder freigegeben hat.
</p>
</a>
{% endif %}
</div>
{% endblock %}