From 4cab57d1369baa19db750a3f223dc114ff6a6d01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Thu, 17 Sep 2026 14:03:01 +0200 Subject: [PATCH] feat: additional functionality and layout adjustments for season teamers --- migrations/Version20260917120000.php | 38 ++++++ migrations/Version20260917123000.php | 36 ++++++ migrations/Version20260917140000.php | 43 +++++++ src/Config/SeasonChecklistStatus.php | 23 ++++ .../SeasonAssignment/DetailController.php | 1 + .../SeasonAssignment/DoneTeamerController.php | 50 -------- .../SeasonAssignment/TaskToggleController.php | 30 +++-- .../SeasonTaskDocument/EditController.php | 70 +++++++++++ .../PropagateController.php | 81 +++++++++++++ src/Entity/SeasonAssignment.php | 20 --- src/Entity/SeasonAssignmentTask.php | 45 ++++++- src/Form/SeasonTaskDocumentType.php | 19 ++- .../SeasonAssignmentTaskRepository.php | 41 +++++++ .../Cron/SeasonTaskReminderService.php | 5 +- src/Service/Season/SeasonChecklistBuilder.php | 65 +++++++--- src/Service/Season/SeasonTaskSynchronizer.php | 48 ++++++-- templates/_partials/_alert.html.twig | 4 +- .../season_assignment/_checklist.html.twig | 28 +++++ .../_checklist_status.html.twig | 21 ++++ .../season_assignment/_done_teamer.html.twig | 17 --- .../season_assignment/_task_row.html.twig | 17 +-- .../season_assignment/detail.html.twig | 27 +---- .../season_task_document/index.html.twig | 11 +- .../season_task_document/modal_edit.html.twig | 27 ++++- .../modal_propagate.html.twig | 35 ++++++ .../SeasonAssignmentTaskRepositoryTest.php | 24 ++++ .../Cron/SeasonTaskReminderServiceTest.php | 15 --- .../Season/SeasonChecklistBuilderTest.php | 54 +++++++++ .../Season/SeasonChecklistStatusTest.php | 99 +++++++++++++++ .../Season/SeasonTaskSynchronizerTest.php | 114 +++++++++++++++++- 30 files changed, 916 insertions(+), 192 deletions(-) create mode 100644 migrations/Version20260917120000.php create mode 100644 migrations/Version20260917123000.php create mode 100644 migrations/Version20260917140000.php create mode 100644 src/Config/SeasonChecklistStatus.php delete mode 100644 src/Controller/Administrative/SeasonAssignment/DoneTeamerController.php create mode 100644 src/Controller/Administrative/System/SeasonTaskDocument/PropagateController.php create mode 100644 templates/administrative/season_assignment/_checklist.html.twig create mode 100644 templates/administrative/season_assignment/_checklist_status.html.twig delete mode 100644 templates/administrative/season_assignment/_done_teamer.html.twig create mode 100644 templates/administrative/system/season_task_document/modal_propagate.html.twig create mode 100644 tests/Service/Season/SeasonChecklistBuilderTest.php create mode 100644 tests/Service/Season/SeasonChecklistStatusTest.php diff --git a/migrations/Version20260917120000.php b/migrations/Version20260917120000.php new file mode 100644 index 0000000..11727af --- /dev/null +++ b/migrations/Version20260917120000.php @@ -0,0 +1,38 @@ +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'); + } +} diff --git a/migrations/Version20260917123000.php b/migrations/Version20260917123000.php new file mode 100644 index 0000000..22914f3 --- /dev/null +++ b/migrations/Version20260917123000.php @@ -0,0 +1,36 @@ +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'); + } +} diff --git a/migrations/Version20260917140000.php b/migrations/Version20260917140000.php new file mode 100644 index 0000000..5705c61 --- /dev/null +++ b/migrations/Version20260917140000.php @@ -0,0 +1,43 @@ +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'); + } +} diff --git a/src/Config/SeasonChecklistStatus.php b/src/Config/SeasonChecklistStatus.php new file mode 100644 index 0000000..ce43e6d --- /dev/null +++ b/src/Config/SeasonChecklistStatus.php @@ -0,0 +1,23 @@ +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'), ]); } diff --git a/src/Controller/Administrative/SeasonAssignment/DoneTeamerController.php b/src/Controller/Administrative/SeasonAssignment/DoneTeamerController.php deleted file mode 100644 index dc664fd..0000000 --- a/src/Controller/Administrative/SeasonAssignment/DoneTeamerController.php +++ /dev/null @@ -1,50 +0,0 @@ -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, - ]); - } -} diff --git a/src/Controller/Administrative/SeasonAssignment/TaskToggleController.php b/src/Controller/Administrative/SeasonAssignment/TaskToggleController.php index c8d8c57..e1ae547 100644 --- a/src/Controller/Administrative/SeasonAssignment/TaskToggleController.php +++ b/src/Controller/Administrative/SeasonAssignment/TaskToggleController.php @@ -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), ]); } } diff --git a/src/Controller/Administrative/System/SeasonTaskDocument/EditController.php b/src/Controller/Administrative/System/SeasonTaskDocument/EditController.php index 1a4aaac..7fbad78 100644 --- a/src/Controller/Administrative/System/SeasonTaskDocument/EditController.php +++ b/src/Controller/Administrative/System/SeasonTaskDocument/EditController.php @@ -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; + } } diff --git a/src/Controller/Administrative/System/SeasonTaskDocument/PropagateController.php b/src/Controller/Administrative/System/SeasonTaskDocument/PropagateController.php new file mode 100644 index 0000000..f8636fc --- /dev/null +++ b/src/Controller/Administrative/System/SeasonTaskDocument/PropagateController.php @@ -0,0 +1,81 @@ +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, + ]); + } +} diff --git a/src/Entity/SeasonAssignment.php b/src/Entity/SeasonAssignment.php index 20d16a6..97d9b41 100644 --- a/src/Entity/SeasonAssignment.php +++ b/src/Entity/SeasonAssignment.php @@ -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 */ @@ -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 */ diff --git a/src/Entity/SeasonAssignmentTask.php b/src/Entity/SeasonAssignmentTask.php index c8787a5..73c9bc3 100644 --- a/src/Entity/SeasonAssignmentTask.php +++ b/src/Entity/SeasonAssignmentTask.php @@ -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; } diff --git a/src/Form/SeasonTaskDocumentType.php b/src/Form/SeasonTaskDocumentType.php index b1bccb6..13dfad9 100644 --- a/src/Form/SeasonTaskDocumentType.php +++ b/src/Form/SeasonTaskDocumentType.php @@ -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, + ]) ; } diff --git a/src/Repository/SeasonAssignmentTaskRepository.php b/src/Repository/SeasonAssignmentTaskRepository.php index db25e5a..b6d689b 100644 --- a/src/Repository/SeasonAssignmentTaskRepository.php +++ b/src/Repository/SeasonAssignmentTaskRepository.php @@ -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. diff --git a/src/Service/Cron/SeasonTaskReminderService.php b/src/Service/Cron/SeasonTaskReminderService.php index f497902..9c7aab0 100644 --- a/src/Service/Cron/SeasonTaskReminderService.php +++ b/src/Service/Cron/SeasonTaskReminderService.php @@ -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 { diff --git a/src/Service/Season/SeasonChecklistBuilder.php b/src/Service/Season/SeasonChecklistBuilder.php index 16bf9a2..4394ee2 100644 --- a/src/Service/Season/SeasonChecklistBuilder.php +++ b/src/Service/Season/SeasonChecklistBuilder.php @@ -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 */ - 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(), ]; } } diff --git a/src/Service/Season/SeasonTaskSynchronizer.php b/src/Service/Season/SeasonTaskSynchronizer.php index 3b3f016..ea3a0cd 100644 --- a/src/Service/Season/SeasonTaskSynchronizer.php +++ b/src/Service/Season/SeasonTaskSynchronizer.php @@ -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(); } } diff --git a/templates/_partials/_alert.html.twig b/templates/_partials/_alert.html.twig index a680fcd..afa82b5 100644 --- a/templates/_partials/_alert.html.twig +++ b/templates/_partials/_alert.html.twig @@ -1,7 +1,7 @@ {% set type = type|default('info') %} -
- {{ icon('alert', 'w-5 h-5 flex-shrink-0') }} +
+ {{ icon(type == 'success' ? 'check' : 'alert', 'w-5 h-5 flex-shrink-0') }}
{% block message %} {{ message | nl2br }} diff --git a/templates/administrative/season_assignment/_checklist.html.twig b/templates/administrative/season_assignment/_checklist.html.twig new file mode 100644 index 0000000..c6ed874 --- /dev/null +++ b/templates/administrative/season_assignment/_checklist.html.twig @@ -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 : htmx re-parses a response that + starts with a row as "" + 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. #} +
+
+
+ + + + + + + + + + + + {% for row in checklist %} + {% include 'administrative/season_assignment/_task_row.html.twig' with { 'row': row } only %} + {% endfor %} + +
Dokument/ToDoFrist 1Frist 2ToDoerledigt
+
+ {% include 'administrative/season_assignment/_checklist_status.html.twig' with { 'checklistStatus': checklistStatus } only %} +
diff --git a/templates/administrative/season_assignment/_checklist_status.html.twig b/templates/administrative/season_assignment/_checklist_status.html.twig new file mode 100644 index 0000000..8d45229 --- /dev/null +++ b/templates/administrative/season_assignment/_checklist_status.html.twig @@ -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') %} +
+ {% embed '_partials/_alert.html.twig' with { 'type': 'success' } only %} + {% block message %} + Alle ToDos erledigt. + Teamer:in ist vollständig erfasst. + {% endblock %} + {% endembed %} +
+{% elseif checklistStatus == constant('App\\Config\\SeasonChecklistStatus::RETURNS_COMPLETE') %} +
+ {% embed '_partials/_alert.html.twig' with { 'type': 'info' } only %} + {% block message %} + Teamerdokumente vollständig. + Der wöchentliche Reminder ist deaktiviert. + {% endblock %} + {% endembed %} +
+{% endif %} diff --git a/templates/administrative/season_assignment/_done_teamer.html.twig b/templates/administrative/season_assignment/_done_teamer.html.twig deleted file mode 100644 index 162a774..0000000 --- a/templates/administrative/season_assignment/_done_teamer.html.twig +++ /dev/null @@ -1,17 +0,0 @@ -{# Swapped in place by its own route, so the id and the outerHTML swap have to stay. #} -
- - -
diff --git a/templates/administrative/season_assignment/_task_row.html.twig b/templates/administrative/season_assignment/_task_row.html.twig index a3f2e18..86b7e8a 100644 --- a/templates/administrative/season_assignment/_task_row.html.twig +++ b/templates/administrative/season_assignment/_task_row.html.twig @@ -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. #} - +{# 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. #} +
{{ row.definition.label }}
{% if row.definition.provides == constant('App\\Config\\SeasonTaskProvision::STANDARD') %} @@ -31,10 +32,10 @@ @@ -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 %}
diff --git a/templates/administrative/season_assignment/detail.html.twig b/templates/administrative/season_assignment/detail.html.twig index f90a05e..a4e7c88 100644 --- a/templates/administrative/season_assignment/detail.html.twig +++ b/templates/administrative/season_assignment/detail.html.twig @@ -82,32 +82,7 @@

Checkliste

-
- - - - - - - - - - - - - {% for row in checklist %} - {% include 'administrative/season_assignment/_task_row.html.twig' with { 'row': row } only %} - {% endfor %} - -
Dokument/ToDoFrist 1Frist 2erhaltenerledigt
-
-

- Nur erledigt schließt einen Punkt ab und beendet die Erinnerungen. - erhalten ist ein reiner Vermerk. -

-
- {% include 'administrative/season_assignment/_done_teamer.html.twig' %} -
+ {% include 'administrative/season_assignment/_checklist.html.twig' %}
diff --git a/templates/administrative/system/season_task_document/index.html.twig b/templates/administrative/system/season_task_document/index.html.twig index 1ee9e13..47e433d 100644 --- a/templates/administrative/system/season_task_document/index.html.twig +++ b/templates/administrative/system/season_task_document/index.html.twig @@ -7,7 +7,9 @@ Saisonteam-Dokumente

- 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.

@@ -35,6 +37,13 @@
{% if row.document is not null %} + {{ form_rest(form) }} diff --git a/templates/administrative/system/season_task_document/modal_propagate.html.twig b/templates/administrative/system/season_task_document/modal_propagate.html.twig new file mode 100644 index 0000000..3ac859d --- /dev/null +++ b/templates/administrative/system/season_task_document/modal_propagate.html.twig @@ -0,0 +1,35 @@ +{% extends 'htmx_modal.html.twig' %} + +{% block title %}Dokument übertragen{% endblock %} + +{% block content %} +
+ Soll {{ seasonTaskDocument.document.displayName ?? seasonTaskDocument.document.originalFilename }} + auf bestehende Saisoneinsätze übertragen werden? +
+ {% if tasks | length > 0 %} +
+ Betroffen sind {{ tasks | length }} offene Einsätze bei + {{ seasonTaskDocument.task.label }}. Bereits erledigte Punkte und beendete + Einsätze behalten ihr bisheriges Dokument. +
+
    + {% for task in tasks %} +
  • + {{ task.seasonAssignment | season_assignment_label }} +
  • + {% endfor %} +
+
+ +
+ {% else %} +
+ Es gibt keinen offenen Einsatz, auf den das Dokument übertragen werden könnte. +
+ {% endif %} +{% endblock %} diff --git a/tests/Repository/SeasonAssignmentTaskRepositoryTest.php b/tests/Repository/SeasonAssignmentTaskRepositoryTest.php index bf36784..bbd386f 100644 --- a/tests/Repository/SeasonAssignmentTaskRepositoryTest.php +++ b/tests/Repository/SeasonAssignmentTaskRepositoryTest.php @@ -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, diff --git a/tests/Service/Cron/SeasonTaskReminderServiceTest.php b/tests/Service/Cron/SeasonTaskReminderServiceTest.php index 8b3c6af..098b165 100644 --- a/tests/Service/Cron/SeasonTaskReminderServiceTest.php +++ b/tests/Service/Cron/SeasonTaskReminderServiceTest.php @@ -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 diff --git a/tests/Service/Season/SeasonChecklistBuilderTest.php b/tests/Service/Season/SeasonChecklistBuilderTest.php new file mode 100644 index 0000000..2164b79 --- /dev/null +++ b/tests/Service/Season/SeasonChecklistBuilderTest.php @@ -0,0 +1,54 @@ +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']); + } +} diff --git a/tests/Service/Season/SeasonChecklistStatusTest.php b/tests/Service/Season/SeasonChecklistStatusTest.php new file mode 100644 index 0000000..be30f17 --- /dev/null +++ b/tests/Service/Season/SeasonChecklistStatusTest.php @@ -0,0 +1,99 @@ +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)); + } + } + } +} diff --git a/tests/Service/Season/SeasonTaskSynchronizerTest.php b/tests/Service/Season/SeasonTaskSynchronizerTest.php index f571e12..8efbb84 100644 --- a/tests/Service/Season/SeasonTaskSynchronizerTest.php +++ b/tests/Service/Season/SeasonTaskSynchronizerTest.php @@ -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[] */