feat: all-season assignments
This commit is contained in:
1 parent
675cec1b69
commit
cf876ed224
119 files changed
+6194
-294
No files matched your search
@@ -6,6 +6,7 @@ use App\Service\Cron\DispositionReminderService;
|
||||
use App\Service\Cron\DispositionStatusService;
|
||||
use App\Service\Cron\FeedbackReminderService;
|
||||
use App\Service\Cron\FlushLogsService;
|
||||
use App\Service\Cron\SeasonTaskReminderService;
|
||||
use App\Service\Cron\UploadReminderService;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
@@ -25,6 +26,7 @@ class CronCommand extends Command
|
||||
private readonly FeedbackReminderService $feedbackReminderService,
|
||||
private readonly DispositionStatusService $dispositionStatusService,
|
||||
private readonly DispositionReminderService $dispositionReminderService,
|
||||
private readonly SeasonTaskReminderService $seasonTaskReminderService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -55,6 +57,12 @@ class CronCommand extends Command
|
||||
$message = $this->dispositionReminderService->sendDispositionReminders();
|
||||
$io->info($message);
|
||||
|
||||
$message = $this->seasonTaskReminderService->sendDeadlineSummary();
|
||||
$io->info($message);
|
||||
|
||||
$message = $this->seasonTaskReminderService->sendTeamerDigest();
|
||||
$io->info($message);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -22,4 +22,5 @@ enum EmailTextKey: string
|
||||
case REMINDER_INVOICE_UPLOAD = 'reminder_invoice_upload';
|
||||
case REMINDER_INVOICE_UPLOAD_FINAL = 'reminder_invoice_upload_final';
|
||||
case REMINDER_DISPOSITION = 'reminder_disposition';
|
||||
case REMINDER_SEASON_TASKS = 'reminder_season_tasks';
|
||||
}
|
||||
@@ -57,23 +57,52 @@ final class SeasonCalendar
|
||||
}
|
||||
|
||||
/**
|
||||
* The season a mail written now would call "die kommende Saison": the one currently running,
|
||||
* or - between two seasons - the next one to start. Never null, unlike seasonYearFor(): a
|
||||
* recruiting mail sent in September is outside every season window but is unmistakably about
|
||||
* the winter ahead.
|
||||
* The season that is running today, or - when today lies outside any window - the one that
|
||||
* starts next.
|
||||
*
|
||||
* The fall-forward is what keeps the teamer's season page reachable all year: without it the
|
||||
* page would disappear between 02.04. and 31.10. for the default window, which covers the
|
||||
* default first deadline of 01.10. for a season starting 01.11. - teamers would be reminded
|
||||
* about documents with nowhere to upload them.
|
||||
*/
|
||||
public function currentOrUpcomingSeasonYear(\DateTimeInterface $date): int
|
||||
public function currentOrUpcomingSeasonYear(?\DateTimeInterface $date = null): int
|
||||
{
|
||||
if (null !== $seasonYear = $this->seasonYearFor($date)) {
|
||||
$day = CarbonImmutable::instance($date ?? new \DateTimeImmutable())->startOfDay();
|
||||
|
||||
if (null !== $seasonYear = $this->seasonYearFor($day)) {
|
||||
return $seasonYear;
|
||||
}
|
||||
|
||||
$day = CarbonImmutable::instance($date)->startOfDay();
|
||||
$year = (int) $day->format('Y');
|
||||
|
||||
// Outside any window, so the next start is this year's unless it has already passed,
|
||||
// which for a wrapping window can only mean the season already began - handled above.
|
||||
return $day <= $this->boundaryFor($this->seasonStart, $year) ? $year : $year + 1;
|
||||
// Off-season, so the next window is the one whose start still lies ahead. The candidates
|
||||
// bracket the turn of the year, which is all a MM-DD window can ever straddle.
|
||||
foreach ([$year - 1, $year, $year + 1] as $candidate) {
|
||||
if ($this->boundaryFor($this->seasonStart, $candidate) > $day) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new \LogicException(sprintf('No season starts after %s.', $day->format('Y-m-d')));
|
||||
}
|
||||
|
||||
/**
|
||||
* The inclusive first and last day of a season, for queries that cannot call seasonYearFor()
|
||||
* per row - which is every query, since DQL knows nothing about this class.
|
||||
*
|
||||
* @return array{CarbonImmutable, CarbonImmutable}
|
||||
*/
|
||||
public function windowFor(int $seasonYear): array
|
||||
{
|
||||
$start = $this->boundaryFor($this->seasonStart, $seasonYear);
|
||||
$end = $this->boundaryFor($this->seasonEnd, $seasonYear);
|
||||
|
||||
// Wrapping window: the season ends in the following calendar year.
|
||||
if ($end < $start) {
|
||||
$end = $this->boundaryFor($this->seasonEnd, $seasonYear + 1);
|
||||
}
|
||||
|
||||
return [$start, $end];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,6 +126,15 @@ final class SeasonCalendar
|
||||
return sprintf('Saison %d/%02d', $seasonYear, ($seasonYear + 1) % 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* The season label for a date, for the common case of "which season does this belong to" -
|
||||
* "?" when the date is not known yet, which a half-filled create form can be in.
|
||||
*/
|
||||
public function labelFor(?\DateTimeInterface $date): string
|
||||
{
|
||||
return null === $date ? '?' : $this->label($this->seasonYearFor($date));
|
||||
}
|
||||
|
||||
private function boundaryFor(string $monthDay, int $year): CarbonImmutable
|
||||
{
|
||||
$boundary = CarbonImmutable::createFromFormat('Y-m-d', sprintf('%d-%s', $year, $monthDay));
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Config;
|
||||
|
||||
/**
|
||||
* The formalities the office works through for every season assignment.
|
||||
*
|
||||
* This enum is the single source of truth: the entries themselves, their German labels, their
|
||||
* order and both of their properties live here and nowhere else. Rows in season_assignment_task
|
||||
* only carry state, and App\Service\Season\SeasonTaskSynchronizer creates the ones an assignment
|
||||
* is missing - so adding a case here is all it takes to add an entry everywhere, including on
|
||||
* assignments that already exist.
|
||||
*
|
||||
* Removing a case leaves its rows behind on purpose. Every read resolves a stored key through
|
||||
* tryFrom() and skips what no longer exists, which keeps the history recoverable instead of
|
||||
* deleting it.
|
||||
*/
|
||||
enum SeasonTask: string
|
||||
{
|
||||
case MASTER_DATA = 'master_data';
|
||||
case VEHICLE = 'vehicle';
|
||||
case CONTRACT = 'contract';
|
||||
case ADDITIONAL_CONTRACT = 'additional_contract';
|
||||
case SAAS_REGISTERED = 'saas_registered';
|
||||
case MUNICIPALITY_REGISTERED = 'municipality_registered';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::MASTER_DATA => 'Stammdaten',
|
||||
self::VEHICLE => 'KfZ',
|
||||
self::CONTRACT => 'Vertrag',
|
||||
self::ADDITIONAL_CONTRACT => 'Zusatzvertrag',
|
||||
self::SAAS_REGISTERED => 'SaaS angemeldet',
|
||||
self::MUNICIPALITY_REGISTERED => 'Gemeinde angemeldet',
|
||||
};
|
||||
}
|
||||
|
||||
public function provides(): SeasonTaskProvision
|
||||
{
|
||||
return match ($this) {
|
||||
self::MASTER_DATA, self::VEHICLE => SeasonTaskProvision::STANDARD,
|
||||
self::CONTRACT, self::ADDITIONAL_CONTRACT => SeasonTaskProvision::INDIVIDUAL,
|
||||
self::SAAS_REGISTERED, self::MUNICIPALITY_REGISTERED => SeasonTaskProvision::NONE,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the teamer has to send something back. This is what puts a task on the teamer's
|
||||
* own page and into their weekly reminder; everything else is office-internal.
|
||||
*/
|
||||
public function requiresReturn(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::MASTER_DATA, self::VEHICLE, self::CONTRACT, self::ADDITIONAL_CONTRACT => true,
|
||||
self::SAAS_REGISTERED, self::MUNICIPALITY_REGISTERED => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return self[]
|
||||
*/
|
||||
public static function requiringReturn(): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
self::cases(),
|
||||
static fn (self $task): bool => true === $task->requiresReturn()
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Config;
|
||||
|
||||
/**
|
||||
* What the office hands out for a season task.
|
||||
*
|
||||
* NONE nothing is issued, the task is either a pure return or an internal check
|
||||
* INDIVIDUAL a document produced for this one teamer and uploaded onto their season assignment
|
||||
* STANDARD the same document for everyone, taken from the global document library
|
||||
*/
|
||||
enum SeasonTaskProvision: string
|
||||
{
|
||||
case NONE = 'none';
|
||||
case INDIVIDUAL = 'individual';
|
||||
case STANDARD = 'standard';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Traits\BlameableEntity;
|
||||
use App\Entity\Traits\SoftDeletableEntity;
|
||||
use App\Entity\Traits\TimestampableEntity;
|
||||
use App\Repository\SeasonAssignmentRepository;
|
||||
use App\Validator\Constraints as AppAssert;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* The placement of an all-season teamer, assigned directly by an administrative user.
|
||||
*
|
||||
* It carries its own period and house rather than pointing at a Destination: a Destination is one
|
||||
* travel with one departure window, while an all-season teamer sits at one house for the whole
|
||||
* season, so the relation only ever meant "borrow three scalars from an arbitrary travel row".
|
||||
*
|
||||
* Deliberately not an Assignment plus a Disposition: those two model the short term process,
|
||||
* where an assignment is published, teamers apply and an admin turns an application into a
|
||||
* placement. All-season teamers never apply, so there is nothing to publish, nothing to staff
|
||||
* and no contract workflow to run - Disposition cannot even be constructed without an
|
||||
* Application. One record carries the whole placement instead.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: SeasonAssignmentRepository::class)]
|
||||
#[AppAssert\SeasonAssignment]
|
||||
class SeasonAssignment implements BlameableEntityInterface, TimestampableEntityInterface, SoftDeletableEntityInterface
|
||||
{
|
||||
use BlameableEntity;
|
||||
use TimestampableEntity;
|
||||
use SoftDeletableEntity;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
/**
|
||||
* Left open on purpose: administrative users lay the season's assignments out first and
|
||||
* only place teamers on them later, so an unassigned assignment is a normal working state
|
||||
* rather than an incomplete record.
|
||||
*/
|
||||
#[ORM\ManyToOne(inversedBy: 'seasonAssignments')]
|
||||
private ?Teamer $teamer = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
|
||||
#[Assert\NotNull(message: 'Bitte gib das Startdatum an')]
|
||||
private ?\DateTimeImmutable $dateFrom = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
|
||||
#[Assert\NotNull(message: 'Bitte gib das Enddatum an')]
|
||||
#[Assert\GreaterThan(propertyPath: 'dateFrom', message: 'Das Enddatum muss nach dem Beginndatum liegen')]
|
||||
private ?\DateTimeImmutable $dateTo = null;
|
||||
|
||||
/**
|
||||
* A key of the configured "houses" map, which App\Config\HouseCatalog turns into a name for
|
||||
* display. Only the code is stored: a house renamed in config must not leave old placements
|
||||
* showing the old name. Deliberately no Assert\Choice - the map is curated and can lose an
|
||||
* entry, and a record that named that house has to stay editable.
|
||||
*/
|
||||
#[ORM\Column(length: 32)]
|
||||
#[Assert\NotBlank(message: 'Bitte gib das Haus an')]
|
||||
private ?string $hotelCode = null;
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
#[Assert\NotNull(message: 'Bitte gib das Jobprofil an')]
|
||||
private ?JobProfile $jobProfile = null;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
private ?string $remarks = null;
|
||||
|
||||
/**
|
||||
* The office's note that it considers this teamer done. Informative only: it changes no mail
|
||||
* and no query - the teamer keeps being reminded while a return-required checklist entry is
|
||||
* open. Administrative users only; there is no teamer-side write path.
|
||||
*/
|
||||
#[ORM\Column]
|
||||
private bool $doneTeamer = false;
|
||||
|
||||
/**
|
||||
* @var Collection<int, SeasonAssignmentTask>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'seasonAssignment', targetEntity: SeasonAssignmentTask::class, cascade: ['persist'])]
|
||||
private Collection $tasks;
|
||||
|
||||
/**
|
||||
* @var Collection<int, Upload>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'seasonAssignment', targetEntity: Upload::class)]
|
||||
private Collection $documents;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->uuid = Uuid::v4();
|
||||
$this->tasks = new ArrayCollection();
|
||||
$this->documents = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUuid(): string
|
||||
{
|
||||
return $this->uuid;
|
||||
}
|
||||
|
||||
public function getTeamer(): ?Teamer
|
||||
{
|
||||
return $this->teamer;
|
||||
}
|
||||
|
||||
public function setTeamer(?Teamer $teamer): static
|
||||
{
|
||||
$this->teamer = $teamer;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public function getHotelCode(): ?string
|
||||
{
|
||||
return $this->hotelCode;
|
||||
}
|
||||
|
||||
public function setHotelCode(?string $hotelCode): static
|
||||
{
|
||||
$this->hotelCode = $hotelCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getJobProfile(): ?JobProfile
|
||||
{
|
||||
return $this->jobProfile;
|
||||
}
|
||||
|
||||
public function setJobProfile(?JobProfile $jobProfile): static
|
||||
{
|
||||
$this->jobProfile = $jobProfile;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRemarks(): ?string
|
||||
{
|
||||
return $this->remarks;
|
||||
}
|
||||
|
||||
public function setRemarks(?string $remarks): static
|
||||
{
|
||||
$this->remarks = $remarks;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isDoneTeamer(): bool
|
||||
{
|
||||
return $this->doneTeamer;
|
||||
}
|
||||
|
||||
public function setDoneTeamer(bool $doneTeamer): static
|
||||
{
|
||||
$this->doneTeamer = $doneTeamer;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, SeasonAssignmentTask>
|
||||
*/
|
||||
public function getTasks(): Collection
|
||||
{
|
||||
return $this->tasks;
|
||||
}
|
||||
|
||||
public function addTask(SeasonAssignmentTask $task): static
|
||||
{
|
||||
if (!$this->tasks->contains($task)) {
|
||||
$this->tasks->add($task);
|
||||
$task->setSeasonAssignment($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeTask(SeasonAssignmentTask $task): static
|
||||
{
|
||||
if ($this->tasks->removeElement($task)) {
|
||||
// set the owning side to null (unless already changed)
|
||||
if ($task->getSeasonAssignment() === $this) {
|
||||
$task->setSeasonAssignment(null);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Upload>
|
||||
*/
|
||||
public function getDocuments(): Collection
|
||||
{
|
||||
return $this->documents;
|
||||
}
|
||||
|
||||
public function addDocument(Upload $document): static
|
||||
{
|
||||
if (!$this->documents->contains($document)) {
|
||||
$this->documents->add($document);
|
||||
$document->setSeasonAssignment($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeDocument(Upload $document): static
|
||||
{
|
||||
if ($this->documents->removeElement($document)) {
|
||||
// set the owning side to null (unless already changed)
|
||||
if ($document->getSeasonAssignment() === $this) {
|
||||
$document->setSeasonAssignment(null);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Config\SeasonTask;
|
||||
use App\Entity\Traits\BlameableEntity;
|
||||
use App\Entity\Traits\TimestampableEntity;
|
||||
use App\Repository\SeasonAssignmentTaskRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* The state of one checklist entry on one season assignment.
|
||||
*
|
||||
* The entries themselves are defined by App\Config\SeasonTask; this table only records what the
|
||||
* office has done with them. State lives in a row rather than in a JSON column because the nightly
|
||||
* reminder asks "every entry whose deadline is today and that is not done" across all assignments,
|
||||
* which JSON would turn into a full scan filtered in PHP.
|
||||
*
|
||||
* Deliberately not soft-deletable, unlike almost everything else in this app: rows are derived
|
||||
* from the enum and synced, never deleted by a user, and a soft-deleted row would silently drop
|
||||
* out of the reminder queries. Upload sets the same precedent.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: SeasonAssignmentTaskRepository::class)]
|
||||
#[ORM\UniqueConstraint(name: 'season_assignment_task_unique', columns: ['season_assignment_id', 'task_key'])]
|
||||
class SeasonAssignmentTask implements BlameableEntityInterface, TimestampableEntityInterface
|
||||
{
|
||||
use BlameableEntity;
|
||||
use TimestampableEntity;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(inversedBy: 'tasks')]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private ?SeasonAssignment $seasonAssignment = null;
|
||||
|
||||
#[ORM\Column(length: 64)]
|
||||
private string $taskKey;
|
||||
|
||||
/**
|
||||
* The only thing that closes a task and the only thing that stops a reminder.
|
||||
*/
|
||||
#[ORM\Column]
|
||||
private bool $done = false;
|
||||
|
||||
/**
|
||||
* Explicit rather than leaning on Blameable's updatedBy, which any later edit overwrites:
|
||||
* the office wants to see who ticked the entry off and when.
|
||||
*/
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $doneAt = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?string $doneBy = null;
|
||||
|
||||
/**
|
||||
* Purely informational: it records that a usable file arrived and gates nothing at all.
|
||||
*/
|
||||
#[ORM\Column]
|
||||
private bool $uploaded = false;
|
||||
|
||||
/**
|
||||
* Null means "not overridden" - the deadlines are computed on read by
|
||||
* App\Service\Season\SeasonTaskDeadlineResolver. Storing the computed value would leave them
|
||||
* stale as soon as the assignment's dates move.
|
||||
*/
|
||||
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $deadlineOverride1 = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $deadlineOverride2 = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->uuid = Uuid::v4();
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->getTask()?->label() ?? $this->taskKey;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUuid(): string
|
||||
{
|
||||
return $this->uuid;
|
||||
}
|
||||
|
||||
public function getSeasonAssignment(): ?SeasonAssignment
|
||||
{
|
||||
return $this->seasonAssignment;
|
||||
}
|
||||
|
||||
public function setSeasonAssignment(?SeasonAssignment $seasonAssignment): static
|
||||
{
|
||||
$this->seasonAssignment = $seasonAssignment;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTaskKey(): string
|
||||
{
|
||||
return $this->taskKey;
|
||||
}
|
||||
|
||||
public function setTaskKey(string $taskKey): static
|
||||
{
|
||||
$this->taskKey = $taskKey;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Null when the stored key no longer resolves, which is what happens to the rows of a case
|
||||
* that was removed from the enum. Callers skip those rows instead of failing on them.
|
||||
*/
|
||||
public function getTask(): ?SeasonTask
|
||||
{
|
||||
return SeasonTask::tryFrom($this->taskKey);
|
||||
}
|
||||
|
||||
public function isDone(): bool
|
||||
{
|
||||
return $this->done;
|
||||
}
|
||||
|
||||
public function setDone(bool $done): static
|
||||
{
|
||||
$this->done = $done;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDoneAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->doneAt;
|
||||
}
|
||||
|
||||
public function setDoneAt(?\DateTimeImmutable $doneAt): static
|
||||
{
|
||||
$this->doneAt = $doneAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDoneBy(): ?string
|
||||
{
|
||||
return $this->doneBy;
|
||||
}
|
||||
|
||||
public function setDoneBy(?string $doneBy): static
|
||||
{
|
||||
$this->doneBy = $doneBy;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isUploaded(): bool
|
||||
{
|
||||
return $this->uploaded;
|
||||
}
|
||||
|
||||
public function setUploaded(bool $uploaded): static
|
||||
{
|
||||
$this->uploaded = $uploaded;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDeadlineOverride1(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->deadlineOverride1;
|
||||
}
|
||||
|
||||
public function setDeadlineOverride1(?\DateTimeImmutable $deadlineOverride1): static
|
||||
{
|
||||
$this->deadlineOverride1 = $deadlineOverride1;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDeadlineOverride2(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->deadlineOverride2;
|
||||
}
|
||||
|
||||
public function setDeadlineOverride2(?\DateTimeImmutable $deadlineOverride2): static
|
||||
{
|
||||
$this->deadlineOverride2 = $deadlineOverride2;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Config\SeasonTask;
|
||||
use App\Entity\Traits\BlameableEntity;
|
||||
use App\Entity\Traits\TimestampableEntity;
|
||||
use App\Repository\SeasonTaskDocumentRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* Binds a checklist entry that hands out a standard document to the library upload that document
|
||||
* actually is.
|
||||
*
|
||||
* One row per task key at most; no row means no document is deposited, which the views have to
|
||||
* show as such rather than fail on. The mapping points at a library Upload instead of holding a
|
||||
* file of its own, so replacing the library file through the existing ReplaceController keeps this
|
||||
* reference intact.
|
||||
*
|
||||
* This is a task-type to configuration relation. It is unrelated to the files on a season
|
||||
* assignment, between which and the checklist there is deliberately no relation at all.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: SeasonTaskDocumentRepository::class)]
|
||||
class SeasonTaskDocument implements BlameableEntityInterface, TimestampableEntityInterface
|
||||
{
|
||||
use BlameableEntity;
|
||||
use TimestampableEntity;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\Column(length: 64, unique: true)]
|
||||
private string $taskKey;
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private ?Upload $document = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->uuid = Uuid::v4();
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->getTask()?->label() ?? $this->taskKey;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUuid(): string
|
||||
{
|
||||
return $this->uuid;
|
||||
}
|
||||
|
||||
public function getTaskKey(): string
|
||||
{
|
||||
return $this->taskKey;
|
||||
}
|
||||
|
||||
public function setTaskKey(string $taskKey): static
|
||||
{
|
||||
$this->taskKey = $taskKey;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTask(): ?SeasonTask
|
||||
{
|
||||
return SeasonTask::tryFrom($this->taskKey);
|
||||
}
|
||||
|
||||
public function getDocument(): ?Upload
|
||||
{
|
||||
return $this->document;
|
||||
}
|
||||
|
||||
public function setDocument(?Upload $document): static
|
||||
{
|
||||
$this->document = $document;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,9 @@ class Teamer implements TimestampableEntityInterface, SoftDeletableEntityInterfa
|
||||
#[ORM\OneToMany(mappedBy: 'teamer', targetEntity: Disposition::class, cascade: ['remove'])]
|
||||
private Collection $dispositions;
|
||||
|
||||
#[ORM\OneToMany(mappedBy: 'teamer', targetEntity: SeasonAssignment::class, cascade: ['remove'])]
|
||||
private Collection $seasonAssignments;
|
||||
|
||||
#[ORM\OneToOne(mappedBy: 'teamer', fetch: 'EXTRA_LAZY')]
|
||||
private ?User $user = null;
|
||||
|
||||
@@ -176,6 +179,9 @@ class Teamer implements TimestampableEntityInterface, SoftDeletableEntityInterfa
|
||||
#[ORM\Column(length: 8, nullable: true)]
|
||||
private ?string $driverLicenseReviewedBy = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private bool $allSeason = false;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->uuid = Uuid::v4();
|
||||
@@ -188,6 +194,7 @@ class Teamer implements TimestampableEntityInterface, SoftDeletableEntityInterfa
|
||||
$this->bookmarks = new ArrayCollection();
|
||||
$this->applications = new ArrayCollection();
|
||||
$this->dispositions = new ArrayCollection();
|
||||
$this->seasonAssignments = new ArrayCollection();
|
||||
$this->customAvailabilities = new ArrayCollection();
|
||||
}
|
||||
|
||||
@@ -791,6 +798,36 @@ class Teamer implements TimestampableEntityInterface, SoftDeletableEntityInterfa
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, SeasonAssignment>
|
||||
*/
|
||||
public function getSeasonAssignments(): Collection
|
||||
{
|
||||
return $this->seasonAssignments;
|
||||
}
|
||||
|
||||
public function addSeasonAssignment(SeasonAssignment $seasonAssignment): static
|
||||
{
|
||||
if (!$this->seasonAssignments->contains($seasonAssignment)) {
|
||||
$this->seasonAssignments->add($seasonAssignment);
|
||||
$seasonAssignment->setTeamer($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeSeasonAssignment(SeasonAssignment $seasonAssignment): static
|
||||
{
|
||||
if ($this->seasonAssignments->removeElement($seasonAssignment)) {
|
||||
// set the owning side to null (unless already changed)
|
||||
if ($seasonAssignment->getTeamer() === $this) {
|
||||
$seasonAssignment->setTeamer(null);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUser(): ?User
|
||||
{
|
||||
return $this->user;
|
||||
@@ -987,4 +1024,16 @@ class Teamer implements TimestampableEntityInterface, SoftDeletableEntityInterfa
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isAllSeason(): bool
|
||||
{
|
||||
return $this->allSeason;
|
||||
}
|
||||
|
||||
public function setAllSeason(bool $allSeason): static
|
||||
{
|
||||
$this->allSeason = $allSeason;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,17 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface
|
||||
public const TYPE_DRIVER_LICENSE = 'driver_license';
|
||||
public const TYPE_RECEIPT = 'receipt';
|
||||
|
||||
/**
|
||||
* A document the office issues to one single teamer. Deliberately not TYPE_DOCUMENT: that type
|
||||
* is the staff-wide library and UploadVoter grants every teamer access to all of it.
|
||||
*/
|
||||
public const TYPE_SEASON_DOCUMENT_OUT = 'season_document_out';
|
||||
|
||||
/**
|
||||
* A signed document the teamer returns. These accumulate, like receipts.
|
||||
*/
|
||||
public const TYPE_SEASON_DOCUMENT_IN = 'season_document_in';
|
||||
|
||||
public const STATUS_NEW = 'new';
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_CHECKED = 'checked';
|
||||
@@ -74,6 +85,9 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface
|
||||
#[ORM\ManyToOne(inversedBy: 'documents')]
|
||||
private ?Disposition $disposition = null;
|
||||
|
||||
#[ORM\ManyToOne(inversedBy: 'documents')]
|
||||
private ?SeasonAssignment $seasonAssignment = null;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
private ?string $comment = null;
|
||||
|
||||
@@ -146,6 +160,8 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface
|
||||
self::TYPE_DOCUMENT => 'Info',
|
||||
self::TYPE_DRIVER_LICENSE => 'Führerschein',
|
||||
self::TYPE_RECEIPT => 'Beleg',
|
||||
self::TYPE_SEASON_DOCUMENT_OUT => 'Saisonunterlage',
|
||||
self::TYPE_SEASON_DOCUMENT_IN => 'Rückläufer Saison',
|
||||
default => 'Dokument',
|
||||
};
|
||||
}
|
||||
@@ -268,6 +284,18 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSeasonAssignment(): ?SeasonAssignment
|
||||
{
|
||||
return $this->seasonAssignment;
|
||||
}
|
||||
|
||||
public function setSeasonAssignment(?SeasonAssignment $seasonAssignment): static
|
||||
{
|
||||
$this->seasonAssignment = $seasonAssignment;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getComment(): ?string
|
||||
{
|
||||
return $this->comment;
|
||||
|
||||
@@ -122,13 +122,9 @@ class AssignmentType extends AbstractType
|
||||
;
|
||||
},
|
||||
])
|
||||
->add('remarks', TextareaType::class, [
|
||||
->add('remarks', AutosizeTextareaType::class, [
|
||||
'label' => 'Anmerkungen intern',
|
||||
'required' => false,
|
||||
'attr' => [
|
||||
'data-controller' => 'textarea-autosize',
|
||||
'data-action' => 'textarea-autosize#resize',
|
||||
],
|
||||
])
|
||||
->add('pickup', ChoiceType::class, [
|
||||
'label' => 'Busbegleitung ab',
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\Form\FormView;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* A textarea that grows with its content, wiring up assets/controllers/textarea_autosize_controller.js.
|
||||
*
|
||||
* The controller overwrites the rows attribute on connect, so `rows` never had an effect on
|
||||
* these fields - `min_rows` is the floor it actually reads.
|
||||
*/
|
||||
class AutosizeTextareaType extends AbstractType
|
||||
{
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults(['min_rows' => 1]);
|
||||
$resolver->setAllowedTypes('min_rows', 'int');
|
||||
}
|
||||
|
||||
public function buildView(FormView $view, FormInterface $form, array $options): void
|
||||
{
|
||||
// Merged rather than set, and the caller's own attributes win: a field that needs a
|
||||
// second Stimulus controller on the same element composes the data-controller list
|
||||
// itself, which is the only case this can collide on.
|
||||
$view->vars['attr'] = array_merge([
|
||||
'data-controller' => 'textarea-autosize',
|
||||
'data-action' => 'textarea-autosize#resize',
|
||||
'data-textarea-autosize-min-rows-value' => $options['min_rows'],
|
||||
], $view->vars['attr']);
|
||||
}
|
||||
|
||||
public function getParent(): string
|
||||
{
|
||||
return TextareaType::class;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ namespace App\Form;
|
||||
|
||||
use App\Entity\User;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
@@ -14,23 +13,15 @@ class DisableUserType extends AbstractType
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('disabledReason', TextareaType::class, [
|
||||
->add('disabledReason', AutosizeTextareaType::class, [
|
||||
'label' => 'Begründung',
|
||||
'attr' => [
|
||||
'data-controller' => 'textarea-autosize',
|
||||
'data-action' => 'textarea-autosize#resize',
|
||||
],
|
||||
'constraints' => [
|
||||
new NotBlank(message: 'Bitte gib die Begründung ein'),
|
||||
],
|
||||
])
|
||||
->add('disabledReasonInternal', TextareaType::class, [
|
||||
->add('disabledReasonInternal', AutosizeTextareaType::class, [
|
||||
'label' => 'Begründung intern',
|
||||
'required' => false,
|
||||
'attr' => [
|
||||
'data-controller' => 'textarea-autosize',
|
||||
'data-action' => 'textarea-autosize#resize',
|
||||
],
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ use App\Entity\Disposition;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
@@ -23,12 +22,8 @@ class DispositionCallOffType extends AbstractType
|
||||
'E&P' => Disposition::CALLED_OFF_BY_OFFICE,
|
||||
],
|
||||
])
|
||||
->add('calledOffReason', TextareaType::class, [
|
||||
->add('calledOffReason', AutosizeTextareaType::class, [
|
||||
'label' => 'Begründung',
|
||||
'attr' => [
|
||||
'data-controller' => 'textarea-autosize',
|
||||
'data-action' => 'textarea-autosize#resize',
|
||||
],
|
||||
'constraints' => [
|
||||
new NotBlank(message: 'Bitte gib die Begründung ein'),
|
||||
],
|
||||
|
||||
@@ -6,7 +6,6 @@ use App\Config\EmailTextDefinition;
|
||||
use App\Entity\EmailText;
|
||||
use App\Validator\KnownPlaceholders;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
@@ -31,13 +30,9 @@ class EmailTextType extends AbstractType
|
||||
'required' => false,
|
||||
'constraints' => [$placeholders],
|
||||
])
|
||||
->add('body', TextareaType::class, [
|
||||
->add('body', AutosizeTextareaType::class, [
|
||||
'label' => 'Text',
|
||||
'attr' => [
|
||||
'data-controller' => 'textarea-autosize',
|
||||
'data-action' => 'textarea-autosize#resize',
|
||||
'data-textarea-autosize-min-rows-value' => 12,
|
||||
],
|
||||
'min_rows' => 12,
|
||||
'constraints' => [$placeholders],
|
||||
])
|
||||
;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Config\HouseCatalog;
|
||||
use App\Entity\JobProfile;
|
||||
use App\Model\SeasonAssignmentFilterDto;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class SeasonAssignmentFilterType extends AbstractType
|
||||
{
|
||||
public function __construct(private readonly HouseCatalog $houseCatalog)
|
||||
{
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('teamerName', TextType::class, [
|
||||
'label' => 'Name',
|
||||
'required' => false,
|
||||
])
|
||||
// By code, not by name as the destination filters do: a season assignment stores
|
||||
// the code itself, so this is an exact match rather than a substring guess.
|
||||
->add('hotelCodes', MultiselectType::class, [
|
||||
'label' => 'Haus',
|
||||
'required' => false,
|
||||
'empty_label' => 'nicht filtern',
|
||||
'choices' => $this->houseCatalog->getCodeChoices(),
|
||||
])
|
||||
->add('jobProfiles', MultiselectEntityType::class, [
|
||||
'label' => 'Job-Profile',
|
||||
'class' => JobProfile::class,
|
||||
'required' => false,
|
||||
'choice_label' => 'name',
|
||||
'empty_label' => 'nicht filtern',
|
||||
'query_builder' => function (EntityRepository $repository) {
|
||||
return $repository->createQueryBuilder('job_profile')
|
||||
->orderBy('job_profile.name', \SortDirection::Ascending);
|
||||
},
|
||||
])
|
||||
->add('includeDeleted', CheckboxType::class, [
|
||||
'label' => 'gelöschte Saisoneinsätze anzeigen',
|
||||
'required' => false,
|
||||
])
|
||||
// AbstractFilterHandler::handleRequest() branches on exactly these two names
|
||||
->add('apply', SubmitType::class, [
|
||||
'label' => 'filtern',
|
||||
])
|
||||
->add('reset', SubmitType::class, [
|
||||
'label' => 'reset',
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver
|
||||
->setDefaults([
|
||||
'data_class' => SeasonAssignmentFilterDto::class,
|
||||
])
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Config\HouseCatalog;
|
||||
use App\Entity\JobProfile;
|
||||
use App\Entity\SeasonAssignment;
|
||||
use App\Entity\Teamer;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class SeasonAssignmentType extends AbstractType
|
||||
{
|
||||
public function __construct(private readonly HouseCatalog $houseCatalog)
|
||||
{
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
// Optional: assignments are laid out first and staffed later. The endpoint only
|
||||
// ever answers with all-season teamers, so a short term teamer cannot be picked
|
||||
// here even by typing their full name.
|
||||
->add('teamer', AutocompleteEntityType::class, [
|
||||
'label' => 'Teamer:in',
|
||||
'class' => Teamer::class,
|
||||
'required' => false,
|
||||
'placeholder' => 'noch nicht zugewiesen',
|
||||
'label_property' => 'fullName',
|
||||
'label_function' => fn (Teamer $teamer) => (string) $teamer,
|
||||
'endpoint_route' => 'app_admin_autocomplete_season_teamer',
|
||||
])
|
||||
->add('dateFrom', DatepickerType::class, [
|
||||
'label' => 'Datum von',
|
||||
])
|
||||
->add('dateTo', DatepickerType::class, [
|
||||
'label' => 'Datum bis',
|
||||
])
|
||||
// The houses map is the whole list of places an all-season teamer can be placed, so
|
||||
// a plain select over it needs neither an autocomplete endpoint nor a BusPro call.
|
||||
->add('hotelCode', ChoiceType::class, [
|
||||
'label' => 'Haus',
|
||||
'placeholder' => 'Bitte auswählen',
|
||||
'choices' => $this->houseCatalog->getCodeChoices(),
|
||||
])
|
||||
->add('jobProfile', EntityType::class, [
|
||||
'label' => 'Jobprofil',
|
||||
'class' => JobProfile::class,
|
||||
'choice_label' => 'name',
|
||||
'placeholder' => 'Bitte auswählen',
|
||||
'query_builder' => function (EntityRepository $repository) {
|
||||
return $repository->createQueryBuilder('job_profile')
|
||||
->where('job_profile.deletedAt IS NULL')
|
||||
->orderBy('job_profile.name', \SortDirection::Ascending);
|
||||
},
|
||||
])
|
||||
->add('remarks', AutosizeTextareaType::class, [
|
||||
'label' => 'Anmerkungen intern',
|
||||
'required' => false,
|
||||
'min_rows' => 4,
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver
|
||||
->setDefaults([
|
||||
'data_class' => SeasonAssignment::class,
|
||||
])
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Entity\SeasonAssignmentTask;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* The two deadline overrides of one checklist entry.
|
||||
*
|
||||
* Both are optional: an empty field means "not overridden", and the deadline falls back to what
|
||||
* App\Service\Season\SeasonTaskDeadlineResolver computes from the destination's travel start.
|
||||
*/
|
||||
class SeasonTaskDeadlineType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('deadlineOverride1', DatepickerType::class, [
|
||||
'label' => 'Frist 1',
|
||||
'required' => false,
|
||||
])
|
||||
->add('deadlineOverride2', DatepickerType::class, [
|
||||
'label' => 'Frist 2',
|
||||
'required' => false,
|
||||
'help' => 'Ohne Angabe gilt Frist 1.',
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => SeasonAssignmentTask::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Entity\SeasonTaskDocument;
|
||||
use App\Entity\Upload;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class SeasonTaskDocumentType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('document', AutocompleteEntityType::class, [
|
||||
'label' => 'Dokument',
|
||||
'class' => Upload::class,
|
||||
'label_property' => 'originalFilename',
|
||||
'label_function' => fn (Upload $upload) => $upload->getDisplayName() ?? $upload->getOriginalFilename(),
|
||||
'endpoint_route' => 'app_admin_autocomplete_document',
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => SeasonTaskDocument::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ namespace App\Form;
|
||||
|
||||
use App\Entity\Disposition;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
@@ -12,13 +11,9 @@ class SpecialAgreementsType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder->add('specialAgreements', TextareaType::class, [
|
||||
$builder->add('specialAgreements', AutosizeTextareaType::class, [
|
||||
'label' => 'zusätzliche Absprache',
|
||||
'required' => false,
|
||||
'attr' => [
|
||||
'data-controller' => 'textarea-autosize',
|
||||
'data-action' => 'textarea-autosize#resize',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace App\Form;
|
||||
|
||||
use App\Model\TeamerMailingDto;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
@@ -17,13 +16,9 @@ class TeamerMailingType extends AbstractType
|
||||
->add('subject', TextType::class, [
|
||||
'label' => 'Betreff',
|
||||
])
|
||||
->add('message', TextareaType::class, [
|
||||
->add('message', AutosizeTextareaType::class, [
|
||||
'label' => 'Nachricht',
|
||||
'attr' => [
|
||||
'data-controller' => 'textarea-autosize',
|
||||
'data-action' => 'textarea-autosize#resize',
|
||||
'data-textarea-autosize-min-rows-value' => 12,
|
||||
],
|
||||
'min_rows' => 12,
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
+2
-11
@@ -5,7 +5,6 @@ namespace App\Form;
|
||||
use App\Entity\User;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormEvent;
|
||||
use Symfony\Component\Form\FormEvents;
|
||||
@@ -30,23 +29,15 @@ class UserType extends AbstractType
|
||||
'required' => false,
|
||||
'help' => 'Gesperrte Benutzer:innen können sich nicht anmelden.',
|
||||
])
|
||||
->add('disabledReason', TextareaType::class, [
|
||||
->add('disabledReason', AutosizeTextareaType::class, [
|
||||
'label' => 'Begründung',
|
||||
'required' => false,
|
||||
'help' => 'Wird bei der Anmeldung angezeigt.',
|
||||
'attr' => [
|
||||
'data-controller' => 'textarea-autosize',
|
||||
'data-action' => 'textarea-autosize#resize',
|
||||
],
|
||||
])
|
||||
->add('disabledReasonInternal', TextareaType::class, [
|
||||
->add('disabledReasonInternal', AutosizeTextareaType::class, [
|
||||
'label' => 'Begründung intern',
|
||||
'required' => false,
|
||||
'help' => 'Wird der Benutzer:in nicht angezeigt.',
|
||||
'attr' => [
|
||||
'data-controller' => 'textarea-autosize',
|
||||
'data-action' => 'textarea-autosize#resize',
|
||||
],
|
||||
])
|
||||
;
|
||||
|
||||
|
||||
@@ -116,6 +116,30 @@ class AdminMenuBuilder extends AbstractMenuBuilder
|
||||
],
|
||||
],
|
||||
]);
|
||||
$menu->addChild('Saisonteamer', [
|
||||
'route' => 'app_administrative_season_teamer_index',
|
||||
'linkAttributes' => [
|
||||
'title' => 'Saisonteamer',
|
||||
],
|
||||
'extras' => [
|
||||
'icon' => 'users',
|
||||
'routes' => [
|
||||
['pattern' => '/^app_administrative_season_teamer_/'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
$menu->addChild('Saisoneinsätze', [
|
||||
'route' => 'app_administrative_season_assignment_index',
|
||||
'linkAttributes' => [
|
||||
'title' => 'Saisoneinsätze',
|
||||
],
|
||||
'extras' => [
|
||||
'icon' => 'calendar',
|
||||
'routes' => [
|
||||
['pattern' => '/^app_administrative_season_assignment_/'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
$menu->addChild('Verfügbarkeiten', [
|
||||
'route' => 'app_administrative_availability_index',
|
||||
'linkAttributes' => [
|
||||
@@ -272,6 +296,17 @@ class AdminMenuBuilder extends AbstractMenuBuilder
|
||||
],
|
||||
],
|
||||
]);
|
||||
$settingsMenu->addChild('Saisonteam-Dokumente', [
|
||||
'route' => 'app_administrative_system_season_task_document_index',
|
||||
'linkAttributes' => [
|
||||
'title' => 'Saisonteam-Dokumente',
|
||||
],
|
||||
'extras' => [
|
||||
'routes' => [
|
||||
['pattern' => '/^app_administrative_system_season_task_document_/'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
$settingsMenu->addChild('Ansprechpersonen', [
|
||||
'route' => 'app_admin_system_contact_index',
|
||||
'linkAttributes' => [
|
||||
|
||||
@@ -82,6 +82,30 @@ class ManagerMenuBuilder extends AbstractMenuBuilder
|
||||
],
|
||||
],
|
||||
]);
|
||||
$menu->addChild('Saisonteamer', [
|
||||
'route' => 'app_administrative_season_teamer_index',
|
||||
'linkAttributes' => [
|
||||
'title' => 'Saisonteamer',
|
||||
],
|
||||
'extras' => [
|
||||
'icon' => 'users',
|
||||
'routes' => [
|
||||
['pattern' => '/^app_administrative_season_teamer_/'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
$menu->addChild('Saisoneinsätze', [
|
||||
'route' => 'app_administrative_season_assignment_index',
|
||||
'linkAttributes' => [
|
||||
'title' => 'Saisoneinsätze',
|
||||
],
|
||||
'extras' => [
|
||||
'icon' => 'calendar',
|
||||
'routes' => [
|
||||
['pattern' => '/^app_administrative_season_assignment_/'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
$menu->addChild('Feedbackübersicht', [
|
||||
'route' => 'app_administrative_feedback_index',
|
||||
'linkAttributes' => [
|
||||
@@ -175,6 +199,17 @@ class ManagerMenuBuilder extends AbstractMenuBuilder
|
||||
],
|
||||
],
|
||||
]);
|
||||
$settingsMenu->addChild('Saisonunterlagen', [
|
||||
'route' => 'app_administrative_system_season_task_document_index',
|
||||
'linkAttributes' => [
|
||||
'title' => 'Saisonunterlagen',
|
||||
],
|
||||
'extras' => [
|
||||
'routes' => [
|
||||
['pattern' => '/^app_administrative_system_season_task_document_/'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->addAdminItem($menu);
|
||||
$this->addTeamerItem($menu);
|
||||
|
||||
@@ -2,10 +2,27 @@
|
||||
|
||||
namespace App\Menu;
|
||||
|
||||
use App\Entity\SeasonAssignment;
|
||||
use App\Entity\User;
|
||||
use App\Service\Season\SeasonAssignmentResolver;
|
||||
use Knp\Menu\FactoryInterface;
|
||||
use Knp\Menu\ItemInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class TeamerMenuBuilder extends AbstractMenuBuilder
|
||||
{
|
||||
public function __construct(
|
||||
FactoryInterface $factory,
|
||||
Security $security,
|
||||
RequestStack $requestStack,
|
||||
TranslatorInterface $translator,
|
||||
private readonly SeasonAssignmentResolver $seasonAssignmentResolver,
|
||||
) {
|
||||
parent::__construct($factory, $security, $requestStack, $translator);
|
||||
}
|
||||
|
||||
public function createMainMenu(array $options): ItemInterface
|
||||
{
|
||||
$menu = $this->createRootElement();
|
||||
@@ -36,6 +53,23 @@ class TeamerMenuBuilder extends AbstractMenuBuilder
|
||||
'icon' => 'profile',
|
||||
],
|
||||
]);
|
||||
|
||||
// Only for an all-season teamer who actually has an assignment this season: the page
|
||||
// would have nothing to show otherwise, and it throws a 404 in exactly that case.
|
||||
if (null !== $this->resolveSeasonAssignment()) {
|
||||
$profileMenu->addChild('Saison-Teamer:in', [
|
||||
'route' => 'app_teamer_season_index',
|
||||
'linkAttributes' => [
|
||||
'title' => 'Saison-Teamer:in',
|
||||
],
|
||||
'extras' => [
|
||||
'routes' => [
|
||||
['pattern' => '/^app_teamer_season_/'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$profileMenu->addChild('Stammdaten & Foto', [
|
||||
'route' => 'app_teamer_profile_index',
|
||||
'linkAttributes' => [
|
||||
@@ -120,4 +154,15 @@ class TeamerMenuBuilder extends AbstractMenuBuilder
|
||||
|
||||
return $menu;
|
||||
}
|
||||
|
||||
private function resolveSeasonAssignment(): ?SeasonAssignment
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
|
||||
if (false === $user instanceof User || null === $teamer = $user->getTeamer()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->seasonAssignmentResolver->resolveFor($teamer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
use App\Entity\JobProfile;
|
||||
|
||||
class SeasonAssignmentFilterDto extends AbstractFilterDto
|
||||
{
|
||||
protected ?string $teamerName = null;
|
||||
/** @var string[] keys of the configured "houses" map */
|
||||
protected array $hotelCodes = [];
|
||||
protected array $jobProfiles = [];
|
||||
protected bool $includeDeleted = false;
|
||||
|
||||
public function getTeamerName(): ?string
|
||||
{
|
||||
return $this->teamerName;
|
||||
}
|
||||
|
||||
public function setTeamerName(?string $teamerName): static
|
||||
{
|
||||
$this->teamerName = $teamerName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getHotelCodes(): array
|
||||
{
|
||||
return $this->hotelCodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $hotelCodes
|
||||
*/
|
||||
public function setHotelCodes(array $hotelCodes): static
|
||||
{
|
||||
$this->hotelCodes = $hotelCodes;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return JobProfile[]
|
||||
*/
|
||||
public function getJobProfiles(): array
|
||||
{
|
||||
return $this->jobProfiles;
|
||||
}
|
||||
|
||||
public function setJobProfiles(array $jobProfiles): static
|
||||
{
|
||||
$this->jobProfiles = $jobProfiles;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isIncludeDeleted(): bool
|
||||
{
|
||||
return $this->includeDeleted;
|
||||
}
|
||||
|
||||
public function setIncludeDeleted(bool $includeDeleted): static
|
||||
{
|
||||
$this->includeDeleted = $includeDeleted;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,14 @@ class TeamerFilterDto extends AbstractFilterDto
|
||||
protected bool $includeInactive = false;
|
||||
protected bool $includeDeleted = false;
|
||||
|
||||
/**
|
||||
* The scope of the list this filter belongs to, not a filter the user set.
|
||||
*
|
||||
* The leading underscore keeps it out of AbstractFilterDto::getActiveFiltersCount(),
|
||||
* so the all-season list does not permanently claim to have one filter active.
|
||||
*/
|
||||
protected bool $_allSeason = false;
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
return $this->name;
|
||||
@@ -132,4 +140,16 @@ class TeamerFilterDto extends AbstractFilterDto
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isAllSeason(): bool
|
||||
{
|
||||
return $this->_allSeason;
|
||||
}
|
||||
|
||||
public function setAllSeason(bool $allSeason): static
|
||||
{
|
||||
$this->_allSeason = $allSeason;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\SeasonAssignment;
|
||||
use App\Entity\Teamer;
|
||||
use App\Model\SeasonAssignmentFilterDto;
|
||||
use App\Repository\Traits\QueryHelperTrait;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\Query;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<SeasonAssignment>
|
||||
*
|
||||
* @method SeasonAssignment|null find($id, $lockMode = null, $lockVersion = null)
|
||||
* @method SeasonAssignment|null findOneBy(array $criteria, array $orderBy = null)
|
||||
* @method SeasonAssignment[] findAll()
|
||||
* @method SeasonAssignment[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
|
||||
*/
|
||||
class SeasonAssignmentRepository extends ServiceEntityRepository
|
||||
{
|
||||
use QueryHelperTrait;
|
||||
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, SeasonAssignment::class);
|
||||
}
|
||||
|
||||
public function getListQuery(SeasonAssignmentFilterDto $filterDto): Query
|
||||
{
|
||||
$qb = $this->createQueryBuilder('season_assignment');
|
||||
|
||||
$qb
|
||||
->select('season_assignment', 'teamer', 'job_profile')
|
||||
// an assignment waiting for a teamer has to stay in the list - that is exactly
|
||||
// the state an administrative user is looking for when placing people
|
||||
->leftJoin('season_assignment.teamer', 'teamer')
|
||||
->innerJoin('season_assignment.jobProfile', 'job_profile')
|
||||
;
|
||||
|
||||
$this->applyFilterSettings($filterDto, $qb);
|
||||
|
||||
return $qb
|
||||
->addOrderBy('season_assignment.dateFrom', \SortDirection::Descending)
|
||||
->addOrderBy('teamer.lastName', \SortDirection::Ascending)
|
||||
->getQuery()
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* The assignments a teamer already has in one season, for the rule that allows only one.
|
||||
*
|
||||
* Bucketed by the assignment's own first day, which is exactly how
|
||||
* App\Config\SeasonCalendar::seasonYearFor() decides what season something belongs to.
|
||||
* Deliberately not SeasonPeriodFilter: that one also requires the period to end inside the
|
||||
* window, and a placement running past the season's last day still belongs to it here.
|
||||
*/
|
||||
public function countInSeasonWindow(
|
||||
Teamer $teamer,
|
||||
\DateTimeImmutable $windowStart,
|
||||
\DateTimeImmutable $windowEnd,
|
||||
?int $excludeId = null,
|
||||
): int {
|
||||
return (int) $this
|
||||
->createInSeasonWindowQueryBuilder($teamer, $windowStart, $windowEnd, $excludeId)
|
||||
->getQuery()
|
||||
->getSingleScalarResult()
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* A teamer's assignments in one season, earliest first - what the teamer's own season page
|
||||
* is about. Bucketed by the assignment's own first day, like countInSeasonWindow().
|
||||
*
|
||||
* @return SeasonAssignment[]
|
||||
*/
|
||||
public function findForTeamerInSeason(
|
||||
Teamer $teamer,
|
||||
\DateTimeImmutable $windowStart,
|
||||
\DateTimeImmutable $windowEnd,
|
||||
): array {
|
||||
$qb = $this->createInSeasonWindowQueryBuilder($teamer, $windowStart, $windowEnd);
|
||||
|
||||
return $qb
|
||||
->select('season_assignment')
|
||||
->addOrderBy('season_assignment.dateFrom', \SortDirection::Ascending)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracted so the conditions can be pinned by a test compiling the DQL - the suite has no
|
||||
* fixtures, so what is asserted is what the query says, not what it returns.
|
||||
*
|
||||
* The window is read off the assignment itself, so there is no second row that could be
|
||||
* soft-deleted underneath a live placement and drop it out of the season it belongs to.
|
||||
*/
|
||||
public function createInSeasonWindowQueryBuilder(
|
||||
Teamer $teamer,
|
||||
\DateTimeImmutable $windowStart,
|
||||
\DateTimeImmutable $windowEnd,
|
||||
?int $excludeId = null,
|
||||
): QueryBuilder {
|
||||
$qb = $this->createQueryBuilder('season_assignment');
|
||||
|
||||
$qb
|
||||
->select($qb->expr()->count('season_assignment'))
|
||||
->where($qb->expr()->andX(
|
||||
$qb->expr()->eq('season_assignment.teamer', ':teamer'),
|
||||
$qb->expr()->isNull('season_assignment.deletedAt'),
|
||||
$qb->expr()->gte('season_assignment.dateFrom', ':windowStart'),
|
||||
$qb->expr()->lte('season_assignment.dateFrom', ':windowEnd')
|
||||
))
|
||||
->setParameter('teamer', $teamer)
|
||||
->setParameter('windowStart', $windowStart)
|
||||
->setParameter('windowEnd', $windowEnd)
|
||||
;
|
||||
|
||||
if (null !== $excludeId) {
|
||||
$qb
|
||||
->andWhere($qb->expr()->neq('season_assignment.id', ':excludeId'))
|
||||
->setParameter('excludeId', $excludeId)
|
||||
;
|
||||
}
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
private function applyFilterSettings(SeasonAssignmentFilterDto $filterDto, QueryBuilder $qb): void
|
||||
{
|
||||
if (null !== $filterDto->getTeamerName()) {
|
||||
$qb
|
||||
->andWhere($qb->expr()->orX(
|
||||
$qb->expr()->like('teamer.firstName', ':teamerName'),
|
||||
$qb->expr()->like('teamer.lastName', ':teamerName')
|
||||
))
|
||||
->setParameter('teamerName', '%'.$this->escapeLikeWildcards($filterDto->getTeamerName()).'%')
|
||||
;
|
||||
}
|
||||
|
||||
if (0 < count($filterDto->getHotelCodes())) {
|
||||
$qb
|
||||
->andWhere($qb->expr()->in('season_assignment.hotelCode', ':hotelCodes'))
|
||||
->setParameter('hotelCodes', $filterDto->getHotelCodes())
|
||||
;
|
||||
}
|
||||
|
||||
if (0 < count($filterDto->getJobProfiles())) {
|
||||
$qb
|
||||
->andWhere($qb->expr()->in('season_assignment.jobProfile', ':jobProfiles'))
|
||||
->setParameter('jobProfiles', $filterDto->getJobProfiles())
|
||||
;
|
||||
}
|
||||
|
||||
if (false === $filterDto->isIncludeDeleted()) {
|
||||
$qb->andWhere($qb->expr()->isNull('season_assignment.deletedAt'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\SeasonAssignmentTask;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<SeasonAssignmentTask>
|
||||
*
|
||||
* @method SeasonAssignmentTask|null find($id, $lockMode = null, $lockVersion = null)
|
||||
* @method SeasonAssignmentTask|null findOneBy(array $criteria, array $orderBy = null)
|
||||
* @method SeasonAssignmentTask[] findAll()
|
||||
* @method SeasonAssignmentTask[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
|
||||
*/
|
||||
class SeasonAssignmentTaskRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, SeasonAssignmentTask::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every open entry that could still be reminded about: not ticked off, on a live assignment
|
||||
* that has a teamer on it.
|
||||
*
|
||||
* The deadlines themselves are not part of this query. They are computed from the
|
||||
* assignment's first day by a relative offset, and month arithmetic is not invertible
|
||||
* day by day - "minus one month" from the 31st and back again lands somewhere else - so a
|
||||
* query built by inverting the offset would silently skip entries. The reminder resolves
|
||||
* every row through SeasonTaskDeadlineResolver instead; the set is small, one assignment per
|
||||
* all-season teamer per season.
|
||||
*
|
||||
* @return SeasonAssignmentTask[]
|
||||
*/
|
||||
public function findOpen(?\DateTimeImmutable $windowStart = null, ?\DateTimeImmutable $windowEnd = null): array
|
||||
{
|
||||
return $this
|
||||
->createOpenQueryBuilder($windowStart, $windowEnd)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracted so the conditions can be pinned by a test compiling the DQL - the suite has no
|
||||
* fixtures, so what is asserted is what the query says, not what it returns.
|
||||
*/
|
||||
public function createOpenQueryBuilder(
|
||||
?\DateTimeImmutable $windowStart = null,
|
||||
?\DateTimeImmutable $windowEnd = null,
|
||||
): QueryBuilder {
|
||||
$qb = $this->createQueryBuilder('task');
|
||||
|
||||
$qb
|
||||
->select('task', 'season_assignment', 'teamer')
|
||||
->innerJoin('task.seasonAssignment', 'season_assignment')
|
||||
// no teamer means nobody to remind and nobody to be late
|
||||
->innerJoin('season_assignment.teamer', 'teamer')
|
||||
->where($qb->expr()->andX(
|
||||
$qb->expr()->eq('task.done', ':done'),
|
||||
$qb->expr()->isNull('season_assignment.deletedAt'),
|
||||
$qb->expr()->isNull('teamer.deletedAt')
|
||||
))
|
||||
->setParameter('done', false)
|
||||
->addOrderBy('teamer.lastName', \SortDirection::Ascending)
|
||||
->addOrderBy('teamer.firstName', \SortDirection::Ascending)
|
||||
;
|
||||
|
||||
if (null !== $windowStart) {
|
||||
$qb
|
||||
->andWhere($qb->expr()->gte('season_assignment.dateFrom', ':windowStart'))
|
||||
->setParameter('windowStart', $windowStart)
|
||||
;
|
||||
}
|
||||
|
||||
if (null !== $windowEnd) {
|
||||
$qb
|
||||
->andWhere($qb->expr()->lte('season_assignment.dateFrom', ':windowEnd'))
|
||||
->setParameter('windowEnd', $windowEnd)
|
||||
;
|
||||
}
|
||||
|
||||
return $qb;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Config\SeasonTask;
|
||||
use App\Entity\SeasonTaskDocument;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<SeasonTaskDocument>
|
||||
*
|
||||
* @method SeasonTaskDocument|null find($id, $lockMode = null, $lockVersion = null)
|
||||
* @method SeasonTaskDocument|null findOneBy(array $criteria, array $orderBy = null)
|
||||
* @method SeasonTaskDocument[] findAll()
|
||||
* @method SeasonTaskDocument[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
|
||||
*/
|
||||
class SeasonTaskDocumentRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, SeasonTaskDocument::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* The mapping keyed by task, for views that render the whole checklist at once and would
|
||||
* otherwise query per row.
|
||||
*
|
||||
* @return array<string, SeasonTaskDocument>
|
||||
*/
|
||||
public function findIndexedByTaskKey(): array
|
||||
{
|
||||
$indexed = [];
|
||||
|
||||
foreach ($this->findAll() as $seasonTaskDocument) {
|
||||
$indexed[$seasonTaskDocument->getTaskKey()] = $seasonTaskDocument;
|
||||
}
|
||||
|
||||
return $indexed;
|
||||
}
|
||||
|
||||
public function findForTask(SeasonTask $task): ?SeasonTaskDocument
|
||||
{
|
||||
return $this->findOneBy(['taskKey' => $task->value]);
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,13 @@ class TeamerRepository extends ServiceEntityRepository
|
||||
;
|
||||
}
|
||||
|
||||
if (true === $filterDto->isAllSeason()) {
|
||||
$qb
|
||||
->andWhere($qb->expr()->eq('teamer.allSeason', ':allSeason'))
|
||||
->setParameter('allSeason', true)
|
||||
;
|
||||
}
|
||||
|
||||
if (false === $filterDto->isIncludeDeleted()) {
|
||||
$qb->andWhere($qb->expr()->isNull('teamer.deletedAt'));
|
||||
}
|
||||
@@ -167,11 +174,15 @@ class TeamerRepository extends ServiceEntityRepository
|
||||
return $qb;
|
||||
}
|
||||
|
||||
public function getAutocompletionData(string $search): array
|
||||
/**
|
||||
* @param bool $allSeasonOnly restricts the suggestions to all-season teamers, so a season
|
||||
* assignment cannot be created for someone who works short term
|
||||
*/
|
||||
private function createAutocompletionQueryBuilder(string $search, bool $allSeasonOnly): QueryBuilder
|
||||
{
|
||||
$qb = $this->createQueryBuilder('teamer');
|
||||
|
||||
$teamers = $qb
|
||||
$qb
|
||||
->where($qb->expr()->orX(
|
||||
$qb->expr()->like('teamer.lastName', ':search'),
|
||||
$qb->expr()->like('teamer.firstName', ':search'),
|
||||
@@ -180,6 +191,22 @@ class TeamerRepository extends ServiceEntityRepository
|
||||
->orderBy('teamer.lastName', \SortDirection::Ascending)
|
||||
->addOrderBy('teamer.firstName', \SortDirection::Ascending)
|
||||
->setParameter('search', '%'.$this->escapeLikeWildcards($search).'%')
|
||||
;
|
||||
|
||||
if (true === $allSeasonOnly) {
|
||||
$qb
|
||||
->andWhere($qb->expr()->eq('teamer.allSeason', ':allSeason'))
|
||||
->setParameter('allSeason', true)
|
||||
;
|
||||
}
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
public function getAutocompletionData(string $search, bool $allSeasonOnly = false): array
|
||||
{
|
||||
$teamers = $this
|
||||
->createAutocompletionQueryBuilder($search, $allSeasonOnly)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Model\DocumentFilterDto;
|
||||
use App\Repository\Traits\QueryHelperTrait;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\Query;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
@@ -46,17 +47,17 @@ class UploadRepository extends ServiceEntityRepository
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* The picker behind every "choose a document" field, restricted to the global library.
|
||||
*
|
||||
* Without the type condition it offers every upload in the system - Honorarverträge,
|
||||
* Honorarnoten, Belege, Führerscheine - so picking one would have pointed a setting at a
|
||||
* teamer's private file.
|
||||
*/
|
||||
public function getAutocompletionData(string $search): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('upload');
|
||||
|
||||
$documents = $qb
|
||||
->where($qb->expr()->orX(
|
||||
$qb->expr()->like('upload.originalFilename', ':filename'),
|
||||
$qb->expr()->like('upload.displayName', ':filename')
|
||||
))
|
||||
->setParameter('filename', '%'.$this->escapeLikeWildcards($search).'%')
|
||||
->orderBy('upload.originalFilename', \SortDirection::Ascending)
|
||||
$documents = $this
|
||||
->createAutocompletionQueryBuilder($search)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
@@ -79,6 +80,24 @@ class UploadRepository extends ServiceEntityRepository
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function createAutocompletionQueryBuilder(string $search): QueryBuilder
|
||||
{
|
||||
$qb = $this->createQueryBuilder('upload');
|
||||
|
||||
return $qb
|
||||
->where($qb->expr()->andX(
|
||||
$qb->expr()->eq('upload.type', ':type'),
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->like('upload.originalFilename', ':filename'),
|
||||
$qb->expr()->like('upload.displayName', ':filename')
|
||||
)
|
||||
))
|
||||
->setParameter('type', Upload::TYPE_DOCUMENT)
|
||||
->setParameter('filename', '%'.$this->escapeLikeWildcards($search).'%')
|
||||
->orderBy('upload.originalFilename', \SortDirection::Ascending)
|
||||
;
|
||||
}
|
||||
|
||||
public function getBatchDownloadListQuery(
|
||||
string $type = Upload::TYPE_INVOICE,
|
||||
string $status = Upload::STATUS_PAID,
|
||||
|
||||
@@ -50,9 +50,30 @@ class UploadVoter extends Voter
|
||||
/** @var User $user */
|
||||
$user = $token->getUser();
|
||||
|
||||
return Upload::TYPE_DOCUMENT === $upload->getType() || $user === $upload->getOwner();
|
||||
if (Upload::TYPE_DOCUMENT === $upload->getType() || $user === $upload->getOwner()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// A document the office issues for one single teamer is owned by the admin who
|
||||
// uploaded it, so neither rule above reaches it - and it must not be TYPE_DOCUMENT,
|
||||
// since that type is the staff-wide library every teamer may read. The teamer it was
|
||||
// issued to is therefore let in through their own season assignment.
|
||||
return static::DOWNLOAD === $attribute && $this->isOwnSeasonDocument($upload, $user);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function isOwnSeasonDocument(Upload $upload, User $user): bool
|
||||
{
|
||||
if (Upload::TYPE_SEASON_DOCUMENT_OUT !== $upload->getType()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (null === $teamer = $upload->getSeasonAssignment()?->getTeamer()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $teamer === $user->getTeamer();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Common;
|
||||
|
||||
use App\Entity\JobProfile;
|
||||
use App\Model\AbstractFilterDto;
|
||||
use App\Model\SeasonAssignmentFilterDto;
|
||||
|
||||
class SeasonAssignmentFilterHandler extends AbstractFilterHandler
|
||||
{
|
||||
protected string $namespace = 'filter:season_assignment';
|
||||
protected string $modelClass = SeasonAssignmentFilterDto::class;
|
||||
|
||||
public function getFilterSettings(): SeasonAssignmentFilterDto
|
||||
{
|
||||
$filterDto = new $this->modelClass();
|
||||
|
||||
if (null === $data = $this->getSession()->get($this->namespace)) {
|
||||
return $filterDto;
|
||||
}
|
||||
|
||||
$filterDto
|
||||
->setTeamerName($data['teamer_name'])
|
||||
// ?? []: sessions written before the houses filter replaced the destination one
|
||||
// still carry the old payload under this same namespace
|
||||
->setHotelCodes($data['hotel_codes'] ?? [])
|
||||
->setIncludeDeleted((bool) ($data['include_deleted'] ?? false))
|
||||
;
|
||||
|
||||
if (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 SeasonAssignmentFilterDto $filterDto */
|
||||
$this->getSession()->set($this->namespace, [
|
||||
'teamer_name' => $filterDto->getTeamerName(),
|
||||
'hotel_codes' => $filterDto->getHotelCodes(),
|
||||
'include_deleted' => $filterDto->isIncludeDeleted(),
|
||||
'job_profiles' => array_map(function (JobProfile $jobProfile) {
|
||||
return $jobProfile->getId();
|
||||
}, $filterDto->getJobProfiles()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,43 @@ use App\Model\TeamerFilterDto;
|
||||
|
||||
class TeamerFilterHandler extends AbstractFilterHandler
|
||||
{
|
||||
protected string $namespace = 'filter:teamer';
|
||||
/**
|
||||
* The value the teamer lists pass around as a 'scope' query parameter to say which
|
||||
* of the two they are working on.
|
||||
*/
|
||||
public const SCOPE_ALL_SEASON = 'all_season';
|
||||
|
||||
public const NAMESPACE_DEFAULT = 'filter:teamer';
|
||||
|
||||
/**
|
||||
* The all-season list keeps its own session bucket so filtering one teamer list
|
||||
* never silently changes what the other one shows.
|
||||
*/
|
||||
public const NAMESPACE_ALL_SEASON = 'filter:teamer:all_season';
|
||||
|
||||
protected string $namespace = self::NAMESPACE_DEFAULT;
|
||||
protected string $modelClass = TeamerFilterDto::class;
|
||||
|
||||
/**
|
||||
* Point the handler at the list the request belongs to. Every caller reading teamer
|
||||
* filter settings has to do this first - the mailing included, otherwise it would
|
||||
* resolve its recipients from the other list's filter.
|
||||
*/
|
||||
public function setScope(?string $scope): static
|
||||
{
|
||||
return $this->setNamespace(self::SCOPE_ALL_SEASON === $scope
|
||||
? self::NAMESPACE_ALL_SEASON
|
||||
: self::NAMESPACE_DEFAULT);
|
||||
}
|
||||
|
||||
public function getFilterSettings(): TeamerFilterDto
|
||||
{
|
||||
$filterDto = new $this->modelClass();
|
||||
|
||||
// the bucket the settings live in is what makes a list an all-season list, so the
|
||||
// scope is derived here rather than left to each caller to remember
|
||||
$filterDto->setAllSeason(self::NAMESPACE_ALL_SEASON === $this->namespace);
|
||||
|
||||
if (null === $data = $this->getSession()->get($this->namespace)) {
|
||||
return $filterDto;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Cron;
|
||||
|
||||
use App\Config\EmailTextKey;
|
||||
use App\Config\SeasonCalendar;
|
||||
use App\Email\Mailer;
|
||||
use App\Entity\SeasonAssignmentTask;
|
||||
use App\Repository\SeasonAssignmentTaskRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Service\Season\SeasonAssignmentLabeler;
|
||||
use App\Service\Season\SeasonTaskDeadlineResolver;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* The two reminders around the season checklist: one to the office when a deadline is reached,
|
||||
* one to the teamer while they still owe us something.
|
||||
*
|
||||
* Both are idempotent without recording anything, like UploadReminderService: the office summary
|
||||
* picks out exactly one day per deadline, and the teamer digest picks out exactly one weekday.
|
||||
*
|
||||
* Only "done" stops either of them. An arriving file does not, and neither does the "uploaded"
|
||||
* tick, which is a note for the office and gates nothing. Neither does doneTeamer on the
|
||||
* assignment: that flag is informative only.
|
||||
*/
|
||||
class SeasonTaskReminderService
|
||||
{
|
||||
private const DIGEST_WEEKDAY = 1;
|
||||
|
||||
public function __construct(
|
||||
private readonly SeasonAssignmentTaskRepository $seasonAssignmentTaskRepository,
|
||||
private readonly SeasonTaskDeadlineResolver $deadlineResolver,
|
||||
private readonly SeasonAssignmentLabeler $labeler,
|
||||
private readonly SeasonCalendar $seasonCalendar,
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly Mailer $mailer,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* One mail to the office listing every entry whose deadline is reached today.
|
||||
*
|
||||
* Frist 2 defaults to Frist 1, so the two normally fall on the same day. A row is reported
|
||||
* once in that case and reads as Frist 1: a second, identical line would be noise, and the
|
||||
* office only pushes the second deadline out deliberately.
|
||||
*/
|
||||
public function sendDeadlineSummary(?\DateTimeImmutable $today = null): string
|
||||
{
|
||||
$today = ($today ?? new \DateTimeImmutable('today'))->setTime(0, 0);
|
||||
|
||||
$violations = [];
|
||||
|
||||
foreach ($this->seasonAssignmentTaskRepository->findOpen() as $task) {
|
||||
if (null === $level = $this->levelDueOn($task, $today)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$teamer = (string) $task->getSeasonAssignment()?->getTeamer();
|
||||
|
||||
$violations[$teamer][] = [
|
||||
'task' => $task,
|
||||
'label' => $task->getTask()?->label() ?? $task->getTaskKey(),
|
||||
'level' => $level,
|
||||
];
|
||||
}
|
||||
|
||||
if (0 === $count = count($violations)) {
|
||||
$message = 'No season task deadlines reached today';
|
||||
$this->logger->info($message);
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
// the two roles that carry ROLE_ADMINISTRATIVE: the repository matches roles as they are
|
||||
// stored on the user, and nobody is ever stored with the hierarchy-granted one
|
||||
foreach ($this->userRepository->getUsersByRoles(['ROLE_TEAM_ADMIN', 'ROLE_MANAGER']) as $user) {
|
||||
if (true === $user->isMuteNotifications()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->mailer->createAndSendEmail([
|
||||
'violations' => $violations,
|
||||
], [
|
||||
'to' => $user->getEmail(),
|
||||
'subject' => 'Fristen für Saisonunterlagen erreicht',
|
||||
'template' => 'email/season_deadlines.html.twig',
|
||||
]);
|
||||
}
|
||||
|
||||
$message = 'Sent the season task deadline summary for '.$count.' teamers';
|
||||
$this->logger->info($message);
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* One mail per teamer, every Monday, listing what they still have to send us. It repeats
|
||||
* until an administrative user ticks the entry off; it is not tied to a deadline at all.
|
||||
*/
|
||||
public function sendTeamerDigest(?\DateTimeImmutable $today = null): string
|
||||
{
|
||||
$today = ($today ?? new \DateTimeImmutable('today'))->setTime(0, 0);
|
||||
|
||||
if (self::DIGEST_WEEKDAY !== (int) $today->format('N')) {
|
||||
$message = 'Season task digests are only sent on mondays';
|
||||
$this->logger->info($message);
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
// Scoped to the season being prepared, so a teamer is never chased about a season that
|
||||
// is long over.
|
||||
[$windowStart, $windowEnd] = $this->seasonCalendar->windowFor(
|
||||
$this->seasonCalendar->currentOrUpcomingSeasonYear($today)
|
||||
);
|
||||
|
||||
$open = $this->seasonAssignmentTaskRepository->findOpen(
|
||||
$windowStart->toDateTimeImmutable(),
|
||||
$windowEnd->toDateTimeImmutable()
|
||||
);
|
||||
|
||||
$perTeamer = [];
|
||||
|
||||
foreach ($open as $task) {
|
||||
$seasonAssignment = $task->getSeasonAssignment();
|
||||
|
||||
if (null === $seasonAssignment) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// office-internal entries never reach the teamer
|
||||
if (true !== $task->getTask()?->requiresReturn()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$perTeamer[$seasonAssignment->getId()]['assignment'] = $seasonAssignment;
|
||||
$perTeamer[$seasonAssignment->getId()]['labels'][] = $task->getTask()->label();
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
|
||||
foreach ($perTeamer as $entry) {
|
||||
$teamer = $entry['assignment']->getTeamer();
|
||||
$email = $teamer?->getCommunication()?->getEmail();
|
||||
|
||||
if (null === $email) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->mailer->createAndSendText(EmailTextKey::REMINDER_SEASON_TASKS, [
|
||||
// the placeholder keeps its name: admins can have edited this text in the
|
||||
// database, and renaming it would blank it out in those stored versions
|
||||
'destination' => $this->labeler->label($entry['assignment']),
|
||||
'tasks' => implode(', ', $entry['labels']),
|
||||
], [
|
||||
'to' => $email,
|
||||
]);
|
||||
|
||||
++$count;
|
||||
}
|
||||
|
||||
$message = 'Sent '.$count.' season task digests to teamers';
|
||||
$this->logger->info($message);
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which deadline of this entry falls on $day, or null for none. Frist 1 wins when both do.
|
||||
*/
|
||||
private function levelDueOn(SeasonAssignmentTask $task, \DateTimeImmutable $day): ?int
|
||||
{
|
||||
if ($this->isSameDay($this->deadlineResolver->resolveFirst($task), $day)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->isSameDay($this->deadlineResolver->resolveSecond($task), $day)) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function isSameDay(?\DateTimeImmutable $deadline, \DateTimeImmutable $day): bool
|
||||
{
|
||||
return null !== $deadline && $deadline->format('Y-m-d') === $day->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Season;
|
||||
|
||||
use App\Config\HouseCatalog;
|
||||
use App\Config\SeasonCalendar;
|
||||
use App\Entity\SeasonAssignment;
|
||||
|
||||
/**
|
||||
* How a season assignment names itself - "Saison 2026/27 - Rotbach".
|
||||
*
|
||||
* Not SeasonAssignment::__toString(): the name needs the configured season window and the
|
||||
* configured houses map, neither of which an entity can reach. Everything that shows or logs a
|
||||
* placement goes through here, so the list, the delete dialog, the audit log and the teamer mail
|
||||
* cannot drift apart.
|
||||
*/
|
||||
class SeasonAssignmentLabeler
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SeasonCalendar $seasonCalendar,
|
||||
private readonly HouseCatalog $houseCatalog,
|
||||
) {
|
||||
}
|
||||
|
||||
public function label(SeasonAssignment $seasonAssignment): string
|
||||
{
|
||||
return sprintf(
|
||||
'%s - %s',
|
||||
$this->seasonCalendar->labelFor($seasonAssignment->getDateFrom()),
|
||||
$this->houseLabel($seasonAssignment->getHotelCode())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A code that has since been dropped from the houses map still has to name itself, so it
|
||||
* falls back to the raw code rather than rendering an empty cell.
|
||||
*/
|
||||
public function houseLabel(?string $hotelCode): string
|
||||
{
|
||||
if (null === $hotelCode) {
|
||||
return '?';
|
||||
}
|
||||
|
||||
return $this->houseCatalog->getName($hotelCode) ?? $hotelCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Season;
|
||||
|
||||
use App\Config\SeasonCalendar;
|
||||
use App\Entity\SeasonAssignment;
|
||||
use App\Entity\Teamer;
|
||||
use App\Repository\SeasonAssignmentRepository;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* The season assignment a teamer's own page is about.
|
||||
*
|
||||
* Resolved by season rather than by date range, using SeasonCalendar - the same vocabulary the
|
||||
* statistics screens use - and falling forward to the next season when today is off-season, so
|
||||
* the page stays reachable while the documents for the coming season are already being chased.
|
||||
*
|
||||
* App\Validator\Constraints\SeasonAssignment allows only one per teamer and season, so at most
|
||||
* one is expected here; the earliest is taken and a warning logged for rows that predate the
|
||||
* rule.
|
||||
*/
|
||||
class SeasonAssignmentResolver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SeasonAssignmentRepository $seasonAssignmentRepository,
|
||||
private readonly SeasonCalendar $seasonCalendar,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function resolveFor(Teamer $teamer): ?SeasonAssignment
|
||||
{
|
||||
if (false === $teamer->isAllSeason()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$windowStart, $windowEnd] = $this->seasonCalendar->windowFor(
|
||||
$this->seasonCalendar->currentOrUpcomingSeasonYear()
|
||||
);
|
||||
|
||||
$seasonAssignments = $this->seasonAssignmentRepository->findForTeamerInSeason(
|
||||
$teamer,
|
||||
$windowStart->toDateTimeImmutable(),
|
||||
$windowEnd->toDateTimeImmutable()
|
||||
);
|
||||
|
||||
if (0 === count($seasonAssignments)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (1 < count($seasonAssignments)) {
|
||||
$this->logger->warning('Multiple season assignments resolved for one teamer', [
|
||||
'teamer_id' => $teamer->getId(),
|
||||
'count' => count($seasonAssignments),
|
||||
]);
|
||||
}
|
||||
|
||||
return $seasonAssignments[0];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Season;
|
||||
|
||||
use App\Config\SeasonTask;
|
||||
use App\Config\SeasonTaskProvision;
|
||||
use App\Entity\SeasonAssignment;
|
||||
use App\Entity\SeasonAssignmentTask;
|
||||
use App\Entity\SeasonTaskDocument;
|
||||
use App\Repository\SeasonTaskDocumentRepository;
|
||||
|
||||
/**
|
||||
* Puts one checklist row together: the stored state, the entry it belongs to, both resolved
|
||||
* deadlines and - for an entry handing out a standard document - the library file it points at.
|
||||
*
|
||||
* Shared by the detail page and the per-row htmx toggles, which re-render a single row and have
|
||||
* to arrive at exactly the same one.
|
||||
*/
|
||||
class SeasonChecklistBuilder
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SeasonTaskDeadlineResolver $deadlineResolver,
|
||||
private readonly SeasonTaskDocumentRepository $seasonTaskDocumentRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows in enum order, which is the order the office works through them. Rows whose key no
|
||||
* longer resolves are left out: the entry they belong to was removed from the enum.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function build(SeasonAssignment $seasonAssignment): array
|
||||
{
|
||||
$tasks = [];
|
||||
|
||||
foreach ($seasonAssignment->getTasks() as $task) {
|
||||
if (null !== $definition = $task->getTask()) {
|
||||
$tasks[$definition->value] = $task;
|
||||
}
|
||||
}
|
||||
|
||||
$documents = $this->seasonTaskDocumentRepository->findIndexedByTaskKey();
|
||||
|
||||
$rows = [];
|
||||
|
||||
foreach (SeasonTask::cases() as $case) {
|
||||
if (false === isset($tasks[$case->value])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[] = $this->buildRow($tasks[$case->value], $documents[$case->value] ?? null);
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function buildRow(SeasonAssignmentTask $task, ?SeasonTaskDocument $seasonTaskDocument = null): array
|
||||
{
|
||||
$definition = $task->getTask();
|
||||
|
||||
if (null === $definition) {
|
||||
throw new \InvalidArgumentException(sprintf('Task key "%s" is not defined.', $task->getTaskKey()));
|
||||
}
|
||||
|
||||
if (null === $seasonTaskDocument && SeasonTaskProvision::STANDARD === $definition->provides()) {
|
||||
$seasonTaskDocument = $this->seasonTaskDocumentRepository->findForTask($definition);
|
||||
}
|
||||
|
||||
return [
|
||||
'task' => $task,
|
||||
'definition' => $definition,
|
||||
'deadline1' => $this->deadlineResolver->resolveFirst($task),
|
||||
'deadline2' => $this->deadlineResolver->resolveSecond($task),
|
||||
'document' => $seasonTaskDocument?->getDocument(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Season;
|
||||
|
||||
use App\Entity\SeasonAssignmentTask;
|
||||
|
||||
/**
|
||||
* The two deadlines of a checklist entry.
|
||||
*
|
||||
* Neither is stored: both are computed from the assignment's first day, so moving the placement's
|
||||
* dates moves the deadlines with them instead of leaving them stale. A task row only carries the
|
||||
* overrides, which is what "null" means there.
|
||||
*
|
||||
* Frist 2 defaults to Frist 1 and therefore follows an overridden Frist 1 as well - the office
|
||||
* pushes the second deadline out deliberately or not at all.
|
||||
*
|
||||
* Reminders resolve through this class in PHP rather than reproducing the offset in DQL:
|
||||
* "-1 month" is not invertible day by day (31.03. minus a month is 03.03., and that plus a
|
||||
* month is 03.04.), so a query built by inverting the offset would silently miss tasks.
|
||||
*/
|
||||
class SeasonTaskDeadlineResolver
|
||||
{
|
||||
public function __construct(private readonly string $seasonTaskDeadlineOffset)
|
||||
{
|
||||
}
|
||||
|
||||
public function resolveFirst(SeasonAssignmentTask $task): ?\DateTimeImmutable
|
||||
{
|
||||
if (null !== $override = $task->getDeadlineOverride1()) {
|
||||
return $override;
|
||||
}
|
||||
|
||||
return $this->defaultDeadline($task);
|
||||
}
|
||||
|
||||
public function resolveSecond(SeasonAssignmentTask $task): ?\DateTimeImmutable
|
||||
{
|
||||
if (null !== $override = $task->getDeadlineOverride2()) {
|
||||
return $override;
|
||||
}
|
||||
|
||||
return $this->resolveFirst($task);
|
||||
}
|
||||
|
||||
/**
|
||||
* Null only when the assignment has no start date yet, which the create form does not
|
||||
* allow but a half-built object in memory can still be in.
|
||||
*/
|
||||
private function defaultDeadline(SeasonAssignmentTask $task): ?\DateTimeImmutable
|
||||
{
|
||||
$dateFrom = $task->getSeasonAssignment()?->getDateFrom();
|
||||
|
||||
return $dateFrom?->modify($this->seasonTaskDeadlineOffset);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Season;
|
||||
|
||||
use App\Config\SeasonTask;
|
||||
use App\Entity\SeasonAssignment;
|
||||
use App\Entity\SeasonAssignmentTask;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* Brings a season assignment's checklist rows in line with the entries defined in
|
||||
* App\Config\SeasonTask.
|
||||
*
|
||||
* Called on write paths only - after an assignment is created, and at the top of the
|
||||
* administrative detail controller before it renders - never lazily during an arbitrary GET, so
|
||||
* no read path ends up flushing.
|
||||
*
|
||||
* It only ever creates. A case removed from the enum leaves its row behind: every read resolves
|
||||
* the stored key through SeasonTask::tryFrom() and skips what no longer exists, which keeps a
|
||||
* removed entry from erroring out and keeps its history recoverable.
|
||||
*/
|
||||
class SeasonTaskSynchronizer
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $entityManager)
|
||||
{
|
||||
}
|
||||
|
||||
public function sync(SeasonAssignment $seasonAssignment): void
|
||||
{
|
||||
$existing = [];
|
||||
|
||||
foreach ($seasonAssignment->getTasks() as $task) {
|
||||
$existing[$task->getTaskKey()] = true;
|
||||
}
|
||||
|
||||
$created = false;
|
||||
|
||||
foreach (SeasonTask::cases() as $case) {
|
||||
if (true === isset($existing[$case->value])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$task = (new SeasonAssignmentTask())->setTaskKey($case->value);
|
||||
|
||||
$seasonAssignment->addTask($task);
|
||||
$this->entityManager->persist($task);
|
||||
|
||||
$created = true;
|
||||
}
|
||||
|
||||
if (true === $created) {
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ class AppExtension extends AbstractExtension
|
||||
new TwigFilter('format_rating', [AppRuntime::class, 'formatRating'], ['is_safe' => ['html']]),
|
||||
new TwigFilter('license_label', [AppRuntime::class, 'licenseLabel']),
|
||||
new TwigFilter('teamer_status_label', [AppRuntime::class, 'teamerStatusLabel']),
|
||||
new TwigFilter('season_assignment_label', [AppRuntime::class, 'seasonAssignmentLabel']),
|
||||
new TwigFilter('house_label', [AppRuntime::class, 'houseLabel']),
|
||||
new TwigFilter('bpn_country_label', [AppRuntime::class, 'bpnCountryLabel']),
|
||||
new TwigFilter('bpn_pickup_label', [AppRuntime::class, 'bpnPickupLabel']),
|
||||
new TwigFilter('upload_status_badge', [AppRuntime::class, 'renderUploadStatusBadge'], ['is_safe' => ['html']]),
|
||||
|
||||
+35
-1
@@ -4,8 +4,10 @@ namespace App\Twig;
|
||||
|
||||
use App\BusProNet\DataProvider\CountryDataProvider;
|
||||
use App\BusProNet\DataProvider\PickupDataProvider;
|
||||
use App\Entity\SeasonAssignment;
|
||||
use App\Entity\Teamer;
|
||||
use App\Entity\Upload;
|
||||
use App\Service\Season\SeasonAssignmentLabeler;
|
||||
use App\Service\Teamer\PickupResolver;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
@@ -23,11 +25,43 @@ class AppRuntime implements RuntimeExtensionInterface
|
||||
private readonly CountryDataProvider $countries,
|
||||
private readonly PickupDataProvider $pickups,
|
||||
private readonly PickupResolver $pickupResolver,
|
||||
private readonly SeasonAssignmentLabeler $seasonAssignmentLabeler,
|
||||
private readonly string $environment,
|
||||
) {
|
||||
}
|
||||
|
||||
public function teamerStatusLabel(Teamer $teamer): string
|
||||
public function seasonAssignmentLabel(SeasonAssignment $seasonAssignment): string
|
||||
{
|
||||
return $this->seasonAssignmentLabeler->label($seasonAssignment);
|
||||
}
|
||||
|
||||
/**
|
||||
* The house name for a stored hotel code. Only the code is stored, so every template that
|
||||
* shows a house resolves it here rather than freezing a name into the record.
|
||||
*/
|
||||
public function houseLabel(?string $hotelCode): string
|
||||
{
|
||||
return $this->seasonAssignmentLabeler->houseLabel($hotelCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* All-season is a flag on top of the status, not a status of its own - a teamer can be a
|
||||
* Bestandsteamer:in and an all-season teamer at once. The general Teamübersicht is the only
|
||||
* place that overlap is not otherwise visible, so the marker is appended there; the
|
||||
* dedicated Saisonteamer list passes false, where repeating it on every row says nothing.
|
||||
*/
|
||||
public function teamerStatusLabel(Teamer $teamer, bool $includeAllSeason = true): string
|
||||
{
|
||||
$label = $this->baseTeamerStatusLabel($teamer);
|
||||
|
||||
if (true === $includeAllSeason && true === $teamer->isAllSeason()) {
|
||||
return sprintf('%s · Saison', $label);
|
||||
}
|
||||
|
||||
return $label;
|
||||
}
|
||||
|
||||
private function baseTeamerStatusLabel(Teamer $teamer): string
|
||||
{
|
||||
$labelItems = [];
|
||||
$user = $teamer->getUser();
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Validator\Constraints;
|
||||
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
|
||||
#[\Attribute]
|
||||
class SeasonAssignment extends Constraint
|
||||
{
|
||||
public string $message = 'Für diese:n Teamer:in existiert in dieser Saison bereits ein Saisoneinsatz';
|
||||
|
||||
public function getTargets(): array|string
|
||||
{
|
||||
return static::CLASS_CONSTRAINT;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Validator\Constraints;
|
||||
|
||||
use App\Config\SeasonCalendar;
|
||||
use App\Repository\SeasonAssignmentRepository;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidator;
|
||||
|
||||
/**
|
||||
* One season assignment per teamer per season.
|
||||
*
|
||||
* "Once a year" was a stated fact rather than an enforced one; this is what enforces it, on
|
||||
* create and on edit alike, so the teamer's season view can rely on resolving a single
|
||||
* assignment.
|
||||
*/
|
||||
class SeasonAssignmentValidator extends ConstraintValidator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SeasonAssignmentRepository $seasonAssignmentRepository,
|
||||
private readonly SeasonCalendar $seasonCalendar,
|
||||
) {
|
||||
}
|
||||
|
||||
public function validate(mixed $value, Constraint $constraint): void
|
||||
{
|
||||
/** @var \App\Entity\SeasonAssignment $seasonAssignment */
|
||||
$seasonAssignment = $value;
|
||||
|
||||
// An assignment the office has laid out but not staffed yet is a normal working state,
|
||||
// and a missing start date is the create form's own problem to report.
|
||||
if (null === $teamer = $seasonAssignment->getTeamer()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (null === $dateFrom = $seasonAssignment->getDateFrom()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A placement starting outside any season window belongs to no season, so there is
|
||||
// nothing to be the second of.
|
||||
if (null === $seasonYear = $this->seasonCalendar->seasonYearFor($dateFrom)) {
|
||||
return;
|
||||
}
|
||||
|
||||
[$windowStart, $windowEnd] = $this->seasonCalendar->windowFor($seasonYear);
|
||||
|
||||
$existing = $this->seasonAssignmentRepository->countInSeasonWindow(
|
||||
$teamer,
|
||||
$windowStart->toDateTimeImmutable(),
|
||||
$windowEnd->toDateTimeImmutable(),
|
||||
$seasonAssignment->getId()
|
||||
);
|
||||
|
||||
if (0 < $existing) {
|
||||
$this
|
||||
->context
|
||||
->buildViolation($constraint->message)
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user