feat: filter availabilities

addresses #869at5k4f
This commit is contained in:
Björn Fromme
2026-08-24 12:22:34 +02:00
parent 6795a668da
commit 4ae23bf8a1
8 changed files with 344 additions and 6 deletions
@@ -3,6 +3,7 @@
namespace App\Controller\Administrative\Availability;
use App\Repository\AvailabilityRepository;
use App\Service\Common\AvailabilityFilterHandler;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -15,6 +16,7 @@ class IndexController extends AbstractController
public function __construct(
private readonly AvailabilityRepository $availabilityRepository,
private readonly PaginatorInterface $paginator,
private readonly AvailabilityFilterHandler $filterHandler,
) {
}
@@ -22,9 +24,11 @@ class IndexController extends AbstractController
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(Request $request): Response
{
$filterDto = $this->filterHandler->getFilterSettings();
$query = $this
->availabilityRepository
->getListQuery(true)
->getListQuery(true, $filterDto)
;
$pagination = $this->paginator->paginate(
@@ -39,6 +43,7 @@ class IndexController extends AbstractController
return $this->render('administrative/availability/index.html.twig', [
'pagination' => $pagination,
'filterDto' => $filterDto,
]);
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Controller\Common;
use App\Controller\Traits\ReturnUrlTrait;
use App\Form\AvailabilityFilterType;
use App\Htmx\HxRedirectResponse;
use App\Repository\JobProfileRepository;
use App\Service\Common\AvailabilityFilterHandler;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class AvailabilityFilterController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(
private readonly AvailabilityFilterHandler $filterHandler,
private readonly JobProfileRepository $jobProfileRepository,
) {
}
#[Route('/common/availability/filter', name: 'app_common_availability_filter')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(Request $request): Response
{
$formData = $this->filterHandler->getFilterSettings();
$formOptions = [
'job_profiles' => $this->jobProfileRepository->getList(),
];
$form = $this->createForm(AvailabilityFilterType::class, $formData, $formOptions);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->filterHandler->handleRequest($form);
$returnUrl = $this->getReturnUrl($request, 'app_administrative_availability_index');
$returnUrl = $this->removeQueryParameters($returnUrl, ['page']);
return new HxRedirectResponse($returnUrl);
}
return $this->render('common/modal_availability_filter.html.twig', [
'filterForm' => $form->createView(),
'filterDto' => $form->getData(),
]);
}
#[Route('/common/availability/filter/reset', name: 'app_common_availability_filter_reset')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function reset(Request $request): Response
{
$this->filterHandler->resetFilterSettings();
$returnUrl = $this->getReturnUrl($request, 'app_administrative_availability_index');
return $this->redirect($returnUrl);
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Form;
use App\Entity\JobProfile;
use App\Entity\Teamer;
use App\Model\AvailabilityFilterDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AvailabilityFilterType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('teamer', AutocompleteEntityType::class, [
'label' => 'Teamer',
'class' => Teamer::class,
'label_property' => 'name',
'label_function' => fn (Teamer $teamer) => (string) $teamer,
'endpoint_route' => 'app_admin_autocomplete_teamer',
])
->add('jobProfiles', MultiselectEntityType::class, [
'label' => 'Job-Profil',
'class' => JobProfile::class,
'required' => false,
'empty_label' => 'nicht filtern',
'choice_label' => 'name',
'choices' => $options['job_profiles'],
])
->add('dateFrom', DatepickerType::class, [
'label' => 'Zeitraum von',
'attr' => [
'placeholder' => 'nicht filtern',
],
])
->add('dateTo', DatepickerType::class, [
'label' => 'Zeitraum bis',
'attr' => [
'placeholder' => 'nicht filtern',
],
])
->add('apply', SubmitType::class, [
'label' => 'filtern',
])
->add('reset', SubmitType::class, [
'label' => 'reset',
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver
->setDefaults([
'data_class' => AvailabilityFilterDto::class,
'job_profiles' => [],
])
;
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace App\Model;
use App\Entity\Teamer;
class AvailabilityFilterDto extends AbstractFilterDto
{
protected ?\DateTimeImmutable $dateFrom = null;
protected ?\DateTimeImmutable $dateTo = null;
protected ?Teamer $teamer = null;
protected array $jobProfiles = [];
public function getDateFrom(): ?\DateTimeImmutable
{
return $this->dateFrom;
}
public function setDateFrom(?\DateTimeImmutable $dateFrom): static
{
$this->dateFrom = $dateFrom;
return $this;
}
public function getDateTo(): ?\DateTimeImmutable
{
return $this->dateTo;
}
public function setDateTo(?\DateTimeImmutable $dateTo): static
{
$this->dateTo = $dateTo;
return $this;
}
public function getTeamer(): ?Teamer
{
return $this->teamer;
}
public function setTeamer(?Teamer $teamer): static
{
$this->teamer = $teamer;
return $this;
}
public function getJobProfiles(): array
{
return $this->jobProfiles;
}
public function setJobProfiles(array $jobProfiles): static
{
$this->jobProfiles = $jobProfiles;
return $this;
}
}
+46 -1
View File
@@ -4,6 +4,7 @@ namespace App\Repository;
use App\Entity\Availability;
use App\Entity\Teamer;
use App\Model\AvailabilityFilterDto;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\Query;
use Doctrine\Persistence\ManagerRegistry;
@@ -23,7 +24,7 @@ class AvailabilityRepository extends ServiceEntityRepository
parent::__construct($registry, Availability::class);
}
public function getListQuery(bool $custom = false): Query
public function getListQuery(bool $custom = false, ?AvailabilityFilterDto $filterDto = null): Query
{
$qb = $this->createQueryBuilder('availability');
@@ -49,6 +50,50 @@ class AvailabilityRepository extends ServiceEntityRepository
;
}
if (null === $filterDto) {
return $qb->getQuery();
}
if (null !== $dateFrom = $filterDto->getDateFrom()) {
$qb
->andWhere($qb->expr()->gte('availability.dateFrom', ':dateFrom'))
->setParameter('dateFrom', $dateFrom)
;
}
if (null !== $dateTo = $filterDto->getDateTo()) {
$qb
->andWhere($qb->expr()->lte('availability.dateTo', ':dateTo'))
->setParameter('dateTo', $dateTo)
;
}
if (null !== $teamer = $filterDto->getTeamer()) {
$qb
->andWhere($qb->expr()->eq('availability.owner', ':teamer'))
->setParameter('teamer', $teamer)
;
}
if (0 < count($jobProfiles = $filterDto->getJobProfiles())) {
// a join on the job-profiles would multiply the availability whenever its
// owner holds more than one of the selected profiles, which would break
// the pagination count, so the owner is matched against a subquery instead
$subQuery = $this
->getEntityManager()
->createQueryBuilder()
->select('profileTeamer.id')
->from(Teamer::class, 'profileTeamer')
->innerJoin('profileTeamer.jobProfiles', 'filterJobProfile')
->where('filterJobProfile IN (:jobProfiles)')
;
$qb
->andWhere($qb->expr()->in('availability.owner', $subQuery->getDQL()))
->setParameter('jobProfiles', $jobProfiles)
;
}
return $qb->getQuery();
}
@@ -0,0 +1,61 @@
<?php
namespace App\Service\Common;
use App\Entity\JobProfile;
use App\Entity\Teamer;
use App\Model\AbstractFilterDto;
use App\Model\AvailabilityFilterDto;
class AvailabilityFilterHandler extends AbstractFilterHandler
{
protected string $namespace = 'filter:availability';
protected string $modelClass = AvailabilityFilterDto::class;
public function getFilterSettings(): AvailabilityFilterDto
{
$filterDto = new $this->modelClass();
if (null === $data = $this->getSession()->get($this->namespace)) {
return $filterDto;
}
$filterDto
->setDateFrom($data['date_from'])
->setDateTo($data['date_to'])
;
if (isset($data['teamer_id'])) {
$teamer = $this
->entityManager
->getRepository(Teamer::class)
->find($data['teamer_id'])
;
$filterDto->setTeamer($teamer);
}
if (isset($data['job_profiles']) && 0 < count($data['job_profiles'])) {
$jobProfiles = $this
->entityManager
->getRepository(JobProfile::class)
->findBy(['id' => $data['job_profiles']])
;
$filterDto->setJobProfiles($jobProfiles);
}
return $filterDto;
}
protected function saveFilterSettings(AbstractFilterDto $filterDto): void
{
/* @var AvailabilityFilterDto $filterDto */
$this->getSession()->set($this->namespace, [
'date_from' => $filterDto->getDateFrom(),
'date_to' => $filterDto->getDateTo(),
'teamer_id' => $filterDto->getTeamer()?->getId(),
'job_profiles' => array_map(function (JobProfile $jobProfile) {
return $jobProfile->getId();
}, $filterDto->getJobProfiles()),
]);
}
}
@@ -3,9 +3,32 @@
{% block title %}Eigene Verfügbarkeiten der Teamer{% endblock %}
{% block content %}
<h1 class="text-2xl font-bold pb-8">
Eigene Verfügbarkeiten der Teamer
</h1>
<div class="flex items-start justify-between pb-4">
<h1 class="text-2xl font-bold">
Eigene Verfügbarkeiten der Teamer
</h1>
<div class="flex flex-col items-end space-y-2 md:flex-row md:items-center md:space-x-2 md:space-y-0">
<button type="button"
class="{{ html_classes('btn btn--small', { 'btn--secondary': filterDto.active }) }}"
title="Filter"
hx-get="{{ path('app_common_availability_filter', { 'r': return_url() }) }}"
hx-target="body"
hx-swap="beforeend">
{{ icon('filter', 'w-4 h-4 shrink-0') }}
{% if filterDto.active %}
<span class="whitespace-nowrap">{{ filterDto.activeFiltersCount }} Filter aktiv</span>
{% else %}
<span>Filter</span>
{% endif %}
</button>
{% if filterDto.active %}
<a href="{{ path('app_common_availability_filter_reset', { 'r': return_url() }) }}"
class="btn btn--small btn--secondary">
Reset
</a>
{% endif %}
</div>
</div>
<div class="data-table-wrapper">
<div class="data-table-wrapper__inner">
<table class="data-table">
@@ -43,7 +66,7 @@
</tr>
{% else %}
<tr>
<td colspan="3">
<td colspan="4">
Keine Daten...
</td>
</tr>
@@ -0,0 +1,19 @@
{% extends 'htmx_modal.html.twig' %}
{% block title %}Verfügbarkeiten filtern{% endblock %}
{% block content %}
{{ form_start(filterForm, { 'attr': { 'hx-post': app.request.uri, 'hx-target': '#htmx-modal', 'hx-swap': 'outerHTML' } }) }}
<div class="flex flex-col space-y-2 pb-4">
{{ form_row(filterForm.teamer) }}
{{ form_row(filterForm.jobProfiles) }}
{{ form_row(filterForm.dateFrom) }}
{{ form_row(filterForm.dateTo) }}
</div>
<div class="flex items-center space-x-2">
{{ form_widget(filterForm.apply, { 'attr': { 'class': 'btn' } }) }}
{{ form_widget(filterForm.reset, { 'attr': { 'class': 'btn btn--secondary' } }) }}
</div>
{{ form_rest(filterForm) }}
{{ form_end(filterForm) }}
{% endblock %}