feat: all-season assignments

This commit is contained in:
Björn Fromme authored and Björn Fromme committed 2026-09-26 12:13:16 +02:00
1 parent 675cec1b69
commit cf876ed224
119 files changed
+6194 -294

No files matched your search

@@ -30,4 +30,22 @@ class TeamerController extends AbstractController
return $this->json($dates);
}
/**
* Season assignments are only ever made for all-season teamers, so their form must not be
* able to offer anybody else.
*/
#[Route('/admin/autocomplete/season-teamer', name: 'app_admin_autocomplete_season_teamer')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function season(Request $request): JsonResponse
{
try {
$query = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
$queryString = $query['search'];
} catch (\JsonException $e) {
throw $this->createNotFoundException();
}
return $this->json($this->teamerRepository->getAutocompletionData($queryString, true));
}
}
@@ -57,7 +57,7 @@ class MailingController extends AbstractController
));
}
$filterDto = $this->filterHandler->getFilterSettings();
$filterDto = $this->filterHandler->setScope($request->query->get('scope'))->getFilterSettings();
// No redirect after the preview mail, the composed mail has to survive it. The
// preview pane is rendered here so the page arrives complete; from then on the
@@ -69,7 +69,8 @@ class MailingController extends AbstractController
'recipients' => $this->mailingService->resolveRecipients($filterDto),
'placeholders' => TeamerMailingService::PLACEHOLDERS,
'hasDraft' => $this->draftHandler->hasDraft(),
'returnUrl' => $this->getReturnUrl($request, 'app_administrative_teamer_index'),
'returnUrl' => $this->getReturnUrl($request, $this->getListRoute($request)),
'scope' => $request->query->get('scope'),
]);
}
@@ -94,11 +95,13 @@ class MailingController extends AbstractController
#[Route('/admin/teamer/mailing/discard', name: 'app_admin_teamer_mailing_discard')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function discard(): Response
public function discard(Request $request): Response
{
$this->draftHandler->resetDraft();
return $this->redirectToRoute('app_admin_teamer_mailing');
return $this->redirectToRoute('app_admin_teamer_mailing', [
'scope' => $request->query->get('scope'),
]);
}
#[Route('/admin/teamer/mailing/confirm', name: 'app_admin_teamer_mailing_confirm', methods: ['POST'])]
@@ -106,7 +109,7 @@ class MailingController extends AbstractController
public function confirm(Request $request): Response
{
$form = $this->createMailingForm($request);
$filterDto = $this->filterHandler->getFilterSettings();
$filterDto = $this->filterHandler->setScope($request->query->get('scope'))->getFilterSettings();
// the composed mail is carried on in hidden fields so no half written mailing has
// to be parked in the session between the confirmation and the send
@@ -115,6 +118,7 @@ class MailingController extends AbstractController
'mailingDto' => $form->getData(),
'valid' => $form->isSubmitted() && $form->isValid(),
'recipients' => $this->mailingService->resolveRecipients($filterDto),
'scope' => $request->query->get('scope'),
]);
}
@@ -127,10 +131,14 @@ class MailingController extends AbstractController
if (false === $form->isSubmitted() || false === $form->isValid()) {
$this->addFlash('error', 'Die Mail konnte nicht gesendet werden, Betreff oder Nachricht fehlen');
return new HxRedirectResponse($this->generateUrl('app_admin_teamer_mailing'));
return new HxRedirectResponse($this->generateUrl('app_admin_teamer_mailing', [
'scope' => $request->query->get('scope'),
]));
}
$recipients = $this->mailingService->resolveRecipients($this->filterHandler->getFilterSettings());
$recipients = $this->mailingService->resolveRecipients(
$this->filterHandler->setScope($request->query->get('scope'))->getFilterSettings()
);
$mailingDto = $form->getData();
// the recipients travel with the message, so the mailing reaches exactly the
@@ -151,7 +159,18 @@ class MailingController extends AbstractController
$recipients->getEligibleCount()
));
return new HxRedirectResponse($this->generateUrl('app_administrative_teamer_index'));
return new HxRedirectResponse($this->generateUrl($this->getListRoute($request)));
}
/**
* The list this mailing was started from, so a send returns to it and an unscoped
* mailing never falls back to the wrong one.
*/
private function getListRoute(Request $request): string
{
return TeamerFilterHandler::SCOPE_ALL_SEASON === $request->query->get('scope')
? 'app_administrative_season_teamer_index'
: 'app_administrative_teamer_index';
}
/**
@@ -30,6 +30,8 @@ class SkillsController extends AbstractController
{
$trainings = $this->trainingRepository->getList();
$allSeasonStatus = $teamer->isAllSeason();
$form = $this->createStatusForm($teamer);
$form->handleRequest($request);
@@ -42,6 +44,15 @@ class SkillsController extends AbstractController
'teamer' => $teamer->getFullName(),
]);
if ($teamer->isAllSeason() !== $allSeasonStatus) {
$this->addFlash('info', 'Der Saisonteamer:innern-Status wurde '.($teamer->isAllSeason() ? 'hinzugefügt' : 'entfernt'));
$logMessage = $teamer->isAllSeason() ? 'Assign all-season status' : 'Remove all-season status';
$this->logger->info($logMessage, [
'teamer' => $teamer->getFullName(),
]);
}
return $this->redirectToRoute('app_admin_teamer_skills', [
'uuid' => $teamer->getUuid(),
'r' => $request->query->get('r'),
@@ -62,10 +73,14 @@ class SkillsController extends AbstractController
->add('status', ChoiceType::class, [
'label' => 'Status',
'choices' => [
'Neuteamer' => Teamer::STATUS_NEW,
'Bestandsteamer' => Teamer::STATUS_EXISTING,
'Neuteamer:in' => Teamer::STATUS_NEW,
'Bestandsteamer:in' => Teamer::STATUS_EXISTING,
],
])
->add('allSeason', CheckboxType::class, [
'label' => 'Saisonteamer:in',
'required' => false,
])
->add('allowOverlappingApplications', CheckboxType::class, [
'label' => 'Überlappende Bewerbungen erlauben',
'required' => false,
@@ -0,0 +1,76 @@
<?php
namespace App\Controller\Administrative\SeasonAssignment;
use App\Config\SeasonCalendar;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\SeasonAssignment;
use App\Form\SeasonAssignmentType;
use App\Service\Season\SeasonAssignmentLabeler;
use App\Service\Season\SeasonTaskSynchronizer;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
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 CreateController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly SeasonTaskSynchronizer $taskSynchronizer,
private readonly SeasonAssignmentLabeler $labeler,
private readonly SeasonCalendar $seasonCalendar,
private readonly LoggerInterface $logger,
) {
}
#[Route('/administrative/season-assignment/create', name: 'app_administrative_season_assignment_create')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(Request $request): Response
{
$seasonAssignment = new SeasonAssignment();
// Almost every assignment laid out runs the full season, so the form opens on the
// season that is running today - or, off-season, the one that starts next, the same
// window the teamer's season page already falls forward to.
[$windowStart, $windowEnd] = $this->seasonCalendar->windowFor($this->seasonCalendar->currentOrUpcomingSeasonYear());
$seasonAssignment
->setDateFrom($windowStart->toDateTimeImmutable())
->setDateTo($windowEnd->toDateTimeImmutable())
;
$returnUrl = $this->getReturnUrl($request, 'app_administrative_season_assignment_index');
$form = $this->createForm(SeasonAssignmentType::class, $seasonAssignment);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->persist($seasonAssignment);
$this->entityManager->flush();
// the checklist is what the office works with from here on, so it exists from the
// moment the assignment does
$this->taskSynchronizer->sync($seasonAssignment);
$this->addFlash('success', 'Der Saisoneinsatz wurde angelegt');
$this->logger->info('Create season assignment', [
'season_assignment_id' => $seasonAssignment->getId(),
'teamer' => $seasonAssignment->getTeamer()?->getFullName(),
'season_assignment' => $this->labeler->label($seasonAssignment),
'job_profile' => $seasonAssignment->getJobProfile()->getName(),
]);
return $this->redirect($returnUrl);
}
return $this->render('administrative/season_assignment/create.html.twig', [
'form' => $form->createView(),
'returnUrl' => $returnUrl,
]);
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Controller\Administrative\SeasonAssignment;
use App\Entity\SeasonAssignment;
use App\Htmx\HxRedirectResponse;
use App\Service\Season\SeasonAssignmentLabeler;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
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 DeleteController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly SeasonAssignmentLabeler $labeler,
private readonly LoggerInterface $logger,
) {
}
#[Route('/administrative/season-assignment/delete/{uuid}', name: 'app_administrative_season_assignment_delete')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(SeasonAssignment $seasonAssignment, Request $request): Response
{
if (true === $request->isMethod('POST')) {
$seasonAssignment->setDeleted();
$this->entityManager->flush();
$this->logger->info('Delete season assignment', [
'season_assignment_id' => $seasonAssignment->getId(),
'teamer' => $seasonAssignment->getTeamer()?->getFullName(),
'season_assignment' => $this->labeler->label($seasonAssignment),
'job_profile' => $seasonAssignment->getJobProfile()->getName(),
]);
$this->addFlash('success', 'Der Saisoneinsatz wurde gelöscht');
return new HxRedirectResponse($this->generateUrl('app_administrative_season_assignment_index'));
}
return $this->render('administrative/season_assignment/modal_delete.html.twig', [
'seasonAssignment' => $seasonAssignment,
]);
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Controller\Administrative\SeasonAssignment;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\SeasonAssignment;
use App\Service\Season\SeasonChecklistBuilder;
use App\Service\Season\SeasonTaskSynchronizer;
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 DetailController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(
private readonly SeasonTaskSynchronizer $taskSynchronizer,
private readonly SeasonChecklistBuilder $checklistBuilder,
) {
}
#[Route('/administrative/season-assignment/detail/{uuid}', name: 'app_administrative_season_assignment_detail')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(SeasonAssignment $seasonAssignment, Request $request): Response
{
// one of the two write paths the synchronizer runs on, so an entry added to the enum
// later shows up on assignments that already exist
$this->taskSynchronizer->sync($seasonAssignment);
return $this->render('administrative/season_assignment/detail.html.twig', [
'seasonAssignment' => $seasonAssignment,
'checklist' => $this->checklistBuilder->build($seasonAssignment),
'returnUrl' => $this->getReturnUrl($request, 'app_administrative_season_assignment_index'),
]);
}
}
@@ -0,0 +1,156 @@
<?php
namespace App\Controller\Administrative\SeasonAssignment;
use App\Entity\SeasonAssignment;
use App\Entity\Upload;
use App\Entity\User;
use App\Htmx\HxRedirectResponse;
use App\Model\UploadDto;
use App\Service\Upload\UploadHandler;
use Doctrine\ORM\EntityManagerInterface;
use Flagception\Manager\FeatureManagerInterface;
use Psr\Log\LoggerInterface;
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;
/**
* The documents the office issues for one season assignment.
*
* Deliberately not Upload::TYPE_DOCUMENT: that type is the staff-wide library, which UploadVoter
* opens to every teamer - an individually issued contract in it would be readable by everyone.
* These carry TYPE_SEASON_DOCUMENT_OUT instead, and the voter lets exactly the teamer of this
* assignment download them.
*
* There is no relation between a file and a checklist entry on purpose: the office looks at the
* documents, decides which entry a file satisfies and ticks it off by hand.
*/
class DocumentController extends AbstractController
{
public function __construct(
private readonly UploadHandler $uploadHandler,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly FeatureManagerInterface $featureManager,
) {
}
#[Route(
path: '/administrative/season-assignment/{uuid}/document',
name: 'app_administrative_season_assignment_document_upload'
)]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function upload(SeasonAssignment $seasonAssignment, Request $request): Response
{
// Dummy form without fields, as elsewhere: the files travel in the upload session, not in
// the form itself.
$form = $this->createFormBuilder()->getForm();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$count = $this->processUpload($seasonAssignment);
$this->addFlash(
'success',
1 === $count ? 'Die Unterlage wurde hochgeladen' : 'Die Unterlagen wurden hochgeladen'
);
return new HxRedirectResponse($this->generateUrl('app_administrative_season_assignment_detail', [
'uuid' => $seasonAssignment->getUuid(),
'r' => $request->query->get('r'),
]));
}
return $this->render('administrative/season_assignment/modal_document_upload.html.twig', [
'seasonAssignment' => $seasonAssignment,
'form' => $form->createView(),
]);
}
#[Route(
path: '/administrative/season-assignment/document/{uuid}',
name: 'app_administrative_season_assignment_document_delete'
)]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function delete(Upload $document, Request $request): Response
{
if (null === $seasonAssignment = $document->getSeasonAssignment()) {
throw $this->createNotFoundException('Not a season assignment document');
}
if (true === $request->isMethod('POST')) {
// Doctrine's DeleteUploadListener removes the file from disk on preRemove.
$this->entityManager->remove($document);
$this->entityManager->flush();
$this->addFlash('success', 'Die Unterlage wurde gelöscht');
$this->logger->info('Delete season assignment document', [
'season_assignment_id' => $seasonAssignment->getId(),
'teamer' => $seasonAssignment->getTeamer()?->getFullName(),
'document_filename' => $document->getOriginalFilename(),
'document_type' => $document->getType(),
]);
return new HxRedirectResponse($this->generateUrl('app_administrative_season_assignment_detail', [
'uuid' => $seasonAssignment->getUuid(),
'r' => $request->query->get('r'),
]));
}
return $this->render('administrative/season_assignment/modal_document_delete.html.twig', [
'document' => $document,
]);
}
private function processUpload(SeasonAssignment $seasonAssignment): int
{
/** @var User $user */
$user = $this->getUser();
$uploadSession = $this->uploadHandler->getUploadSession();
// Only this dropzone's files: the page may hold a second one, so the session must not be
// destroyed wholesale.
$uploadDtos = $uploadSession->getUploadsByType(Upload::TYPE_SEASON_DOCUMENT_OUT);
$uploads = [];
/** @var UploadDto $uploadDto */
foreach ($uploadDtos as $uploadDto) {
$upload = Upload::fromUploadDto($uploadDto, $user, Upload::TYPE_SEASON_DOCUMENT_OUT);
// No status: these are not checked or approved, they are handed out. UploadVoter
// grants CHECK only for "new" and "pending", so a null status keeps them out of the
// checking process by construction.
$upload->setStatus(null);
$seasonAssignment->addDocument($upload);
$this->entityManager->persist($upload);
$uploads[] = $upload;
}
$this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_SEASON_DOCUMENT_OUT, $uploadSession);
$this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_SEASON_DOCUMENT_OUT);
if (true === $this->featureManager->isActive('sanitize_uploads')) {
foreach ($uploads as $upload) {
$this->uploadHandler->ensurePdf($upload);
}
}
$this->entityManager->flush();
$this->logger->info('Upload season assignment documents', [
'user' => $user->getUserIdentifier(),
'season_assignment_id' => $seasonAssignment->getId(),
'count' => count($uploads),
]);
return count($uploads);
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Controller\Administrative\SeasonAssignment;
use App\Entity\SeasonAssignment;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Toggles the office's note that it considers this teamer done.
*
* Informative only: it silences nothing. The weekly teamer digest and the office deadline alerts
* both keep running while a return-required checklist entry is open. Administrative users only -
* there is no teamer-side write path.
*/
class DoneTeamerController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route(
path: '/administrative/season-assignment/{uuid}/done-teamer',
name: 'app_administrative_season_assignment_done_teamer',
methods: ['POST']
)]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(SeasonAssignment $seasonAssignment): Response
{
$seasonAssignment->setDoneTeamer(false === $seasonAssignment->isDoneTeamer());
$this->entityManager->flush();
$this->logger->info('Toggle season assignment done teamer', [
'season_assignment_id' => $seasonAssignment->getId(),
'teamer' => $seasonAssignment->getTeamer()?->getFullName(),
'value' => $seasonAssignment->isDoneTeamer(),
]);
return $this->render('administrative/season_assignment/_done_teamer.html.twig', [
'seasonAssignment' => $seasonAssignment,
]);
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Controller\Administrative\SeasonAssignment;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\SeasonAssignment;
use App\Form\SeasonAssignmentType;
use App\Service\Season\SeasonAssignmentLabeler;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
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 EditController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly SeasonAssignmentLabeler $labeler,
private readonly LoggerInterface $logger,
) {
}
#[Route('/administrative/season-assignment/edit/{uuid}', name: 'app_administrative_season_assignment_edit')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(SeasonAssignment $seasonAssignment, Request $request): Response
{
$returnUrl = $this->getReturnUrl($request, 'app_administrative_season_assignment_index');
$form = $this->createForm(SeasonAssignmentType::class, $seasonAssignment);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->flush();
$this->addFlash('success', 'Der Saisoneinsatz wurde gespeichert');
$this->logger->info('Edit season assignment', [
'season_assignment_id' => $seasonAssignment->getId(),
'teamer' => $seasonAssignment->getTeamer()?->getFullName(),
'season_assignment' => $this->labeler->label($seasonAssignment),
'job_profile' => $seasonAssignment->getJobProfile()->getName(),
]);
return $this->redirect($returnUrl);
}
return $this->render('administrative/season_assignment/edit.html.twig', [
'form' => $form->createView(),
'seasonAssignment' => $seasonAssignment,
'returnUrl' => $returnUrl,
]);
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Controller\Administrative\SeasonAssignment;
use App\Repository\SeasonAssignmentRepository;
use App\Service\Common\SeasonAssignmentFilterHandler;
use Knp\Component\Pager\PaginatorInterface;
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 IndexController extends AbstractController
{
public function __construct(
private readonly SeasonAssignmentFilterHandler $filterHandler,
private readonly SeasonAssignmentRepository $seasonAssignmentRepository,
private readonly PaginatorInterface $paginator,
) {
}
#[Route('/administrative/season-assignment', name: 'app_administrative_season_assignment_index')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(Request $request): Response
{
$filterDto = $this->filterHandler->getFilterSettings();
$query = $this
->seasonAssignmentRepository
->getListQuery($filterDto)
;
$pagination = $this->paginator->paginate(
$query,
$request->query->getInt('page', 1),
$request->query->getInt('limit', 50)
);
return $this->render('administrative/season_assignment/index.html.twig', [
'pagination' => $pagination,
'filterDto' => $filterDto,
]);
}
}
@@ -0,0 +1,69 @@
<?php
namespace App\Controller\Administrative\SeasonAssignment;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\SeasonAssignmentTask;
use App\Form\SeasonTaskDeadlineType;
use App\Htmx\HxRedirectResponse;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
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 TaskDeadlineController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route(
path: '/administrative/season-assignment/task/{uuid}/deadline',
name: 'app_administrative_season_assignment_task_deadline'
)]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(SeasonAssignmentTask $task, Request $request): Response
{
$seasonAssignment = $task->getSeasonAssignment();
$returnUrl = $this->getReturnUrl($request, 'app_administrative_season_assignment_detail', [
'uuid' => $seasonAssignment?->getUuid(),
]);
$form = $this->createForm(SeasonTaskDeadlineType::class, $task, [
'action' => $this->generateUrl('app_administrative_season_assignment_task_deadline', [
'uuid' => $task->getUuid(),
'r' => $returnUrl,
]),
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->flush();
$this->logger->info('Change season assignment task deadlines', [
'season_assignment_id' => $seasonAssignment?->getId(),
'teamer' => $seasonAssignment?->getTeamer()?->getFullName(),
'task' => $task->getTaskKey(),
'deadline_override_1' => $task->getDeadlineOverride1()?->format('Y-m-d'),
'deadline_override_2' => $task->getDeadlineOverride2()?->format('Y-m-d'),
]);
$this->addFlash('success', 'Die Fristen wurden gespeichert');
return new HxRedirectResponse($returnUrl);
}
return $this->render('administrative/season_assignment/modal_task_deadline.html.twig', [
'form' => $form->createView(),
'task' => $task,
]);
}
}
@@ -0,0 +1,78 @@
<?php
namespace App\Controller\Administrative\SeasonAssignment;
use App\Entity\SeasonAssignmentTask;
use App\Service\Season\SeasonChecklistBuilder;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Ticks one checklist entry, re-rendering that single row.
*
* Deliberately one request per row rather than one form for the whole checklist: the office
* works through the list one line at a time while reading a document, and a whole-form submit
* would let two people working the same assignment overwrite each other's ticks.
*/
class TaskToggleController extends AbstractController
{
private const FIELD_DONE = 'done';
private const FIELD_UPLOADED = 'uploaded';
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly SeasonChecklistBuilder $checklistBuilder,
private readonly LoggerInterface $logger,
) {
}
#[Route(
path: '/administrative/season-assignment/task/{uuid}/{field}',
name: 'app_administrative_season_assignment_task_toggle',
// pinned in the route as well as checked below, so this can never shadow the sibling
// .../task/{uuid}/deadline route whichever order the two are loaded in
requirements: ['field' => 'done|uploaded'],
methods: ['POST']
)]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(SeasonAssignmentTask $task, string $field): Response
{
// matched against the two names rather than turned into a property path: the field comes
// out of the URL
if (false === in_array($field, [self::FIELD_DONE, self::FIELD_UPLOADED], true)) {
throw $this->createNotFoundException(sprintf('Unknown checklist field "%s".', $field));
}
if (self::FIELD_DONE === $field) {
$done = false === $task->isDone();
$task
->setDone($done)
// explicit, because Blameable's updatedBy is overwritten by any later edit and
// the office wants to see who closed the entry
->setDoneAt(true === $done ? new \DateTimeImmutable() : null)
->setDoneBy(true === $done ? $this->getUser()?->getUserIdentifier() : null)
;
} else {
$task->setUploaded(false === $task->isUploaded());
}
$this->entityManager->flush();
$this->logger->info('Toggle season assignment task', [
'season_assignment_id' => $task->getSeasonAssignment()?->getId(),
'teamer' => $task->getSeasonAssignment()?->getTeamer()?->getFullName(),
'task' => $task->getTaskKey(),
'field' => $field,
'value' => self::FIELD_DONE === $field ? $task->isDone() : $task->isUploaded(),
]);
return $this->render('administrative/season_assignment/_task_row.html.twig', [
'row' => $this->checklistBuilder->buildRow($task),
]);
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Controller\Administrative\System\SeasonTaskDocument;
use App\Entity\SeasonTaskDocument;
use App\Htmx\HxRedirectResponse;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
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;
/**
* Removes the mapping, not the document: the library file itself stays where it is and is
* managed under Dokumente.
*/
class DeleteController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route(
path: '/administrative/system/season-task-document/delete/{uuid}',
name: 'app_administrative_system_season_task_document_delete'
)]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(SeasonTaskDocument $seasonTaskDocument, Request $request): Response
{
if (true === $request->isMethod('POST')) {
$taskKey = $seasonTaskDocument->getTaskKey();
$this->entityManager->remove($seasonTaskDocument);
$this->entityManager->flush();
$this->logger->info('Remove season task document', ['task' => $taskKey]);
$this->addFlash('success', 'Die Zuordnung wurde entfernt');
return new HxRedirectResponse(
$this->generateUrl('app_administrative_system_season_task_document_index')
);
}
return $this->render('administrative/system/season_task_document/modal_delete.html.twig', [
'seasonTaskDocument' => $seasonTaskDocument,
]);
}
}
@@ -0,0 +1,75 @@
<?php
namespace App\Controller\Administrative\System\SeasonTaskDocument;
use App\Config\SeasonTask;
use App\Config\SeasonTaskProvision;
use App\Entity\SeasonTaskDocument;
use App\Form\SeasonTaskDocumentType;
use App\Htmx\HxRedirectResponse;
use App\Repository\SeasonTaskDocumentRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
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;
/**
* Sets the library document a checklist entry hands out. Addressed by the entry rather than by a
* record: the mapping may not exist yet, and there is at most one per entry.
*/
class EditController extends AbstractController
{
public function __construct(
private readonly SeasonTaskDocumentRepository $seasonTaskDocumentRepository,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route(
path: '/administrative/system/season-task-document/edit/{taskKey}',
name: 'app_administrative_system_season_task_document_edit'
)]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(string $taskKey, Request $request): Response
{
$task = SeasonTask::tryFrom($taskKey);
if (null === $task || SeasonTaskProvision::STANDARD !== $task->provides()) {
throw $this->createNotFoundException(sprintf('No standard document is handed out for "%s".', $taskKey));
}
$seasonTaskDocument = $this->seasonTaskDocumentRepository->findForTask($task)
?? (new SeasonTaskDocument())->setTaskKey($task->value);
$form = $this->createForm(SeasonTaskDocumentType::class, $seasonTaskDocument);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
if (null === $seasonTaskDocument->getId()) {
$this->entityManager->persist($seasonTaskDocument);
}
$this->entityManager->flush();
$this->logger->info('Change season task document', [
'task' => $task->value,
'document_id' => $seasonTaskDocument->getDocument()?->getId(),
]);
$this->addFlash('success', 'Das Dokument wurde hinterlegt');
return new HxRedirectResponse(
$this->generateUrl('app_administrative_system_season_task_document_index')
);
}
return $this->render('administrative/system/season_task_document/modal_edit.html.twig', [
'form' => $form->createView(),
'task' => $task,
]);
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Controller\Administrative\System\SeasonTaskDocument;
use App\Config\SeasonTask;
use App\Config\SeasonTaskProvision;
use App\Repository\SeasonTaskDocumentRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Which document from the global library a checklist entry hands out.
*
* The rows are the enum's entries, not database records: only the entries that hand out a
* standard document can carry a mapping at all, and one without a mapping is a valid - if
* incomplete - state that the teamer's page has to show as "kein Dokument hinterlegt".
*/
class IndexController extends AbstractController
{
public function __construct(private readonly SeasonTaskDocumentRepository $seasonTaskDocumentRepository)
{
}
#[Route('/administrative/system/season-task-document', name: 'app_administrative_system_season_task_document_index')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(): Response
{
$mapped = $this->seasonTaskDocumentRepository->findIndexedByTaskKey();
$rows = [];
foreach (SeasonTask::cases() as $case) {
if (SeasonTaskProvision::STANDARD !== $case->provides()) {
continue;
}
$rows[] = [
'task' => $case,
'document' => $mapped[$case->value] ?? null,
];
}
return $this->render('administrative/system/season_task_document/index.html.twig', [
'rows' => $rows,
]);
}
}
@@ -17,7 +17,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class IndexController extends AbstractController
{
public function __construct(
protected readonly TeamerFilterHandler $filterHandler,
private readonly TeamerFilterHandler $filterHandler,
private readonly TeamerRepository $teamerRepository,
private readonly PaginatorInterface $paginator,
private readonly EntityManagerInterface $entityManager,
@@ -28,7 +28,38 @@ class IndexController extends AbstractController
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(Request $request): Response
{
$filterDto = $this->filterHandler->getFilterSettings();
return $this->renderList($request, 'administrative/teamer/index.html.twig', null);
}
#[Route('/administrative/season-teamer', name: 'app_administrative_season_teamer_index')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function season(Request $request): Response
{
return $this->renderList($request, 'administrative/teamer/season_index.html.twig', TeamerFilterHandler::SCOPE_ALL_SEASON);
}
#[Route('/administrative/teamer/viewed/{uuid}', name: 'app_administrative_teamer_viewed', methods: ['POST'])]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function viewed(Teamer $teamer): Response
{
$teamer->setViewed(true);
$this->entityManager->flush();
return new HxRedirectResponse($this->generateUrl('app_administrative_teamer_index'));
}
/**
* Both teamer lists differ only in the scope they run under, so they share one body.
* The scope is set explicitly on either path to keep the two independent of the order
* in which they are called within a request.
*/
private function renderList(Request $request, string $template, ?string $scope): Response
{
$filterDto = $this
->filterHandler
->setScope($scope)
->getFilterSettings()
;
$query = $this
->teamerRepository
@@ -41,19 +72,9 @@ class IndexController extends AbstractController
$request->query->getInt('limit', 50)
);
return $this->render('administrative/teamer/index.html.twig', [
return $this->render($template, [
'pagination' => $pagination,
'filterDto' => $filterDto,
]);
}
#[Route('/administrative/teamer/viewed/{uuid}', name: 'app_administrative_teamer_viewed', methods: ['POST'])]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function viewed(Teamer $teamer): Response
{
$teamer->setViewed(true);
$this->entityManager->flush();
return new HxRedirectResponse($this->generateUrl('app_administrative_teamer_index'));
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Controller\Common;
use App\Controller\Traits\ReturnUrlTrait;
use App\Form\SeasonAssignmentFilterType;
use App\Htmx\HxRedirectResponse;
use App\Service\Common\SeasonAssignmentFilterHandler;
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 SeasonAssignmentFilterController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(private readonly SeasonAssignmentFilterHandler $filterHandler)
{
}
#[Route('/common/season-assignment/filter', name: 'app_common_season_assignment_filter')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(Request $request): Response
{
$formData = $this->filterHandler->getFilterSettings();
$form = $this->createForm(SeasonAssignmentFilterType::class, $formData);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->filterHandler->handleRequest($form);
$returnUrl = $this->getReturnUrl($request, 'app_administrative_season_assignment_index');
return new HxRedirectResponse($returnUrl);
}
return $this->render('common/modal_season_assignment_filter.html.twig', [
'filterForm' => $form->createView(),
'filterDto' => $form->getData(),
]);
}
#[Route('/common/season-assignment/filter/reset', name: 'app_common_season_assignment_filter_reset')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function reset(Request $request): Response
{
$this->filterHandler->resetFilterSettings();
$returnUrl = $this->getReturnUrl($request, 'app_administrative_season_assignment_index');
return $this->redirect($returnUrl);
}
}
@@ -24,13 +24,15 @@ class TeamerFilterController extends AbstractController
#[IsGranted('ROLE_USER')]
public function index(Request $request): Response
{
$this->applyScope($request);
$formData = $this->filterHandler->getFilterSettings();
$form = $this->createForm(TeamerFilterType::class, $formData);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->filterHandler->handleRequest($form);
$returnUrl = $this->getReturnUrl($request, 'app_administrative_teamer_index');
$returnUrl = $this->getReturnUrl($request, $this->getDefaultRoute($request));
return new HxRedirectResponse($returnUrl);
}
@@ -45,9 +47,26 @@ class TeamerFilterController extends AbstractController
#[IsGranted('ROLE_USER')]
public function reset(Request $request): Response
{
$this->applyScope($request);
$this->filterHandler->resetFilterSettings();
$returnUrl = $this->getReturnUrl($request, 'app_administrative_teamer_index');
$returnUrl = $this->getReturnUrl($request, $this->getDefaultRoute($request));
return $this->redirect($returnUrl);
}
/**
* The modal serves both teamer lists; the scope says whose filter is being edited.
*/
private function applyScope(Request $request): void
{
$this->filterHandler->setScope($request->query->get('scope'));
}
private function getDefaultRoute(Request $request): string
{
return TeamerFilterHandler::SCOPE_ALL_SEASON === $request->query->get('scope')
? 'app_administrative_season_teamer_index'
: 'app_administrative_teamer_index';
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\Controller\Teamer\Season;
use App\Entity\User;
use App\Service\Season\SeasonAssignmentResolver;
use App\Service\Season\SeasonChecklistBuilder;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* What an all-season teamer has to download, sign and return.
*
* Only the entries expecting something back are listed, whether or not the office issues a
* document for them first; the office-internal checks never appear here. Frist 2 does not either:
* it is an internal escalation.
*/
class IndexController extends AbstractController
{
public function __construct(
private readonly SeasonAssignmentResolver $seasonAssignmentResolver,
private readonly SeasonChecklistBuilder $checklistBuilder,
) {
}
#[Route('/teamer/season', name: 'app_teamer_season_index')]
#[IsGranted('ROLE_TEAMER')]
public function index(): Response
{
/** @var User $user */
$user = $this->getUser();
if (null === $teamer = $user->getTeamer()) {
throw $this->createNotFoundException('No teamer for this user');
}
if (null === $seasonAssignment = $this->seasonAssignmentResolver->resolveFor($teamer)) {
throw $this->createNotFoundException('No season assignment for this teamer');
}
$rows = array_filter(
$this->checklistBuilder->build($seasonAssignment),
static fn (array $row): bool => true === $row['definition']->requiresReturn()
);
return $this->render('teamer/season/index.html.twig', [
'seasonAssignment' => $seasonAssignment,
'rows' => $rows,
'documents' => $seasonAssignment->getDocuments(),
]);
}
}
@@ -0,0 +1,111 @@
<?php
namespace App\Controller\Teamer\Season;
use App\Entity\SeasonAssignment;
use App\Entity\Upload;
use App\Entity\User;
use App\Htmx\HxRedirectResponse;
use App\Model\UploadDto;
use App\Service\Upload\UploadHandler;
use Doctrine\ORM\EntityManagerInterface;
use Flagception\Manager\FeatureManagerInterface;
use Psr\Log\LoggerInterface;
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;
/**
* The signed documents a teamer returns for their season assignment.
*
* They accumulate, like Belege: there is no relation between a file and a checklist entry, and
* nothing here closes one - only an administrative tick on "erledigt" does that.
*/
class UploadController extends AbstractController
{
public function __construct(
private readonly UploadHandler $uploadHandler,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly FeatureManagerInterface $featureManager,
) {
}
#[Route('/teamer/season/upload/{uuid}', name: 'app_teamer_season_upload')]
#[IsGranted('ROLE_TEAMER')]
public function index(SeasonAssignment $seasonAssignment, Request $request): Response
{
/** @var User $user */
$user = $this->getUser();
// the assignment is addressed by uuid, so this is what keeps one teamer out of another's
if ($user->getTeamer() !== $seasonAssignment->getTeamer()) {
throw $this->createNotFoundException('Not this teamer\'s season assignment');
}
// Dummy form without fields, as elsewhere: the files travel in the upload session.
$form = $this->createFormBuilder()->getForm();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$count = $this->processUpload($seasonAssignment, $user);
$this->addFlash(
'success',
1 === $count ? 'Das Dokument wurde hochgeladen' : 'Die Dokumente wurden hochgeladen'
);
return new HxRedirectResponse($this->generateUrl('app_teamer_season_index'));
}
return $this->render('teamer/season/modal_upload.html.twig', [
'seasonAssignment' => $seasonAssignment,
'form' => $form->createView(),
]);
}
private function processUpload(SeasonAssignment $seasonAssignment, User $user): int
{
$uploadSession = $this->uploadHandler->getUploadSession();
// Only this dropzone's files: the session is shared, so it must not be destroyed whole.
$uploadDtos = $uploadSession->getUploadsByType(Upload::TYPE_SEASON_DOCUMENT_IN);
$uploads = [];
/** @var UploadDto $uploadDto */
foreach ($uploadDtos as $uploadDto) {
$upload = Upload::fromUploadDto($uploadDto, $user, Upload::TYPE_SEASON_DOCUMENT_IN);
// No status: a returned document is not run through the checking process, the office
// ticks the checklist entry off by hand.
$upload->setStatus(null);
$seasonAssignment->addDocument($upload);
$this->entityManager->persist($upload);
$uploads[] = $upload;
}
$this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_SEASON_DOCUMENT_IN, $uploadSession);
$this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_SEASON_DOCUMENT_IN);
if (true === $this->featureManager->isActive('sanitize_uploads')) {
foreach ($uploads as $upload) {
$this->uploadHandler->ensurePdf($upload);
}
}
$this->entityManager->flush();
$this->logger->info('Upload season documents', [
'user' => $user->getUserIdentifier(),
'season_assignment_id' => $seasonAssignment->getId(),
'count' => count($uploads),
]);
return count($uploads);
}
}