Implemented additional event information

This commit is contained in:
2026-08-12 15:54:00 +02:00
parent 9581176a0c
commit e95de52768
16 changed files with 327 additions and 22 deletions
@@ -27,7 +27,7 @@ class SetParticipationOptionsCommand
* Geltungsbereichs eindeutig, damit Antworten robust referenzieren können. * Geltungsbereichs eindeutig, damit Antworten robust referenzieren können.
* *
* @param array<int, array<string, mixed>> $questions * @param array<int, array<string, mixed>> $questions
* @return array<int, array{key: string, label: string, options: array<int, array{value: string, label: string}>}> * @return array<int, array{key: string, title: string, label: string, options: array<int, array{value: string, label: string}>}>
*/ */
private function normalize(array $questions): array private function normalize(array $questions): array
{ {
@@ -35,21 +35,25 @@ class SetParticipationOptionsCommand
$usedKeys = []; $usedKeys = [];
foreach ($questions as $question) { foreach ($questions as $question) {
$label = trim((string)($question['label'] ?? '')); // Titel ist Pflicht (Überschrift/Feldbeschriftung); die Frage ist optionaler Erklärtext.
if ($label === '') { $title = trim((string)($question['title'] ?? ''));
if ($title === '') {
continue; continue;
} }
$label = trim((string)($question['label'] ?? ''));
$options = $this->normalizeOptions($question['options'] ?? []); $options = $this->normalizeOptions($question['options'] ?? []);
if ($options === []) { if ($options === []) {
continue; continue;
} }
$key = $this->uniqueSlug((string)($question['key'] ?? ''), $label, $usedKeys); $key = $this->uniqueSlug((string)($question['key'] ?? ''), $title, $usedKeys);
$usedKeys[] = $key; $usedKeys[] = $key;
$normalized[] = [ $normalized[] = [
'key' => $key, 'key' => $key,
'title' => $title,
'label' => $label, 'label' => $label,
'options' => $options, 'options' => $options,
]; ];
@@ -72,11 +72,63 @@ class UpdateParticipantCommand {
$p->save(); $p->save();
if ($this->request->participationOptions !== null) {
$this->syncParticipationOptions($p);
}
$this->response->success = true; $this->response->success = true;
$this->response->participant = $p; $this->response->participant = $p;
return $this->response; return $this->response;
} }
/**
* Ersetzt die zugeordneten Teilnahmeoptionen anhand der übergebenen Antworten. Nachträgliche Zuordnung im
* Back-Office ist lückenlos möglich: nur gegen die Event-Definition gültige Antworten werden gespeichert,
* leere/entfallene Antworten werden abgeräumt. Frage-/Options-Label werden als Snapshot mitgeschrieben.
*/
private function syncParticipationOptions(EventParticipant $participant): void
{
$participant->selectedOptions()->delete();
$answers = $this->request->participationOptions ?? [];
foreach ($participant->event?->participation_options ?? [] as $question) {
$key = $question['key'] ?? null;
if ($key === null) {
continue;
}
$value = $answers[$key] ?? null;
if ($value === null || $value === '') {
continue;
}
$option = null;
foreach ($question['options'] ?? [] as $candidate) {
if (($candidate['value'] ?? null) === $value) {
$option = $candidate;
break;
}
}
if ($option === null) {
continue;
}
$label = trim((string)($question['label'] ?? ''));
$participant->selectedOptions()->create([
'tenant' => $participant->tenant,
'event_id' => $participant->event_id,
'question_key' => $key,
'question_label' => $label !== '' ? $label : ($question['title'] ?? $key),
'value' => $value,
'value_label' => $option['label'] ?? $value,
]);
}
}
private function handleAmountChanges(EventParticipant $participant) { private function handleAmountChanges(EventParticipant $participant) {
$this->response->amountPaid = $participant->amount_paid; $this->response->amountPaid = $participant->amount_paid;
$this->response->amountExpected = $participant->amount; $this->response->amountExpected = $participant->amount;
@@ -36,5 +36,7 @@ class UpdateParticipantRequest {
public string $amountPaid, public string $amountPaid,
public string $amountExpected, public string $amountExpected,
public string $cocStatus, public string $cocStatus,
/** Antworten auf die Teilnahmeoptionen: [ question_key => value ]. null = unverändert lassen. */
public ?array $participationOptions = null,
) {} ) {}
} }
@@ -208,6 +208,9 @@ class DetailsController extends CommonController {
case 'by-participation-group': case 'by-participation-group':
$participants = $this->eventParticipants->groupByParticipationType($event, $request); $participants = $this->eventParticipants->groupByParticipationType($event, $request);
break; break;
case 'by-participation-option':
$participants = $this->eventParticipants->groupByParticipationOption($event, $request);
break;
case 'signed-off': case 'signed-off':
$participants = $this->eventParticipants->getSignedOffParticipants($event, $request); $participants = $this->eventParticipants->getSignedOffParticipants($event, $request);
break; break;
@@ -19,6 +19,10 @@ class ByGroupController extends CommonController
$participants = $this->eventParticipants->groupByParticipationType($event, $request, $request->input('groupName')); $participants = $this->eventParticipants->groupByParticipationType($event, $request, $request->input('groupName'));
$recipients = $this->eventParticipants->getMailAddresses($participants[$request->input('groupName')]); $recipients = $this->eventParticipants->getMailAddresses($participants[$request->input('groupName')]);
break; break;
case 'by-participation-option':
$participants = $this->eventParticipants->groupByParticipationOption($event, $request, $request->input('groupName'));
$recipients = $this->eventParticipants->getMailAddresses($participants[$request->input('groupName')] ?? []);
break;
case 'signed-off': case 'signed-off':
$participants = $this->eventParticipants->getSignedOffParticipants($event, $request, $request->input('groupName')); $participants = $this->eventParticipants->getSignedOffParticipants($event, $request, $request->input('groupName'));
$recipients = $this->eventParticipants->getMailAddresses($participants[$request->input('groupName')]); $recipients = $this->eventParticipants->getMailAddresses($participants[$request->input('groupName')]);
@@ -44,6 +44,7 @@ class ParticipantUpdateController extends CommonController {
amountPaid: $request->input('amountPaid'), amountPaid: $request->input('amountPaid'),
amountExpected: $request->input('amountExpected'), amountExpected: $request->input('amountExpected'),
cocStatus: $request->input('cocStatus'), cocStatus: $request->input('cocStatus'),
participationOptions: $request->has('participationOptions') ? (array)$request->input('participationOptions') : null,
); );
$command = new UpdateParticipantCommand($updateRequest); $command = new UpdateParticipantCommand($updateRequest);
+7 -1
View File
@@ -1,9 +1,10 @@
<script setup> <script setup>
import {reactive, inject, onMounted} from 'vue'; import {onMounted} from 'vue';
import AppLayout from "../../../../resources/js/layouts/AppLayout.vue"; import AppLayout from "../../../../resources/js/layouts/AppLayout.vue";
import ShadowedBox from "../../../Views/Components/ShadowedBox.vue"; import ShadowedBox from "../../../Views/Components/ShadowedBox.vue";
import TabbedPage from "../../../Views/Components/TabbedPage.vue"; import TabbedPage from "../../../Views/Components/TabbedPage.vue";
import ParticipantsList from "./Partials/ParticipantsList.vue"; import ParticipantsList from "./Partials/ParticipantsList.vue";
import ParticipantsByOption from "./Partials/ParticipantsByOption.vue";
import Overview from "./Partials/Overview.vue"; import Overview from "./Partials/Overview.vue";
const props = defineProps({ const props = defineProps({
@@ -31,6 +32,11 @@ const tabs = [
component: ParticipantsList, component: ParticipantsList,
endpoint: "/api/v1/event/details/" + props.event.identifier + '/participants/by-participation-group', endpoint: "/api/v1/event/details/" + props.event.identifier + '/participants/by-participation-group',
}, },
{
title: 'Teilis nach Teilnahmeoption',
component: ParticipantsByOption,
endpoint: "/api/v1/event/details/" + props.event.identifier + '/participants/by-participation-option',
},
{ {
title: 'Abgemeldete Teilis', title: 'Abgemeldete Teilis',
component: ParticipantsList, component: ParticipantsList,
@@ -1,5 +1,5 @@
<script setup> <script setup>
import {onMounted, reactive, watch} from "vue"; import {computed, onMounted, reactive, watch} from "vue";
import AmountInput from "../../../../Views/Components/AmountInput.vue"; import AmountInput from "../../../../Views/Components/AmountInput.vue";
import DialableTelephoneNumber from "../../../../Views/Components/DialableTelephoneNumber.vue"; import DialableTelephoneNumber from "../../../../Views/Components/DialableTelephoneNumber.vue";
@@ -60,8 +60,18 @@ const form = reactive({
amountPaid: '', amountPaid: '',
amountExpected: '', amountExpected: '',
cocStatus: '', cocStatus: '',
participationOptions: {},
}); });
// Teilnahmeoptionen des Events (Definition mit Titel/Frage/Optionen).
const participationQuestions = computed(() => staticProps.event?.participationOptions ?? []);
// Gewählte Antwort (Options-Label) eines Teilnehmers zu einer Frage für die Ansicht „Titel: Antwort".
function answerLabelFor(questionKey) {
const answer = (props.participant?.participationOptions ?? []).find(o => o.questionKey === questionKey);
return answer?.valueLabel ?? '—';
}
watch( watch(
() => props.participant, () => props.participant,
(participant) => { (participant) => {
@@ -94,6 +104,9 @@ watch(
form.amountPaid = participant?.amountPaid.short ?? ''; form.amountPaid = participant?.amountPaid.short ?? '';
form.amountExpected = participant?.amountExpected.short ?? ''; form.amountExpected = participant?.amountExpected.short ?? '';
form.cocStatus = participant?.efz_status ?? ''; form.cocStatus = participant?.efz_status ?? '';
form.participationOptions = Object.fromEntries(
(participant?.participationOptions ?? []).map(o => [o.questionKey, o.value])
);
}, },
{ immediate: true } { immediate: true }
); );
@@ -388,6 +401,27 @@ function saveParticipant() {
</table> </table>
</div> </div>
</div> </div>
<div class="participationData" v-if="participationQuestions.length > 0">
<div>
<h3>Teilnahmeoptionen</h3>
<table>
<tr v-for="question in participationQuestions" :key="question.key">
<th>{{ question.title || question.label }}</th>
<td>
<span v-if="!staticProps.editMode">{{ answerLabelFor(question.key) }}</span>
<select v-else v-model="form.participationOptions[question.key]">
<option value=""> keine </option>
<option v-for="option in question.options" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</td>
</tr>
</table>
</div>
<div></div>
</div>
</div> </div>
<button v-if="!staticProps.editMode" class="button" @click="enableEditMode">Bearbeiten</button> <button v-if="!staticProps.editMode" class="button" @click="enableEditMode">Bearbeiten</button>
@@ -407,6 +441,10 @@ function saveParticipant() {
gap: 20px; gap: 20px;
} }
.participationData table tr th {
width: 200px;
}
.participationData div { .participationData div {
flex: 1; flex: 1;
vertical-align: top; vertical-align: top;
@@ -0,0 +1,67 @@
<script setup>
import {computed, inject} from "vue";
import ParticipantsList from "./ParticipantsList.vue";
const props = defineProps({
data: {
type: Object,
default: () => ({participants: {}, event: {}, listType: ''}),
},
});
// Von TabbedPage bereitgestellt: erlaubt den Sprung zum Übersicht-Tab (Index 0), wo die
// Teilnahmeoptionen konfiguriert werden.
const selectTab = inject('selectTab', null);
const hasOptions = computed(() => (props.data?.event?.participationOptions?.length ?? 0) > 0);
function goToOverview() {
if (selectTab) {
selectTab(0);
}
}
</script>
<template>
<ParticipantsList v-if="hasOptions" :data="data" :show-group-mail="false"/>
<div v-else class="no-options">
<h3>Keine Teilnahmeoptionen angelegt</h3>
<p>
Für diese Veranstaltung sind noch keine Teilnahmeoptionen angelegt. Sobald du unter
<strong>Übersicht Teilnahmeoptionen</strong> Fragen definierst, werden die Teilnehmenden hier nach
ihrer Auswahl gruppiert.
</p>
<button v-if="selectTab" type="button" class="button" @click="goToOverview">
Zu den Teilnahmeoptionen
</button>
</div>
</template>
<style scoped>
.no-options {
max-width: 640px;
margin: 20px auto;
padding: 24px;
background: #f8fafc;
border: 1px solid #e5e7eb;
border-radius: 10px;
text-align: center;
}
.no-options p {
color: #4b5563;
line-height: 1.6;
}
.button {
margin-top: 12px;
padding: 8px 18px;
background: #2563eb;
color: #fff;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
}
</style>
@@ -20,6 +20,11 @@ const props = defineProps({
listType: '', listType: '',
}), }),
}, },
// Steuert, ob je Gruppe der „E-Mail an Gruppe senden"-Button angezeigt wird.
showGroupMail: {
type: Boolean,
default: true,
},
}); });
const today = format(new Date(), "yyyy-MM-dd"); const today = format(new Date(), "yyyy-MM-dd");
@@ -404,7 +409,8 @@ function mailToGroup(groupKey) {
27 Jahre und älter: <strong>{{ getAgeCounts(participants)['27+'] ?? 0 }}</strong> 27 Jahre und älter: <strong>{{ getAgeCounts(participants)['27+'] ?? 0 }}</strong>
</td> </td>
<td class="pl-summary-action"> <td class="pl-summary-action">
<input type="button" class="button" @click="mailToGroup(groupKey)" value="E-Mail an Gruppe senden" /> <input v-if="showGroupMail" type="button" class="button" @click="mailToGroup(groupKey)"
value="E-Mail an Gruppe senden"/>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -14,12 +14,13 @@ const props = defineProps({
// neue Einträge bekommen serverseitig beim Speichern einen stabilen Slug. // neue Einträge bekommen serverseitig beim Speichern einen stabilen Slug.
const questions = ref((props.event.participationOptions ?? []).map(q => ({ const questions = ref((props.event.participationOptions ?? []).map(q => ({
key: q.key ?? '', key: q.key ?? '',
title: q.title ?? '',
label: q.label ?? '', label: q.label ?? '',
options: (q.options ?? []).map(o => ({value: o.value ?? '', label: o.label ?? ''})), options: (q.options ?? []).map(o => ({value: o.value ?? '', label: o.label ?? ''})),
}))) })))
function addQuestion() { function addQuestion() {
questions.value.push({key: '', label: '', options: [{value: '', label: ''}, {value: '', label: ''}]}) questions.value.push({key: '', title: '', label: '', options: [{value: '', label: ''}, {value: '', label: ''}]})
} }
function removeQuestion(index) { function removeQuestion(index) {
@@ -36,8 +37,8 @@ function removeOption(question, index) {
function validate() { function validate() {
for (const question of questions.value) { for (const question of questions.value) {
if (question.label.trim() === '') { if (question.title.trim() === '') {
toast.error('Bitte für jede Frage eine Beschriftung angeben.') toast.error('Bitte für jede Frage einen Titel angeben.')
return false return false
} }
@@ -83,12 +84,23 @@ async function save() {
<div v-for="(question, qIndex) in questions" :key="qIndex" class="question-card"> <div v-for="(question, qIndex) in questions" :key="qIndex" class="question-card">
<div class="question-head"> <div class="question-head">
<div class="question-fields">
<label class="field-label">Titel (Überschrift / Feldbeschriftung)</label>
<input
type="text"
v-model="question.title"
class="width-full"
placeholder="Titel, z. B. „Kurs“"
/>
<label class="field-label">Frage / Erklärtext (optional)</label>
<input <input
type="text" type="text"
v-model="question.label" v-model="question.label"
class="width-full" class="width-full"
placeholder="Frage, z. B. „Welchen Kurs möchtest du belegen?“" placeholder="z. B. „Welchen Kurs möchtest du belegen?“"
/> />
</div>
<label class="link remove-link" @click="removeQuestion(qIndex)">Frage entfernen</label> <label class="link remove-link" @click="removeQuestion(qIndex)">Frage entfernen</label>
</div> </div>
@@ -127,10 +139,25 @@ async function save() {
.question-head { .question-head {
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 12px; gap: 12px;
} }
.question-fields {
flex: 1;
}
.field-label {
display: block;
font-size: 0.78rem;
color: #6b7280;
margin: 6px 0 2px;
}
.question-fields .field-label:first-child {
margin-top: 0;
}
.options { .options {
margin-top: 12px; margin-top: 12px;
padding-left: 12px; padding-left: 12px;
@@ -28,7 +28,10 @@ const next = () => emit('next', nextVisibleFrom(props.event, STEP_PARTICIPATION_
<table class="form-table"> <table class="form-table">
<tr v-for="question in questions" :key="question.key"> <tr v-for="question in questions" :key="question.key">
<td>{{ question.label }}:</td> <td>
{{ question.title || question.label }}:
<span v-if="question.title && question.label" class="option-hint">{{ question.label }}</span>
</td>
<td> <td>
<select v-model="formData.participationOptions[question.key]"> <select v-model="formData.participationOptions[question.key]">
<option value="">Bitte wählen</option> <option value="">Bitte wählen</option>
@@ -46,3 +49,14 @@ const next = () => emit('next', nextVisibleFrom(props.event, STEP_PARTICIPATION_
</div> </div>
</div> </div>
</template> </template>
<style scoped>
.option-hint {
display: block;
margin-top: 2px;
font-size: 0.8rem;
font-style: italic;
font-weight: 400;
color: #6b7280;
}
</style>
@@ -40,7 +40,7 @@ const selectedParticipationOptions = computed(() =>
(props.event.participationOptions ?? []).map(question => { (props.event.participationOptions ?? []).map(question => {
const value = props.formData.participationOptions[question.key] const value = props.formData.participationOptions[question.key]
const option = (question.options ?? []).find(o => o.value === value) const option = (question.options ?? []).find(o => o.value === value)
return {label: question.label, value: option?.label ?? ''} return {label: question.title || question.label, value: option?.label ?? ''}
}).filter(entry => entry.value !== '') }).filter(entry => entry.value !== '')
) )
@@ -63,6 +63,84 @@ class EventParticipantRepository {
return $participants; return $participants;
} }
/**
* Gruppiert die (aktiven) Teilnehmenden nach ihren gewählten Teilnahmeoptionen. Je am Event definierter
* Frage entsteht eine Sektion mit Gruppen „Frage: Option" (in Definition-Reihenfolge); Teilnehmende ohne
* Antwort landen unter „Frage: (keine Angabe)". Ein Teilnehmer erscheint je Frage einmal, kann also über
* mehrere Fragen hinweg in mehreren Gruppen auftauchen. `$filter` liefert nur die eine passende Gruppe
* (für die E-Mail-Auflösung in ByGroupController).
*/
public function groupByParticipationOption(Event $event, Request $request, ?string $filter = null): array
{
$definitions = $event->participation_options ?? [];
$allParticipants = $this->getForList($event, $request);
$groups = [];
foreach ($definitions as $question) {
$questionKey = $question['key'] ?? null;
if ($questionKey === null) {
continue;
}
// Überschrift: bevorzugt der Titel, sonst die (längere) Frage bzw. der Key als Fallback.
$questionLabel = trim((string)($question['title'] ?? '')) !== ''
? $question['title']
: ($question['label'] ?? $questionKey);
$noAnswerKey = $questionLabel . ': (keine Angabe)';
// Vordefinierte Optionen in Reihenfolge: value => Gruppen-Key.
$definedGroupKeys = [];
foreach ($question['options'] ?? [] as $option) {
$definedGroupKeys[$option['value']] = $questionLabel . ': ' . ($option['label'] ?? $option['value']);
}
$collected = [];
foreach ($allParticipants as $participant) {
$answer = null;
foreach ($participant['participationOptions'] ?? [] as $selectedOption) {
if (($selectedOption['questionKey'] ?? null) === $questionKey) {
$answer = $selectedOption;
break;
}
}
if ($answer === null || ($answer['value'] ?? '') === '') {
$collected[$noAnswerKey][] = $participant;
continue;
}
// Bevorzugt die definierte Option; falls sie zwischenzeitlich entfernt wurde, den Snapshot nutzen.
$groupKey = $definedGroupKeys[$answer['value']] ?? ($questionLabel . ': ' . ($answer['valueLabel'] ?? $answer['value']));
$collected[$groupKey][] = $participant;
}
// Ausgabe je Frage: definierte Optionen zuerst (in Reihenfolge), dann Snapshot-Reste, „(keine Angabe)" ans Ende.
foreach ($definedGroupKeys as $groupKey) {
if (!empty($collected[$groupKey])) {
$groups[$groupKey] = $collected[$groupKey];
unset($collected[$groupKey]);
}
}
foreach ($collected as $groupKey => $participants) {
if ($groupKey === $noAnswerKey) {
continue;
}
$groups[$groupKey] = $participants;
}
if (!empty($collected[$noAnswerKey])) {
$groups[$noAnswerKey] = $collected[$noAnswerKey];
}
}
if ($filter !== null) {
return array_key_exists($filter, $groups) ? [$filter => $groups[$filter]] : [];
}
return $groups;
}
public function getSignedOffParticipants(Event $event, Request $request, ?string $filter = null) : array { public function getSignedOffParticipants(Event $event, Request $request, ?string $filter = null) : array {
$allParticipants = $this->getForList($event, $request, true); $allParticipants = $this->getForList($event, $request, true);
$participants = []; $participants = [];
+2 -2
View File
@@ -32,7 +32,7 @@
</template> </template>
<script setup> <script setup>
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue' import {nextTick, onMounted, onUnmounted, ref, watch} from 'vue'
const props = defineProps({ const props = defineProps({
show: Boolean, show: Boolean,
@@ -115,7 +115,7 @@ onUnmounted(() => {
} }
.modal-body { .modal-body {
max-height: 600px; max-height: 80vh;
overflow: auto; overflow: auto;
} }
+4 -1
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, shallowRef, onMounted } from "vue" import {onMounted, provide, ref, shallowRef} from "vue"
const props = defineProps({ const props = defineProps({
tabs: { tabs: {
@@ -55,6 +55,9 @@ async function selectTab(index) {
} }
} }
// Kind-Komponenten (z. B. Leer-Zustands-Hinweise) können damit gezielt zu einem anderen Tab springen.
provide('selectTab', selectTab)
onMounted(() => { onMounted(() => {
if (props.tabs.length > 0) { if (props.tabs.length > 0) {
selectTab(props.subTabIndex) selectTab(props.subTabIndex)