feat: additional functionality and layout adjustments for season teamers
This commit is contained in:
1 parent
80c6016da3
commit
4cab57d136
30 files changed
+916
-192
No files matched your search
@@ -33,6 +33,7 @@ class DetailController extends AbstractController
|
||||
return $this->render('administrative/season_assignment/detail.html.twig', [
|
||||
'seasonAssignment' => $seasonAssignment,
|
||||
'checklist' => $this->checklistBuilder->build($seasonAssignment),
|
||||
'checklistStatus' => $this->checklistBuilder->status($seasonAssignment),
|
||||
'returnUrl' => $this->getReturnUrl($request, 'app_administrative_season_assignment_index'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
<?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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -12,16 +12,19 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Ticks one checklist entry, re-rendering that single row.
|
||||
* Ticks one checklist entry and re-renders the checklist.
|
||||
*
|
||||
* Deliberately one request per row rather than one form for the whole checklist: the office
|
||||
* Deliberately one request per tick 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.
|
||||
* would let two people working the same assignment overwrite each other's ticks. The answer
|
||||
* still carries the whole checklist, because the status box below the table is derived from
|
||||
* every row at once - and a tick re-rendered from the database also picks up what a colleague
|
||||
* has ticked in the meantime.
|
||||
*/
|
||||
class TaskToggleController extends AbstractController
|
||||
{
|
||||
private const FIELD_DONE = 'done';
|
||||
private const FIELD_UPLOADED = 'uploaded';
|
||||
private const FIELD_OFFICE_DONE = 'office_done';
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
@@ -35,7 +38,7 @@ class TaskToggleController extends AbstractController
|
||||
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'],
|
||||
requirements: ['field' => 'done|office_done'],
|
||||
methods: ['POST']
|
||||
)]
|
||||
#[IsGranted('ROLE_ADMINISTRATIVE')]
|
||||
@@ -43,7 +46,7 @@ class TaskToggleController extends AbstractController
|
||||
{
|
||||
// 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)) {
|
||||
if (false === in_array($field, [self::FIELD_DONE, self::FIELD_OFFICE_DONE], true)) {
|
||||
throw $this->createNotFoundException(sprintf('Unknown checklist field "%s".', $field));
|
||||
}
|
||||
|
||||
@@ -58,7 +61,7 @@ class TaskToggleController extends AbstractController
|
||||
->setDoneBy(true === $done ? $this->getUser()?->getUserIdentifier() : null)
|
||||
;
|
||||
} else {
|
||||
$task->setUploaded(false === $task->isUploaded());
|
||||
$task->setOfficeDone(false === $task->isOfficeDone());
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
@@ -68,11 +71,18 @@ class TaskToggleController extends AbstractController
|
||||
'teamer' => $task->getSeasonAssignment()?->getTeamer()?->getFullName(),
|
||||
'task' => $task->getTaskKey(),
|
||||
'field' => $field,
|
||||
'value' => self::FIELD_DONE === $field ? $task->isDone() : $task->isUploaded(),
|
||||
'value' => self::FIELD_DONE === $field ? $task->isDone() : $task->isOfficeDone(),
|
||||
]);
|
||||
|
||||
return $this->render('administrative/season_assignment/_task_row.html.twig', [
|
||||
'row' => $this->checklistBuilder->buildRow($task),
|
||||
$seasonAssignment = $task->getSeasonAssignment();
|
||||
|
||||
if (null === $seasonAssignment) {
|
||||
throw $this->createNotFoundException('The checklist entry has no season assignment.');
|
||||
}
|
||||
|
||||
return $this->render('administrative/season_assignment/_checklist.html.twig', [
|
||||
'checklist' => $this->checklistBuilder->build($seasonAssignment),
|
||||
'checklistStatus' => $this->checklistBuilder->status($seasonAssignment),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,18 @@ namespace App\Controller\Administrative\System\SeasonTaskDocument;
|
||||
use App\Config\SeasonTask;
|
||||
use App\Config\SeasonTaskProvision;
|
||||
use App\Entity\SeasonTaskDocument;
|
||||
use App\Entity\Upload;
|
||||
use App\Entity\User;
|
||||
use App\Form\SeasonTaskDocumentType;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use App\Repository\SeasonTaskDocumentRepository;
|
||||
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\Form\FormError;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
@@ -19,13 +25,23 @@ 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.
|
||||
*
|
||||
* The file can be picked from the library or uploaded here, in which case it joins the library as
|
||||
* an ordinary Upload::TYPE_DOCUMENT - so next season it can simply be picked. An uploaded file
|
||||
* wins over the pick: the picker already shows the mapping's current document, so a new file is
|
||||
* always the more recent statement of intent.
|
||||
*
|
||||
* Changing the mapping changes what the *next* assignments get. It never reaches an assignment
|
||||
* that already exists - that is what the propagate action is for.
|
||||
*/
|
||||
class EditController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SeasonTaskDocumentRepository $seasonTaskDocumentRepository,
|
||||
private readonly UploadHandler $uploadHandler,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly FeatureManagerInterface $featureManager,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -48,6 +64,23 @@ class EditController extends AbstractController
|
||||
$form = $this->createForm(SeasonTaskDocumentType::class, $seasonTaskDocument);
|
||||
$form->handleRequest($request);
|
||||
|
||||
$uploaded = null;
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$dropped = $this->uploadHandler->getUploadSession()->getUploadsByType(Upload::TYPE_DOCUMENT);
|
||||
|
||||
// A dropped file wins over the pick. Editing an existing mapping shows its current
|
||||
// document in the picker, so having to clear that first to replace it would be a
|
||||
// detour through a field the user is not talking about.
|
||||
if (false === $dropped->isEmpty()) {
|
||||
$uploaded = $this->storeUploadedDocument($form);
|
||||
|
||||
$seasonTaskDocument->setDocument($uploaded);
|
||||
} elseif (null === $seasonTaskDocument->getDocument()) {
|
||||
$form->addError(new FormError('Bitte wähle ein Dokument aus oder lade eine Datei hoch'));
|
||||
}
|
||||
}
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
if (null === $seasonTaskDocument->getId()) {
|
||||
$this->entityManager->persist($seasonTaskDocument);
|
||||
@@ -58,6 +91,7 @@ class EditController extends AbstractController
|
||||
$this->logger->info('Change season task document', [
|
||||
'task' => $task->value,
|
||||
'document_id' => $seasonTaskDocument->getDocument()?->getId(),
|
||||
'uploaded' => null !== $uploaded,
|
||||
]);
|
||||
|
||||
$this->addFlash('success', 'Das Dokument wurde hinterlegt');
|
||||
@@ -72,4 +106,40 @@ class EditController extends AbstractController
|
||||
'task' => $task,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The dropped file, turned into a library document. It carries Upload::TYPE_DOCUMENT like
|
||||
* everything under Dokumente, so next season it is simply picked from the autocomplete.
|
||||
*
|
||||
* The dropzone takes one file, and a second one would be a user error rather than a case to
|
||||
* model, so only the first is kept and the rest of the session entry is dropped with it.
|
||||
*/
|
||||
private function storeUploadedDocument(FormInterface $form): Upload
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
$uploadSession = $this->uploadHandler->getUploadSession();
|
||||
|
||||
$upload = Upload::fromUploadDto(
|
||||
$uploadSession->getUploadsByType(Upload::TYPE_DOCUMENT)->first(),
|
||||
$user,
|
||||
Upload::TYPE_DOCUMENT
|
||||
);
|
||||
|
||||
if (null !== $displayName = $form->get('displayName')->getData()) {
|
||||
$upload->setDisplayName($displayName);
|
||||
}
|
||||
|
||||
$this->entityManager->persist($upload);
|
||||
|
||||
$this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_DOCUMENT, $uploadSession);
|
||||
$this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_DOCUMENT);
|
||||
|
||||
if (true === $this->featureManager->isActive('sanitize_uploads')) {
|
||||
$this->uploadHandler->ensurePdf($upload);
|
||||
}
|
||||
|
||||
return $upload;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Administrative\System\SeasonTaskDocument;
|
||||
|
||||
use App\Entity\SeasonTaskDocument;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use App\Repository\SeasonAssignmentTaskRepository;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Copies the mapped document onto the checklist rows of assignments that already exist.
|
||||
*
|
||||
* A checklist row carries the document it was given when it was created, so depositing a new file
|
||||
* changes nothing for a season that was already laid out. That is deliberate for finished work
|
||||
* and unhelpful for a season still being prepared - this action closes exactly that gap, by hand
|
||||
* and with the count shown before it runs.
|
||||
*
|
||||
* Scoped to entries that are not ticked off, on assignments that have not ended: what an entry
|
||||
* already handed out is history and is not rewritten.
|
||||
*
|
||||
* officeDone is left alone. It is the office's own note, an untick is deliberate, and a document
|
||||
* changing underneath is not an office action.
|
||||
*/
|
||||
class PropagateController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SeasonAssignmentTaskRepository $seasonAssignmentTaskRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route(
|
||||
path: '/administrative/system/season-task-document/propagate/{uuid}',
|
||||
name: 'app_administrative_system_season_task_document_propagate'
|
||||
)]
|
||||
#[IsGranted('ROLE_ADMINISTRATIVE')]
|
||||
public function index(SeasonTaskDocument $seasonTaskDocument, Request $request): Response
|
||||
{
|
||||
$tasks = $this->seasonAssignmentTaskRepository->findOpenForTask(
|
||||
$seasonTaskDocument->getTaskKey(),
|
||||
new \DateTimeImmutable('today')
|
||||
);
|
||||
|
||||
if (true === $request->isMethod('POST')) {
|
||||
foreach ($tasks as $task) {
|
||||
$task->setDocument($seasonTaskDocument->getDocument());
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->logger->info('Propagate season task document', [
|
||||
'task' => $seasonTaskDocument->getTaskKey(),
|
||||
'document_id' => $seasonTaskDocument->getDocument()?->getId(),
|
||||
'count' => count($tasks),
|
||||
]);
|
||||
|
||||
$this->addFlash(
|
||||
'success',
|
||||
1 === count($tasks)
|
||||
? 'Das Dokument wurde auf einen offenen Einsatz übertragen'
|
||||
: sprintf('Das Dokument wurde auf %d offene Einsätze übertragen', count($tasks))
|
||||
);
|
||||
|
||||
return new HxRedirectResponse(
|
||||
$this->generateUrl('app_administrative_system_season_task_document_index')
|
||||
);
|
||||
}
|
||||
|
||||
return $this->render('administrative/system/season_task_document/modal_propagate.html.twig', [
|
||||
'seasonTaskDocument' => $seasonTaskDocument,
|
||||
'tasks' => $tasks,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user