feat: mailing to filtered teamer list

addresses #869dv9bur
This commit is contained in:
Björn Fromme
2026-08-11 16:25:53 +02:00
parent 48cd2d6b67
commit 6511f821ac
18 changed files with 1223 additions and 7 deletions
@@ -0,0 +1,62 @@
{% set chips = [] %}
{% if filterDto.name %}
{% set chips = chips|merge([{ 'label': 'Name', 'value': filterDto.name }]) %}
{% endif %}
{% if filterDto.availableAt %}
{% set chips = chips|merge([{ 'label': 'verfügbar am', 'value': filterDto.availableAt|date('d.m.Y') }]) %}
{% endif %}
{% if filterDto.jobProfiles|length > 0 %}
{% set chips = chips|merge([{ 'label': 'Job-Profile', 'value': filterDto.jobProfiles|map(p => p.name)|join(', ') }]) %}
{% endif %}
{% if filterDto.pickupId %}
{% set chips = chips|merge([{ 'label': 'Buszustieg', 'value': filterDto.pickupId }]) %}
{% endif %}
{% if filterDto.skiLicense %}
{% set chips = chips|merge([{ 'label': 'Lizenz', 'value': 'Skilehrer:innen' }]) %}
{% endif %}
{% if filterDto.snowboardLicense %}
{% set chips = chips|merge([{ 'label': 'Lizenz', 'value': 'Snowboardlehrer:innen' }]) %}
{% endif %}
{% if filterDto.driverLicenseVerified %}
{% set chips = chips|merge([{ 'label': 'Führerschein', 'value': 'bestätigt' }]) %}
{% endif %}
{% if filterDto.noTrainings %}
{% set chips = chips|merge([{ 'label': 'Fortbildungen', 'value': 'keine besucht' }]) %}
{% endif %}
{% if filterDto.includeInactive %}
{% set chips = chips|merge([{ 'label': 'inaktive', 'value': 'eingeschlossen' }]) %}
{% endif %}
{% if filterDto.includeDeleted %}
{% set chips = chips|merge([{ 'label': 'gelöschte', 'value': 'eingeschlossen' }]) %}
{% endif %}
<div class="p-4 bg-gray-50 border border-gray-200 rounded-md">
<div class="flex items-start justify-between space-x-4">
<div>
<div class="font-bold pb-2">Aktueller Filter</div>
{% if chips|length > 0 %}
<div class="flex flex-wrap gap-2">
{% for chip in chips %}
<span class="inline-flex items-center space-x-1 py-1 px-2 text-sm bg-white border border-gray-200 rounded">
<span class="text-gray-500">{{ chip.label }}:</span>
<span>{{ chip.value }}</span>
</span>
{% endfor %}
</div>
{% else %}
<div class="text-gray-500">
Keine Filter aktiv die Mail geht an alle aktiven Teamer:innen.
</div>
{% endif %}
</div>
<button type="button"
class="{{ html_classes('btn btn--small shrink-0', { 'btn--secondary': filterDto.active }) }}"
title="Team filtern"
hx-get="{{ path('app_common_teamer_filter', { 'r': return_url() }) }}"
hx-target="body"
hx-swap="beforeend">
{{ icon('filter', 'w-4 h-4 shrink-0') }}
<span>Filter ändern</span>
</button>
</div>
</div>
@@ -0,0 +1,33 @@
{#- the reasons are prepositional phrases on purpose: an attributive adjective would have to
agree in number and gender ("1 gelöschte:r" / "2 gelöschte"), which reads badly next to
the gender neutral Teamer:innen and is not worth inflecting for a status line -#}
{%- set reasons = [] -%}
{%- if recipients.skippedNoEmail > 0 -%}
{%- set reasons = reasons|merge([{ 'count': recipients.skippedNoEmail, 'phrase': 'ohne E-Mail-Adresse' }]) -%}
{%- endif -%}
{%- if recipients.skippedDeleted > 0 -%}
{%- set reasons = reasons|merge([{ 'count': recipients.skippedDeleted, 'phrase': 'mit gelöschtem Account' }]) -%}
{%- endif -%}
{%- if recipients.skippedDisabled > 0 -%}
{%- set reasons = reasons|merge([{ 'count': recipients.skippedDisabled, 'phrase': 'mit gesperrtem Account' }]) -%}
{%- endif -%}
{%- set noun = recipients.skippedCount == 1 ? 'Teamer:in' : 'Teamer:innen' -%}
{%- set verb = recipients.skippedCount == 1 ? 'wird' : 'werden' -%}
{%- if reasons|length == 1 -%}
{#- one reason carries the whole count, so it reads as a single sentence -#}
{{- '%d %s %s %s übersprungen'|format(recipients.skippedCount, noun, reasons|first.phrase, verb) -}}
{%- else -%}
{%- set parts = [] -%}
{%- for reason in reasons -%}
{%- set parts = parts|merge(['%d %s'|format(reason.count, reason.phrase)]) -%}
{%- endfor -%}
{{- '%d %s %s übersprungen: %s und %s'|format(
recipients.skippedCount,
noun,
verb,
parts|slice(0, parts|length - 1)|join(', '),
parts|last
) -}}
{%- endif -%}
+80
View File
@@ -0,0 +1,80 @@
{% extends 'administrative/layout.html.twig' %}
{% block title %}Mailing{% endblock %}
{% block content %}
<div class="flex items-start justify-between pb-4">
<h1 class="text-2xl font-bold">
Mailing an das Team
</h1>
<a href="{{ returnUrl }}" class="btn btn--small btn--secondary shrink-0">
Zurück zur Teamübersicht
</a>
</div>
<div class="pb-6">
{{ include('admin/teamer/_mailing_filter_summary.html.twig', { 'filterDto': filterDto }) }}
</div>
<div class="pb-6">
<div class="text-xl font-bold">
{{ recipients.eligibleCount }} Empfänger:innen
</div>
{% if recipients.hasSkipped %}
<div class="text-sm text-gray-500">
{{ include('admin/teamer/_mailing_skipped.html.twig', { 'recipients': recipients }) }}
</div>
{% endif %}
</div>
{# the draft is parked on every keystroke so that a trip to the filter and back keeps it,
hx-trigger replaces htmx' default submit trigger, the preview button still posts natively #}
{{ form_start(form, { 'attr': {
'hx-post': path('app_admin_teamer_mailing_draft'),
'hx-trigger': 'input changed delay:500ms',
'hx-swap': 'none',
} }) }}
<div class="flex flex-col space-y-4 pb-4 max-w-3xl">
{{ form_row(form.subject) }}
{{ form_row(form.message) }}
</div>
<div class="pb-8 max-w-3xl text-sm text-gray-500">
<div class="pb-2">
Platzhalter für Betreff und Nachricht, sie werden beim Senden pro Person ersetzt:
</div>
<div class="flex flex-wrap gap-2">
{% for placeholder, label in placeholders %}
<span class="inline-flex items-center space-x-1 py-1 px-2 bg-gray-50 border border-gray-200 rounded">
<code>{{ placeholder }}</code>
<span>{{ label }}</span>
</span>
{% endfor %}
</div>
</div>
<div class="flex items-center space-x-2">
{# nobody to write to means there is nothing to confirm, only the preview stays useful #}
{% if recipients.eligibleCount > 0 %}
<button type="button"
class="btn"
hx-post="{{ path('app_admin_teamer_mailing_confirm') }}"
hx-include="closest form"
hx-target="body"
hx-swap="beforeend">
{{ icon('mail', 'w-4 h-4 shrink-0') }}
<span>Senden</span>
</button>
{% endif %}
<button type="submit" class="btn btn--secondary">
Vorschau an mich senden
</button>
{% if hasDraft %}
<a href="{{ path('app_admin_teamer_mailing_discard') }}" class="btn btn--secondary">
Entwurf verwerfen
</a>
{% endif %}
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
@@ -0,0 +1,44 @@
{% extends 'htmx_confirmation_modal.html.twig' %}
{% set sendable = valid and recipients.eligibleCount > 0 %}
{% block title %}Mail wirklich senden?{% endblock %}
{% block content %}
{% if not valid %}
<div class="pb-4">
Betreff und Nachricht müssen ausgefüllt sein, bevor die Mail gesendet werden kann.
</div>
{% elseif recipients.eligibleCount == 0 %}
<div class="pb-4">
Der aktuelle Filter trifft auf niemanden zu, dem geschrieben werden kann.
</div>
{% else %}
<div class="pb-4">
Die Mail geht an <strong>{{ recipients.eligibleCount }}</strong> Teamer:innen. Das lässt
sich nicht zurücknehmen.
</div>
<div class="p-4 bg-gray-50 border border-gray-200 rounded-md">
<div class="font-bold pb-2">{{ mailingDto.subject }}</div>
<div class="text-sm">{{ mailingDto.message | u.truncate(400, '…', false) | nl2br }}</div>
</div>
{% if recipients.hasSkipped %}
<div class="pt-4 text-sm text-gray-500">
{{ include('admin/teamer/_mailing_skipped.html.twig', { 'recipients': recipients }) }}
</div>
{% endif %}
<div id="mailing-confirm-fields" class="hidden">
<input type="hidden" name="{{ field_name(form.subject) }}" value="{{ field_value(form.subject) }}">
{# a textarea keeps the line breaks of the message intact #}
<textarea name="{{ field_name(form.message) }}">{{ field_value(form.message) }}</textarea>
{% if form._token is defined %}
{{ form_widget(form._token) }}
{% endif %}
</div>
{% endif %}
{% endblock %}
{# without a complete mail there is nothing to confirm, the button only leads back to the draft #}
{% block button_confirm %}{{ sendable ? 'Jetzt senden' : 'Zurück zum Entwurf' }}{% endblock %}
{% block button_attribute %}{% if sendable %}hx-post="{{ path('app_admin_teamer_mailing_send') }}" hx-include="#mailing-confirm-fields"{% else %}{{ stimulus_action('modal', 'close') }}{% endif %}{% endblock %}