feat: filter documents

This commit is contained in:
Björn Fromme
2024-08-27 12:38:19 +02:00
parent 5ac77e9e9c
commit bcaab582cc
12 changed files with 476 additions and 191 deletions
@@ -31,7 +31,6 @@ export default class extends Controller {
}
clickOutside(e) {
e.preventDefault()
this.dropdownTarget.classList.toggle('hidden', true)
}
}
@@ -3,6 +3,7 @@
namespace App\Controller\Administrative\Document;
use App\Repository\UploadRepository;
use App\Service\Common\DocumentFilterHandler;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -14,7 +15,8 @@ class IndexController extends AbstractController
{
public function __construct(
private readonly UploadRepository $uploadRepository,
private readonly PaginatorInterface $paginator
private readonly PaginatorInterface $paginator,
private readonly DocumentFilterHandler $filterHandler
) {
}
@@ -22,9 +24,11 @@ class IndexController extends AbstractController
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(Request $request): Response
{
$filterDto = $this->filterHandler->getFilterSettings();
$query = $this
->uploadRepository
->getUploadListQuery()
->getUploadListQuery($filterDto)
;
$pagination = $this->paginator->paginate(
@@ -35,6 +39,7 @@ class IndexController extends AbstractController
return $this->render('administrative/document/index.html.twig', [
'pagination' => $pagination,
'filterDto' => $filterDto,
]);
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\Controller\Common;
use App\Controller\Traits\ReturnUrlTrait;
use App\Form\DocumentFilterType;
use App\Htmx\HxRedirectResponse;
use App\Service\Common\DocumentFilterHandler;
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 DocumentFilterController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(
private readonly DocumentFilterHandler $filterHandler
) {
}
#[Route('/common/document/filter', name: 'app_common_document_filter')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(Request $request): Response
{
$formData = $this->filterHandler->getFilterSettings();
$form = $this->createForm(DocumentFilterType::class, $formData);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->filterHandler->handleRequest($form);
$returnUrl = $this->getReturnUrl($request, 'app_administrative_document_index');
return new HxRedirectResponse($returnUrl);
}
return $this->render('common/modal_document_filter.html.twig', [
'filterForm' => $form->createView(),
'filterDto' => $form->getData(),
]);
}
#[Route('/common/document/filter/reset', name: 'app_common_document_filter_reset')]
public function reset(Request $request): Response
{
$this->filterHandler->resetFilterSettings();
$returnUrl = $this->getReturnUrl($request, 'app_administrative_document_index');
return $this->redirect($returnUrl);
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace App\Form;
use App\Entity\Teamer;
use App\Entity\Upload;
use App\Model\DocumentFilterDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class DocumentFilterType 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('type', ChoiceType::class, [
'label' => 'Typ',
'required' => false,
'placeholder' => 'nicht filtern',
'choices' => [
'Honorarnote' => Upload::TYPE_INVOICE,
'Honorarvertrag' => Upload::TYPE_CONTRACT,
],
])
->add('dateFrom', DatepickerType::class, [
'label' => 'Einsatzzeitraum von',
'attr' => [
'placeholder' => 'nicht filtern',
],
])
->add('dateTo', DatepickerType::class, [
'label' => 'Einsatzzeitraum 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' => DocumentFilterDto::class,
])
;
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace App\Model;
use App\Entity\Teamer;
class DocumentFilterDto extends AbstractFilterDto
{
protected ?Teamer $teamer = null;
protected ?string $type = null;
protected ?\DateTimeImmutable $dateFrom = null;
protected ?\DateTimeImmutable $dateTo = null;
public function getTeamer(): ?Teamer
{
return $this->teamer;
}
public function setTeamer(?Teamer $teamer): static
{
$this->teamer = $teamer;
return $this;
}
public function getType(): ?string
{
return $this->type;
}
public function setType(?string $type): static
{
$this->type = $type;
return $this;
}
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;
}
}
+33 -3
View File
@@ -3,6 +3,7 @@
namespace App\Repository;
use App\Entity\Upload;
use App\Model\DocumentFilterDto;
use App\Repository\Traits\QueryHelperTrait;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\Query;
@@ -78,11 +79,11 @@ class UploadRepository extends ServiceEntityRepository
return $data;
}
public function getUploadListQuery(): Query
public function getUploadListQuery(DocumentFilterDto $filterDto): Query
{
$qb = $this->createQueryBuilder('upload');
return $qb
$qb
->select('upload', 'disposition', 'assignment', 'job_profile', 'destination', 'teamer')
->innerJoin('upload.disposition', 'disposition')
->innerJoin('disposition.assignment', 'assignment')
@@ -92,8 +93,37 @@ class UploadRepository extends ServiceEntityRepository
->addOrderBy('FIELD(upload.status, :new, :checked)', 'ASC')
->setParameter('new', Upload::STATUS_NEW)
->setParameter('checked', Upload::STATUS_CHECKED)
->getQuery()
;
if (null !== $teamer = $filterDto->getTeamer()) {
$qb
->andWhere($qb->expr()->eq('upload.teamer', ':teamer'))
->setParameter('teamer', $teamer)
;
}
if (null !== $type = $filterDto->getType()) {
$qb
->andWhere($qb->expr()->eq('upload.type', ':type'))
->setParameter('type', $type)
;
}
if (null !== $dateFrom = $filterDto->getDateFrom()) {
$qb
->andWhere($qb->expr()->gte('destination.dateFrom', ':dateFrom'))
->setParameter('dateFrom', $dateFrom)
;
}
if (null !== $dateTo = $filterDto->getDateTo()) {
$qb
->andWhere($qb->expr()->lte('destination.dateTo', ':dateTo'))
->setParameter('dateTo', $dateTo)
;
}
return $qb->getQuery();
}
public function getNew(int $limit = 5): array
@@ -0,0 +1,60 @@
<?php
namespace App\Service\Common;
use App\Model\AbstractFilterDto;
use App\Model\AssignmentFilterDto;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
abstract class AbstractFilterHandler
{
protected string $namespace = 'filter:default';
public function __construct(
protected readonly RequestStack $requestStack,
protected readonly EntityManagerInterface $entityManager
) {
}
public function setNamespace(string $namespace): static
{
$this->namespace = $namespace;
return $this;
}
public function handleRequest(FormInterface $form): AbstractFilterDto
{
$filterDto = $form->getData();
if ($form->get('reset')->isClicked()) {
$filterDto = new AssignmentFilterDto();
$this->resetFilterSettings();
} elseif ($form->get('apply')->isClicked()) {
$this->saveFilterSettings($filterDto);
}
return $filterDto;
}
public function resetFilterSettings(): void
{
$this->getSession()->remove($this->namespace);
}
public function getFilterSettings(): AbstractFilterDto
{
}
protected function saveFilterSettings(AbstractFilterDto $filterDto): void
{
}
protected function getSession(): SessionInterface
{
return $this->requestStack->getSession();
}
}
+5 -44
View File
@@ -3,47 +3,12 @@
namespace App\Service\Common;
use App\Entity\JobProfile;
use App\Model\AbstractFilterDto;
use App\Model\AssignmentFilterDto;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
class AssignmentFilterHandler
class AssignmentFilterHandler extends AbstractFilterHandler
{
private string $namespace = 'filter:assignment';
public function __construct(
private readonly RequestStack $requestStack,
private readonly EntityManagerInterface $entityManager
) {
}
public function setNamespace(string $namespace): static
{
$this->namespace = $namespace;
return $this;
}
public function handleRequest(FormInterface $form): AssignmentFilterDto
{
$filterDto = $form->getData();
if ($form->get('reset')->isClicked()) {
$filterDto = new AssignmentFilterDto();
$this->resetFilterSettings();
} elseif ($form->get('apply')->isClicked()) {
$this->saveFilterSettings($filterDto);
}
return $filterDto;
}
public function resetFilterSettings(): void
{
$this->getSession()->remove($this->namespace);
}
protected string $namespace = 'filter:assignment';
public function getFilterSettings(): AssignmentFilterDto
{
@@ -83,8 +48,9 @@ class AssignmentFilterHandler
return $filterDto;
}
private function saveFilterSettings(AssignmentFilterDto $filterDto): void
protected function saveFilterSettings(AbstractFilterDto $filterDto): void
{
/** @var AssignmentFilterDto $filterDto */
$this->getSession()->set($this->namespace, [
'date_from' => $filterDto->getDateFrom(),
'date_to' => $filterDto->getDateTo(),
@@ -97,9 +63,4 @@ class AssignmentFilterHandler
'include_past' => $filterDto->isIncludePast(),
]);
}
private function getSession(): SessionInterface
{
return $this->requestStack->getSession();
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Service\Common;
use App\Entity\Teamer;
use App\Model\AbstractFilterDto;
use App\Model\DocumentFilterDto;
class DocumentFilterHandler extends AbstractFilterHandler
{
protected string $namespace = 'filter:document';
public function getFilterSettings(): DocumentFilterDto
{
if (null === $data = $this->getSession()->get($this->namespace)) {
return new DocumentFilterDto();
}
$filterDto = new DocumentFilterDto();
$filterDto
->setType($data['type'])
->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);
}
return $filterDto;
}
protected function saveFilterSettings(AbstractFilterDto $filterDto): void
{
/** @var DocumentFilterDto $filterDto */
$this->getSession()->set($this->namespace, [
'teamer_id' => $filterDto->getTeamer()?->getId(),
'type' => $filterDto->getType(),
'date_from' => $filterDto->getDateFrom(),
'date_to' => $filterDto->getDateTo(),
]);
}
}
+5 -44
View File
@@ -3,47 +3,12 @@
namespace App\Service\Common;
use App\Entity\JobProfile;
use App\Model\AbstractFilterDto;
use App\Model\TeamerFilterDto;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
class TeamerFilterHandler
class TeamerFilterHandler extends AbstractFilterHandler
{
private string $namespace = 'filter:teamer';
public function __construct(
private readonly RequestStack $requestStack,
private readonly EntityManagerInterface $entityManager
) {
}
public function setNamespace(string $namespace): static
{
$this->namespace = $namespace;
return $this;
}
public function handleRequest(FormInterface $form): TeamerFilterDto
{
$filterDto = $form->getData();
if ($form->get('reset')->isClicked()) {
$filterDto = new TeamerFilterDto();
$this->resetFilterSettings();
} elseif ($form->get('apply')->isClicked()) {
$this->saveFilterSettings($filterDto);
}
return $filterDto;
}
public function resetFilterSettings(): void
{
$this->getSession()->remove($this->namespace);
}
protected string $namespace = 'filter:teamer';
public function getFilterSettings(): TeamerFilterDto
{
@@ -74,8 +39,9 @@ class TeamerFilterHandler
return $filterDto;
}
private function saveFilterSettings(TeamerFilterDto $filterDto): void
protected function saveFilterSettings(AbstractFilterDto $filterDto): void
{
/** @var TeamerFilterDto $filterDto */
$this->getSession()->set($this->namespace, [
'name' => $filterDto->getName(),
'available_at' => $filterDto->getAvailableAt(),
@@ -88,9 +54,4 @@ class TeamerFilterHandler
}, $filterDto->getJobProfiles()),
]);
}
private function getSession(): SessionInterface
{
return $this->requestStack->getSession();
}
}
+119 -97
View File
@@ -3,104 +3,126 @@
{% block title %}Dokumentenübersicht{% endblock %}
{% block content %}
<h1 class="text-2xl font-bold pb-8">
<div class="flex items-start justify-between pb-4">
<h1 class="text-2xl font-bold">
Dokumentenübersicht
</h1>
<div class="data-table-wrapper">
<div class="data-table-wrapper__inner">
<table class="data-table">
<thead>
<tr>
<th>
{{ knp_pagination_sortable(pagination, 'Einsatz&shy;zeitraum', 'destination.dateFrom') }}
</th>
<th>
{{ knp_pagination_sortable(pagination, 'Destination', 'destination.product') }}
</th>
<th>
{{ knp_pagination_sortable(pagination, 'Jobprofil', 'job_profile.name') }}
</th>
<th>
{{ knp_pagination_sortable(pagination, 'Dokument', 'upload.type') }}
</th>
<th>
{{ knp_pagination_sortable(pagination, 'Teamer:in', 'teamer.lastName') }}
</th>
<th>
{{ knp_pagination_sortable(pagination, 'Status', 'upload.status') }}
</th>
<th></th>
</tr>
</thead>
<tbody>
{% for upload in pagination %}
{% set disposition = upload.disposition %}
{% set assignment = disposition.assignment %}
<tr>
<td>
{{ assignment.destination.dateFrom|date('d.m.Y') }}
-
{{ assignment.destination.dateTo|date('d.m.Y') }}
</td>
<td>
<div class="flex items-start space-x-2">
{{ icon('flag-' ~ assignment.destination.country, 'w-5 h-5 shrink-0') }}
<div class="flex-1">
{{ assignment.destination.product }}
<br>
{{ assignment.destination.hotel }}
</div>
</div>
</td>
<td>
{{ assignment.jobProfile.name }}
</td>
<td>
{{ ('label.document.type.' ~ upload.type)|trans }}
</td>
<td>
{{ disposition.teamer }}
</td>
<td>
{{ upload.status|upload_status_badge }}
</td>
<td>
<div class="flex items-center space-x-2 justify-end">
{% if is_granted('DELETE', upload) %}
<button type="button"
class="text-red-500"
hx-get="{{ path('app_administrative_document_delete', { 'uuid': upload.uuid }) }}"
hx-target="body"
hx-swap="beforeend">
{{ icon('delete') }}
</button>
{% endif %}
{% if is_granted('CHECK', upload) %}
<button type="button"
hx-get="{{ path('app_administrative_document_check', { 'uuid': upload.uuid }) }}"
hx-target="body"
hx-swap="beforeend">
{{ icon('edit') }}
</button>
{% endif %}
<a href="{{ path('app_common_download', { 'uuid': upload.uuid, 'inline': true }) }}"
target="_blank"
title="Download">
{{ icon('eye') }}
</a>
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="7">
Keine Daten...
</td>
</tr>
{% endfor %}
</tbody>
</table>
{{ knp_pagination_render(pagination) }}
</div>
<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_document_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_document_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">
<thead>
<tr>
<th>
{{ knp_pagination_sortable(pagination, 'Einsatz&shy;zeitraum', 'destination.dateFrom') }}
</th>
<th>
{{ knp_pagination_sortable(pagination, 'Destination', 'destination.product') }}
</th>
<th>
{{ knp_pagination_sortable(pagination, 'Jobprofil', 'job_profile.name') }}
</th>
<th>
{{ knp_pagination_sortable(pagination, 'Dokument', 'upload.type') }}
</th>
<th>
{{ knp_pagination_sortable(pagination, 'Teamer:in', 'teamer.lastName') }}
</th>
<th>
{{ knp_pagination_sortable(pagination, 'Status', 'upload.status') }}
</th>
<th></th>
</tr>
</thead>
<tbody>
{% for upload in pagination %}
{% set disposition = upload.disposition %}
{% set assignment = disposition.assignment %}
<tr>
<td>
{{ assignment.destination.dateFrom|date('d.m.Y') }}
-
{{ assignment.destination.dateTo|date('d.m.Y') }}
</td>
<td>
<div class="flex items-start space-x-2">
{{ icon('flag-' ~ assignment.destination.country, 'w-5 h-5 shrink-0') }}
<div class="flex-1">
{{ assignment.destination.product }}
<br>
{{ assignment.destination.hotel }}
</div>
</div>
</td>
<td>
{{ assignment.jobProfile.name }}
</td>
<td>
{{ ('label.document.type.' ~ upload.type)|trans }}
</td>
<td>
{{ disposition.teamer }}
</td>
<td>
{{ upload.status|upload_status_badge }}
</td>
<td>
<div class="flex items-center space-x-2 justify-end">
{% if is_granted('DELETE', upload) %}
<button type="button"
class="text-red-500"
hx-get="{{ path('app_administrative_document_delete', { 'uuid': upload.uuid }) }}"
hx-target="body"
hx-swap="beforeend">
{{ icon('delete') }}
</button>
{% endif %}
{% if is_granted('CHECK', upload) %}
<button type="button"
hx-get="{{ path('app_administrative_document_check', { 'uuid': upload.uuid }) }}"
hx-target="body"
hx-swap="beforeend">
{{ icon('edit') }}
</button>
{% endif %}
<a href="{{ path('app_common_download', { 'uuid': upload.uuid, 'inline': true }) }}"
target="_blank"
title="Download">
{{ icon('eye') }}
</a>
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="7">
Keine Daten...
</td>
</tr>
{% endfor %}
</tbody>
</table>
{{ knp_pagination_render(pagination) }}
</div>
</div>
{% endblock %}
@@ -0,0 +1,19 @@
{% extends 'htmx_modal.html.twig' %}
{% block title %}Dokumente 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.type) }}
{{ form_row(filterForm.teamer) }}
{{ 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 %}