feat: timeline displaying assignments and dispositions

This commit is contained in:
Björn Fromme
2025-03-15 11:34:12 +01:00
parent 3d85949ade
commit b55d847c85
6 changed files with 395 additions and 1 deletions
@@ -0,0 +1,44 @@
<?php
namespace App\Controller\Administrative\Assignment;
use App\Model\AssignmentFilterDto;
use App\Repository\AssignmentRepository;
use App\Service\Assignment\TimelineService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class TimelineController extends AbstractController
{
public function __construct(
private readonly AssignmentRepository $assignmentRepository,
private readonly TimelineService $timelineService
) {
}
#[Route('/administrative/timeline', name: 'app_administrative_assignment_timeline')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(): Response
{
$period = $this->timelineService->getActiveWindow();
$filterDto = new AssignmentFilterDto();
$filterDto
->setDateFrom($period->first()->toDateTimeImmutable())
->setDateTo($period->last()->toDateTimeImmutable())
;
$assignments = $this
->assignmentRepository
->getListQuery($filterDto, 'destination.dateFrom')
->getResult()
;
$timelineData = $this->timelineService->preprocess($assignments);
return $this->render('administrative/assignment/timeline.html.twig', [
'data' => $timelineData,
'period' => $period,
]);
}
}
+13
View File
@@ -147,6 +147,19 @@ class Destination implements TimestampableEntityInterface, SoftDeletableEntityIn
return $this->hotelCode;
}
public function getHotelCodeNormalized(): ?string
{
if (null === $hotelCode = $this->hotelCode) {
return null;
}
if (str_starts_with($hotelCode, 'SER')) {
return substr($hotelCode, 2, 3);
}
return substr($hotelCode, 0, 3);
}
public function setHotelCode(?string $hotelCode): static
{
$this->hotelCode = $hotelCode;
+164
View File
@@ -0,0 +1,164 @@
<?php
namespace App\Model;
use App\Entity\Assignment;
use App\Entity\Disposition;
class TimelineItem
{
public const TYPE_ASSIGNMENT = 'assignment';
public const TYPE_DISPOSITION = 'disposition';
private ?\DateTimeImmutable $dateFrom = null;
private ?\DateTimeImmutable $dateTo = null;
private ?string $status = null;
private ?string $dateRange = null;
private ?string $jobProfile = null;
private ?string $teamerName = null;
private ?string $teamerUuid = null;
public function __construct(private readonly int $assignmentId, private readonly string $type)
{
}
public static function fromAssignment(Assignment $assignment): static
{
$dateFrom = $assignment->getDestination()->getDateFrom();
$dateTo = $assignment->getDestination()->getDateTo();
return (new static($assignment->getId(), static::TYPE_ASSIGNMENT))
->setDateFrom($dateFrom)
->setDateTo($dateTo)
->setJobProfile($assignment->getJobProfile()->getName())
->setDateRange(sprintf('%s - %s', $dateFrom->format('d.m.Y'), $dateTo->format('d.m.Y')))
->setStatus($assignment->getStatus());
;
}
public static function fromDisposition(Disposition $disposition): static
{
$assignment = $disposition->getAssignment();
$dateFrom = $assignment->getDestination()->getDateFrom();
$dateTo = $assignment->getDestination()->getDateTo();
return (new static($assignment->getId(), static::TYPE_DISPOSITION))
->setDateFrom($dateFrom)
->setDateTo($dateTo)
->setJobProfile($assignment->getJobProfile()->getName())
->setDateRange(sprintf('%s - %s', $dateFrom->format('d.m.Y'), $dateTo->format('d.m.Y')))
->setStatus($disposition->getStatus())
->setTeamerName($disposition->getTeamer()->getFullName())
->setTeamerUuid($disposition->getTeamer()->getUuid())
;
}
public function getAssignmentId(): int
{
return $this->assignmentId;
}
public function getType(): ?string
{
return $this->type;
}
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 getDateRange(): ?string
{
return $this->dateRange;
}
public function setDateRange(?string $dateRange): static
{
$this->dateRange = $dateRange;
return $this;
}
public function getJobProfile(): ?string
{
return $this->jobProfile;
}
public function setJobProfile(?string $jobProfile): static
{
$this->jobProfile = $jobProfile;
return $this;
}
public function getStatus(): ?string
{
return $this->status;
}
public function setStatus(?string $status): static
{
$this->status = $status;
return $this;
}
public function getTeamerName(): ?string
{
return $this->teamerName;
}
public function setTeamerName(?string $teamerName): static
{
$this->teamerName = $teamerName;
return $this;
}
public function getTeamerUuid(): ?string
{
return $this->teamerUuid;
}
public function setTeamerUuid(?string $teamerUuid): static
{
$this->teamerUuid = $teamerUuid;
return $this;
}
public function getKey(): ?string
{
return $this->getDateFrom()?->format('Ymd');
}
public function getNumberOfDays(): int
{
if (null === $this->getDateFrom() || null === $this->getDateTo()) {
return 1;
}
return $this->getDateFrom()->diff($this->getDateTo())->days;
}
}
+5 -1
View File
@@ -27,7 +27,7 @@ class AssignmentRepository extends ServiceEntityRepository
parent::__construct($registry, Assignment::class);
}
public function getListQuery(AssignmentFilterDto $filterDto): Query
public function getListQuery(AssignmentFilterDto $filterDto, ?string $orderBy = null): Query
{
$qb = $this->createQueryBuilder('assignment');
@@ -46,6 +46,10 @@ class AssignmentRepository extends ServiceEntityRepository
->setParameter('applicationStatus', Application::STATUS_REJECTED)
;
if (null !== $orderBy) {
$qb->orderBy($qb->expr()->asc($orderBy));
}
$this->applyFilterSettings($filterDto, $qb);
return $qb->getQuery();
@@ -0,0 +1,80 @@
<?php
namespace App\Service\Assignment;
use App\Entity\Assignment;
use App\Model\TimelineItem;
use Carbon\CarbonPeriod;
class TimelineService
{
/**
* @param Assignment[] $assignments
* @return array
*/
public function preprocess(array $assignments): array
{
$rows = [];
foreach ($assignments as $assignment) {
$destination = $assignment->getDestination();
$hotelCode = $destination->getHotelCodeNormalized();
// add first row for current hotel unless existing
if (false === isset($rows[$hotelCode])) {
$rows[$hotelCode] = [[]];
}
if (0 === $assignment->getDispositions()->count()) {
$item = TimelineItem::fromAssignment($assignment);
$this->addItem($rows[$hotelCode], $item);
} else {
foreach ($assignment->getDispositions() as $disposition) {
$item = TimelineItem::fromDisposition($disposition);
$this->addItem($rows[$hotelCode], $item);
}
}
}
return $rows;
}
private function addItem(array &$rows, TimelineItem $item): void
{
$itemAdded = false;
foreach ($rows as $index => $row) {
$addToRow = true;
foreach ($row as $entry) {
/** @var TimelineItem $entry */
if (
$item->getDateFrom() >= $entry->getDateFrom() && $item->getDateFrom() < $entry->getDateTo()
|| $item->getDateTo() >= $entry->getDateFrom() && $item->getDateTo() < $entry->getDateTo()
) {
$addToRow = false;
}
}
if (true === $addToRow) {
$rows[$index][$item->getKey()] = $item;
$itemAdded = true;
break;
}
}
if (false === $itemAdded) {
$rows[] = [
$item->getKey() => $item,
];
}
}
public function getActiveWindow(): CarbonPeriod
{
$dateFrom = (new \DateTimeImmutable())->modify('-6 weeks');
$dateTo = (new \DateTimeImmutable())->modify('+6 months');
return CarbonPeriod::create($dateFrom, $dateTo);
}
}
@@ -0,0 +1,89 @@
{% extends 'administrative/layout.html.twig' %}
{% block title %}Einsatzübersicht{% endblock %}
{% block content %}
<div class="overflow-scroll max-h-screen border border-gray-300 mb-16">
<table>
<tr class="[&_th]:sticky [&_th]:top-0 [&_th]:z-10">
<th></th>
{% for day in period %}
<th>
{{ day | date('d.m.Y') }}
</th>
{% endfor %}
</tr>
{% for hotelCode, rows in data %}
<tr class="border-t-2 border-gray-700">
<td class="border border-gray-700 bg-gray-200 align-middle font-bold sticky left-0 z-10" rowspan="{{ rows | length }}">
{{ hotelCode }}
</td>
{% for day in period %}
{% if colspan is defined and colspan > 1 %}
{% set colspan = colspan - 1 %}
{% elseif rows[0][day|date('Ymd')] is defined %}
{% set item = rows[0][day|date('Ymd')] %}
{% set colspan = item.numberOfDays %}
<td class="{{ html_classes('border border-gray-700', {
'bg-orange-300': item.type == 'assignment',
'bg-yellow-300': item.type == 'disposition' and item.status != 'confirmed',
'bg-green-300': item.type == 'disposition' and item.status == 'confirmed',
}) }}" colspan="{{ colspan }}">
{% if item.type == 'disposition' %}
<button type="button"
class="flex items-center space-x-1"
title="Teamer:inneninfo {{ item.teamerName }}"
hx-get="{{ path('app_administrative_teamer_info', { 'uuid': item.teamerUuid }) }}"
hx-target="body"
hx-swap="beforeend">
<span>{{ item.teamerName | raw }}</span>
{{ icon('info', 'w-4 h-4') }}
</button>
{{ item.jobProfile }}<br>{{ item.dateRange }}
{% else %}
{{ item.jobProfile }}<br>{{ item.dateRange }}
{% endif %}
</td>
{% else %}
<td class="border border-gray-700"></td>
{% endif %}
{% endfor %}
</tr>
{% for row in rows[1:] %}
<tr>
{% for day in period %}
{% if colspan is defined and colspan > 1 %}
{% set colspan = colspan - 1 %}
{% elseif row[day|date('Ymd')] is defined %}
{% set item = row[day|date('Ymd')] %}
{% set colspan = item.numberOfDays %}
<td class="{{ html_classes('border border-gray-700', {
'bg-orange-300': item.type == 'assignment',
'bg-yellow-300': item.type == 'disposition' and item.status != 'confirmed',
'bg-green-300': item.type == 'disposition' and item.status == 'confirmed',
}) }}" colspan="{{ colspan }}">
{% if item.type == 'disposition' %}
<button type="button"
class="flex items-center space-x-1"
title="Teamer:inneninfo {{ item.teamerName }}"
hx-get="{{ path('app_administrative_teamer_info', { 'uuid': item.teamerUuid }) }}"
hx-target="body"
hx-swap="beforeend">
<span>{{ item.teamerName | raw }}</span>
{{ icon('info', 'w-4 h-4') }}
</button>
{{ item.jobProfile }}<br>{{ item.dateRange }}
{% else %}
{{ item.jobProfile }}<br>{{ item.dateRange }}
{% endif %}
</td>
{% else %}
<td class="border border-gray-700"></td>
{% endif %}
{% endfor %}
</tr>
{% endfor %}
{% endfor %}
</table>
</div>
{% endblock %}