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
{