diff --git a/assets/styles/components/timeline.css b/assets/styles/components/timeline.css index 058b79a..5ffb146 100644 --- a/assets/styles/components/timeline.css +++ b/assets/styles/components/timeline.css @@ -1,4 +1,4 @@ .scroll-container { @apply overflow-scroll; - max-height: calc(100vh - 140px); + max-height: calc(100vh - 240px); } diff --git a/src/Controller/Administrative/Assignment/TimelineController.php b/src/Controller/Administrative/Assignment/TimelineController.php index 3b55fc3..495153c 100644 --- a/src/Controller/Administrative/Assignment/TimelineController.php +++ b/src/Controller/Administrative/Assignment/TimelineController.php @@ -2,9 +2,9 @@ namespace App\Controller\Administrative\Assignment; -use App\Model\AssignmentFilterDto; use App\Repository\AssignmentRepository; use App\Service\Assignment\TimelineService; +use App\Service\Common\TimelineFilterHandler; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -14,7 +14,8 @@ class TimelineController extends AbstractController { public function __construct( private readonly AssignmentRepository $assignmentRepository, - private readonly TimelineService $timelineService + private readonly TimelineService $timelineService, + private readonly TimelineFilterHandler $filterHandler ) { } @@ -23,12 +24,9 @@ class TimelineController extends AbstractController public function index(): Response { $period = $this->timelineService->getActiveWindow(); - $filterDto = new AssignmentFilterDto(); - $filterDto - ->setDateFrom($period->first()->toDateTimeImmutable()) - ->setDateTo($period->last()->toDateTimeImmutable()) - ->setIncludePast(true) - ; + $this->filterHandler->setPeriod($period); + + $filterDto = $this->filterHandler->getFilterSettings(); $assignments = $this ->assignmentRepository @@ -40,6 +38,7 @@ class TimelineController extends AbstractController return $this->render('administrative/assignment/timeline.html.twig', [ 'data' => $timelineData, 'period' => $period, + 'filterDto' => $filterDto, ]); } } diff --git a/src/Controller/Common/TimelineFilterController.php b/src/Controller/Common/TimelineFilterController.php new file mode 100644 index 0000000..1f4d6f3 --- /dev/null +++ b/src/Controller/Common/TimelineFilterController.php @@ -0,0 +1,62 @@ +timelineService->getActiveWindow(); + $this->filterHandler->setPeriod($period); + } + + #[Route('/common/timeline/filter', name: 'app_common_timeline_filter')] + #[IsGranted('ROLE_ADMINISTRATIVE')] + public function index(Request $request): Response + { + $formData = $this->filterHandler->getFilterSettings(); + $formOptions = [ + 'min_date' => $formData->getPeriod()->first()->toDateTimeImmutable(), + 'max_date' => $formData->getPeriod()->last()->toDateTimeImmutable(), + ]; + + $form = $this->createForm(TimelineFilterType::class, $formData, $formOptions); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->filterHandler->handleRequest($form); + $returnUrl = $this->getReturnUrl($request, 'app_administrative_assignment_timeline'); + + return new HxRedirectResponse($returnUrl); + } + + return $this->render('common/modal_timeline_filter.html.twig', [ + 'filterForm' => $form->createView(), + 'filterDto' => $form->getData(), + ]); + } + + #[Route('/common/timeline/filter/reset', name: 'app_common_timeline_filter_reset')] + public function reset(Request $request): Response + { + $this->filterHandler->resetFilterSettings(); + $returnUrl = $this->getReturnUrl($request, 'app_administrative_assignment_timeline'); + + return $this->redirect($returnUrl); + } +} diff --git a/src/Form/TimelineFilterType.php b/src/Form/TimelineFilterType.php new file mode 100644 index 0000000..1e321d6 --- /dev/null +++ b/src/Form/TimelineFilterType.php @@ -0,0 +1,75 @@ +destinations; + sort($destinations); + foreach ($destinations as $item) { + $this->hotelChoices[$item] = $item; + } + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('dateFrom', DatepickerType::class, [ + 'label' => 'Zeitraum von', + 'required' => false, + 'min_date' => $options['min_date'], + 'max_date' => $options['max_date'], + 'attr' => [ + 'placeholder' => 'nicht filtern', + ], + ]) + ->add('dateTo', DatepickerType::class, [ + 'label' => 'Zeitraum bis', + 'required' => false, + 'min_date' => $options['min_date'], + 'max_date' => $options['max_date'], + 'attr' => [ + 'placeholder' => 'nicht filtern', + ], + ]) + ->add('apply', SubmitType::class, [ + 'label' => 'filtern', + ]) + ->add('reset', SubmitType::class, [ + 'label' => 'reset', + ]) + ; + + $builder + ->add('hotels', MultiselectType::class, [ + 'label' => 'Haus/Destination', + 'required' => false, + 'empty_label' => 'nicht filtern', + 'choices' => $this->hotelChoices, + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver + ->setDefaults([ + 'data_class' => TimelineFilterDto::class, + 'min_date' => null, + 'max_date' => null, + ]) + ->setAllowedTypes('min_date', [\DateTimeImmutable::class, 'null']) + ->setAllowedTypes('max_date', [\DateTimeImmutable::class, 'null']) + ; + } +} diff --git a/src/Model/AbstractFilterDto.php b/src/Model/AbstractFilterDto.php index 41c4cf8..81c555a 100644 --- a/src/Model/AbstractFilterDto.php +++ b/src/Model/AbstractFilterDto.php @@ -17,6 +17,12 @@ abstract class AbstractFilterDto foreach ($reflection->getProperties() as $property) { $name = $property->getName(); + + // don't count properties starting with '_' + if (str_starts_with($name, '_')) { + continue; + } + if ($this->$name !== $freshInstance->$name) { ++$filterCount; } @@ -24,4 +30,4 @@ abstract class AbstractFilterDto return $filterCount; } -} \ No newline at end of file +} diff --git a/src/Model/AssignmentFilterDto.php b/src/Model/AssignmentFilterDto.php index 4ca6c91..84ea219 100644 --- a/src/Model/AssignmentFilterDto.php +++ b/src/Model/AssignmentFilterDto.php @@ -2,9 +2,6 @@ namespace App\Model; -use App\Entity\Destination; -use App\Entity\JobProfile; - class AssignmentFilterDto extends AbstractFilterDto { public const DURATION_WEEKEND = 1; @@ -116,4 +113,4 @@ class AssignmentFilterDto extends AbstractFilterDto return $this; } -} \ No newline at end of file +} diff --git a/src/Model/TimelineFilterDto.php b/src/Model/TimelineFilterDto.php new file mode 100644 index 0000000..8774d45 --- /dev/null +++ b/src/Model/TimelineFilterDto.php @@ -0,0 +1,22 @@ +_period; + } + + public function setPeriod(?CarbonPeriod $_period): static + { + $this->_period = $_period; + + return $this; + } +} diff --git a/src/Model/TimelineItem.php b/src/Model/TimelineItem.php index 5ca1d82..18350a2 100644 --- a/src/Model/TimelineItem.php +++ b/src/Model/TimelineItem.php @@ -159,6 +159,6 @@ class TimelineItem return 1; } - return $this->getDateFrom()->diff($this->getDateTo())->days; + return $this->getDateFrom()->diff($this->getDateTo())->days + 1; } } diff --git a/src/Repository/AssignmentRepository.php b/src/Repository/AssignmentRepository.php index 7add518..324265a 100644 --- a/src/Repository/AssignmentRepository.php +++ b/src/Repository/AssignmentRepository.php @@ -6,6 +6,7 @@ use App\Entity\Application; use App\Entity\Assignment; use App\Entity\Teamer; use App\Model\AssignmentFilterDto; +use App\Model\TimelineFilterDto; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\Query; use Doctrine\ORM\Query\Expr\Join; @@ -309,4 +310,29 @@ class AssignmentRepository extends ServiceEntityRepository return array_column($availableAssignments, 'id'); } + + public function getSelectableHotelCodes(): array + { + $qb = $this->createQueryBuilder('assignment'); + + $codes = []; + + $result = $qb + ->select('assignment.id', 'destination.hotelCode') + ->innerJoin('assignment.destination', 'destination') + ->groupBy('destination.hotelCode') + ->orderBy('destination.hotelCode', 'ASC') + ->getQuery() + ->getArrayResult(); + + foreach ($result as $row) { + if (str_starts_with($row['hotelCode'], 'SER')) { + $codes[] = substr($row['hotelCode'], 2, 3); + } else { + $codes[] = substr($row['hotelCode'], 0, 3); + } + } + + return array_unique($codes); + } } diff --git a/src/Service/Assignment/TimelineService.php b/src/Service/Assignment/TimelineService.php index c94d446..d43ddd5 100644 --- a/src/Service/Assignment/TimelineService.php +++ b/src/Service/Assignment/TimelineService.php @@ -49,8 +49,8 @@ class TimelineService foreach ($row as $entry) { /** @var TimelineItem $entry */ if ( - $item->getDateFrom() >= $entry->getDateFrom() && $item->getDateFrom() < $entry->getDateTo() - || $item->getDateTo() >= $entry->getDateFrom() && $item->getDateTo() < $entry->getDateTo() + $item->getDateFrom() >= $entry->getDateFrom() && $item->getDateFrom() <= $entry->getDateTo() + || $item->getDateTo() >= $entry->getDateFrom() && $item->getDateTo() <= $entry->getDateTo() ) { $addToRow = false; } diff --git a/src/Service/Common/TimelineFilterHandler.php b/src/Service/Common/TimelineFilterHandler.php new file mode 100644 index 0000000..c922753 --- /dev/null +++ b/src/Service/Common/TimelineFilterHandler.php @@ -0,0 +1,60 @@ +period = $period; + } + + public function getFilterSettings(): TimelineFilterDto + { + if (null === $data = $this->getSession()->get($this->namespace)) { + $filterDto = new TimelineFilterDto(); + $filterDto->setPeriod($this->period); + + return $filterDto; + } + + $filterDto = new TimelineFilterDto(); + + // override dates from form in case outside of selectable period + $periodFrom = $this->period->first()->toDateTimeImmutable(); + $periodTo = $this->period->last()->toDateTimeImmutable(); + + if (isset($data['date_from'])) { + $dateFrom = max($periodFrom, $data['date_from']); + $periodFrom = $dateFrom; + } + if (isset($data['date_to'])) { + $dateTo = max($periodTo, $data['date_to']); + $periodTo = $dateTo; + } + if (isset($data['hotels']) && 0 < count($data['hotels'])) { + $filterDto->setHotels($data['hotels']); + } + + $filterDto->setPeriod(new CarbonPeriod($periodFrom, $periodTo)); + + return $filterDto; + } + + protected function saveFilterSettings(AbstractFilterDto $filterDto): void + { + /** @var TimelineFilterDto $filterDto */ + $this->getSession()->set($this->namespace, [ + 'date_from' => $filterDto->getDateFrom(), + 'date_to' => $filterDto->getDateTo(), + 'hotels' => $filterDto->getHotels(), + ]); + } +} diff --git a/templates/administrative/assignment/timeline.html.twig b/templates/administrative/assignment/timeline.html.twig index bb05fce..38aba31 100644 --- a/templates/administrative/assignment/timeline.html.twig +++ b/templates/administrative/assignment/timeline.html.twig @@ -1,13 +1,38 @@ {% extends 'administrative/layout.html.twig' %} -{% block title %}Einsatzübersicht{% endblock %} +{% block title %}Timeline{% endblock %} {% block content %} +
+

+ Timeline +

+
+ + {% if filterDto.active %} + + Reset + + {% endif %} +
+
- {% for day in period %} + {% for day in filterDto.period %} @@ -18,7 +43,7 @@ - {% for day in period %} + {% for day in filterDto.period %} {% if colspan is defined and colspan > 1 %} {% set colspan = colspan - 1 %} {% elseif rows[0][day|date('Ymd')] is defined %} @@ -39,10 +64,8 @@ {{ item.teamerName | raw }} {{ icon('info', 'w-4 h-4') }} - {{ item.jobProfile }} - {% else %} - {{ item.jobProfile }} {% endif %} + {{ item.jobProfile }} {% else %} @@ -51,7 +74,7 @@ {% for row in rows[1:] %} - {% for day in period %} + {% for day in filterDto.period %} {% if colspan is defined and colspan > 1 %} {% set colspan = colspan - 1 %} {% elseif row[day|date('Ymd')] is defined %} @@ -72,10 +95,8 @@ {{ item.teamerName | raw }} {{ icon('info', 'w-4 h-4') }} - {{ item.jobProfile }} - {% else %} - {{ item.jobProfile }} {% endif %} + {{ item.jobProfile }} {% else %} diff --git a/templates/common/modal_timeline_filter.html.twig b/templates/common/modal_timeline_filter.html.twig new file mode 100644 index 0000000..d3bc2b8 --- /dev/null +++ b/templates/common/modal_timeline_filter.html.twig @@ -0,0 +1,20 @@ +{% extends 'htmx_modal.html.twig' %} + +{% block title %}Timeline filtern{% endblock %} + +{% block content %} + {{ form_start(filterForm, { 'attr': { 'hx-post': app.request.uri, 'hx-target': '#htmx-modal', 'hx-swap': 'outerHTML' } }) }} +
+
+ {{ form_row(filterForm.dateFrom) }} + {{ form_row(filterForm.dateTo) }} +
+ {{ form_row(filterForm.hotels) }} +
+ {{ form_widget(filterForm.apply, { 'attr': { 'class': 'btn' } }) }} + {{ form_widget(filterForm.reset, { 'attr': { 'class': 'btn btn--secondary' } }) }} +
+
+ {{ form_rest(filterForm) }} + {{ form_end(filterForm) }} +{% endblock %} diff --git a/templates/htmx_modal.html.twig b/templates/htmx_modal.html.twig index 07afc8b..53e146f 100644 --- a/templates/htmx_modal.html.twig +++ b/templates/htmx_modal.html.twig @@ -12,7 +12,7 @@ {{ icon('close', 'w-6 h-6')}} -
+
{% block content %}{% endblock %}
{{ day | date('d.m.Y') }} {{ hotelCode }}