feat: remind teamers of open questionnaires on the dashboard

This commit is contained in:
fromme committed 2026-10-27 16:59:44 +01:00
1 parent 5e4e574076
commit 890e04f8a6
7 files changed
+313 -4

No files matched your search

+14
View File
@@ -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
*/
+10
View File
@@ -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,
]);
}
+71
View File
@@ -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<array{disposition: Disposition, draft: bool}>
*/
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');
+5 -4
View File
@@ -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
{
@@ -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) %}
<h3 class="text-xl font-bold pb-4">
Fragebogen zur Busbegleitung
</h3>
<p class="pb-4">
Wie lief dein Einsatz? Dein Feedback hilft uns, besser zu werden.
</p>
<a href="{{ path('app_teamer_questionnaire_edit', { 'uuid': disposition.uuid }) }}" class="btn inline-block mb-4">
Zum Fragebogen
</a>
{% endif %}
<div class="pt-4">
<a href="{{ returnUrl }}" class="underline">
zurück
+18
View File
@@ -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 %}
<p>Bitte fülle den Fragebogen zu deiner Busbegleitung aus:</p>
<ul class="pt-1">
{% for row in pendingQuestionnaires %}
{% set destination = row.disposition.assignment.destination %}
<li>
<a href="{{ path('app_teamer_questionnaire_edit', { 'uuid': row.disposition.uuid }) }}" class="underline">
{{ destination.dateFrom|date('d.m.y') }} - {{ destination.dateTo|date('d.m.y') }}, {{ destination.hotel }}
</a>
{% if row.draft %}(Entwurf gespeichert){% endif %}
</li>
{% endfor %}
</ul>
{% endblock %}
{% endembed %}
{% endif %}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div class="bg-gray-100 border border-gray-200 rounded-md px-4 py-2">
<h2 class="font-bold text-lg pb-2">
@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace App\Tests\Repository;
use App\Config\QuestionnaireCatalog;
use App\Entity\Application;
use App\Entity\Assignment;
use App\Entity\Destination;
use App\Entity\Disposition;
use App\Entity\QuestionnaireResponse;
use App\Entity\Teamer;
use App\Model\Questionnaire\Definition;
use App\Repository\DispositionRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* Which questionnaires the dashboard reminds a teamer of. Run against db_test with real rows,
* because the question is which rows come back: the response is joined as an arbitrary entity,
* and a wrong JOIN condition would quietly let a draft, or a response to another questionnaire,
* decide the outcome.
*
* Every test runs inside a transaction that is rolled back, so db_test - shared between
* branches - keeps nothing.
*/
class DispositionRepositoryQuestionnaireTest extends KernelTestCase
{
private const string GO_LIVE = '2026-01-01';
private EntityManagerInterface $entityManager;
private DispositionRepository $repository;
private Teamer $teamer;
protected function setUp(): void
{
self::bootKernel();
$this->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<string, array{\Closure(Disposition): mixed}>
*/
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<array{disposition: Disposition, draft: bool}>
*/
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);
}
}