feat: remind teamers of open questionnaires on the dashboard
This commit is contained in:
1 parent
5e4e574076
commit
890e04f8a6
7 files changed
+313
-4
No files matched your search
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user