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
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Drops the season assignment's done_teamer flag.
|
||||
*
|
||||
* It was a note the office set by hand and it gated nothing - neither the weekly teamer digest
|
||||
* nor the office deadline summary ever read it. Both stages it was meant to express follow from
|
||||
* the checklist rows, so App\Service\Season\SeasonChecklistBuilder derives them instead.
|
||||
*
|
||||
* Written by hand rather than taken from doctrine:migrations:diff, like Version20260916160000:
|
||||
* the diff against this database also carries the pre-existing drift in datetime and JSON column
|
||||
* declarations, which has nothing to do with this change.
|
||||
*/
|
||||
final class Version20260917120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Drop the informative done_teamer flag from season assignments';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE season_assignment DROP done_teamer');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// The hand-set notes themselves cannot be recovered, so the column comes back unset.
|
||||
$this->addSql('ALTER TABLE season_assignment ADD done_teamer TINYINT(1) NOT NULL');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Renames the checklist entry's uploaded flag to office_done.
|
||||
*
|
||||
* The flag is the office's own step on an entry, and that step differs per entry - filing a
|
||||
* returned contract is not the same act as registering someone with the municipality. Named
|
||||
* after a file arriving, it read as a second kind of "done" everywhere else, next to the done
|
||||
* flag that actually closes the entry.
|
||||
*
|
||||
* A rename, not a new column: the ticks themselves stay.
|
||||
*/
|
||||
final class Version20260917123000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Rename season_assignment_task.uploaded to office_done';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE season_assignment_task CHANGE uploaded office_done TINYINT(1) NOT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE season_assignment_task CHANGE office_done uploaded TINYINT(1) NOT NULL');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Copies the library document onto the checklist row.
|
||||
*
|
||||
* The library holds what the next assignments get and is replaced from season to season, so
|
||||
* resolving it on read rewrote what an assignment that already exists - a finished one included -
|
||||
* hands out. The row carries its own reference from now on.
|
||||
*
|
||||
* The backfill maps every existing row to what it resolves to today, so nothing changes for the
|
||||
* rows that are already out there. ON DELETE SET NULL keeps a row readable when the library file
|
||||
* itself is deleted: "kein Dokument hinterlegt" is a state every view already handles.
|
||||
*/
|
||||
final class Version20260917140000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Give each season checklist row its own document reference';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE season_assignment_task ADD document_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE season_assignment_task ADD CONSTRAINT FK_BEEB9D8DC33F7837 FOREIGN KEY (document_id) REFERENCES upload (id) ON DELETE SET NULL');
|
||||
$this->addSql('CREATE INDEX IDX_BEEB9D8DC33F7837 ON season_assignment_task (document_id)');
|
||||
|
||||
$this->addSql('UPDATE season_assignment_task t JOIN season_task_document d ON d.task_key = t.task_key SET t.document_id = d.document_id');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE season_assignment_task DROP FOREIGN KEY FK_BEEB9D8DC33F7837');
|
||||
$this->addSql('DROP INDEX IDX_BEEB9D8DC33F7837 ON season_assignment_task');
|
||||
$this->addSql('ALTER TABLE season_assignment_task DROP document_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Config;
|
||||
|
||||
/**
|
||||
* How far the office has worked through one season assignment's checklist.
|
||||
*
|
||||
* Derived from the rows by App\Service\Season\SeasonChecklistBuilder, never stored: the two
|
||||
* completed stages follow from the entries themselves, so a column would only be a second,
|
||||
* hand-maintained copy of what the rows already say.
|
||||
*/
|
||||
enum SeasonChecklistStatus: string
|
||||
{
|
||||
case OPEN = 'open';
|
||||
|
||||
/**
|
||||
* Everything the teamer has to send back has arrived and been ticked off, so the weekly
|
||||
* digest has nothing left to chase. Office-internal entries can still be open.
|
||||
*/
|
||||
case RETURNS_COMPLETE = 'returns_complete';
|
||||
|
||||
case COMPLETE = 'complete';
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -77,14 +77,6 @@ class SeasonAssignment implements BlameableEntityInterface, TimestampableEntityI
|
||||
#[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>
|
||||
*/
|
||||
@@ -186,18 +178,6 @@ class SeasonAssignment implements BlameableEntityInterface, TimestampableEntityI
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isDoneTeamer(): bool
|
||||
{
|
||||
return $this->doneTeamer;
|
||||
}
|
||||
|
||||
public function setDoneTeamer(bool $doneTeamer): static
|
||||
{
|
||||
$this->doneTeamer = $doneTeamer;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, SeasonAssignmentTask>
|
||||
*/
|
||||
|
||||
@@ -61,10 +61,31 @@ class SeasonAssignmentTask implements BlameableEntityInterface, TimestampableEnt
|
||||
private ?string $doneBy = null;
|
||||
|
||||
/**
|
||||
* Purely informational: it records that a usable file arrived and gates nothing at all.
|
||||
* The office's own step on this entry, ticked by an administrative user. Purely
|
||||
* informational: it gates nothing at all, and only $done closes the entry.
|
||||
*
|
||||
* Named for who ticks it rather than for what is ticked, because the step differs per entry:
|
||||
* filing a returned contract is not the same act as registering someone with the
|
||||
* municipality, and a name taken from one of those reads as a second $done on the others.
|
||||
*/
|
||||
#[ORM\Column]
|
||||
private bool $uploaded = false;
|
||||
private bool $officeDone = false;
|
||||
|
||||
/**
|
||||
* The document this entry hands out, copied from the library when the row was created.
|
||||
*
|
||||
* A snapshot rather than a live lookup: the library holds what the *next* assignments get,
|
||||
* and it is replaced from season to season, so resolving it on read would rewrite what a
|
||||
* finished assignment handed out years ago. An already laid out season that should still get
|
||||
* a newly deposited file is served by the propagate action instead, which is deliberate and
|
||||
* leaves completed work alone.
|
||||
*
|
||||
* SET NULL rather than a guard on the library: deleting the file leaves the row readable and
|
||||
* showing "kein Dokument hinterlegt", which is a state the views already handle.
|
||||
*/
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\JoinColumn(onDelete: 'SET NULL')]
|
||||
private ?Upload $document = null;
|
||||
|
||||
/**
|
||||
* Null means "not overridden" - the deadlines are computed on read by
|
||||
@@ -166,14 +187,26 @@ class SeasonAssignmentTask implements BlameableEntityInterface, TimestampableEnt
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isUploaded(): bool
|
||||
public function isOfficeDone(): bool
|
||||
{
|
||||
return $this->uploaded;
|
||||
return $this->officeDone;
|
||||
}
|
||||
|
||||
public function setUploaded(bool $uploaded): static
|
||||
public function setOfficeDone(bool $officeDone): static
|
||||
{
|
||||
$this->uploaded = $uploaded;
|
||||
$this->officeDone = $officeDone;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDocument(): ?Upload
|
||||
{
|
||||
return $this->document;
|
||||
}
|
||||
|
||||
public function setDocument(?Upload $document): static
|
||||
{
|
||||
$this->document = $document;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -5,21 +5,38 @@ namespace App\Form;
|
||||
use App\Entity\SeasonTaskDocument;
|
||||
use App\Entity\Upload;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* Either an existing library document or a newly uploaded one.
|
||||
*
|
||||
* The upload itself does not travel in the form - it goes through the dropzone into the upload
|
||||
* session, like everywhere else in this app - so the controller decides which of the two the
|
||||
* mapping ends up with: a dropped file wins over the pick, and a submit carrying neither is
|
||||
* refused.
|
||||
*/
|
||||
class SeasonTaskDocumentType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('document', AutocompleteEntityType::class, [
|
||||
'label' => 'Dokument',
|
||||
'label' => 'Vorhandenes Dokument',
|
||||
'required' => false,
|
||||
'class' => Upload::class,
|
||||
'label_property' => 'originalFilename',
|
||||
'label_function' => fn (Upload $upload) => $upload->getDisplayName() ?? $upload->getOriginalFilename(),
|
||||
'endpoint_route' => 'app_admin_autocomplete_document',
|
||||
])
|
||||
// Unmapped: it names the uploaded file, which is an Upload and not part of the
|
||||
// mapping. Renaming a picked document would rename it for the whole library.
|
||||
->add('displayName', TextType::class, [
|
||||
'label' => 'angezeigter Name der neuen Datei',
|
||||
'required' => false,
|
||||
'mapped' => false,
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,47 @@ class SeasonAssignmentTaskRepository extends ServiceEntityRepository
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows a newly deposited library document may still be copied onto: the entry is not
|
||||
* ticked off yet and its assignment has not ended.
|
||||
*
|
||||
* A finished entry keeps what it handed out - that is the whole point of the row carrying its
|
||||
* own document - and so does an assignment that is over, whether or not every entry on it was
|
||||
* ticked.
|
||||
*
|
||||
* @return SeasonAssignmentTask[]
|
||||
*/
|
||||
public function findOpenForTask(string $taskKey, \DateTimeImmutable $today): array
|
||||
{
|
||||
return $this
|
||||
->createOpenForTaskQueryBuilder($taskKey, $today)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracted like createOpenQueryBuilder() below, so a test can pin the conditions by
|
||||
* compiling the DQL rather than by persisting rows.
|
||||
*/
|
||||
public function createOpenForTaskQueryBuilder(string $taskKey, \DateTimeImmutable $today): QueryBuilder
|
||||
{
|
||||
$qb = $this->createQueryBuilder('task');
|
||||
|
||||
return $qb
|
||||
->innerJoin('task.seasonAssignment', 'season_assignment')
|
||||
->where($qb->expr()->andX(
|
||||
$qb->expr()->eq('task.taskKey', ':taskKey'),
|
||||
$qb->expr()->eq('task.done', ':done'),
|
||||
$qb->expr()->gte('season_assignment.dateTo', ':today'),
|
||||
$qb->expr()->isNull('season_assignment.deletedAt')
|
||||
))
|
||||
->setParameter('taskKey', $taskKey)
|
||||
->setParameter('done', false)
|
||||
->setParameter('today', $today)
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
||||
@@ -18,9 +18,8 @@ use Psr\Log\LoggerInterface;
|
||||
* 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.
|
||||
* Only "done" stops either of them. An arriving file does not, and neither does the "office done"
|
||||
* tick, which is a note for the office and gates nothing.
|
||||
*/
|
||||
class SeasonTaskReminderService
|
||||
{
|
||||
|
||||
@@ -2,26 +2,26 @@
|
||||
|
||||
namespace App\Service\Season;
|
||||
|
||||
use App\Config\SeasonChecklistStatus;
|
||||
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.
|
||||
* deadlines and the document the row was given when it was created.
|
||||
*
|
||||
* 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.
|
||||
* The document comes off the row, never out of the library: the library is what the next
|
||||
* assignments get, and replacing a file there must not change what an assignment that already
|
||||
* exists hands out.
|
||||
*
|
||||
* Shared by the detail page and the htmx toggle, which re-renders the checklist after a tick and
|
||||
* has to arrive at exactly the same rows.
|
||||
*/
|
||||
class SeasonChecklistBuilder
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SeasonTaskDeadlineResolver $deadlineResolver,
|
||||
private readonly SeasonTaskDocumentRepository $seasonTaskDocumentRepository,
|
||||
) {
|
||||
public function __construct(private readonly SeasonTaskDeadlineResolver $deadlineResolver)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,8 +40,6 @@ class SeasonChecklistBuilder
|
||||
}
|
||||
}
|
||||
|
||||
$documents = $this->seasonTaskDocumentRepository->findIndexedByTaskKey();
|
||||
|
||||
$rows = [];
|
||||
|
||||
foreach (SeasonTask::cases() as $case) {
|
||||
@@ -49,16 +47,49 @@ class SeasonChecklistBuilder
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[] = $this->buildRow($tasks[$case->value], $documents[$case->value] ?? null);
|
||||
$rows[] = $this->buildRow($tasks[$case->value]);
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* How far the checklist has been worked through. Rows whose key no longer resolves are
|
||||
* skipped here just like in build(): the entry they belong to was removed from the enum, and
|
||||
* counting it would leave the assignment permanently unfinished.
|
||||
*/
|
||||
public function status(SeasonAssignment $seasonAssignment): SeasonChecklistStatus
|
||||
{
|
||||
$open = 0;
|
||||
$openWithReturn = 0;
|
||||
|
||||
foreach ($seasonAssignment->getTasks() as $task) {
|
||||
if (null === $definition = $task->getTask()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($task->isDone()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
++$open;
|
||||
|
||||
if ($definition->requiresReturn()) {
|
||||
++$openWithReturn;
|
||||
}
|
||||
}
|
||||
|
||||
return match (true) {
|
||||
0 === $open => SeasonChecklistStatus::COMPLETE,
|
||||
0 === $openWithReturn => SeasonChecklistStatus::RETURNS_COMPLETE,
|
||||
default => SeasonChecklistStatus::OPEN,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function buildRow(SeasonAssignmentTask $task, ?SeasonTaskDocument $seasonTaskDocument = null): array
|
||||
private function buildRow(SeasonAssignmentTask $task): array
|
||||
{
|
||||
$definition = $task->getTask();
|
||||
|
||||
@@ -66,16 +97,12 @@ class SeasonChecklistBuilder
|
||||
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(),
|
||||
'document' => $task->getDocument(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,10 @@
|
||||
namespace App\Service\Season;
|
||||
|
||||
use App\Config\SeasonTask;
|
||||
use App\Config\SeasonTaskProvision;
|
||||
use App\Entity\SeasonAssignment;
|
||||
use App\Entity\SeasonAssignmentTask;
|
||||
use App\Repository\SeasonTaskDocumentRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
@@ -18,11 +20,18 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
* 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.
|
||||
*
|
||||
* Creating is also the only point at which the library is read: the document is copied onto the
|
||||
* row and the office tick set with it. Both are a starting state, not a rule, so neither is
|
||||
* undone by a later sync - a deposited file reaches an assignment that already exists only
|
||||
* through the propagate action.
|
||||
*/
|
||||
class SeasonTaskSynchronizer
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $entityManager)
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly SeasonTaskDocumentRepository $seasonTaskDocumentRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
public function sync(SeasonAssignment $seasonAssignment): void
|
||||
@@ -33,23 +42,42 @@ class SeasonTaskSynchronizer
|
||||
$existing[$task->getTaskKey()] = true;
|
||||
}
|
||||
|
||||
$created = false;
|
||||
$missing = [];
|
||||
|
||||
foreach (SeasonTask::cases() as $case) {
|
||||
if (true === isset($existing[$case->value])) {
|
||||
continue;
|
||||
if (false === isset($existing[$case->value])) {
|
||||
$missing[] = $case;
|
||||
}
|
||||
}
|
||||
|
||||
if ([] === $missing) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Queried once, and only when there is something to create: sync() runs on every render
|
||||
// of the administrative detail page, where nothing is usually missing.
|
||||
$documents = $this->seasonTaskDocumentRepository->findIndexedByTaskKey();
|
||||
|
||||
foreach ($missing as $case) {
|
||||
$task = (new SeasonAssignmentTask())->setTaskKey($case->value);
|
||||
|
||||
// The library holds what the next assignments get and is replaced from season to
|
||||
// season, so the file is copied onto the row here instead of being resolved on every
|
||||
// read. What an assignment hands out is decided once, when it is laid out.
|
||||
if (SeasonTaskProvision::STANDARD === $case->provides() && true === isset($documents[$case->value])) {
|
||||
// The office step is covered the moment the row has its document: there is
|
||||
// nothing left to put together for this teamer. The checkbox stays, so it can
|
||||
// be unticked.
|
||||
$task
|
||||
->setDocument($documents[$case->value]->getDocument())
|
||||
->setOfficeDone(true)
|
||||
;
|
||||
}
|
||||
|
||||
$seasonAssignment->addTask($task);
|
||||
$this->entityManager->persist($task);
|
||||
|
||||
$created = true;
|
||||
}
|
||||
|
||||
if (true === $created) {
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{% set type = type|default('info') %}
|
||||
|
||||
<div class="{{ html_classes('p-4 mb-4 flex items-start space-x-2 text-gray-100 rounded-md', { 'bg-blue-600': type == 'info', 'bg-red-600': type == 'warning' }) }}">
|
||||
{{ icon('alert', 'w-5 h-5 flex-shrink-0') }}
|
||||
<div class="{{ html_classes('p-4 mb-4 flex items-start space-x-2 text-gray-100 rounded-md', { 'bg-blue-600': type == 'info', 'bg-red-600': type == 'warning', 'bg-green-600': type == 'success' }) }}">
|
||||
{{ icon(type == 'success' ? 'check' : 'alert', 'w-5 h-5 flex-shrink-0') }}
|
||||
<div class="leading-relaxed text-base">
|
||||
{% block message %}
|
||||
{{ message | nl2br }}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{# Table and status box together, swapped as one by the row toggles - hence the id and the
|
||||
outerHTML swap on the checkboxes inside _task_row.html.twig.
|
||||
|
||||
The box cannot be swapped out of band alongside a single <tr>: htmx re-parses a response that
|
||||
starts with a row as "<table><tbody>" + response, and the HTML parser foster-parents any
|
||||
non-table element out of that tbody again, so the box would be dropped without an error. #}
|
||||
<div id="season-checklist">
|
||||
<div class="data-table-wrapper overflow-x-auto">
|
||||
<table class="w-full text-left">
|
||||
<thead>
|
||||
<tr class="border-b-2 border-gray-300">
|
||||
<th class="py-2 pr-4">Dokument/ToDo</th>
|
||||
<th class="py-2 pr-4">Frist 1</th>
|
||||
<th class="py-2 pr-4">Frist 2</th>
|
||||
<th class="py-2 pr-4 text-center">ToDo</th>
|
||||
<th class="py-2 pr-4 text-center">erledigt</th>
|
||||
<th class="py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in checklist %}
|
||||
{% include 'administrative/season_assignment/_task_row.html.twig' with { 'row': row } only %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% include 'administrative/season_assignment/_checklist_status.html.twig' with { 'checklistStatus': checklistStatus } only %}
|
||||
</div>
|
||||
@@ -0,0 +1,21 @@
|
||||
{# Derived from every row at once, so it is re-rendered with the whole checklist rather than on
|
||||
its own. Nothing is shown while entries are open: the table sits right above. #}
|
||||
{% if checklistStatus == constant('App\\Config\\SeasonChecklistStatus::COMPLETE') %}
|
||||
<div class="pt-4">
|
||||
{% embed '_partials/_alert.html.twig' with { 'type': 'success' } only %}
|
||||
{% block message %}
|
||||
<span class="font-bold">Alle ToDos erledigt.</span>
|
||||
Teamer:in ist vollständig erfasst.
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
</div>
|
||||
{% elseif checklistStatus == constant('App\\Config\\SeasonChecklistStatus::RETURNS_COMPLETE') %}
|
||||
<div class="pt-4">
|
||||
{% embed '_partials/_alert.html.twig' with { 'type': 'info' } only %}
|
||||
{% block message %}
|
||||
<span class="font-bold">Teamerdokumente vollständig.</span>
|
||||
Der wöchentliche Reminder ist deaktiviert.
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -1,17 +0,0 @@
|
||||
{# Swapped in place by its own route, so the id and the outerHTML swap have to stay. #}
|
||||
<div id="season-done-teamer" class="flex items-start space-x-2">
|
||||
<input type="checkbox"
|
||||
id="season-done-teamer-input"
|
||||
class="mt-1 text-primary"
|
||||
{{ seasonAssignment.doneTeamer ? 'checked' : '' }}
|
||||
hx-post="{{ path('app_administrative_season_assignment_done_teamer', { 'uuid': seasonAssignment.uuid }) }}"
|
||||
hx-target="#season-done-teamer"
|
||||
hx-swap="outerHTML">
|
||||
<label for="season-done-teamer-input">
|
||||
<span class="font-bold">Teamer:in erledigt</span>
|
||||
<span class="block text-sm text-gray-500">
|
||||
Reiner Vermerk, die wöchentliche Erinnerung läuft weiter,
|
||||
solange ein Punkt der Checkliste mit Rückgabe offen ist.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -1,6 +1,7 @@
|
||||
{# One checklist row. Rendered inside the table on the detail page and again on its own by the
|
||||
toggle route, which swaps exactly this element - hence the id and the outerHTML swap. #}
|
||||
<tr id="season-task-{{ row.task.uuid }}" class="border-b border-gray-200">
|
||||
{# One checklist row. The toggles swap the whole checklist rather than this row: the status box
|
||||
below the table is derived from every row at once, so a row-only swap would leave it a stage
|
||||
behind. #}
|
||||
<tr class="border-b border-gray-200">
|
||||
<td class="py-2 pr-4 align-top">
|
||||
<div class="font-bold">{{ row.definition.label }}</div>
|
||||
{% if row.definition.provides == constant('App\\Config\\SeasonTaskProvision::STANDARD') %}
|
||||
@@ -31,10 +32,10 @@
|
||||
<td class="py-2 pr-4 align-top text-center">
|
||||
<input type="checkbox"
|
||||
class="text-primary"
|
||||
{{ row.task.uploaded ? 'checked' : '' }}
|
||||
aria-label="Dokument erhalten"
|
||||
hx-post="{{ path('app_administrative_season_assignment_task_toggle', { 'uuid': row.task.uuid, 'field': 'uploaded' }) }}"
|
||||
hx-target="#season-task-{{ row.task.uuid }}"
|
||||
{{ row.task.officeDone ? 'checked' : '' }}
|
||||
aria-label="Büro erledigt"
|
||||
hx-post="{{ path('app_administrative_season_assignment_task_toggle', { 'uuid': row.task.uuid, 'field': 'office_done' }) }}"
|
||||
hx-target="#season-checklist"
|
||||
hx-swap="outerHTML">
|
||||
</td>
|
||||
<td class="py-2 pr-4 align-top text-center">
|
||||
@@ -43,7 +44,7 @@
|
||||
{{ row.task.done ? 'checked' : '' }}
|
||||
aria-label="Erledigt"
|
||||
hx-post="{{ path('app_administrative_season_assignment_task_toggle', { 'uuid': row.task.uuid, 'field': 'done' }) }}"
|
||||
hx-target="#season-task-{{ row.task.uuid }}"
|
||||
hx-target="#season-checklist"
|
||||
hx-swap="outerHTML">
|
||||
{% if row.task.done and row.task.doneAt is not null %}
|
||||
<div class="text-xs text-gray-500 whitespace-nowrap">
|
||||
|
||||
@@ -82,32 +82,7 @@
|
||||
<h2 class="text-xl font-bold pb-2">
|
||||
Checkliste
|
||||
</h2>
|
||||
<div class="data-table-wrapper overflow-x-auto">
|
||||
<table class="w-full text-left">
|
||||
<thead>
|
||||
<tr class="border-b-2 border-gray-300">
|
||||
<th class="py-2 pr-4">Dokument/ToDo</th>
|
||||
<th class="py-2 pr-4">Frist 1</th>
|
||||
<th class="py-2 pr-4">Frist 2</th>
|
||||
<th class="py-2 pr-4 text-center">erhalten</th>
|
||||
<th class="py-2 pr-4 text-center">erledigt</th>
|
||||
<th class="py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in checklist %}
|
||||
{% include 'administrative/season_assignment/_task_row.html.twig' with { 'row': row } only %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="pt-2 text-sm text-gray-500">
|
||||
Nur <em>erledigt</em> schließt einen Punkt ab und beendet die Erinnerungen.
|
||||
<em>erhalten</em> ist ein reiner Vermerk.
|
||||
</p>
|
||||
<div class="pt-4">
|
||||
{% include 'administrative/season_assignment/_done_teamer.html.twig' %}
|
||||
</div>
|
||||
{% include 'administrative/season_assignment/_checklist.html.twig' %}
|
||||
</div>
|
||||
|
||||
<div class="pb-8">
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
Saisonteam-Dokumente
|
||||
</h1>
|
||||
<p class="pb-8 text-gray-500">
|
||||
Auswahl der Standarddokumente für das Saisonteam.
|
||||
Auswahl der Standarddokumente für das Saisonteam. Ein hier hinterlegtes Dokument gilt für
|
||||
Saisoneinsätze, die ab dann angelegt werden; bestehende Einsätze behalten ihre Datei und
|
||||
können es über {{ icon('refresh', 'w-4 h-4 inline-block') }} nachträglich erhalten.
|
||||
</p>
|
||||
<div class="data-table-wrapper">
|
||||
<div class="data-table-wrapper__inner">
|
||||
@@ -35,6 +37,13 @@
|
||||
<td>
|
||||
<div class="flex items-center space-x-2 justify-end">
|
||||
{% if row.document is not null %}
|
||||
<button type="button"
|
||||
title="auf offene Einsätze übertragen"
|
||||
hx-get="{{ path('app_administrative_system_season_task_document_propagate', { 'uuid': row.document.uuid }) }}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend">
|
||||
{{ icon('refresh') }}
|
||||
</button>
|
||||
<button type="button"
|
||||
class="text-red-500"
|
||||
hx-get="{{ path('app_administrative_system_season_task_document_delete', { 'uuid': row.document.uuid }) }}"
|
||||
|
||||
@@ -3,11 +3,32 @@
|
||||
{% block title %}Dokument für {{ task.label }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ form_start(form, { 'attr': { 'hx-post': app.request.uri, 'hx-target': '#htmx-modal', 'hx-swap': 'outerHTML' } }) }}
|
||||
<div class="flex flex-col space-y-4 pb-8">
|
||||
{% set formAttr = stimulus_controller('form-upload-guard')|stimulus_action('form-upload-guard', 'guard', 'submit') %}
|
||||
{{ form_start(form, { 'attr': { 'hx-post': app.request.uri, 'hx-target': '#htmx-modal', 'hx-swap': 'outerHTML' }|merge(formAttr.toArray()) }) }}
|
||||
{{ form_errors(form) }}
|
||||
<div class="pb-4 text-gray-500">
|
||||
Das Dokument gilt für Saisoneinsätze, die ab jetzt angelegt werden. Bestehende Einsätze
|
||||
behalten ihre Datei - sie lässt sich von der Übersicht aus auf offene Einsätze übertragen.
|
||||
</div>
|
||||
<div class="flex flex-col space-y-4 pb-4">
|
||||
{{ form_row(form.document) }}
|
||||
</div>
|
||||
<button type="submit" class="btn">
|
||||
<div class="pb-4">
|
||||
<div class="font-bold">oder neue Datei hochladen</div>
|
||||
<div class="pb-2 text-sm text-gray-500">
|
||||
Eine hochgeladene Datei ersetzt die Auswahl oben.
|
||||
</div>
|
||||
{% include '_partials/_upload_collection_form.html.twig' with {
|
||||
'endpoint_upload': path('_uploader_upload_document'),
|
||||
'max_filesize': 5,
|
||||
'max_files': 1,
|
||||
'accepted_files': 'application/pdf',
|
||||
} %}
|
||||
</div>
|
||||
<div class="flex flex-col space-y-4 pb-8">
|
||||
{{ form_row(form.displayName) }}
|
||||
</div>
|
||||
<button type="submit" class="btn" {{ stimulus_target('form-upload-guard', 'submit') }}>
|
||||
Speichern
|
||||
</button>
|
||||
{{ form_rest(form) }}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{% extends 'htmx_modal.html.twig' %}
|
||||
|
||||
{% block title %}Dokument übertragen{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="pb-4">
|
||||
Soll <em>{{ seasonTaskDocument.document.displayName ?? seasonTaskDocument.document.originalFilename }}</em>
|
||||
auf bestehende Saisoneinsätze übertragen werden?
|
||||
</div>
|
||||
{% if tasks | length > 0 %}
|
||||
<div class="pb-4">
|
||||
Betroffen sind <strong>{{ tasks | length }}</strong> offene Einsätze bei
|
||||
<em>{{ seasonTaskDocument.task.label }}</em>. Bereits erledigte Punkte und beendete
|
||||
Einsätze behalten ihr bisheriges Dokument.
|
||||
</div>
|
||||
<ul class="pb-8 divide-y divide-gray-200">
|
||||
{% for task in tasks %}
|
||||
<li class="py-2">
|
||||
{{ task.seasonAssignment | season_assignment_label }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<form hx-post="{{ path('app_administrative_system_season_task_document_propagate', { 'uuid': seasonTaskDocument.uuid }) }}"
|
||||
hx-target="#htmx-modal"
|
||||
hx-swap="outerHTML">
|
||||
<button type="submit" class="btn">
|
||||
übertragen
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="pb-4 text-gray-500">
|
||||
Es gibt keinen offenen Einsatz, auf den das Dokument übertragen werden könnte.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -57,6 +57,30 @@ class SeasonAssignmentTaskRepositoryTest extends KernelTestCase
|
||||
$this->assertStringContainsString('season_assignment.dateFrom <= :windowEnd', $scoped);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the propagate action may still rewrite: an entry that is not ticked off, on an
|
||||
* assignment that has not ended. A finished entry keeps the document it handed out.
|
||||
*/
|
||||
public function testPropagationSkipsTickedEntriesAndEndedAssignments(): void
|
||||
{
|
||||
/** @var EntityManagerInterface $entityManager */
|
||||
$entityManager = static::getContainer()->get(EntityManagerInterface::class);
|
||||
|
||||
/** @var SeasonAssignmentTaskRepository $repository */
|
||||
$repository = $entityManager->getRepository(SeasonAssignmentTask::class);
|
||||
|
||||
$dql = $repository
|
||||
->createOpenForTaskQueryBuilder('contract', new \DateTimeImmutable('2026-11-01'))
|
||||
->getQuery()
|
||||
->getDQL()
|
||||
;
|
||||
|
||||
$this->assertStringContainsString('task.taskKey = :taskKey', $dql);
|
||||
$this->assertStringContainsString('task.done = :done', $dql);
|
||||
$this->assertStringContainsString('season_assignment.dateTo >= :today', $dql);
|
||||
$this->assertStringContainsString('season_assignment.deletedAt IS NULL', $dql);
|
||||
}
|
||||
|
||||
private function createOpenQueryBuilder(
|
||||
?\DateTimeImmutable $windowStart = null,
|
||||
?\DateTimeImmutable $windowEnd = null,
|
||||
|
||||
@@ -185,21 +185,6 @@ class SeasonTaskReminderServiceTest extends TestCase
|
||||
$this->service->sendTeamerDigest($this->monday());
|
||||
}
|
||||
|
||||
/**
|
||||
* The flag is the office's own note and silences nothing: only ticking the entry off does.
|
||||
*/
|
||||
public function testDoneTeamerDoesNotSuppressTheDigest(): void
|
||||
{
|
||||
$task = $this->task('2026-11-01');
|
||||
$task->getSeasonAssignment()->setDoneTeamer(true);
|
||||
|
||||
$this->repository->method('findOpen')->willReturn([$task]);
|
||||
|
||||
$this->mailer->expects($this->once())->method('createAndSendText');
|
||||
|
||||
$this->service->sendTeamerDigest($this->monday());
|
||||
}
|
||||
|
||||
private function monday(): \DateTimeImmutable
|
||||
{
|
||||
// 2026-10-05 is a monday, and inside the season the digest is scoped to
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service\Season;
|
||||
|
||||
use App\Config\SeasonTask;
|
||||
use App\Entity\SeasonAssignment;
|
||||
use App\Entity\SeasonAssignmentTask;
|
||||
use App\Entity\Upload;
|
||||
use App\Service\Season\SeasonChecklistBuilder;
|
||||
use App\Service\Season\SeasonTaskDeadlineResolver;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* The row hands out what it was given, not what the library holds right now - that is what keeps
|
||||
* a finished assignment from being rewritten when next season's file is deposited.
|
||||
*/
|
||||
class SeasonChecklistBuilderTest extends TestCase
|
||||
{
|
||||
public function testTheRowHandsOutItsOwnDocument(): void
|
||||
{
|
||||
$document = new Upload();
|
||||
|
||||
$seasonAssignment = new SeasonAssignment();
|
||||
$seasonAssignment->addTask(
|
||||
(new SeasonAssignmentTask())
|
||||
->setTaskKey(SeasonTask::MASTER_DATA->value)
|
||||
->setDocument($document)
|
||||
);
|
||||
|
||||
$builder = new SeasonChecklistBuilder($this->createMock(SeasonTaskDeadlineResolver::class));
|
||||
|
||||
$rows = $builder->build($seasonAssignment);
|
||||
|
||||
$this->assertCount(1, $rows);
|
||||
$this->assertSame($document, $rows[0]['document']);
|
||||
}
|
||||
|
||||
public function testARowWithoutADocumentIsBuiltAllTheSame(): void
|
||||
{
|
||||
$seasonAssignment = new SeasonAssignment();
|
||||
$seasonAssignment->addTask(
|
||||
(new SeasonAssignmentTask())->setTaskKey(SeasonTask::MASTER_DATA->value)
|
||||
);
|
||||
|
||||
$builder = new SeasonChecklistBuilder($this->createMock(SeasonTaskDeadlineResolver::class));
|
||||
|
||||
$rows = $builder->build($seasonAssignment);
|
||||
|
||||
$this->assertCount(1, $rows);
|
||||
$this->assertNull($rows[0]['document']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service\Season;
|
||||
|
||||
use App\Config\SeasonChecklistStatus;
|
||||
use App\Config\SeasonTask;
|
||||
use App\Entity\SeasonAssignment;
|
||||
use App\Entity\SeasonAssignmentTask;
|
||||
use App\Service\Season\SeasonChecklistBuilder;
|
||||
use App\Service\Season\SeasonTaskDeadlineResolver;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* The status the detail page shows instead of the removed done_teamer flag. It is derived, so
|
||||
* the whole point is which combination of rows produces which stage - no database needed.
|
||||
*/
|
||||
class SeasonChecklistStatusTest extends TestCase
|
||||
{
|
||||
private SeasonChecklistBuilder $builder;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->builder = new SeasonChecklistBuilder($this->createMock(SeasonTaskDeadlineResolver::class));
|
||||
}
|
||||
|
||||
public function testItIsOpenWhileAnEntryRequiringAReturnIsOpen(): void
|
||||
{
|
||||
$seasonAssignment = $this->assignmentWithAllTasks();
|
||||
|
||||
$this->assertSame(SeasonChecklistStatus::OPEN, $this->builder->status($seasonAssignment));
|
||||
}
|
||||
|
||||
public function testItIsOpenWhenOnlyOfficeInternalEntriesAreDone(): void
|
||||
{
|
||||
$seasonAssignment = $this->assignmentWithAllTasks();
|
||||
|
||||
$this->markDone($seasonAssignment, static fn (SeasonTask $task): bool => false === $task->requiresReturn());
|
||||
|
||||
$this->assertSame(SeasonChecklistStatus::OPEN, $this->builder->status($seasonAssignment));
|
||||
}
|
||||
|
||||
public function testReturnsAreCompleteWhileAnOfficeInternalEntryIsOpen(): void
|
||||
{
|
||||
$seasonAssignment = $this->assignmentWithAllTasks();
|
||||
|
||||
$this->markDone($seasonAssignment, static fn (SeasonTask $task): bool => $task->requiresReturn());
|
||||
|
||||
$this->assertSame(SeasonChecklistStatus::RETURNS_COMPLETE, $this->builder->status($seasonAssignment));
|
||||
}
|
||||
|
||||
public function testItIsCompleteOnceEveryEntryIsDone(): void
|
||||
{
|
||||
$seasonAssignment = $this->assignmentWithAllTasks();
|
||||
|
||||
$this->markDone($seasonAssignment, static fn (SeasonTask $task): bool => true);
|
||||
|
||||
$this->assertSame(SeasonChecklistStatus::COMPLETE, $this->builder->status($seasonAssignment));
|
||||
}
|
||||
|
||||
/**
|
||||
* A row of an entry that was removed from the enum would otherwise keep the assignment
|
||||
* unfinished forever, so it is skipped here just like everywhere else.
|
||||
*/
|
||||
public function testItIgnoresRowsWhoseEntryNoLongerExists(): void
|
||||
{
|
||||
$seasonAssignment = $this->assignmentWithAllTasks();
|
||||
|
||||
$this->markDone($seasonAssignment, static fn (SeasonTask $task): bool => true);
|
||||
|
||||
$seasonAssignment->addTask((new SeasonAssignmentTask())->setTaskKey('removed_entry'));
|
||||
|
||||
$this->assertSame(SeasonChecklistStatus::COMPLETE, $this->builder->status($seasonAssignment));
|
||||
}
|
||||
|
||||
private function assignmentWithAllTasks(): SeasonAssignment
|
||||
{
|
||||
$seasonAssignment = new SeasonAssignment();
|
||||
|
||||
foreach (SeasonTask::cases() as $case) {
|
||||
$seasonAssignment->addTask((new SeasonAssignmentTask())->setTaskKey($case->value));
|
||||
}
|
||||
|
||||
return $seasonAssignment;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(SeasonTask): bool $matches
|
||||
*/
|
||||
private function markDone(SeasonAssignment $seasonAssignment, callable $matches): void
|
||||
{
|
||||
foreach ($seasonAssignment->getTasks() as $task) {
|
||||
if (null !== $definition = $task->getTask()) {
|
||||
$task->setDone($matches($definition));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@ namespace App\Tests\Service\Season;
|
||||
use App\Config\SeasonTask;
|
||||
use App\Entity\SeasonAssignment;
|
||||
use App\Entity\SeasonAssignmentTask;
|
||||
use App\Entity\SeasonTaskDocument;
|
||||
use App\Entity\Upload;
|
||||
use App\Repository\SeasonTaskDocumentRepository;
|
||||
use App\Service\Season\SeasonTaskSynchronizer;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
@@ -20,12 +23,18 @@ use PHPUnit\Framework\TestCase;
|
||||
class SeasonTaskSynchronizerTest extends TestCase
|
||||
{
|
||||
private EntityManagerInterface&MockObject $entityManager;
|
||||
private SeasonTaskDocumentRepository&MockObject $seasonTaskDocumentRepository;
|
||||
private SeasonTaskSynchronizer $synchronizer;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$this->synchronizer = new SeasonTaskSynchronizer($this->entityManager);
|
||||
$this->seasonTaskDocumentRepository = $this->createMock(SeasonTaskDocumentRepository::class);
|
||||
|
||||
$this->synchronizer = new SeasonTaskSynchronizer(
|
||||
$this->entityManager,
|
||||
$this->seasonTaskDocumentRepository
|
||||
);
|
||||
}
|
||||
|
||||
public function testItCreatesOneRowPerEntryInEnumOrder(): void
|
||||
@@ -89,6 +98,109 @@ class SeasonTaskSynchronizerTest extends TestCase
|
||||
$this->assertContains('entry_removed_from_the_enum', $this->keysOf($seasonAssignment));
|
||||
}
|
||||
|
||||
public function testAnEntryHandingOutADepositedDocumentStartsTickedForTheOffice(): void
|
||||
{
|
||||
$this->depositDocumentsFor(SeasonTask::MASTER_DATA);
|
||||
|
||||
$seasonAssignment = new SeasonAssignment();
|
||||
|
||||
$this->synchronizer->sync($seasonAssignment);
|
||||
|
||||
$this->assertSame([SeasonTask::MASTER_DATA->value], $this->officeDoneKeysOf($seasonAssignment));
|
||||
}
|
||||
|
||||
/**
|
||||
* The row keeps the file it was given. Replacing the library document later is what the next
|
||||
* season gets, and what the propagate action carries over by hand - never a read of this row.
|
||||
*/
|
||||
public function testTheDocumentIsCopiedOntoTheRow(): void
|
||||
{
|
||||
$document = new Upload();
|
||||
|
||||
$this->depositDocuments([SeasonTask::MASTER_DATA], $document);
|
||||
|
||||
$seasonAssignment = new SeasonAssignment();
|
||||
|
||||
$this->synchronizer->sync($seasonAssignment);
|
||||
|
||||
foreach ($seasonAssignment->getTasks() as $task) {
|
||||
$this->assertSame(
|
||||
SeasonTask::MASTER_DATA->value === $task->getTaskKey() ? $document : null,
|
||||
$task->getDocument(),
|
||||
sprintf('Entry "%s" was given the wrong document.', $task->getTaskKey())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The entry hands a document out, it is just not deposited yet - so the office still has to
|
||||
* put it together and the row starts open.
|
||||
*/
|
||||
public function testAnEntryWithoutADepositedDocumentStartsOpen(): void
|
||||
{
|
||||
$this->depositDocumentsFor();
|
||||
|
||||
$seasonAssignment = new SeasonAssignment();
|
||||
|
||||
$this->synchronizer->sync($seasonAssignment);
|
||||
|
||||
$this->assertSame([], $this->officeDoneKeysOf($seasonAssignment));
|
||||
}
|
||||
|
||||
public function testThePreTickNeverOverwritesAnExistingRow(): void
|
||||
{
|
||||
$this->depositDocumentsFor(SeasonTask::MASTER_DATA);
|
||||
|
||||
$seasonAssignment = new SeasonAssignment();
|
||||
$seasonAssignment->addTask(
|
||||
(new SeasonAssignmentTask())->setTaskKey(SeasonTask::MASTER_DATA->value)
|
||||
);
|
||||
|
||||
$this->synchronizer->sync($seasonAssignment);
|
||||
|
||||
$this->assertSame(
|
||||
[],
|
||||
$this->officeDoneKeysOf($seasonAssignment),
|
||||
'The office unticked this row, and the next sync has to leave it alone.'
|
||||
);
|
||||
}
|
||||
|
||||
private function depositDocumentsFor(SeasonTask ...$tasks): void
|
||||
{
|
||||
$this->depositDocuments($tasks, new Upload());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param SeasonTask[] $tasks
|
||||
*/
|
||||
private function depositDocuments(array $tasks, Upload $document): void
|
||||
{
|
||||
$documents = [];
|
||||
|
||||
foreach ($tasks as $task) {
|
||||
$documents[$task->value] = (new SeasonTaskDocument())
|
||||
->setTaskKey($task->value)
|
||||
->setDocument($document)
|
||||
;
|
||||
}
|
||||
|
||||
$this->seasonTaskDocumentRepository->method('findIndexedByTaskKey')->willReturn($documents);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private function officeDoneKeysOf(SeasonAssignment $seasonAssignment): array
|
||||
{
|
||||
return array_values(array_map(
|
||||
static fn (SeasonAssignmentTask $task): string => $task->getTaskKey(),
|
||||
array_filter(
|
||||
$seasonAssignment->getTasks()->toArray(),
|
||||
static fn (SeasonAssignmentTask $task): bool => $task->isOfficeDone()
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user