feat: mailing to filtered teamer list
addresses #869dv9bur
This commit is contained in:
@@ -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 -%}
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user