Implemented additional event information
This commit is contained in:
+8
-4
@@ -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);
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user