refactor: split the questionnaire into teamer and bus feedback
This commit is contained in:
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,
|
||||
)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user