diff --git a/src/Controller/Admin/Teamer/DeleteAccountController.php b/src/Controller/Admin/Teamer/DeleteAccountController.php index 929645c..b6c72e8 100644 --- a/src/Controller/Admin/Teamer/DeleteAccountController.php +++ b/src/Controller/Admin/Teamer/DeleteAccountController.php @@ -5,8 +5,8 @@ namespace App\Controller\Admin\Teamer; use App\Entity\Teamer; use App\Htmx\HxRedirectResponse; use App\Repository\ApplicationRepository; -use App\Repository\DispositionRepository; use App\Service\Teamer\AccountDeletionHandler; +use App\Service\Teamer\UpcomingPlacementCollector; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -18,7 +18,7 @@ class DeleteAccountController extends AbstractController public function __construct( private readonly AccountDeletionHandler $accountDeletionHandler, private readonly ApplicationRepository $applicationRepository, - private readonly DispositionRepository $dispositionRepository, + private readonly UpcomingPlacementCollector $upcomingPlacementCollector, ) { } @@ -39,7 +39,8 @@ class DeleteAccountController extends AbstractController return $this->render('admin/teamer/modal_delete_account.html.twig', [ 'teamer' => $teamer, 'pendingApplicationCount' => count($this->applicationRepository->getPendingForTeamer($teamer, 100)), - 'upcomingDispositionCount' => count($this->dispositionRepository->getUpcomingDispositionsByTeamer($teamer, 100)), + // season assignments included: an active season placement is an open commitment as well + 'upcomingPlacementCount' => count($this->upcomingPlacementCollector->collect($teamer)), ]); } diff --git a/src/Controller/Administrative/Teamer/UpcomingAssignmentsController.php b/src/Controller/Administrative/Teamer/UpcomingAssignmentsController.php index 26a99f7..fb42494 100644 --- a/src/Controller/Administrative/Teamer/UpcomingAssignmentsController.php +++ b/src/Controller/Administrative/Teamer/UpcomingAssignmentsController.php @@ -3,7 +3,7 @@ namespace App\Controller\Administrative\Teamer; use App\Entity\Teamer; -use App\Repository\DispositionRepository; +use App\Service\Teamer\UpcomingPlacementCollector; use Knp\Component\Pager\PaginatorInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -14,7 +14,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; class UpcomingAssignmentsController extends AbstractController { public function __construct( - private readonly DispositionRepository $dispositionRepository, + private readonly UpcomingPlacementCollector $upcomingPlacementCollector, private readonly PaginatorInterface $paginator, ) { } @@ -23,19 +23,11 @@ class UpcomingAssignmentsController extends AbstractController #[IsGranted('ROLE_ADMINISTRATIVE')] public function index(Teamer $teamer, Request $request): Response { - $query = $this - ->dispositionRepository - ->getUpcomingDispositionsByTeamerQuery($teamer) - ; - + // an array rather than a query: dispositions and season assignments are merged in PHP $pagination = $this->paginator->paginate( - $query, + $this->upcomingPlacementCollector->collect($teamer), $request->query->getInt('page', 1), - 10, - [ - 'defaultSortFieldName' => 'destination.dateFrom', - 'defaultSortDirection' => 'asc', - ] + 10 ); return $this->render('administrative/teamer/upcoming_assignments.html.twig', [ diff --git a/src/Controller/Teamer/Disposition/UpcomingController.php b/src/Controller/Teamer/Disposition/UpcomingController.php index 9e7d873..4e476ef 100644 --- a/src/Controller/Teamer/Disposition/UpcomingController.php +++ b/src/Controller/Teamer/Disposition/UpcomingController.php @@ -3,7 +3,8 @@ namespace App\Controller\Teamer\Disposition; use App\Entity\User; -use App\Repository\DispositionRepository; +use App\Service\Season\SeasonAssignmentResolver; +use App\Service\Teamer\UpcomingPlacementCollector; use Knp\Component\Pager\PaginatorInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -14,7 +15,8 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; class UpcomingController extends AbstractController { public function __construct( - private readonly DispositionRepository $dispositionRepository, + private readonly UpcomingPlacementCollector $upcomingPlacementCollector, + private readonly SeasonAssignmentResolver $seasonAssignmentResolver, private readonly PaginatorInterface $paginator, ) { } @@ -27,24 +29,18 @@ class UpcomingController extends AbstractController $user = $this->getUser(); $teamer = $user->getTeamer(); - $query = $this - ->dispositionRepository - ->getUpcomingDispositionsByTeamerQuery($teamer) - ; - + // an array rather than a query: dispositions and season assignments are merged in PHP $pagination = $this->paginator->paginate( - $query, + $this->upcomingPlacementCollector->collect($teamer), $request->query->getInt('page', 1), - 9, - [ - 'defaultSortFieldName' => 'destination.dateFrom', - 'defaultSortDirection' => 'asc', - ] + 9 ); return $this->render('teamer/disposition/upcoming.html.twig', [ 'teamer' => $teamer, 'pagination' => $pagination, + // the season page shows only this one, so only this one is linked + 'linkedSeasonAssignment' => $this->seasonAssignmentResolver->resolveFor($teamer), ]); } } diff --git a/src/Controller/Teamer/IndexController.php b/src/Controller/Teamer/IndexController.php index 8734a6b..f5e626a 100644 --- a/src/Controller/Teamer/IndexController.php +++ b/src/Controller/Teamer/IndexController.php @@ -8,6 +8,8 @@ use App\Entity\Disposition; use App\Entity\Feedback; use App\Entity\News; use App\Entity\User; +use App\Service\Season\SeasonAssignmentResolver; +use App\Service\Teamer\UpcomingPlacementCollector; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; @@ -16,8 +18,11 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; class IndexController extends AbstractController { - public function __construct(private readonly EntityManagerInterface $entityManager) - { + public function __construct( + private readonly EntityManagerInterface $entityManager, + private readonly UpcomingPlacementCollector $upcomingPlacementCollector, + private readonly SeasonAssignmentResolver $seasonAssignmentResolver, + ) { } #[Route('/teamer', name: 'app_teamer_index')] @@ -66,6 +71,9 @@ class IndexController extends AbstractController $dispositionDocuments = $this->processDocuments($upcomingDispositions); + // the list merges in season assignments; the document notices above stay on dispositions + $upcomingPlacements = array_slice($this->upcomingPlacementCollector->collect($teamer), 0, 5); + return $this->render('teamer/index.html.twig', [ 'news' => $news, 'teamer' => $teamer, @@ -73,7 +81,9 @@ class IndexController extends AbstractController 'recentFeedback' => $recentFeedback, 'pendingApplications' => $pendingApplications, 'currentDispositions' => $currentDispositions, - 'upcomingDispositions' => $upcomingDispositions, + 'upcomingPlacements' => $upcomingPlacements, + // the season page shows only this one, so only this one is linked + 'linkedSeasonAssignment' => $this->seasonAssignmentResolver->resolveFor($teamer), 'dispositionDocuments' => $dispositionDocuments, ]); } diff --git a/src/Repository/DispositionRepository.php b/src/Repository/DispositionRepository.php index 54cc87c..537b42e 100644 --- a/src/Repository/DispositionRepository.php +++ b/src/Repository/DispositionRepository.php @@ -52,6 +52,17 @@ class DispositionRepository extends ServiceEntityRepository ; } + /** + * All of them, unlike getUpcomingDispositionsByTeamer(): merged with the season assignments + * by App\Service\Teamer\UpcomingPlacementCollector, which sorts and pages the result. + * + * @return Disposition[] + */ + public function findAllUpcomingByTeamer(Teamer $teamer): array + { + return $this->getUpcomingDispositionsByTeamerQuery($teamer)->getResult(); + } + public function getUpcomingDispositionsByTeamer(Teamer $teamer, int $limit = 5): array { return $this diff --git a/src/Repository/SeasonAssignmentRepository.php b/src/Repository/SeasonAssignmentRepository.php index 4539272..2f76f0c 100644 --- a/src/Repository/SeasonAssignmentRepository.php +++ b/src/Repository/SeasonAssignmentRepository.php @@ -7,6 +7,7 @@ use App\Entity\Teamer; use App\Model\SeasonAssignmentFilterDto; use App\Repository\Traits\QueryHelperTrait; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Query; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; @@ -91,6 +92,42 @@ class SeasonAssignmentRepository extends ServiceEntityRepository ; } + /** + * A teamer's season assignments that have not ended yet, earliest first - listed alongside + * the upcoming dispositions. A running season counts, just as a running disposition does. + * + * @return SeasonAssignment[] + */ + public function findUpcomingByTeamer(Teamer $teamer): array + { + return $this + ->createUpcomingByTeamerQueryBuilder($teamer) + ->getQuery() + ->getResult() + ; + } + + /** + * Extracted so the conditions can be pinned by a test compiling the DQL. + */ + public function createUpcomingByTeamerQueryBuilder(Teamer $teamer): QueryBuilder + { + $qb = $this->createQueryBuilder('season_assignment'); + + return $qb + ->select('season_assignment', 'job_profile') + ->innerJoin('season_assignment.jobProfile', 'job_profile') + ->where($qb->expr()->andX( + $qb->expr()->eq('season_assignment.teamer', ':teamer'), + $qb->expr()->isNull('season_assignment.deletedAt'), + $qb->expr()->gte('season_assignment.dateTo', ':today') + )) + ->setParameter('teamer', $teamer) + ->setParameter('today', new \DateTimeImmutable('today'), Types::DATE_IMMUTABLE) + ->addOrderBy('season_assignment.dateFrom', \SortDirection::Ascending) + ; + } + /** * Extracted so the conditions can be pinned by a test compiling the DQL - the suite has no * fixtures, so what is asserted is what the query says, not what it returns. diff --git a/src/Service/Teamer/UpcomingPlacementCollector.php b/src/Service/Teamer/UpcomingPlacementCollector.php new file mode 100644 index 0000000..814f42b --- /dev/null +++ b/src/Service/Teamer/UpcomingPlacementCollector.php @@ -0,0 +1,62 @@ + + */ + public function collect(Teamer $teamer): array + { + $rows = []; + + // season assignments first: the sort is stable, so on the same start day the season, + // which frames the dispositions inside it, comes before them + foreach ($this->seasonAssignmentRepository->findUpcomingByTeamer($teamer) as $seasonAssignment) { + $rows[] = [ + 'dateFrom' => $seasonAssignment->getDateFrom(), + 'dateTo' => $seasonAssignment->getDateTo(), + 'disposition' => null, + 'seasonAssignment' => $seasonAssignment, + ]; + } + + foreach ($this->dispositionRepository->findAllUpcomingByTeamer($teamer) as $disposition) { + $destination = $disposition->getAssignment()?->getDestination(); + + $rows[] = [ + 'dateFrom' => $destination?->getDateFrom(), + 'dateTo' => $destination?->getDateTo(), + 'disposition' => $disposition, + 'seasonAssignment' => null, + ]; + } + + usort($rows, static fn (array $a, array $b): int => $a['dateFrom'] <=> $b['dateFrom']); + + return $rows; + } +} diff --git a/templates/_partials/_season_badge.html.twig b/templates/_partials/_season_badge.html.twig new file mode 100644 index 0000000..ade33e4 --- /dev/null +++ b/templates/_partials/_season_badge.html.twig @@ -0,0 +1,2 @@ +{# Marks a season assignment listed among dispositions. #} +Saison diff --git a/templates/admin/teamer/modal_delete_account.html.twig b/templates/admin/teamer/modal_delete_account.html.twig index 7dd8974..b7daa07 100644 --- a/templates/admin/teamer/modal_delete_account.html.twig +++ b/templates/admin/teamer/modal_delete_account.html.twig @@ -11,15 +11,15 @@ sich nicht mehr anmelden. Bestehende Einsätze, Dokumente und Feedbacks bleiben erhalten und weiterhin sichtbar. - {% if pendingApplicationCount > 0 or upcomingDispositionCount > 0 %} + {% if pendingApplicationCount > 0 or upcomingPlacementCount > 0 %}