From 890e04f8a64f5596ed33094276ff5f3191a6d142 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Tue, 27 Oct 2026 16:59:44 +0100 Subject: [PATCH] feat: remind teamers of open questionnaires on the dashboard --- src/Config/QuestionnaireCatalog.php | 14 ++ src/Controller/Teamer/IndexController.php | 10 + src/Repository/DispositionRepository.php | 71 +++++++ src/Security/Voter/DispositionVoter.php | 9 +- templates/teamer/disposition/detail.html.twig | 13 ++ templates/teamer/index.html.twig | 18 ++ ...DispositionRepositoryQuestionnaireTest.php | 182 ++++++++++++++++++ 7 files changed, 313 insertions(+), 4 deletions(-) create mode 100644 tests/Repository/DispositionRepositoryQuestionnaireTest.php diff --git a/src/Config/QuestionnaireCatalog.php b/src/Config/QuestionnaireCatalog.php index a251a9a..7a83949 100644 --- a/src/Config/QuestionnaireCatalog.php +++ b/src/Config/QuestionnaireCatalog.php @@ -51,6 +51,20 @@ class QuestionnaireCatalog ) { } + /** + * The services.yaml parameter questionnaire_go_live as a date: the teamer questionnaire is + * offered for assignments ending on or after it, and to nobody while it is null. + */ + public static function parseGoLive(?string $goLive): ?\DateTimeImmutable + { + if (null === $goLive) { + return null; + } + + return \DateTimeImmutable::createFromFormat('!Y-m-d', $goLive) + ?: throw new \InvalidArgumentException(sprintf('questionnaire_go_live "%s" is not a Y-m-d date', $goLive)); + } + /** * @param int|null $version null for the current version */ diff --git a/src/Controller/Teamer/IndexController.php b/src/Controller/Teamer/IndexController.php index f5e626a..8d6c001 100644 --- a/src/Controller/Teamer/IndexController.php +++ b/src/Controller/Teamer/IndexController.php @@ -2,6 +2,7 @@ namespace App\Controller\Teamer; +use App\Config\QuestionnaireCatalog; use App\Entity\Application; use App\Entity\Assignment; use App\Entity\Disposition; @@ -71,6 +72,14 @@ class IndexController extends AbstractController $dispositionDocuments = $this->processDocuments($upcomingDispositions); + // offered to nobody until the go-live date is configured, as in DispositionVoter + $questionnaireGoLive = QuestionnaireCatalog::parseGoLive($this->getParameter('questionnaire_go_live')); + $pendingQuestionnaires = null === $questionnaireGoLive ? [] : $this + ->entityManager + ->getRepository(Disposition::class) + ->findPendingQuestionnaireByTeamer($teamer, $questionnaireGoLive) + ; + // the list merges in season assignments; the document notices above stay on dispositions $upcomingPlacements = array_slice($this->upcomingPlacementCollector->collect($teamer), 0, 5); @@ -85,6 +94,7 @@ class IndexController extends AbstractController // the season page shows only this one, so only this one is linked 'linkedSeasonAssignment' => $this->seasonAssignmentResolver->resolveFor($teamer), 'dispositionDocuments' => $dispositionDocuments, + 'pendingQuestionnaires' => $pendingQuestionnaires, ]); } diff --git a/src/Repository/DispositionRepository.php b/src/Repository/DispositionRepository.php index 537b42e..932be7f 100644 --- a/src/Repository/DispositionRepository.php +++ b/src/Repository/DispositionRepository.php @@ -2,12 +2,16 @@ namespace App\Repository; +use App\Config\QuestionnaireCatalog; use App\Entity\Assignment; use App\Entity\Disposition; +use App\Entity\QuestionnaireResponse; use App\Entity\Teamer; use App\Entity\Upload; +use App\Enum\QuestionnaireResponseStatus; use App\Repository\Filter\SeasonPeriodFilter; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Query; use Doctrine\ORM\Query\Expr\Comparison; use Doctrine\ORM\Query\Expr\Join; @@ -72,6 +76,73 @@ class DispositionRepository extends ServiceEntityRepository ; } + /** + * The teamer's placements whose questionnaire (config/questionnaires/teamer_escort.yaml) is + * still to be submitted: not started, or saved as a draft. The rules are those of + * DispositionVoter::QUESTIONNAIRE - bus escort, 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. + * + * Rows are [0 => Disposition, 'responseStatus' => ?QuestionnaireResponseStatus]. + */ + public function getPendingQuestionnaireQuery(Teamer $teamer, \DateTimeImmutable $goLive): Query + { + $qb = $this->createQueryBuilder('disposition'); + + return $qb + ->select('disposition', 'assignment', 'destination', 'response.status AS responseStatus') + ->innerJoin('disposition.assignment', 'assignment') + ->innerJoin('assignment.destination', 'destination') + ->leftJoin( + QuestionnaireResponse::class, + 'response', + Join::ON, + 'response.disposition = disposition AND response.questionnaire = :questionnaire' + ) + ->where($qb->expr()->andX( + $qb->expr()->eq('disposition.teamer', ':teamer'), + $qb->expr()->neq('disposition.status', ':dispositionCalledOff'), + $qb->expr()->notIn('assignment.status', ':assignmentStatuses'), + $qb->expr()->eq('assignment.skipFormalities', ':skipFormalities'), + $qb->expr()->isNotNull('assignment.pickup'), + $qb->expr()->neq('assignment.pickup', 0), + $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') + ) + )) + ->orderBy('destination.dateTo', \SortDirection::Descending) + ->setParameter('teamer', $teamer) + ->setParameter('questionnaire', QuestionnaireCatalog::TEAMER_ESCORT) + ->setParameter('dispositionCalledOff', Disposition::STATUS_CALLED_OFF) + ->setParameter('assignmentStatuses', [Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED]) + ->setParameter('skipFormalities', false) + ->setParameter('goLive', $goLive, Types::DATE_IMMUTABLE) + ->setParameter('today', new \DateTimeImmutable('today'), Types::DATE_IMMUTABLE) + ->setParameter('draft', QuestionnaireResponseStatus::DRAFT) + ->getQuery() + ; + } + + /** + * @return list + */ + public function findPendingQuestionnaireByTeamer(Teamer $teamer, \DateTimeImmutable $goLive): array + { + return array_map( + static fn (array $row): array => [ + 'disposition' => $row[0], + 'draft' => QuestionnaireResponseStatus::DRAFT === $row['responseStatus'], + ], + $this->getPendingQuestionnaireQuery($teamer, $goLive)->getResult(), + ); + } + public function getRecentDispositionsByTeamerQuery(Teamer $teamer): Query { $qb = $this->createQueryBuilder('disposition'); diff --git a/src/Security/Voter/DispositionVoter.php b/src/Security/Voter/DispositionVoter.php index 659f020..e3b20e1 100644 --- a/src/Security/Voter/DispositionVoter.php +++ b/src/Security/Voter/DispositionVoter.php @@ -2,6 +2,7 @@ namespace App\Security\Voter; +use App\Config\QuestionnaireCatalog; use App\Entity\Disposition; use App\Entity\Upload; use App\Entity\User; @@ -33,10 +34,7 @@ class DispositionVoter extends Voter private readonly Security $security, ?string $questionnaireGoLive, ) { - $this->questionnaireGoLive = null === $questionnaireGoLive - ? null - : (\DateTimeImmutable::createFromFormat('!Y-m-d', $questionnaireGoLive) - ?: throw new \InvalidArgumentException(sprintf('questionnaire_go_live "%s" is not a Y-m-d date', $questionnaireGoLive))); + $this->questionnaireGoLive = QuestionnaireCatalog::parseGoLive($questionnaireGoLive); } protected function supports(string $attribute, mixed $subject): bool @@ -128,6 +126,9 @@ class DispositionVoter extends Voter * assignment ending today counts as over, as in DispositionRepository's feedback queries. * * Skip-formalities placements are left out, as they are from contract, invoice and feedback. + * + * DispositionRepository::getPendingQuestionnaireQuery() applies the same rules in DQL for the + * dashboard; change both together. */ private function assertQuestionnaireAllowed(TokenInterface $token, Disposition $disposition): bool { diff --git a/templates/teamer/disposition/detail.html.twig b/templates/teamer/disposition/detail.html.twig index 857acb6..9a0e79a 100644 --- a/templates/teamer/disposition/detail.html.twig +++ b/templates/teamer/disposition/detail.html.twig @@ -219,6 +219,19 @@ {% endif %} {% endif %} + {# Questionnaire after a bus escort; the edit page forwards to the read-only view once submitted #} + {% if is_granted('QUESTIONNAIRE', disposition) %} +

+ Fragebogen zur Busbegleitung +

+

+ Wie lief dein Einsatz? Dein Feedback hilft uns, besser zu werden. +

+ + Zum Fragebogen + + {% endif %} +
zurück diff --git a/templates/teamer/index.html.twig b/templates/teamer/index.html.twig index 1b6273a..8a48a10 100644 --- a/templates/teamer/index.html.twig +++ b/templates/teamer/index.html.twig @@ -15,6 +15,24 @@ {% set alertType = news.status == 2 ? 'warning' : 'info' %} {% include '_partials/_alert.html.twig' with { 'type': alertType, 'message': news.message } %} {% endif %} + {% if pendingQuestionnaires is not empty %} + {% embed '_partials/_alert.html.twig' with { 'type': 'info' } %} + {% block message %} +

Bitte fülle den Fragebogen zu deiner Busbegleitung aus:

+
+ {% endblock %} + {% endembed %} + {% endif %}

diff --git a/tests/Repository/DispositionRepositoryQuestionnaireTest.php b/tests/Repository/DispositionRepositoryQuestionnaireTest.php new file mode 100644 index 0000000..9c79c22 --- /dev/null +++ b/tests/Repository/DispositionRepositoryQuestionnaireTest.php @@ -0,0 +1,182 @@ +entityManager = self::getContainer()->get(EntityManagerInterface::class); + $this->repository = $this->entityManager->getRepository(Disposition::class); + $this->entityManager->getConnection()->beginTransaction(); + + $this->teamer = (new Teamer())->setFirstName('Kim')->setLastName('Muster')->setGender('d'); + $this->entityManager->persist($this->teamer); + } + + protected function tearDown(): void + { + $this->entityManager->getConnection()->rollBack(); + parent::tearDown(); + } + + public function testAnEscortWithoutAResponseIsPending(): void + { + $disposition = $this->escort(); + + $this->assertSame([['disposition' => $disposition, 'draft' => false]], $this->pending()); + } + + public function testADraftIsStillPendingAndSaysSo(): void + { + $disposition = $this->escort(); + $this->respond($disposition, submit: false); + + $this->assertSame([['disposition' => $disposition, 'draft' => true]], $this->pending()); + } + + public function testASubmittedQuestionnaireIsNoLongerPending(): void + { + $this->respond($this->escort(), submit: true); + + $this->assertSame([], $this->pending()); + } + + /** + * The questionnaire key belongs in the JOIN condition: in the WHERE clause, a submitted + * response to some other questionnaire would hide this one. + */ + public function testAResponseToAnotherQuestionnaireDoesNotCount(): void + { + $disposition = $this->escort(); + $this->respond($disposition, submit: true, questionnaire: 'other'); + + $this->assertSame([['disposition' => $disposition, 'draft' => false]], $this->pending()); + } + + public function testAnAssignmentEndingTodayIsPending(): void + { + $this->escort(dateTo: 'today'); + + $this->assertCount(1, $this->pending()); + } + + /** + * @param \Closure(Disposition): mixed $change + */ + #[\PHPUnit\Framework\Attributes\DataProvider('notPending')] + public function testIsNotPending(\Closure $change): void + { + $change($this->escort()); + $this->entityManager->flush(); + + $this->assertSame([], $this->pending()); + } + + /** + * @return array + */ + public static function notPending(): array + { + return [ + 'no bus escort' => [static fn (Disposition $d) => $d->getAssignment()->setPickup(0)], + 'not over yet' => [static fn (Disposition $d) => $d->getAssignment()->getDestination()->setDateTo(new \DateTimeImmutable('tomorrow'))], + 'ended before go-live' => [static fn (Disposition $d) => $d->getAssignment()->getDestination()->setDateTo(new \DateTimeImmutable('2025-12-31'))], + 'skip formalities' => [static fn (Disposition $d) => $d->getAssignment()->setSkipFormalities(true)], + 'called off' => [static fn (Disposition $d) => $d->setStatus(Disposition::STATUS_CALLED_OFF)], + 'assignment called off' => [static fn (Disposition $d) => $d->getAssignment()->setStatus(Assignment::STATUS_CALLED_OFF)], + ]; + } + + public function testAnotherTeamersEscortIsNotListed(): void + { + $other = (new Teamer())->setFirstName('Alex')->setLastName('Beispiel')->setGender('d'); + $this->entityManager->persist($other); + $this->escort(teamer: $other); + + $this->assertSame([], $this->pending()); + } + + /** + * @return list + */ + private function pending(): array + { + $this->entityManager->flush(); + + return $this->repository->findPendingQuestionnaireByTeamer($this->teamer, new \DateTimeImmutable(self::GO_LIVE)); + } + + private function escort(string $dateTo = 'yesterday', ?Teamer $teamer = null): Disposition + { + $destination = (new Destination()) + ->setProduct('Davos Klosters - Schwendi') + ->setHotel('Schwendi') + ->setHotelCode('DKI') + ->setHotelBusProId(1) + ->setDateFrom(new \DateTimeImmutable($dateTo.' -7 days')) + ->setDateTo(new \DateTimeImmutable($dateTo)) + ->setPickups([['busProId' => 4711, 'city' => 'Stuttgart', 'street' => 'Busbahnhof', 'time' => '21:30']]) + ; + $assignment = (new Assignment()) + ->setDestination($destination) + ->setPickup(4711) + ->setStatus(Assignment::STATUS_STAFFED) + ; + $disposition = (new Disposition(new Application($assignment, $teamer ?? $this->teamer))) + ->setStatus(Disposition::STATUS_ENDED) + ; + + $this->entityManager->persist($destination); + $this->entityManager->persist($assignment); + $this->entityManager->persist($disposition); + + return $disposition; + } + + private function respond(Disposition $disposition, bool $submit, string $questionnaire = QuestionnaireCatalog::TEAMER_ESCORT): void + { + $definition = new Definition($questionnaire, 1, 'Beispiel', null, []); + $response = QuestionnaireResponse::forDisposition($definition, $disposition); + + if ($submit) { + $response->submit($definition, [], [], new \DateTimeImmutable()); + } else { + $response->saveDraft([], []); + } + + $this->entityManager->persist($response); + } +}