Manual mails can be sent

This commit is contained in:
2026-04-18 20:52:13 +02:00
parent ed7f887e3a
commit ff98f0860c
235 changed files with 151212 additions and 50 deletions

View File

@@ -12,8 +12,6 @@ class CertificateOfConductionCheckCommand {
public function execute() : CertificateOfConductionCheckResponse {
$response = new CertificateOfConductionCheckResponse();
$localGroup = str_replace('Stamm ', '', $this->request->participant->localGroup()->first()->name);
$apiResponse = Http::acceptJson()

View File

@@ -190,6 +190,7 @@ class DetailsController extends CommonController {
return response()->json([
'participants' => $participants,
'listType' => $listType,
'event' => $event->toResource()->toArray($request)
]);
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Domains\Event\Controllers\MailCompose;
use App\Scopes\CommonController;
use Illuminate\Http\Request;
class ByGroupController extends CommonController
{
public function __invoke(string $eventIdentifier, string $groupType, Request $request) {
$event = $this->events->getByIdentifier($eventIdentifier, true);
$recipients = [];
switch ($groupType) {
case 'by-local-group':
$participants = $this->eventParticipants->groupByLocalGroup($event, $request, $request->input('groupName'));
$recipients = $this->eventParticipants->getMailAddresses($participants[$request->input('groupName')]);
break;
case 'by-participation-group':
$participants = $this->eventParticipants->groupByParticipationType($event, $request, $request->input('groupName'));
$recipients = $this->eventParticipants->getMailAddresses($participants[$request->input('groupName')]);
break;
case 'signed-off':
$participants = $this->eventParticipants->getSignedOffParticipants($event, $request, $request->input('groupName'));
$recipients = $this->eventParticipants->getMailAddresses($participants[$request->input('groupName')]);
break;
default:
$participants = $this->eventParticipants->getForList($event, $request);
$recipients = $this->eventParticipants->getMailAddresses($participants);
}
return response()->json(['recipients' => $recipients]);
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Domains\Event\Controllers\MailCompose;
use App\Mail\ManualMails\ManualMailsCommonMail;
use App\Mail\ManualMails\ManualMailsReportMail;
use App\Mail\ParticipantCocMails\ParticipantCocCompleteMail;
use App\Scopes\CommonController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
class SendController extends CommonController
{
public function __invoke(string $eventIdentifier, Request $request) {
$recipients = $request->input('recipients')
|> function (string $value) : string { return str_replace(';', ',', $value); }
|> function (string $value) : array { return explode( ',', $value); };
$event = $this->events->getByIdentifier($eventIdentifier, true)->toResource()->toArray($request);
$sentRecipients = [];
$allOkay = true;
$subject = $request->input('subject') ?? 'Neue Nachricht zu Veranstaltung "' . $event['name'] . '"';
foreach ($recipients as $recipient) {
if (in_array($recipient, $sentRecipients)) {
continue;
}
$sentRecipients[] = $recipient;
try {
Mail::to(trim($recipient))->send(new ManualMailsCommonMail(
mailSubject: $subject,
message: $request->input('message'),
event: $event,
));
} catch (\Exception $e) {
$allOkay = false;
}
}
$user = auth()->user();
$reportSubject = sprintf('Sendebericht für Nachricht mit Betreff "%s"', $subject);
Mail::to($user->email)->send(new ManualMailsReportMail(
mailSubject: $reportSubject,
message: $request->input('message'),
event: $event,
originalRecipients: $sentRecipients
));
if ($allOkay) {
return response()->json([
'success' => true,
'message' => sprintf(
'E-Mail wurde erfolgreich an %1$s Personen versendet. Du hast eine Kopie an deine Mail-Adresse erhalten.',
count($sentRecipients)
),
]);
} else {
return response()->json([
'success' => false,
'message' => 'Es gab einen Fehler beim Versenden der Nachrichten.'
]);
}
}
}

View File

@@ -2,6 +2,8 @@
use App\Domains\Event\Controllers\CreateController;
use App\Domains\Event\Controllers\DetailsController;
use App\Domains\Event\Controllers\MailCompose\ByGroupController;
use App\Domains\Event\Controllers\MailCompose\SendController;
use App\Domains\Event\Controllers\ParticipantController;
use App\Domains\Event\Controllers\ParticipantPaymentController;
use App\Domains\Event\Controllers\ParticipantReSignOnController;
@@ -21,6 +23,11 @@ Route::prefix('api/v1')
Route::middleware(['auth'])->group(function () {
Route::post('/create', [CreateController::class, 'doCreate']);
Route::prefix('{eventIdentifier}/mailing')->group(function () {
Route::post('/compose/to-group/{groupType}', ByGroupController::class);
Route::post('/send', SendController::class);
});
Route::prefix('/details/{eventId}') ->group(function () {
Route::get('/summary', [DetailsController::class, 'summary']);

View File

@@ -0,0 +1,104 @@
<script setup>
import {onMounted, reactive, ref} from "vue";
import {useAjax} from "../../../../../resources/js/components/ajaxHandler.js";
import TextEditor from "../../../../Views/Components/TextEditor.vue";
import ErrorText from "../../../../Views/Components/ErrorText.vue";
import {toast} from "vue3-toastify";
const { request } = useAjax();
const props = defineProps({
event: Object,
mailToType: String,
recipientIdentifier: String,
})
const data = reactive({
recipients: null,
});
onMounted(async () => {
const groupTypes = ['all', 'signed-off', 'by-local-group', 'by-participation-group'];
if (!groupTypes.includes(props.mailToType)) {
console.error('Unknown recipient identifier:', props.recipientIdentifier);
return;
}
const response = await request('/api/v1/event/' + props.event.identifier + '/mailing/compose/to-group/' + props.mailToType, {
method: "POST",
body: {
groupName: props.recipientIdentifier
}
});
data.recipients = response.recipients;
form.recipients = data.recipients.join(', ');
});
const errorMessage = ref(null)
const emit = defineEmits([
'closeComposer',
]);
function close() {
emit('closeComposer');
}
async function sendMail() {
const response = await request('/api/v1/event/' + props.event.identifier + '/mailing/send', {
method: "POST",
body: {
recipients: form.recipients,
subject: form.subject,
message: form.message,
}
});
if (response.success) {
close();
toast.success(response.message)
} else {
errorMessage.value = response.message
toast.error(response.message)
}
}
const form = reactive({
recipients: '',
sendCopy: true,
subject: '',
message: '',
});
</script>
<template>
<h2>E-Mail senden</h2>
<div style="display: flex; flex-direction: column; gap: 12px;">
<div>
<label style="font-weight: bold">Empfänger*innen</label>
<textarea v-model="form.recipients" placeholder="Senden an" style="width: 100%;" rows="3"></textarea>
</div>
<div>
<label style="font-weight: bold">Betreff</label>
<input type="text" v-model="form.subject" placeholder="Betreff" style="width: 100%;" />
</div><br /><br />
<div>
<label style="font-weight: bold">Nachricht</label>
<TextEditor v-model="form.message" />
</div>
<strong><ErrorText :message="errorMessage" /></strong>
</div>
<input type="button" @click="sendMail" value="Senden" class="" />
</template>
<style scoped>
</style>

View File

@@ -5,6 +5,8 @@
import CommonSettings from "./CommonSettings.vue";
import EventManagement from "./EventManagement.vue";
import Modal from "../../../../Views/Components/Modal.vue";
import MailCompose from "./MailCompose.vue";
import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue";
const props = defineProps({
data: Object,
@@ -47,6 +49,11 @@
Object.assign(dynamicProps, data);
});
const mailCompose = ref(false);
function mailToGroup() {
mailCompose.value = true
}
</script>
<template>
@@ -85,7 +92,7 @@
</a><br/>
<input type="button" class="fix-button" value="Zahlungserinnerung senden" /><br/>
<input type="button" class="deny-button" value="Letzte Mahnung senden" /><br/>
<input type="button" value="Rundmail senden" /><br/>
<input type="button" value="Rundmail senden" @click="mailToGroup" /><br/>
</div>
</div>
<div class="event-flexbox-row bottom">
@@ -130,6 +137,14 @@
</table>
</Modal>
<FullScreenModal
:show="mailCompose"
title="E-Mail senden"
@close="mailCompose = false"
>
<MailCompose @closeComposer="mailCompose = false" :event="dynamicProps.event" mailToType="all" recipientIdentifier="Alle Teilnehmenden" />
</FullScreenModal>
</template>
<style>

View File

@@ -2,10 +2,12 @@
import { computed, reactive, ref } from "vue";
import Modal from "../../../../Views/Components/Modal.vue";
import ParticipantData from "./ParticipantData.vue";
import MailCompose from "./MailCompose.vue";
import {toast} from "vue3-toastify";
import {useAjax} from "../../../../../resources/js/components/ajaxHandler.js";
import {format, getDay, getMonth, getYear} from "date-fns";
import AmountInput from "../../../../Views/Components/AmountInput.vue";
import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue";
const props = defineProps({
data: {
@@ -14,6 +16,7 @@ const props = defineProps({
localGroups: {},
participants: {},
event: {},
listType: '',
}),
},
});
@@ -31,6 +34,8 @@ const showParticipantDetails = ref(false);
const showParticipant = ref(null);
const editMode = ref(false);
const mailCompose = ref(false);
const openCancelDialog = ref(false);
const openPartialPaymentDialogSwitch = ref(false);
@@ -42,8 +47,6 @@ function openParticipantDetails(input) {
editMode.value = false;
}
console.log(props.data.participants)
async function saveParticipant(formData) {
if (!showParticipant.value?.identifier) {
return;
@@ -283,6 +286,15 @@ async function execPartialPayment() {
openPartialPaymentDialogSwitch.value = false;
}
const mailToType = ref('')
const recipientIdentifier = ref('')
function mailToGroup(groupKey) {
recipientIdentifier.value = groupKey;
mailToType.value = props.data.listType;
mailCompose.value = true
}
</script>
<template>
@@ -383,7 +395,7 @@ async function execPartialPayment() {
27 Jahre und älter: <strong>{{ getAgeCounts(participants)['27+'] ?? 0 }}</strong>
</td>
<td>
E-Mail an Gruppe senden
<input type="button" class="button" @click="mailToGroup(groupKey)" value="E-Mail an Gruppe senden" />
</td>
</tr>
</tbody>
@@ -394,7 +406,7 @@ async function execPartialPayment() {
<Modal
:show="showParticipantDetails"
title="Anmeldedetails ansehen"
@close="showParticipantDetails = false;toast.success('HALLO');"
@close="showParticipantDetails = false;"
>
<ParticipantData
@cancelParticipation="openCancelParticipationDialog"
@@ -433,6 +445,13 @@ async function execPartialPayment() {
<button class="button" @click="execPartialPayment()">Teilbetrag buchen</button>
</Modal>
<FullScreenModal
:show="mailCompose"
title="E-Mail senden"
@close="mailCompose = false"
>
<MailCompose @closeComposer="mailCompose = false" :event="event" :mailToType="mailToType" :recipientIdentifier="recipientIdentifier" />
</FullScreenModal>

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Mail\ManualMails;
use App\Models\Event;
use App\Models\EventParticipant;
use Illuminate\Http\Request;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
class ManualMailsCommonMail extends Mailable {
public function __construct(
private string $mailSubject,
private string $message,
private array $event,
)
{
//
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: $this->mailSubject,
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
view: 'emails.events.manual_mail',
with: [
'mailMessage' => ($this->message),
'eventTitle' => $this->event['name'],
'eventEmail' => $this->event['email'],
],
);
}
/**
* Get the attachments for the message.
*
* @return array<int, Attachment>
*/
public function attachments(): array
{
return [];
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Mail\ManualMails;
use App\Models\Event;
use App\Models\EventParticipant;
use Illuminate\Http\Request;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
class ManualMailsReportMail extends Mailable {
public function __construct(
private string $mailSubject,
private string $message,
private array $event,
private array $originalRecipients,
)
{
//
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: $this->mailSubject,
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
view: 'emails.events.manual_mail_report',
with: [
'mailMessage' => ($this->message),
'eventTitle' => $this->event['name'],
'eventEmail' => $this->event['email'],
'recipients' => implode('<br />', $this->originalRecipients),
'countRecipients' => count($this->originalRecipients),
],
);
}
/**
* Get the attachments for the message.
*
* @return array<int, Attachment>
*/
public function attachments(): array
{
return [];
}
}

View File

@@ -39,31 +39,37 @@ class EventParticipantRepository {
return $participant;
}
public function groupByLocalGroup(Event $event, Request $request) : array {
public function groupByLocalGroup(Event $event, Request $request, ?string $filter = null) : array {
$allParticipants = $this->getForList($event, $request);
$participants = [];
foreach ($allParticipants as $participant) {
$participants[$participant['localgroup']][] = $participant;
if ($filter === null || $participant['localgroup'] === $filter) {
$participants[$participant['localgroup']][] = $participant;
}
}
return $participants;
}
public function groupByParticipationType(Event $event, Request $request) : array {
public function groupByParticipationType(Event $event, Request $request, ?string $filter = null) : array {
$allParticipants = $this->getForList($event, $request);
$participants = [];
foreach ($allParticipants as $participant) {
$participants[$participant['participationType']][] = $participant;
if ($filter === null || $participant['participationType'] === $filter) {
$participants[$participant['participationType']][] = $participant;
}
}
return $participants;
}
public function getSignedOffParticipants(Event $event, Request $request) : array {
public function getSignedOffParticipants(Event $event, Request $request, ?string $filter = null) : array {
$allParticipants = $this->getForList($event, $request, true);
$participants = [];
foreach ($allParticipants as $participant) {
$participants[$participant['participationType']][] = $participant;
if ($filter === null || $participant['participationType'] === $filter) {
$participants[$participant['participationType']][] = $participant;
}
}
return $participants;
@@ -147,4 +153,18 @@ class EventParticipantRepository {
return $data;
}
public function getMailAddresses(array $participants) : array {
$mailAddresses = [];
foreach ($participants as $participant) {
if (!in_array($participant['email_1'], $mailAddresses)) {
$mailAddresses[] = $participant['email_1'];
}
if ($participant['email_2'] !== null && !in_array($participant['email_2'], $mailAddresses)) {
$mailAddresses[] = $participant['email_2'];
}
}
return $mailAddresses;
}
}

View File

@@ -40,26 +40,29 @@ function close() {
// ESC-Key & Focus-Trap
function handleKeyDown(e) {
if (e.key === 'Escape') {
close()
}
if (e.key === 'Tab' && modalRef.value) {
const focusable = modalRef.value.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
if (focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
if (e.key === 'Escape') {
close()
}
if (e.key === 'Tab' && modalRef.value) {
// Wenn der Fokus in einem iframe (z.B. TinyMCE) liegt, nicht eingreifen
if (document.activeElement?.tagName === 'IFRAME') return
const focusable = modalRef.value.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"]), iframe'
)
if (focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
}
}
// Body-Scroll sperren
@@ -87,27 +90,27 @@ onUnmounted(() => {
<style scoped>
.full-screen-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 9999;
display: flex;
justify-content: center;
align-items: center;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
justify-content: center;
align-items: center;
}
.full-screen-modal-content {
background: white;
border-radius: 12px;
position: absolute;
top: 30px;
bottom: 30px;
left: 30px;
right: 30px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
outline: none;
display: flex;
flex-direction: column;
background: white;
border-radius: 12px;
position: absolute;
top: 30px;
bottom: 30px;
left: 30px;
right: 30px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
outline: none;
display: flex;
flex-direction: column;
}
.full-screen-modal-body {

View File

@@ -0,0 +1,31 @@
<script setup>
import Editor from '@tinymce/tinymce-vue';
const model = defineModel()
</script>
<template>
<Editor
v-model="model"
tinymce-script-src="/tinymce/tinymce.min.js"
license-key="gpl"
:init="{
license_key: 'gpl',
base_url: '/tinymce',
suffix: '.min',
menubar: true,
plugins: 'link code table lists',
toolbar: 'undo redo | blocks bold italic underline | bullist numlist | link | table',
language: 'de',
language_url: '/tinymce/langs/de.js',
ui_mode: 'split',
}"
/>
</template>
<style scoped>
.tox .tox-promotion {
display: none !important;
}
</style>