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 %}
Achtung: offene Vorgänge
diff --git a/templates/administrative/teamer/upcoming_assignments.html.twig b/templates/administrative/teamer/upcoming_assignments.html.twig index 1f8a71f..39ba676 100644 --- a/templates/administrative/teamer/upcoming_assignments.html.twig +++ b/templates/administrative/teamer/upcoming_assignments.html.twig @@ -28,24 +28,41 @@ - {% for disposition in pagination %} + {% for row in pagination %} - {{ disposition.assignment.destination.dateFrom|date('d.m.Y') }} - {{ disposition.assignment.destination.dateTo|date('d.m.Y') }} - - - {{ disposition.assignment.destination.product }} -
- {{ disposition.assignment.destination.hotel }} - - - {{ disposition.assignment.jobProfile.name }} + {{ row.dateFrom|date('d.m.Y') }} - {{ row.dateTo|date('d.m.Y') }} + {% if row.seasonAssignment is not null %} + {% set seasonAssignment = row.seasonAssignment %} + + {% include '_partials/_season_badge.html.twig' %} + + {{ seasonAssignment|season_assignment_label }} + + + + {{ seasonAssignment.jobProfile.name }} + + {% else %} + {% set assignment = row.disposition.assignment %} + + {{ assignment.destination.product }} +
+ {{ assignment.destination.hotel }} + + + {{ assignment.jobProfile.name }} + + {% endif %} {% else %} - - Keine Daten... - + + + Keine Daten... + + {% endfor %} diff --git a/templates/teamer/disposition/_card.html.twig b/templates/teamer/disposition/_card.html.twig new file mode 100644 index 0000000..1f39fc6 --- /dev/null +++ b/templates/teamer/disposition/_card.html.twig @@ -0,0 +1,35 @@ +{% set assignment = disposition.assignment %} +
+
+
+ {{ icon('flag-' ~ assignment.destination.country, 'w-5 h-5 shrink-0') }} + {{ assignment.destination.product }} +
+
+ {{ icon('calendar', 'w-5 h-5 shrink-0') }} + {{ assignment.destination.dateFrom|date('d.m.Y') }} - {{ assignment.destination.dateTo|date('d.m.Y') }} +
+
+ {{ icon('profile', 'w-5 h-5 shrink-0') }} + {{ assignment.jobProfile.name }} +
+
+ {{ icon('house', 'w-5 h-5 shrink-0') }} + {{ assignment.destination.hotel }} +
+ {% if assignment.pickup %} + {% set pickup = assignment.destination.pickup(assignment.pickup) %} + {% if pickup is not null %} +
+ {{ icon('bus', 'w-5 h-5 shrink-0') }} + {{ pickup.city }}, {{ pickup.time }} Uhr +
+ {% endif %} + {% endif %} +
+ + {{ icon('info', 'w-4 h-4 shrink-0') }} + Details + +
diff --git a/templates/teamer/disposition/_list.html.twig b/templates/teamer/disposition/_list.html.twig index 4f4bc31..1c9a240 100644 --- a/templates/teamer/disposition/_list.html.twig +++ b/templates/teamer/disposition/_list.html.twig @@ -1,40 +1,6 @@
{% for disposition in pagination %} - {% set assignment = disposition.assignment %} -
-
-
- {{ icon('flag-' ~ assignment.destination.country, 'w-5 h-5 shrink-0') }} - {{ assignment.destination.product }} -
-
- {{ icon('calendar', 'w-5 h-5 shrink-0') }} - {{ assignment.destination.dateFrom|date('d.m.Y') }} - {{ assignment.destination.dateTo|date('d.m.Y') }} -
-
- {{ icon('profile', 'w-5 h-5 shrink-0') }} - {{ assignment.jobProfile.name }} -
-
- {{ icon('house', 'w-5 h-5 shrink-0') }} - {{ assignment.destination.hotel }} -
- {% if assignment.pickup %} - {% set pickup = assignment.destination.pickup(assignment.pickup) %} - {% if pickup is not null %} -
- {{ icon('bus', 'w-5 h-5 shrink-0') }} - {{ pickup.city }}, {{ pickup.time }} Uhr -
- {% endif %} - {% endif %} -
- - {{ icon('info', 'w-4 h-4 shrink-0') }} - Details - -
+ {% include 'teamer/disposition/_card.html.twig' %} {% else %} Keine Einträge... {% endfor %} diff --git a/templates/teamer/disposition/_season_card.html.twig b/templates/teamer/disposition/_season_card.html.twig new file mode 100644 index 0000000..435ab2c --- /dev/null +++ b/templates/teamer/disposition/_season_card.html.twig @@ -0,0 +1,29 @@ +{# A season assignment among the upcoming dispositions. Linked only when it is the one the season + page shows - that page resolves a single assignment and 404s for any other. #} +
+
+
+ {{ seasonAssignment|season_assignment_label }} + {% include '_partials/_season_badge.html.twig' %} +
+
+ {{ icon('calendar', 'w-5 h-5 shrink-0') }} + {{ seasonAssignment.dateFrom|date('d.m.Y') }} - {{ seasonAssignment.dateTo|date('d.m.Y') }} +
+
+ {{ icon('profile', 'w-5 h-5 shrink-0') }} + {{ seasonAssignment.jobProfile.name }} +
+
+ {{ icon('house', 'w-5 h-5 shrink-0') }} + {{ seasonAssignment.hotelCode|house_label }} +
+
+ {% if seasonAssignment is same as(linkedSeasonAssignment) %} + + {{ icon('info', 'w-4 h-4 shrink-0') }} + Details + + {% endif %} +
diff --git a/templates/teamer/disposition/upcoming.html.twig b/templates/teamer/disposition/upcoming.html.twig index 7502cc1..c1ca8cb 100644 --- a/templates/teamer/disposition/upcoming.html.twig +++ b/templates/teamer/disposition/upcoming.html.twig @@ -6,5 +6,16 @@

Meine nächsten Einsätze

- {% include 'teamer/disposition/_list.html.twig' %} -{% endblock %} \ No newline at end of file +
+ {% for row in pagination %} + {% if row.seasonAssignment is not null %} + {% include 'teamer/disposition/_season_card.html.twig' with { 'seasonAssignment': row.seasonAssignment } %} + {% else %} + {% include 'teamer/disposition/_card.html.twig' with { 'disposition': row.disposition } %} + {% endif %} + {% else %} + Keine Einträge... + {% endfor %} +
+ {{ knp_pagination_render(pagination, 'paginator/sliding_boxes.html.twig') }} +{% endblock %} diff --git a/templates/teamer/index.html.twig b/templates/teamer/index.html.twig index 62db48a..1b6273a 100644 --- a/templates/teamer/index.html.twig +++ b/templates/teamer/index.html.twig @@ -116,20 +116,42 @@ Meine Einsätze
    - {% for disposition in upcomingDispositions %} - {% set assignment = disposition.assignment %} + {% for row in upcomingPlacements %}
  • - -
  • {% else %}
  • diff --git a/tests/Repository/SeasonAssignmentRepositoryTest.php b/tests/Repository/SeasonAssignmentRepositoryTest.php index e67b9b5..2a3e998 100644 --- a/tests/Repository/SeasonAssignmentRepositoryTest.php +++ b/tests/Repository/SeasonAssignmentRepositoryTest.php @@ -52,6 +52,25 @@ class SeasonAssignmentRepositoryTest extends KernelTestCase ); } + /** + * Upcoming means "not ended yet", like the dispositions it is listed with - a running season + * is still shown - and a deleted placement is not. + */ + public function testTheUpcomingQueryKeepsRunningAssignmentsAndDropsDeletedOnes(): void + { + /** @var EntityManagerInterface $entityManager */ + $entityManager = static::getContainer()->get(EntityManagerInterface::class); + + /** @var SeasonAssignmentRepository $repository */ + $repository = $entityManager->getRepository(SeasonAssignment::class); + + $dql = $repository->createUpcomingByTeamerQueryBuilder(new Teamer())->getQuery()->getDQL(); + + $this->assertStringContainsString('season_assignment.dateTo >= :today', $dql); + $this->assertStringContainsString('season_assignment.deletedAt IS NULL', $dql); + $this->assertStringNotContainsString('season_assignment.dateFrom >=', $dql); + } + private function createInSeasonWindowQueryBuilder(?int $excludeId = null): \Doctrine\ORM\QueryBuilder { /** @var EntityManagerInterface $entityManager */ diff --git a/tests/Service/Teamer/UpcomingPlacementCollectorTest.php b/tests/Service/Teamer/UpcomingPlacementCollectorTest.php new file mode 100644 index 0000000..86e5871 --- /dev/null +++ b/tests/Service/Teamer/UpcomingPlacementCollectorTest.php @@ -0,0 +1,79 @@ +disposition('2026-10-25', '2026-11-01'); + $late = $this->disposition('2027-05-01', '2027-05-08'); + $season = (new SeasonAssignment()) + ->setDateFrom(new \DateTimeImmutable('2026-11-01')) + ->setDateTo(new \DateTimeImmutable('2027-04-30')); + + $rows = $this->collector([$late, $early], [$season])->collect(new Teamer()); + + $this->assertSame([$early, null, $late], array_column($rows, 'disposition')); + $this->assertSame([null, $season, null], array_column($rows, 'seasonAssignment')); + $this->assertSame('2027-04-30', $rows[1]['dateTo']?->format('Y-m-d')); + } + + /** + * The season frames the dispositions inside it, so on the same first day it comes first. + */ + public function testASeasonStartingOnTheSameDayComesBeforeTheDisposition(): void + { + $disposition = $this->disposition('2026-11-01', '2026-11-08'); + $season = (new SeasonAssignment()) + ->setDateFrom(new \DateTimeImmutable('2026-11-01')) + ->setDateTo(new \DateTimeImmutable('2027-04-30')); + + $rows = $this->collector([$disposition], [$season])->collect(new Teamer()); + + $this->assertSame($season, $rows[0]['seasonAssignment']); + $this->assertSame($disposition, $rows[1]['disposition']); + } + + public function testNothingUpcomingYieldsNoRows(): void + { + $this->assertSame([], $this->collector([], [])->collect(new Teamer())); + } + + /** + * @param Disposition[] $dispositions + * @param SeasonAssignment[] $seasonAssignments + */ + private function collector(array $dispositions, array $seasonAssignments): UpcomingPlacementCollector + { + $dispositionRepository = $this->createStub(DispositionRepository::class); + $dispositionRepository->method('findAllUpcomingByTeamer')->willReturn($dispositions); + + $seasonAssignmentRepository = $this->createStub(SeasonAssignmentRepository::class); + $seasonAssignmentRepository->method('findUpcomingByTeamer')->willReturn($seasonAssignments); + + return new UpcomingPlacementCollector($dispositionRepository, $seasonAssignmentRepository); + } + + private function disposition(string $dateFrom, string $dateTo): Disposition + { + $destination = (new Destination()) + ->setDateFrom(new \DateTimeImmutable($dateFrom)) + ->setDateTo(new \DateTimeImmutable($dateTo)); + + return new Disposition(new Application((new Assignment())->setDestination($destination), new Teamer())); + } +}