From a0170f5952ee16a0879ac1d4f64d93ec9b9339ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Tue, 27 Oct 2026 17:22:22 +0100 Subject: [PATCH] refactor: split the questionnaire into teamer and bus feedback --- ...amer_assignment.yaml => bus_feedback.yaml} | 146 +---------- config/questionnaires/teamer_feedback.yaml | 135 ++++++++++ config/services.yaml | 2 + src/Config/QuestionnaireCatalog.php | 58 ++--- .../Questionnaire/DetailController.php | 16 +- .../Questionnaire/IndexController.php | 26 +- .../Questionnaire/TestController.php | 28 ++- .../QuestionnaireResponseFilterController.php | 12 +- .../Teamer/Questionnaire/DetailController.php | 31 ++- .../Teamer/Questionnaire/EditController.php | 79 +++--- src/Enum/QuestionnaireFlag.php | 13 - src/Form/QuestionnaireType.php | 76 +++--- src/Model/Questionnaire/Definition.php | 22 -- src/Model/Questionnaire/Section.php | 6 +- src/Repository/DispositionRepository.php | 45 +++- .../QuestionnaireResponseRepository.php | 13 +- src/Security/Voter/DispositionVoter.php | 10 +- .../Questionnaire/DispositionContext.php | 17 +- .../questionnaire/detail.html.twig | 14 ++ .../questionnaire/index.html.twig | 18 +- .../questionnaire/test.html.twig | 26 +- templates/forms.html.twig | 16 ++ .../teamer/questionnaire/detail.html.twig | 25 +- templates/teamer/questionnaire/edit.html.twig | 15 +- tests/Config/QuestionnaireCatalogTest.php | 85 ++----- .../Config/QuestionnaireCompletenessTest.php | 137 +++++++++++ .../Questionnaire/TestControllerTest.php | 16 +- .../Questionnaire/EditControllerTest.php | 230 ++++++++++++++++++ tests/Form/QuestionnaireTypeTest.php | 213 +++++++++------- tests/Model/Questionnaire/DefinitionTest.php | 14 -- ...DispositionRepositoryQuestionnaireTest.php | 44 +++- .../Repository/QuestionnaireFixturesTrait.php | 2 +- .../QuestionnaireResponseRepositoryTest.php | 2 +- .../Questionnaire/DispositionContextTest.php | 14 +- tests/Twig/QuestionnaireTemplateTest.php | 62 +++-- 35 files changed, 1114 insertions(+), 554 deletions(-) rename config/questionnaires/{teamer_assignment.yaml => bus_feedback.yaml} (59%) create mode 100644 config/questionnaires/teamer_feedback.yaml delete mode 100644 src/Enum/QuestionnaireFlag.php create mode 100644 tests/Config/QuestionnaireCompletenessTest.php create mode 100644 tests/Controller/Teamer/Questionnaire/EditControllerTest.php diff --git a/config/questionnaires/teamer_assignment.yaml b/config/questionnaires/bus_feedback.yaml similarity index 59% rename from config/questionnaires/teamer_assignment.yaml rename to config/questionnaires/bus_feedback.yaml index c1ec85a..7a0527f 100644 --- a/config/questionnaires/teamer_assignment.yaml +++ b/config/questionnaires/bus_feedback.yaml @@ -1,144 +1,28 @@ -# Questionnaire a teamer fills in after a placement (Disposition, not SeasonAssignment). +# Questionnaire a teamer fills in after a placement with bus escort, in addition to +# teamer_feedback.yaml and on the same page (App\Service\Questionnaire\DispositionContext). # -# Replaces the Google forms "RL-Feedback 25-26" and "Bus-Feedback 25-26"; the question-by-question -# spec, with every decision taken, is in .ddev/plans/questionnaires/. Every teamer gets the -# sections up to "Sonstiges"; the sections marked `only_for: bus_escort` are added when the -# assignment had a bus escort. Questions whose answer the disposition already knows (name, job -# profile, house, dates, destination, escort pickup) are not asked: they are snapshotted into the -# response's context instead. -# -# Parsed and validated by App\Config\QuestionnaireCatalog; QuestionnaireCatalogTest builds it. +# Replaces the Google form "Bus-Feedback 25-26"; the question-by-question spec, with every decision +# taken, is in .ddev/plans/questionnaires/bus-feedback-25-26.yaml. Questions whose answer the +# disposition already knows (travel date, destination, Reiseleitung, first pickup) are not asked: +# they are snapshotted into the response's context instead. # # Labels, help texts, section titles and option labels may be edited in place. Renaming or # removing a question or option key, or changing a type, needs a new version: copy this file to -# teamer_assignment.v1.yaml first, then bump `version` here. Submitted answers are keyed by it. +# bus_feedback.v1.yaml first, then bump `version` here. Submitted answers are keyed by it. +# +# Parsed and validated by App\Config\QuestionnaireCatalog; QuestionnaireCatalogTest builds it. # -# Section fields: key, title, only_for (a App\Enum\QuestionnaireFlag value), questions. # Question fields: key, label, type (text | textarea | choice | multi | date | rating), # required (default false), help, widget (choice only: radio | dropdown, default radio), # options (choice and multi only), show_if ({question, equals | in | lte}, earlier questions only). -key: teamer_assignment +key: bus_feedback version: 1 -title: "Reiseleiter/-in Feedback" -description: |- - Nur durch euer Feedback können wir besser werden! 🙂 - - Bei Bewertunsskalen: 1= schlecht, 5 = sehr gut +title: "Bus-Feedback" sections: - - key: organisation - title: "Organisation & Ablauf" - questions: - - key: office_briefing_rating - label: "Wie gut hast du dich vorab vom Büro gebrieft gefühlt?" - type: rating - required: true - - - key: office_briefing_low_reason - label: "Wenn 1 - 3 Sterne Bewertung, warum?" - type: textarea - required: true - show_if: { question: office_briefing_rating, lte: 3 } - - - key: team_week_seminar - label: "War das Wochenteam-Seminar hilfreich für dich? Warst du bzw. wurdest du gut vorbereitet?" - type: choice - required: true - options: - - { key: very_helpful, label: "Ja, die Schulung war sehr hilfreich und ich fühlte mich bestens vorbereitet." } - - { key: mostly_helpful, label: "Ja, größtenteils – einige Themen hätte man noch vertiefen können." } - - { key: partly_helpful, label: "Es ging – die Schulung war teilweise hilfreich, aber nicht ausreichend für alle Aufgaben." } - - { key: rather_not_helpful, label: "Eher nein – ich fühlte mich in vielen Bereichen unvorbereitet." } - - { key: not_helpful, label: "Nein, die Schulung war kaum hilfreich und hat mich nicht auf den Arbeitsalltag vorbereitet." } - - { key: not_attended, label: "Ich habe nicht teilgenommen." } - - - key: onsite_organisation_rating - label: "Wie gut lief die Reiseorganisation vor Ort?" - type: rating - required: true - - - key: had_organisational_problems - label: "Gab es organisatorische Probleme?" - type: choice - required: true - options: - - { key: "yes", label: "Ja" } - - { key: "no", label: "Nein" } - - - key: organisational_problems - label: "Wenn ja, welche?" - type: text - show_if: { question: had_organisational_problems, equals: "yes" } - - - key: communication - title: "Kommunikation & Zusammenarbeit" - questions: - - key: teamer_collaboration_rating - label: "Wie lief die Kommunikation & Zusammenarbeit mit den E&P Teamer:innen vor Ort während der Reise?" - type: rating - required: true - - - key: support_wished - label: "Welche Unterstützung hättest du dir zusätzlich gewünscht?" - type: textarea - - - key: house_manager_problem_handling - label: "Wie ist deine Hausleitung mit Problemen vor Ort umgegangen?" - type: choice - required: true - options: - - { key: very_good, label: "Sehr gut – Probleme wurden schnell, fair und lösungsorientiert behandelt." } - - { key: good, label: "Gut – die meisten Anliegen wurden ernst genommen und bearbeitet." } - - { key: mixed, label: "Teils gut/teils schlecht – manche Probleme wurden gelöst, andere nicht." } - - { key: rather_poor, label: "Eher schlecht – es wurde wenig unternommen oder nicht auf Augenhöhe kommuniziert." } - - { key: very_poor, label: "Sehr schlecht – Probleme wurden ignoriert oder verschlimmert." } - - - key: felt_overwhelmed - label: "Gab es Momente, in denen du dich überfordert gefühlt hast?" - type: choice - required: true - options: - - { key: "yes", label: "Ja" } - - { key: "no", label: "Nein" } - - - key: overwhelmed_reason - label: "Wenn ja, wodurch?" - type: textarea - show_if: { question: felt_overwhelmed, equals: "yes" } - - - key: improvement - title: "Verbesserung" - questions: - - key: wished_differently - label: "Was hättest du dir anders oder besser gewünscht?" - type: textarea - - - key: would_work_again - label: "Würdest du wieder als Reiseleitung für E&P arbeiten?" - type: choice - required: true - options: - - { key: "yes", label: "Ja" } - - { key: "no", label: "Nein" } - - - key: would_not_work_again_reason - label: "Wenn nein, warum nicht?" - type: textarea - show_if: { question: would_work_again, equals: "no" } - - - key: other - title: "Sonstiges" - questions: - - key: anything_else - label: |- - Du hast nun schon vieles beantwortet, - aber nirgendwo konntest du folgende Aspekte loswerden: - type: textarea - - key: bus_trip title: "Busfahrt" - only_for: bus_escort questions: - key: direction label: "Hin- oder Rückfahrt" @@ -167,7 +51,6 @@ sections: - key: bus_team title: "Busteam" - only_for: bus_escort questions: - key: driver_names label: "Busfahrer-Namen:" @@ -176,7 +59,6 @@ sections: - key: bus_first_pickup title: "1. Abfahrtsort" - only_for: bus_escort questions: - key: on_time_at_first_pickup label: "Bus pünktlich am 1. Abfahrtsort?" @@ -194,7 +76,6 @@ sections: - key: bus_rating title: "Bewertung Bus & Fahrer" - only_for: bus_escort questions: - key: overall_impression label: "Gesamteindruck des Busses" @@ -275,7 +156,6 @@ sections: - key: bus_pickups title: "Ladestellen" - only_for: bus_escort questions: - key: drivers_knew_pickups label: "Waren den Fahrern alle Abfahrtsorte bekannt?" @@ -293,7 +173,6 @@ sections: - key: bus_destination title: "Zielkenntnis" - only_for: bus_escort questions: - key: drivers_knew_destination label: "War den Busfahrern der Zielort und die Ausladestelle bekannt?" @@ -305,7 +184,6 @@ sections: - key: bus_equipment title: "Bus, Technik & Allgemeines" - only_for: bus_escort questions: - key: video label: "Video/DVD-Gerät" @@ -355,7 +233,6 @@ sections: - key: bus_damage title: "Schäden" - only_for: bus_escort questions: - key: bus_damage label: "Entstanden während der Fahrt Schäden am Bus?" @@ -374,7 +251,6 @@ sections: - key: bus_remarks title: "Anmerkungen zur Busfahrt" - only_for: bus_escort questions: - key: bus_remarks label: "Das ging gar nicht / ist besonders hervorzuheben:" diff --git a/config/questionnaires/teamer_feedback.yaml b/config/questionnaires/teamer_feedback.yaml new file mode 100644 index 0000000..8bff0f2 --- /dev/null +++ b/config/questionnaires/teamer_feedback.yaml @@ -0,0 +1,135 @@ +# Questionnaire every teamer fills in after a placement (Disposition, not SeasonAssignment). +# +# Replaces the Google form "RL-Feedback 25-26"; the question-by-question spec, with every decision +# taken, is in .ddev/plans/questionnaires/rl-feedback-25-26.yaml. A teamer who escorted the bus +# also gets bus_feedback.yaml, on the same page. Questions whose answer the disposition already +# knows (name, job profile, house, arrival day) are not asked: they are snapshotted into the +# response's context instead. +# +# Labels, help texts, section titles and option labels may be edited in place. Renaming or +# removing a question or option key, or changing a type, needs a new version: copy this file to +# teamer_feedback.v1.yaml first, then bump `version` here. Submitted answers are keyed by it. +# +# Parsed and validated by App\Config\QuestionnaireCatalog; QuestionnaireCatalogTest builds it. +# +# Question fields: key, label, type (text | textarea | choice | multi | date | rating), +# required (default false), help, widget (choice only: radio | dropdown, default radio), +# options (choice and multi only), show_if ({question, equals | in | lte}, earlier questions only). + +key: teamer_feedback +version: 1 +title: "Reiseleiter/-in Feedback" +description: |- + Nur durch euer Feedback können wir besser werden! 🙂 + + Bei Bewertunsskalen: 1= schlecht, 5 = sehr gut + +sections: + - key: organisation + title: "Organisation & Ablauf" + questions: + - key: office_briefing_rating + label: "Wie gut hast du dich vorab vom Büro gebrieft gefühlt?" + type: rating + required: true + + - key: office_briefing_low_reason + label: "Wenn 1 - 3 Sterne Bewertung, warum?" + type: textarea + required: true + show_if: { question: office_briefing_rating, lte: 3 } + + - key: team_week_seminar + label: "War das Wochenteam-Seminar hilfreich für dich? Warst du bzw. wurdest du gut vorbereitet?" + type: choice + required: true + options: + - { key: very_helpful, label: "Ja, die Schulung war sehr hilfreich und ich fühlte mich bestens vorbereitet." } + - { key: mostly_helpful, label: "Ja, größtenteils – einige Themen hätte man noch vertiefen können." } + - { key: partly_helpful, label: "Es ging – die Schulung war teilweise hilfreich, aber nicht ausreichend für alle Aufgaben." } + - { key: rather_not_helpful, label: "Eher nein – ich fühlte mich in vielen Bereichen unvorbereitet." } + - { key: not_helpful, label: "Nein, die Schulung war kaum hilfreich und hat mich nicht auf den Arbeitsalltag vorbereitet." } + - { key: not_attended, label: "Ich habe nicht teilgenommen." } + + - key: onsite_organisation_rating + label: "Wie gut lief die Reiseorganisation vor Ort?" + type: rating + required: true + + - key: had_organisational_problems + label: "Gab es organisatorische Probleme?" + type: choice + required: true + options: + - { key: "yes", label: "Ja" } + - { key: "no", label: "Nein" } + + - key: organisational_problems + label: "Wenn ja, welche?" + type: text + show_if: { question: had_organisational_problems, equals: "yes" } + + - key: communication + title: "Kommunikation & Zusammenarbeit" + questions: + - key: teamer_collaboration_rating + label: "Wie lief die Kommunikation & Zusammenarbeit mit den E&P Teamer:innen vor Ort während der Reise?" + type: rating + required: true + + - key: support_wished + label: "Welche Unterstützung hättest du dir zusätzlich gewünscht?" + type: textarea + + - key: house_manager_problem_handling + label: "Wie ist deine Hausleitung mit Problemen vor Ort umgegangen?" + type: choice + required: true + options: + - { key: very_good, label: "Sehr gut – Probleme wurden schnell, fair und lösungsorientiert behandelt." } + - { key: good, label: "Gut – die meisten Anliegen wurden ernst genommen und bearbeitet." } + - { key: mixed, label: "Teils gut/teils schlecht – manche Probleme wurden gelöst, andere nicht." } + - { key: rather_poor, label: "Eher schlecht – es wurde wenig unternommen oder nicht auf Augenhöhe kommuniziert." } + - { key: very_poor, label: "Sehr schlecht – Probleme wurden ignoriert oder verschlimmert." } + + - key: felt_overwhelmed + label: "Gab es Momente, in denen du dich überfordert gefühlt hast?" + type: choice + required: true + options: + - { key: "yes", label: "Ja" } + - { key: "no", label: "Nein" } + + - key: overwhelmed_reason + label: "Wenn ja, wodurch?" + type: textarea + show_if: { question: felt_overwhelmed, equals: "yes" } + + - key: improvement + title: "Verbesserung" + questions: + - key: wished_differently + label: "Was hättest du dir anders oder besser gewünscht?" + type: textarea + + - key: would_work_again + label: "Würdest du wieder als Reiseleitung für E&P arbeiten?" + type: choice + required: true + options: + - { key: "yes", label: "Ja" } + - { key: "no", label: "Nein" } + + - key: would_not_work_again_reason + label: "Wenn nein, warum nicht?" + type: textarea + show_if: { question: would_work_again, equals: "no" } + + - key: other + title: "Sonstiges" + questions: + - key: anything_else + label: |- + Du hast nun schon vieles beantwortet, + aber nirgendwo konntest du folgende Aspekte loswerden: + type: textarea diff --git a/config/services.yaml b/config/services.yaml index 7502b52..9a7eb22 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -473,3 +473,5 @@ when@dev: when@test: parameters: mailing_bus_transport: 'sync' + # the questionnaire tests need it offered; any day before the placements they create + questionnaire_go_live: '2026-01-01' diff --git a/src/Config/QuestionnaireCatalog.php b/src/Config/QuestionnaireCatalog.php index 6e9ca17..4e8a69b 100644 --- a/src/Config/QuestionnaireCatalog.php +++ b/src/Config/QuestionnaireCatalog.php @@ -4,7 +4,6 @@ namespace App\Config; use App\Enum\ChoiceWidget; use App\Enum\ConditionOperator; -use App\Enum\QuestionnaireFlag; use App\Enum\QuestionType; use App\Model\Questionnaire\Condition; use App\Model\Questionnaire\Definition; @@ -27,12 +26,17 @@ use Symfony\Component\Yaml\Yaml; class QuestionnaireCatalog { /** - * The questionnaire a teamer fills in after a placement; see DispositionVoter::QUESTIONNAIRE. + * The questionnaire every teamer fills in after a placement; see DispositionVoter::QUESTIONNAIRE. */ - public const string TEAMER_ASSIGNMENT = 'teamer_assignment'; + public const string TEAMER_FEEDBACK = 'teamer_feedback'; + + /** + * The one a teamer who escorted the bus fills in as well, on the same page. + */ + public const string BUS_FEEDBACK = 'bus_feedback'; private const array ROOT_FIELDS = ['key', 'version', 'title', 'description', 'sections']; - private const array SECTION_FIELDS = ['key', 'title', 'only_for', 'questions']; + private const array SECTION_FIELDS = ['key', 'title', 'questions']; private const array QUESTION_FIELDS = ['key', 'label', 'type', 'required', 'help', 'widget', 'options', 'show_if']; private const array OPTION_FIELDS = ['key', 'label']; @@ -80,6 +84,26 @@ class QuestionnaireCatalog return $this->load(sprintf('%s/%s.v%d.yaml', $this->questionnaireDir, $key, $version)); } + /** + * The keys of the questionnaires there are, i.e. of their current files. + * + * @return list + */ + public function keys(): array + { + $keys = []; + + foreach (glob($this->questionnaireDir.'/*.yaml') ?: [] as $file) { + if (1 === preg_match('/^([a-z0-9_]+)\.yaml$/', basename($file), $match)) { + $keys[] = $match[1]; + } + } + + sort($keys); + + return $keys; + } + /** * Every definition in the directory, archived versions included. * @@ -130,8 +154,6 @@ class QuestionnaireCatalog $sectionKeys = []; /** @var array $questions every question parsed so far, for show_if */ $questions = []; - /** @var array $questionFlags the only_for of each question's section */ - $questionFlags = []; foreach ($data['sections'] as $index => $sectionData) { $context = sprintf('%s: section #%d', $file, $index + 1); @@ -149,38 +171,16 @@ class QuestionnaireCatalog throw $this->error($context, 'questions must be a non-empty list'); } - $onlyFor = null; - - if (array_key_exists('only_for', $sectionData)) { - $onlyFor = QuestionnaireFlag::tryFrom((string) $sectionData['only_for']) - ?? throw $this->error($context, sprintf('unknown only_for "%s"', $sectionData['only_for'])); - } - $sectionQuestions = []; foreach ($sectionData['questions'] as $questionData) { $question = $this->buildQuestion($questionData, $questions, $context); - // A question that may be asked without the flag cannot depend on one that is only - // asked with it: it would stay hidden for everyone without the flag. - if (null !== $question->showIf - && null !== ($controllingFlag = $questionFlags[$question->showIf->question]) - && $controllingFlag !== $onlyFor - ) { - throw $this->error(sprintf('%s: question "%s": show_if', $context, $question->key), sprintf('"%s" is only asked for %s', $question->showIf->question, $controllingFlag->value)); - } - $questions[$question->key] = $question; - $questionFlags[$question->key] = $onlyFor; $sectionQuestions[] = $question; } - $sections[] = new Section( - $key, - $this->optionalString($sectionData['title'] ?? null, 'title', $context), - $sectionQuestions, - $onlyFor, - ); + $sections[] = new Section($key, $this->optionalString($sectionData['title'] ?? null, 'title', $context), $sectionQuestions); } return new Definition( diff --git a/src/Controller/Administrative/Questionnaire/DetailController.php b/src/Controller/Administrative/Questionnaire/DetailController.php index da95cd5..a7aa593 100644 --- a/src/Controller/Administrative/Questionnaire/DetailController.php +++ b/src/Controller/Administrative/Questionnaire/DetailController.php @@ -5,12 +5,14 @@ namespace App\Controller\Administrative\Questionnaire; use App\Config\QuestionnaireCatalog; use App\Controller\Traits\ReturnUrlTrait; use App\Entity\QuestionnaireResponse; +use App\Repository\QuestionnaireResponseRepository; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Routing\Requirement\Requirement; use Symfony\Component\Security\Http\Attribute\IsGranted; /** @@ -23,10 +25,12 @@ class DetailController extends AbstractController public function __construct( private readonly QuestionnaireCatalog $catalog, + private readonly QuestionnaireResponseRepository $responseRepository, ) { } - #[Route('/administrative/questionnaire/{uuid}', name: 'app_administrative_questionnaire_detail')] + // the requirement keeps /administrative/questionnaire/list from being read as a uuid + #[Route('/administrative/questionnaire/{uuid}', name: 'app_administrative_questionnaire_detail', requirements: ['uuid' => Requirement::UUID])] #[IsGranted('ROLE_ADMINISTRATIVE')] public function index( #[MapEntity(mapping: ['uuid' => 'uuid'])] @@ -38,9 +42,19 @@ class DetailController extends AbstractController throw new NotFoundHttpException(); } + // the other questionnaire of the same placement, e.g. the bus feedback beside the teamer feedback + $siblings = array_filter( + $this->responseRepository->findByDisposition($response->getDisposition()), + static fn (QuestionnaireResponse $sibling): bool => $sibling !== $response && $sibling->isSubmitted(), + ); + return $this->render('administrative/questionnaire/detail.html.twig', [ 'definition' => $this->catalog->get($response->getQuestionnaire(), $response->getVersion()), 'response' => $response, + 'siblings' => array_map(fn (QuestionnaireResponse $sibling): array => [ + 'response' => $sibling, + 'title' => $this->catalog->get($sibling->getQuestionnaire(), $sibling->getVersion())->title, + ], array_values($siblings)), 'returnUrl' => $this->getReturnUrl($request, 'app_administrative_questionnaire_index'), ]); } diff --git a/src/Controller/Administrative/Questionnaire/IndexController.php b/src/Controller/Administrative/Questionnaire/IndexController.php index f83c186..19b9057 100644 --- a/src/Controller/Administrative/Questionnaire/IndexController.php +++ b/src/Controller/Administrative/Questionnaire/IndexController.php @@ -13,8 +13,9 @@ use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; /** - * The submitted teamer questionnaires. Only administrative users read them: they rate the house - * manager among others, so house managers are deliberately not let in. + * The submitted responses to one questionnaire, teamer or bus feedback. Only administrative users + * read them: they rate the house manager among others, so house managers are deliberately not + * let in. */ class IndexController extends AbstractController { @@ -26,14 +27,25 @@ class IndexController extends AbstractController ) { } - #[Route('/administrative/questionnaire', name: 'app_administrative_questionnaire_index')] + #[Route( + '/administrative/questionnaire/list/{questionnaire}', + name: 'app_administrative_questionnaire_index', + requirements: ['questionnaire' => '[a-z0-9_]+'], + defaults: ['questionnaire' => QuestionnaireCatalog::TEAMER_FEEDBACK], + )] #[IsGranted('ROLE_ADMINISTRATIVE')] - public function index(Request $request): Response + public function index(string $questionnaire, Request $request): Response { + $keys = $this->catalog->keys(); + + if (!in_array($questionnaire, $keys, true)) { + throw $this->createNotFoundException(sprintf('No questionnaire "%s"', $questionnaire)); + } + $filterDto = $this->filterHandler->getFilterSettings(); $pagination = $this->paginator->paginate( - $this->responseRepository->getListQuery(QuestionnaireCatalog::TEAMER_ASSIGNMENT, $filterDto), + $this->responseRepository->getListQuery($questionnaire, $filterDto), $request->query->getInt('page', 1), $request->query->getInt('limit', 50), [ @@ -43,7 +55,9 @@ class IndexController extends AbstractController ); return $this->render('administrative/questionnaire/index.html.twig', [ - 'definition' => $this->catalog->get(QuestionnaireCatalog::TEAMER_ASSIGNMENT), + 'definition' => $this->catalog->get($questionnaire), + // for switching between the questionnaires + 'definitions' => array_map($this->catalog->get(...), $keys), 'pagination' => $pagination, 'filterDto' => $filterDto, ]); diff --git a/src/Controller/Administrative/Questionnaire/TestController.php b/src/Controller/Administrative/Questionnaire/TestController.php index 752c67f..53d3dcc 100644 --- a/src/Controller/Administrative/Questionnaire/TestController.php +++ b/src/Controller/Administrative/Questionnaire/TestController.php @@ -5,8 +5,8 @@ namespace App\Controller\Administrative\Questionnaire; use App\Config\QuestionnaireCatalog; use App\Controller\Traits\ReturnUrlTrait; use App\Entity\Assignment; -use App\Enum\QuestionnaireFlag; use App\Form\QuestionnaireType; +use App\Model\Questionnaire\Definition; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -15,10 +15,11 @@ use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; /** - * The teamer questionnaire as a teamer gets it, for trying out on any assignment - before or after - * the trip, placed or not. It shows the assignment's own variant, with or without the bus - * sections; ?bus_escort=1 or 0 tries the other one. Nothing is saved: "Absenden" shows the answers - * as they would be stored, "Entwurf speichern" just keeps them in the form. + * The questionnaires as a teamer gets them, for trying out on any assignment - before or after the + * trip, placed or not. It shows what a placement on this assignment gets - the teamer feedback, + * and the bus feedback too for a bus escort; ?bus_escort=1 or 0 tries the other case. Nothing is + * saved: "Absenden" shows the answers as they would be stored, "Entwurf speichern" just keeps them + * in the form. */ class TestController extends AbstractController { @@ -39,12 +40,13 @@ class TestController extends AbstractController $busEscort = $request->query->has('bus_escort') ? $request->query->getBoolean('bus_escort') : $assignment->hasBusEscort(); - $definition = $this->catalog - ->get(QuestionnaireCatalog::TEAMER_ASSIGNMENT) - ->forFlags($busEscort ? [QuestionnaireFlag::BUS_ESCORT] : []) - ; + $definitions = [$this->catalog->get(QuestionnaireCatalog::TEAMER_FEEDBACK)]; - $form = $this->createForm(QuestionnaireType::class, [], ['definition' => $definition]); + if ($busEscort) { + $definitions[] = $this->catalog->get(QuestionnaireCatalog::BUS_FEEDBACK); + } + + $form = $this->createForm(QuestionnaireType::class, [], ['definitions' => $definitions]); $form->handleRequest($request); $submitted = null; @@ -52,7 +54,10 @@ class TestController extends AbstractController if ($form->isSubmitted() && $form->isValid()) { if (QuestionnaireType::isFinal($form)) { - $submitted = $definition->withoutHiddenAnswers($form->getData()); + $submitted = array_map(static fn (Definition $definition): array => [ + 'definition' => $definition, + 'answers' => $definition->withoutHiddenAnswers($form->getData()[$definition->key] ?? []), + ], $definitions); } else { $draft = true; } @@ -60,7 +65,6 @@ class TestController extends AbstractController return $this->render('administrative/questionnaire/test.html.twig', [ 'assignment' => $assignment, - 'definition' => $definition, 'form' => $form, 'submitted' => $submitted, 'draft' => $draft, diff --git a/src/Controller/Common/QuestionnaireResponseFilterController.php b/src/Controller/Common/QuestionnaireResponseFilterController.php index 2a4969e..c3c0817 100644 --- a/src/Controller/Common/QuestionnaireResponseFilterController.php +++ b/src/Controller/Common/QuestionnaireResponseFilterController.php @@ -2,6 +2,7 @@ namespace App\Controller\Common; +use App\Controller\Traits\ReturnUrlTrait; use App\Form\QuestionnaireResponseFilterType; use App\Htmx\HxRedirectResponse; use App\Service\Common\QuestionnaireResponseFilterHandler; @@ -11,8 +12,13 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; +/** + * One filter for both questionnaire lists; it returns to the list it was opened from. + */ class QuestionnaireResponseFilterController extends AbstractController { + use ReturnUrlTrait; + public function __construct(private readonly QuestionnaireResponseFilterHandler $filterHandler) { } @@ -28,7 +34,7 @@ class QuestionnaireResponseFilterController extends AbstractController if ($form->isSubmitted() && $form->isValid()) { $this->filterHandler->handleRequest($form); - return new HxRedirectResponse($this->generateUrl('app_administrative_questionnaire_index')); + return new HxRedirectResponse($this->getReturnUrl($request, 'app_administrative_questionnaire_index')); } return $this->render('common/modal_questionnaire_response_filter.html.twig', [ @@ -39,10 +45,10 @@ class QuestionnaireResponseFilterController extends AbstractController #[Route('/common/questionnaire-response/filter/reset', name: 'app_common_questionnaire_response_filter_reset')] #[IsGranted('ROLE_ADMINISTRATIVE')] - public function reset(): Response + public function reset(Request $request): Response { $this->filterHandler->resetFilterSettings(); - return $this->redirectToRoute('app_administrative_questionnaire_index'); + return $this->redirect($this->getReturnUrl($request, 'app_administrative_questionnaire_index')); } } diff --git a/src/Controller/Teamer/Questionnaire/DetailController.php b/src/Controller/Teamer/Questionnaire/DetailController.php index 473b920..a94feb8 100644 --- a/src/Controller/Teamer/Questionnaire/DetailController.php +++ b/src/Controller/Teamer/Questionnaire/DetailController.php @@ -5,6 +5,7 @@ namespace App\Controller\Teamer\Questionnaire; use App\Config\QuestionnaireCatalog; use App\Entity\Disposition; use App\Repository\QuestionnaireResponseRepository; +use App\Service\Questionnaire\DispositionContext; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; @@ -12,8 +13,8 @@ use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; /** - * The teamer's own submitted questionnaire, read-only, rendered with the definition version it - * was submitted under. + * The teamer's own submitted questionnaires for a placement, read-only, each rendered with the + * definition version it was submitted under. */ class DetailController extends AbstractController { @@ -30,16 +31,34 @@ class DetailController extends AbstractController #[MapEntity(mapping: ['uuid' => 'uuid'])] Disposition $disposition, ): Response { - $response = $this->responseRepository->findOneByDisposition(QuestionnaireCatalog::TEAMER_ASSIGNMENT, $disposition); + $submitted = []; + $open = false; + $responses = $this->responseRepository->findByDisposition($disposition); - if (true !== $response?->isSubmitted()) { + // what applies now, plus anything submitted under an earlier state of the assignment + $keys = array_unique([...DispositionContext::questionnaires($disposition->getAssignment()), ...array_keys($responses)]); + + foreach ($keys as $key) { + $response = $responses[$key] ?? null; + + if (true === $response?->isSubmitted()) { + $submitted[] = [ + 'definition' => $this->catalog->get($key, $response->getVersion()), + 'response' => $response, + ]; + } else { + $open = true; + } + } + + if ([] === $submitted) { return $this->redirectToRoute('app_teamer_questionnaire_edit', ['uuid' => $disposition->getUuid()]); } return $this->render('teamer/questionnaire/detail.html.twig', [ 'disposition' => $disposition, - 'definition' => $this->catalog->get($response->getQuestionnaire(), $response->getVersion()), - 'response' => $response, + 'submitted' => $submitted, + 'open' => $open, ]); } } diff --git a/src/Controller/Teamer/Questionnaire/EditController.php b/src/Controller/Teamer/Questionnaire/EditController.php index ce9d39f..b4d0a38 100644 --- a/src/Controller/Teamer/Questionnaire/EditController.php +++ b/src/Controller/Teamer/Questionnaire/EditController.php @@ -18,9 +18,13 @@ use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; /** - * The teamer's questionnaire after a placement - with the bus sections if it was a bus escort - - * saved as a draft until it is submitted. A submitted one is final and only shown read-only + * The teamer's questionnaires after a placement on one page: the teamer feedback, and after a bus + * escort the bus feedback too. Saved as drafts until submitted; one submit submits all of them, + * each as a response of its own. Submitted ones are final and only shown read-only * (DetailController). + * + * Only the questionnaires not yet submitted are offered, so one that applies later - an escort + * added after the teamer submitted - can still be filled in. */ class EditController extends AbstractController { @@ -40,45 +44,60 @@ class EditController extends AbstractController Disposition $disposition, Request $request, ): Response { - $response = $this->responseRepository->findOneByDisposition(QuestionnaireCatalog::TEAMER_ASSIGNMENT, $disposition); + $responses = $this->responseRepository->findByDisposition($disposition); + $definitions = []; - if (true === $response?->isSubmitted()) { + foreach (DispositionContext::questionnaires($disposition->getAssignment()) as $key) { + if (true !== ($responses[$key] ?? null)?->isSubmitted()) { + $definitions[] = $this->catalog->get($key); + } + } + + if ([] === $definitions) { return $this->redirectToRoute('app_teamer_questionnaire_detail', ['uuid' => $disposition->getUuid()]); } - $definition = $this->catalog - ->get(QuestionnaireCatalog::TEAMER_ASSIGNMENT) - ->forFlags(DispositionContext::flags($disposition->getAssignment())) - ; + $data = []; - $form = $this->createForm(QuestionnaireType::class, $response?->getAnswers() ?? [], [ - 'definition' => $definition, - ]); + foreach ($definitions as $definition) { + $data[$definition->key] = ($responses[$definition->key] ?? null)?->getAnswers() ?? []; + } + + $form = $this->createForm(QuestionnaireType::class, $data, ['definitions' => $definitions]); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { - if (null === $response) { - $response = QuestionnaireResponse::forDisposition($definition, $disposition); - $this->entityManager->persist($response); - } - $context = DispositionContext::build($disposition); $final = QuestionnaireType::isFinal($form); - if (true === $final) { - $response->submit($definition, $form->getData(), $context, new \DateTimeImmutable()); - } else { - $response->saveDraft($form->getData(), $context); + foreach ($definitions as $definition) { + $response = $responses[$definition->key] ?? null; + + if (null === $response) { + $response = QuestionnaireResponse::forDisposition($definition, $disposition); + $this->entityManager->persist($response); + $responses[$definition->key] = $response; + } + + $answers = $form->getData()[$definition->key] ?? []; + + if (true === $final) { + $response->submit($definition, $answers, $context, new \DateTimeImmutable()); + } else { + $response->saveDraft($answers, $context); + } } $this->entityManager->flush(); - $this->logger->info($final ? 'Submit questionnaire' : 'Save questionnaire draft', [ - 'questionnaire' => $response->getQuestionnaire(), - 'questionnaire_response_id' => $response->getId(), - 'disposition_id' => $disposition->getId(), - 'teamer_id' => $disposition->getTeamer()->getId(), - ]); + foreach ($definitions as $definition) { + $this->logger->info($final ? 'Submit questionnaire' : 'Save questionnaire draft', [ + 'questionnaire' => $definition->key, + 'questionnaire_response_id' => $responses[$definition->key]->getId(), + 'disposition_id' => $disposition->getId(), + 'teamer_id' => $disposition->getTeamer()->getId(), + ]); + } if (true === $final) { $this->addFlash('success', 'Danke! Dein Fragebogen wurde abgeschickt.'); @@ -91,11 +110,15 @@ class EditController extends AbstractController return $this->redirectToRoute('app_teamer_questionnaire_edit', ['uuid' => $disposition->getUuid()]); } + $drafts = array_filter($responses, static fn (QuestionnaireResponse $response): bool => !$response->isSubmitted()); + return $this->render('teamer/questionnaire/edit.html.twig', [ 'disposition' => $disposition, - 'definition' => $definition, - 'response' => $response, 'form' => $form, + 'lastSaved' => [] === $drafts ? null : max(array_map( + static fn (QuestionnaireResponse $response): \DateTimeInterface => $response->getUpdatedAt() ?? $response->getCreatedAt(), + $drafts, + )), ]); } } diff --git a/src/Enum/QuestionnaireFlag.php b/src/Enum/QuestionnaireFlag.php deleted file mode 100644 index eca4ec4..0000000 --- a/src/Enum/QuestionnaireFlag.php +++ /dev/null @@ -1,13 +0,0 @@ - $definitions */ + $definitions = $options['definitions']; - foreach ($definition->sections as $section) { - $sectionBuilder = $builder->create($section->key, FormType::class, [ - 'inherit_data' => true, - 'label' => $section->title ?? false, - 'block_prefix' => 'questionnaire_section', + foreach ($definitions as $definition) { + $questionnaireBuilder = $builder->create($definition->key, FormType::class, [ + 'label' => $definition->title, + 'help' => $definition->description, + 'empty_data' => [], + 'block_prefix' => 'questionnaire_part', ]); - foreach ($section->questions as $question) { - $sectionBuilder->add($question->key, ...$this->field($question)); + foreach ($definition->sections as $section) { + $sectionBuilder = $builder->create($section->key, FormType::class, [ + 'inherit_data' => true, + 'label' => $section->title ?? false, + 'block_prefix' => 'questionnaire_section', + ]); + + foreach ($section->questions as $question) { + $sectionBuilder->add($question->key, ...$this->field($definition, $question)); + } + + $questionnaireBuilder->add($sectionBuilder); } - $builder->add($sectionBuilder); + $builder->add($questionnaireBuilder); } $builder @@ -63,24 +76,26 @@ class QuestionnaireType extends AbstractType ->add(self::SUBMIT, SubmitType::class, [ 'label' => 'Absenden', ]) - ->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) use ($definition): void { + ->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) use ($definitions): void { $form = $event->getForm(); if (!$this->isFinal($form)) { return; } - $answers = $form->getData(); + foreach ($definitions as $definition) { + $answers = $form->getData()[$definition->key] ?? []; - foreach ($definition->sections as $section) { - foreach ($section->questions as $question) { - if ($question->required - && $definition->isVisible($question, $answers) - && $this->isEmpty($answers[$question->key] ?? null) - ) { - $form->get($section->key)->get($question->key)->addError( - new FormError('Bitte beantworte diese Frage.') - ); + foreach ($definition->sections as $section) { + foreach ($section->questions as $question) { + if ($question->required + && $definition->isVisible($question, $answers) + && $this->isEmpty($answers[$question->key] ?? null) + ) { + $form->get($definition->key)->get($section->key)->get($question->key)->addError( + new FormError('Bitte beantworte diese Frage.') + ); + } } } } @@ -91,8 +106,10 @@ class QuestionnaireType extends AbstractType public function configureOptions(OptionsResolver $resolver): void { $resolver - ->setRequired('definition') - ->setAllowedTypes('definition', Definition::class) + ->setRequired('definitions') + ->setAllowedTypes('definitions', Definition::class.'[]') + ->setAllowedValues('definitions', static fn (array $definitions): bool => [] !== $definitions + && count($definitions) === count(array_unique(array_map(static fn (Definition $definition): string => $definition->key, $definitions)))) ->setDefaults([ 'data_class' => null, 'empty_data' => [], @@ -114,16 +131,17 @@ class QuestionnaireType extends AbstractType /** * @return array{0: class-string, 1: array} */ - private function field(Question $question): array + private function field(Definition $definition, Question $question): array { $common = [ 'label' => $question->label, 'help' => $question->help, 'required' => false, - // read by assets/controllers/questionnaire_controller.js - 'row_attr' => ['data-question-key' => $question->key] + (null === $question->showIf ? [] : [ + // read by assets/controllers/questionnaire_controller.js; prefixed with the questionnaire, + // since question keys are only unique within one + 'row_attr' => ['data-question-key' => $definition->key.'.'.$question->key] + (null === $question->showIf ? [] : [ 'data-show-if' => json_encode([ - 'question' => $question->showIf->question, + 'question' => $definition->key.'.'.$question->showIf->question, 'operator' => $question->showIf->operator->value, 'value' => $question->showIf->value, ], JSON_THROW_ON_ERROR), diff --git a/src/Model/Questionnaire/Definition.php b/src/Model/Questionnaire/Definition.php index 8359171..efeec31 100644 --- a/src/Model/Questionnaire/Definition.php +++ b/src/Model/Questionnaire/Definition.php @@ -2,8 +2,6 @@ namespace App\Model\Questionnaire; -use App\Enum\QuestionnaireFlag; - /** * One version of a questionnaire as read from config/questionnaires/. Responses store its key and * version, and their answers are keyed by Question::$key. @@ -22,26 +20,6 @@ readonly class Definition ) { } - /** - * The questionnaire as one subject gets it: without the sections marked `only_for` a flag the - * subject does not have. Key and version stay, so answers to it are answers to this definition. - * - * @param list $flags - */ - public function forFlags(array $flags): static - { - return new static( - $this->key, - $this->version, - $this->title, - $this->description, - array_values(array_filter( - $this->sections, - static fn (Section $section): bool => null === $section->onlyFor || in_array($section->onlyFor, $flags, true), - )), - ); - } - /** * @return array in the order they are asked, keyed by question key */ diff --git a/src/Model/Questionnaire/Section.php b/src/Model/Questionnaire/Section.php index 249004f..18a7c12 100644 --- a/src/Model/Questionnaire/Section.php +++ b/src/Model/Questionnaire/Section.php @@ -2,19 +2,15 @@ namespace App\Model\Questionnaire; -use App\Enum\QuestionnaireFlag; - readonly class Section { /** - * @param list $questions - * @param QuestionnaireFlag|null $onlyFor part of the questionnaire only when the subject has this flag + * @param list $questions */ public function __construct( public string $key, public ?string $title, public array $questions, - public ?QuestionnaireFlag $onlyFor = null, ) { } } diff --git a/src/Repository/DispositionRepository.php b/src/Repository/DispositionRepository.php index 8676154..e86ed9f 100644 --- a/src/Repository/DispositionRepository.php +++ b/src/Repository/DispositionRepository.php @@ -77,30 +77,38 @@ class DispositionRepository extends ServiceEntityRepository } /** - * The teamer's placements whose questionnaire (config/questionnaires/teamer_assignment.yaml) is - * still to be submitted: not started, or saved as a draft. The rules are those of + * The teamer's placements with a questionnaire still to be submitted - not started, or saved as + * a draft: the teamer feedback, and after a bus escort the bus feedback too + * (DispositionContext::questionnaires(), spelled out here in DQL). The rules are those of * DispositionVoter::QUESTIONNAIRE - over (today included), on or after go-live, neither called * off nor skip-formalities - so every row shown on the dashboard can be opened. * - * The response is joined as an arbitrary entity because the association is one-way; the - * questionnaire key has to be part of the JOIN condition, or a response to another - * questionnaire would hide this one. + * The responses are joined as arbitrary entities because the association is one-way; the + * questionnaire key has to be part of each JOIN condition, or one questionnaire's response + * would stand in for the other's. * - * Rows are [0 => Disposition, 'responseStatus' => ?QuestionnaireResponseStatus]. + * Rows are [0 => Disposition, 'teamerStatus' => ?QuestionnaireResponseStatus, + * 'busStatus' => ?QuestionnaireResponseStatus]. */ public function getPendingQuestionnaireQuery(Teamer $teamer, \DateTimeImmutable $goLive): Query { $qb = $this->createQueryBuilder('disposition'); return $qb - ->select('disposition', 'assignment', 'destination', 'response.status AS responseStatus') + ->select('disposition', 'assignment', 'destination', 'teamer_response.status AS teamerStatus', 'bus_response.status AS busStatus') ->innerJoin('disposition.assignment', 'assignment') ->innerJoin('assignment.destination', 'destination') ->leftJoin( QuestionnaireResponse::class, - 'response', + 'teamer_response', Join::ON, - 'response.disposition = disposition AND response.questionnaire = :questionnaire' + 'teamer_response.disposition = disposition AND teamer_response.questionnaire = :teamerQuestionnaire' + ) + ->leftJoin( + QuestionnaireResponse::class, + 'bus_response', + Join::ON, + 'bus_response.disposition = disposition AND bus_response.questionnaire = :busQuestionnaire' ) ->where($qb->expr()->andX( $qb->expr()->eq('disposition.teamer', ':teamer'), @@ -110,13 +118,23 @@ class DispositionRepository extends ServiceEntityRepository $qb->expr()->gte('destination.dateTo', ':goLive'), $qb->expr()->lte('destination.dateTo', ':today'), $qb->expr()->orX( - $qb->expr()->isNull('response.id'), - $qb->expr()->eq('response.status', ':draft') + $qb->expr()->isNull('teamer_response.id'), + $qb->expr()->eq('teamer_response.status', ':draft'), + // a bus escort, i.e. Assignment::hasBusEscort(), owing the bus feedback + $qb->expr()->andX( + $qb->expr()->isNotNull('assignment.pickup'), + $qb->expr()->neq('assignment.pickup', 0), + $qb->expr()->orX( + $qb->expr()->isNull('bus_response.id'), + $qb->expr()->eq('bus_response.status', ':draft') + ) + ) ) )) ->orderBy('destination.dateTo', \SortDirection::Descending) ->setParameter('teamer', $teamer) - ->setParameter('questionnaire', QuestionnaireCatalog::TEAMER_ASSIGNMENT) + ->setParameter('teamerQuestionnaire', QuestionnaireCatalog::TEAMER_FEEDBACK) + ->setParameter('busQuestionnaire', QuestionnaireCatalog::BUS_FEEDBACK) ->setParameter('dispositionCalledOff', Disposition::STATUS_CALLED_OFF) ->setParameter('assignmentStatuses', [Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED]) ->setParameter('skipFormalities', false) @@ -135,7 +153,8 @@ class DispositionRepository extends ServiceEntityRepository return array_map( static fn (array $row): array => [ 'disposition' => $row[0], - 'draft' => QuestionnaireResponseStatus::DRAFT === $row['responseStatus'], + 'draft' => QuestionnaireResponseStatus::DRAFT === $row['teamerStatus'] + || QuestionnaireResponseStatus::DRAFT === $row['busStatus'], ], $this->getPendingQuestionnaireQuery($teamer, $goLive)->getResult(), ); diff --git a/src/Repository/QuestionnaireResponseRepository.php b/src/Repository/QuestionnaireResponseRepository.php index 46b0f73..caf8b6b 100644 --- a/src/Repository/QuestionnaireResponseRepository.php +++ b/src/Repository/QuestionnaireResponseRepository.php @@ -80,8 +80,17 @@ class QuestionnaireResponseRepository extends ServiceEntityRepository return $qb->getQuery(); } - public function findOneByDisposition(string $questionnaire, Disposition $disposition): ?QuestionnaireResponse + /** + * @return array keyed by questionnaire key + */ + public function findByDisposition(Disposition $disposition): array { - return $this->findOneBy(['questionnaire' => $questionnaire, 'disposition' => $disposition]); + $responses = []; + + foreach ($this->findBy(['disposition' => $disposition]) as $response) { + $responses[$response->getQuestionnaire()] = $response; + } + + return $responses; } } diff --git a/src/Security/Voter/DispositionVoter.php b/src/Security/Voter/DispositionVoter.php index bf9ce46..58ba58b 100644 --- a/src/Security/Voter/DispositionVoter.php +++ b/src/Security/Voter/DispositionVoter.php @@ -117,11 +117,11 @@ class DispositionVoter extends Voter } /** - * Whether the teamer may fill in, and later read, the questionnaire after this placement - * (config/questionnaires/teamer_assignment.yaml). Every placement gets it; one with a bus - * escort gets the bus sections as well, which is the form's business, not this voter's. Only - * the teamer themself: the office reads the submitted responses through ROLE_ADMINISTRATIVE, - * not through this attribute. + * Whether the teamer may fill in, and later read, the questionnaires after this placement: + * every placement gets the teamer feedback, a bus escort the bus feedback as well + * (DispositionContext::questionnaires() - which ones is not this voter's business). Only the + * teamer themself: the office reads the submitted responses through ROLE_ADMINISTRATIVE, not + * through this attribute. * * "Over" is the destination's end date, not Disposition::STATUS_ENDED: the cron sets that a * run late, and it moves on to checking_invoice and completed while the date stays true. An diff --git a/src/Service/Questionnaire/DispositionContext.php b/src/Service/Questionnaire/DispositionContext.php index 46d3448..b79358c 100644 --- a/src/Service/Questionnaire/DispositionContext.php +++ b/src/Service/Questionnaire/DispositionContext.php @@ -2,9 +2,9 @@ namespace App\Service\Questionnaire; +use App\Config\QuestionnaireCatalog; use App\Entity\Assignment; use App\Entity\Disposition; -use App\Enum\QuestionnaireFlag; /** * What a disposition already knows, snapshotted into a QuestionnaireResponse so the questionnaire @@ -16,14 +16,19 @@ use App\Enum\QuestionnaireFlag; final class DispositionContext { /** - * Which `only_for` sections of the questionnaire apply to a placement on this assignment. - * Takes the assignment so the office's test mode can ask before anyone is placed. + * The questionnaires a placement on this assignment gets, in the order they are shown: every + * teamer the teamer feedback, a bus escort the bus feedback as well. Takes the assignment so + * the office's test mode can ask before anyone is placed. * - * @return list + * DispositionRepository::getPendingQuestionnaireQuery() spells the same rule in DQL. + * + * @return list questionnaire keys */ - public static function flags(Assignment $assignment): array + public static function questionnaires(Assignment $assignment): array { - return $assignment->hasBusEscort() ? [QuestionnaireFlag::BUS_ESCORT] : []; + return $assignment->hasBusEscort() + ? [QuestionnaireCatalog::TEAMER_FEEDBACK, QuestionnaireCatalog::BUS_FEEDBACK] + : [QuestionnaireCatalog::TEAMER_FEEDBACK]; } /** diff --git a/templates/administrative/questionnaire/detail.html.twig b/templates/administrative/questionnaire/detail.html.twig index 108e932..add70d2 100644 --- a/templates/administrative/questionnaire/detail.html.twig +++ b/templates/administrative/questionnaire/detail.html.twig @@ -35,6 +35,20 @@ {% endfor %} + {% if siblings is not empty %} +

+ Zum selben Einsatz +

+ + {% endif %}
zurück diff --git a/templates/administrative/questionnaire/index.html.twig b/templates/administrative/questionnaire/index.html.twig index 14ac90f..1865011 100644 --- a/templates/administrative/questionnaire/index.html.twig +++ b/templates/administrative/questionnaire/index.html.twig @@ -1,12 +1,22 @@ {% extends 'administrative/layout.html.twig' %} -{% block title %}Fragebögen{% endblock %} +{% block title %}Fragebögen: {{ definition.title }}{% endblock %} {% block content %}
-

- Fragebögen: {{ definition.title }} -

+