refactor: split the questionnaire into teamer and bus feedback

This commit is contained in:
fromme committed 2026-10-27 17:22:22 +01:00
1 parent 2f4f9800dd
commit a0170f5952
35 files changed
+1114 -554

No files matched your search

@@ -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'),
]);
}
@@ -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,
]);
@@ -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,
@@ -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'));
}
}
@@ -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,
]);
}
}
@@ -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,
)),
]);
}
}