diff --git a/.ddev/config.yaml b/.ddev/config.yaml index 9329488..fa47698 100644 --- a/.ddev/config.yaml +++ b/.ddev/config.yaml @@ -27,6 +27,7 @@ web_environment: [] nodejs_version: "18" corepack_enable: false disable_upload_dirs_warning: true + xhgui_https_port: "8142" xhgui_http_port: "8143" diff --git a/config/email_texts.yaml b/config/email_texts.yaml index 4bf0a4c..0847bc2 100644 --- a/config/email_texts.yaml +++ b/config/email_texts.yaml @@ -176,6 +176,20 @@ texts: **{destination}** + # Weekly, for as long as an entry of the season checklist is not ticked off. It is not tied + # to a deadline, so it must not name one. + reminder_season_tasks: + label: 'Erinnerung: Saisonunterlagen' + placeholders: ['destination', 'tasks'] + subject: 'Reminder: Fehlende Saisonunterlagen' + headline: 'Hallo aus Köln,' + body: |- + für deinen Saisoneinsatz **{destination}** fehlen uns noch folgende Unterlagen: + + {tasks} + + Im E&P-Team Portal findest du unter "Saisonunterlagen" die Vorlagen zum Herunterladen und kannst deine unterschriebenen Unterlagen direkt hochladen. + reminder_invoice_upload: label: 'Erinnerung: Honorarnote hochladen' placeholders: ['destination', 'invoiceUploadDeadlineDays'] @@ -232,3 +246,4 @@ placeholders: dateFrom: 'Beginn des Einsatzes' dateTo: 'Ende des Einsatzes' diffInDays: 'Tage bis zum Beginn des Einsatzes' + tasks: 'Noch offene Punkte der Saison-Checkliste, kommagetrennt' diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index 1fd2e72..367e4b1 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -37,6 +37,13 @@ when@test: dbal: # "TEST_TOKEN" is typically set by ParaTest dbname_suffix: '_test%env(default::TEST_TOKEN)%' + # schema:create otherwise emits MySQL 8's platform default (utf8mb4_0900_ai_ci), + # while the migrations that built dev and production declare utf8mb4_unicode_ci. + # Different collation means different string comparison and ORDER BY semantics, + # so tests would sort and match differently than production. + default_table_options: + charset: utf8mb4 + collation: utf8mb4_unicode_ci when@prod: doctrine: diff --git a/config/packages/liip_imagine.yaml b/config/packages/liip_imagine.yaml index e63132c..b879906 100644 --- a/config/packages/liip_imagine.yaml +++ b/config/packages/liip_imagine.yaml @@ -17,7 +17,13 @@ liip_imagine: format: jpeg jpeg_quality: 75 filters: - thumbnail: { size: [ 128, 128 ], mode: outbound } + # Phone photos carry their orientation in EXIF rather than in the pixels, so + # without this the table renders them sideways while every other filter set, + # which has always had it, shows them upright. + auto_rotate: ~ + # outbound crops to the box but never scales up, so a photo narrower than + # 128px came out oblong and the rounded-full avatar with it. + thumbnail: { size: [ 128, 128 ], mode: outbound, allow_upscale: true } profile: format: jpeg jpeg_quality: 75 diff --git a/config/packages/oneup_uploader.yaml b/config/packages/oneup_uploader.yaml index 5535c8f..bb490e2 100644 --- a/config/packages/oneup_uploader.yaml +++ b/config/packages/oneup_uploader.yaml @@ -45,6 +45,21 @@ oneup_uploader: namer: app.upload_namer storage: directory: '%kernel.project_dir%/uploads/receipt' + # Saisonunterlagen. Like receipt above, both the mapping key and the directory have to + # be exactly the Upload::TYPE_* value - out for what the office issues to one teamer, + # in for what that teamer sends back signed. + season_document_out: + frontend: dropzone + use_orphanage: true + namer: app.upload_namer + storage: + directory: '%kernel.project_dir%/uploads/season_document_out' + season_document_in: + frontend: dropzone + use_orphanage: true + namer: app.upload_namer + storage: + directory: '%kernel.project_dir%/uploads/season_document_in' chunks: maxage: 86400 storage: diff --git a/config/services.yaml b/config/services.yaml index e2c30aa..fc9836c 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -79,6 +79,12 @@ parameters: season_start: '11-01' season_end: '04-01' + # How far ahead of the destination's first travel day a season assignment's checklist + # entries fall due. A relative date expression, applied to destination.dateFrom, and the + # single definition of both deadlines: the first is this offset, the second defaults to + # the first. Per-entry overrides are stored on the task row; this is what they override. + season_task_deadline_offset: '-1 month' + # Houses, hotel code: name. The name is used both as a label and to match # destination.hotel, the code to match destination.hotelCode. houses: @@ -124,6 +130,7 @@ services: $mailingBusTransport: '%mailing_bus_transport%' $seasonStart: '%season_start%' $seasonEnd: '%season_end%' + $seasonTaskDeadlineOffset: '%season_task_deadline_offset%' # Tagged by interface rather than listed by hand: order between collectors is # irrelevant, so the only thing an explicit list could add here is the chance to diff --git a/migrations/Version20260824124850.php b/migrations/Version20260824124850.php new file mode 100644 index 0000000..7f5abda --- /dev/null +++ b/migrations/Version20260824124850.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE teamer ADD all_season TINYINT(1) NOT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE teamer DROP all_season'); + } +} diff --git a/migrations/Version20260909124625.php b/migrations/Version20260909124625.php new file mode 100644 index 0000000..4b9dca0 --- /dev/null +++ b/migrations/Version20260909124625.php @@ -0,0 +1,39 @@ +addSql('CREATE TABLE season_assignment (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, remarks LONGTEXT DEFAULT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, deleted_at DATETIME DEFAULT NULL, teamer_id INT DEFAULT NULL, destination_id INT DEFAULT NULL, job_profile_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_FB891DD17F50A6 (uuid), INDEX IDX_FB891D4302FF75 (teamer_id), INDEX IDX_FB891D816C6140 (destination_id), INDEX IDX_FB891DA68D82A5 (job_profile_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE season_assignment ADD CONSTRAINT FK_FB891D4302FF75 FOREIGN KEY (teamer_id) REFERENCES teamer (id)'); + $this->addSql('ALTER TABLE season_assignment ADD CONSTRAINT FK_FB891D816C6140 FOREIGN KEY (destination_id) REFERENCES destination (id)'); + $this->addSql('ALTER TABLE season_assignment ADD CONSTRAINT FK_FB891DA68D82A5 FOREIGN KEY (job_profile_id) REFERENCES job_profile (id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE season_assignment DROP FOREIGN KEY FK_FB891D4302FF75'); + $this->addSql('ALTER TABLE season_assignment DROP FOREIGN KEY FK_FB891D816C6140'); + $this->addSql('ALTER TABLE season_assignment DROP FOREIGN KEY FK_FB891DA68D82A5'); + $this->addSql('DROP TABLE season_assignment'); + } +} diff --git a/migrations/Version20260915103000.php b/migrations/Version20260915103000.php new file mode 100644 index 0000000..20124a7 --- /dev/null +++ b/migrations/Version20260915103000.php @@ -0,0 +1,49 @@ +addSql('CREATE TABLE season_assignment_task (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, task_key VARCHAR(64) NOT NULL, done TINYINT(1) NOT NULL, done_at DATETIME DEFAULT NULL, done_by VARCHAR(255) DEFAULT NULL, uploaded TINYINT(1) NOT NULL, deadline_override1 DATE DEFAULT NULL, deadline_override2 DATE DEFAULT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, season_assignment_id INT NOT NULL, UNIQUE INDEX UNIQ_BEEB9D8DD17F50A6 (uuid), UNIQUE INDEX season_assignment_task_unique (season_assignment_id, task_key), INDEX IDX_BEEB9D8DB291FD85 (season_assignment_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE season_task_document (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, task_key VARCHAR(64) NOT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, document_id INT NOT NULL, UNIQUE INDEX UNIQ_FBAB9F47D17F50A6 (uuid), UNIQUE INDEX UNIQ_FBAB9F47E512532E (task_key), INDEX IDX_FBAB9F47C33F7837 (document_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE season_assignment_task ADD CONSTRAINT FK_BEEB9D8DB291FD85 FOREIGN KEY (season_assignment_id) REFERENCES season_assignment (id)'); + $this->addSql('ALTER TABLE season_task_document ADD CONSTRAINT FK_FBAB9F47C33F7837 FOREIGN KEY (document_id) REFERENCES upload (id)'); + $this->addSql('ALTER TABLE season_assignment ADD done_teamer TINYINT(1) NOT NULL'); + $this->addSql('ALTER TABLE upload ADD season_assignment_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE upload ADD CONSTRAINT FK_17BDE61FB291FD85 FOREIGN KEY (season_assignment_id) REFERENCES season_assignment (id)'); + $this->addSql('CREATE INDEX IDX_17BDE61FB291FD85 ON upload (season_assignment_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE upload DROP FOREIGN KEY FK_17BDE61FB291FD85'); + $this->addSql('DROP INDEX IDX_17BDE61FB291FD85 ON upload'); + $this->addSql('ALTER TABLE upload DROP season_assignment_id'); + $this->addSql('ALTER TABLE season_assignment DROP done_teamer'); + $this->addSql('ALTER TABLE season_assignment_task DROP FOREIGN KEY FK_BEEB9D8DB291FD85'); + $this->addSql('ALTER TABLE season_task_document DROP FOREIGN KEY FK_FBAB9F47C33F7837'); + $this->addSql('DROP TABLE season_assignment_task'); + $this->addSql('DROP TABLE season_task_document'); + } +} diff --git a/migrations/Version20260916160000.php b/migrations/Version20260916160000.php new file mode 100644 index 0000000..e54caf6 --- /dev/null +++ b/migrations/Version20260916160000.php @@ -0,0 +1,54 @@ +addSql('ALTER TABLE season_assignment ADD date_from DATE DEFAULT NULL, ADD date_to DATE DEFAULT NULL, ADD hotel_code VARCHAR(32) DEFAULT NULL'); + + $this->addSql('UPDATE season_assignment sa JOIN destination d ON d.id = sa.destination_id SET sa.date_from = d.date_from, sa.date_to = d.date_to, sa.hotel_code = CASE WHEN d.hotel_code LIKE \'SER%\' THEN SUBSTRING(d.hotel_code, 4, 3) ELSE SUBSTRING(d.hotel_code, 1, 3) END'); + + $this->addSql('ALTER TABLE season_assignment CHANGE date_from date_from DATE NOT NULL, CHANGE date_to date_to DATE NOT NULL, CHANGE hotel_code hotel_code VARCHAR(32) NOT NULL'); + + $this->addSql('ALTER TABLE season_assignment DROP FOREIGN KEY FK_FB891D816C6140'); + $this->addSql('DROP INDEX IDX_FB891D816C6140 ON season_assignment'); + $this->addSql('ALTER TABLE season_assignment DROP destination_id'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE season_assignment ADD destination_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE season_assignment ADD CONSTRAINT FK_FB891D816C6140 FOREIGN KEY (destination_id) REFERENCES destination (id)'); + $this->addSql('CREATE INDEX IDX_FB891D816C6140 ON season_assignment (destination_id)'); + + // The destination a placement was cut from cannot be recovered from a period and a house + // code, so the relation comes back empty. + $this->addSql('ALTER TABLE season_assignment DROP date_from, DROP date_to, DROP hotel_code'); + } +} diff --git a/src/Command/CronCommand.php b/src/Command/CronCommand.php index 546db52..960f74f 100644 --- a/src/Command/CronCommand.php +++ b/src/Command/CronCommand.php @@ -6,6 +6,7 @@ use App\Service\Cron\DispositionReminderService; use App\Service\Cron\DispositionStatusService; use App\Service\Cron\FeedbackReminderService; use App\Service\Cron\FlushLogsService; +use App\Service\Cron\SeasonTaskReminderService; use App\Service\Cron\UploadReminderService; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -25,6 +26,7 @@ class CronCommand extends Command private readonly FeedbackReminderService $feedbackReminderService, private readonly DispositionStatusService $dispositionStatusService, private readonly DispositionReminderService $dispositionReminderService, + private readonly SeasonTaskReminderService $seasonTaskReminderService, ) { parent::__construct(); } @@ -55,6 +57,12 @@ class CronCommand extends Command $message = $this->dispositionReminderService->sendDispositionReminders(); $io->info($message); + $message = $this->seasonTaskReminderService->sendDeadlineSummary(); + $io->info($message); + + $message = $this->seasonTaskReminderService->sendTeamerDigest(); + $io->info($message); + return Command::SUCCESS; } } diff --git a/src/Config/EmailTextKey.php b/src/Config/EmailTextKey.php index 086b152..7f934de 100644 --- a/src/Config/EmailTextKey.php +++ b/src/Config/EmailTextKey.php @@ -22,4 +22,5 @@ enum EmailTextKey: string case REMINDER_INVOICE_UPLOAD = 'reminder_invoice_upload'; case REMINDER_INVOICE_UPLOAD_FINAL = 'reminder_invoice_upload_final'; case REMINDER_DISPOSITION = 'reminder_disposition'; + case REMINDER_SEASON_TASKS = 'reminder_season_tasks'; } diff --git a/src/Config/SeasonCalendar.php b/src/Config/SeasonCalendar.php index a2175f4..68b0805 100644 --- a/src/Config/SeasonCalendar.php +++ b/src/Config/SeasonCalendar.php @@ -57,23 +57,52 @@ final class SeasonCalendar } /** - * The season a mail written now would call "die kommende Saison": the one currently running, - * or - between two seasons - the next one to start. Never null, unlike seasonYearFor(): a - * recruiting mail sent in September is outside every season window but is unmistakably about - * the winter ahead. + * The season that is running today, or - when today lies outside any window - the one that + * starts next. + * + * The fall-forward is what keeps the teamer's season page reachable all year: without it the + * page would disappear between 02.04. and 31.10. for the default window, which covers the + * default first deadline of 01.10. for a season starting 01.11. - teamers would be reminded + * about documents with nowhere to upload them. */ - public function currentOrUpcomingSeasonYear(\DateTimeInterface $date): int + public function currentOrUpcomingSeasonYear(?\DateTimeInterface $date = null): int { - if (null !== $seasonYear = $this->seasonYearFor($date)) { + $day = CarbonImmutable::instance($date ?? new \DateTimeImmutable())->startOfDay(); + + if (null !== $seasonYear = $this->seasonYearFor($day)) { return $seasonYear; } - $day = CarbonImmutable::instance($date)->startOfDay(); $year = (int) $day->format('Y'); - // Outside any window, so the next start is this year's unless it has already passed, - // which for a wrapping window can only mean the season already began - handled above. - return $day <= $this->boundaryFor($this->seasonStart, $year) ? $year : $year + 1; + // Off-season, so the next window is the one whose start still lies ahead. The candidates + // bracket the turn of the year, which is all a MM-DD window can ever straddle. + foreach ([$year - 1, $year, $year + 1] as $candidate) { + if ($this->boundaryFor($this->seasonStart, $candidate) > $day) { + return $candidate; + } + } + + throw new \LogicException(sprintf('No season starts after %s.', $day->format('Y-m-d'))); + } + + /** + * The inclusive first and last day of a season, for queries that cannot call seasonYearFor() + * per row - which is every query, since DQL knows nothing about this class. + * + * @return array{CarbonImmutable, CarbonImmutable} + */ + public function windowFor(int $seasonYear): array + { + $start = $this->boundaryFor($this->seasonStart, $seasonYear); + $end = $this->boundaryFor($this->seasonEnd, $seasonYear); + + // Wrapping window: the season ends in the following calendar year. + if ($end < $start) { + $end = $this->boundaryFor($this->seasonEnd, $seasonYear + 1); + } + + return [$start, $end]; } /** @@ -97,6 +126,15 @@ final class SeasonCalendar return sprintf('Saison %d/%02d', $seasonYear, ($seasonYear + 1) % 100); } + /** + * The season label for a date, for the common case of "which season does this belong to" - + * "?" when the date is not known yet, which a half-filled create form can be in. + */ + public function labelFor(?\DateTimeInterface $date): string + { + return null === $date ? '?' : $this->label($this->seasonYearFor($date)); + } + private function boundaryFor(string $monthDay, int $year): CarbonImmutable { $boundary = CarbonImmutable::createFromFormat('Y-m-d', sprintf('%d-%s', $year, $monthDay)); diff --git a/src/Config/SeasonTask.php b/src/Config/SeasonTask.php new file mode 100644 index 0000000..de86cb8 --- /dev/null +++ b/src/Config/SeasonTask.php @@ -0,0 +1,70 @@ + 'Stammdaten', + self::VEHICLE => 'KfZ', + self::CONTRACT => 'Vertrag', + self::ADDITIONAL_CONTRACT => 'Zusatzvertrag', + self::SAAS_REGISTERED => 'SaaS angemeldet', + self::MUNICIPALITY_REGISTERED => 'Gemeinde angemeldet', + }; + } + + public function provides(): SeasonTaskProvision + { + return match ($this) { + self::MASTER_DATA, self::VEHICLE => SeasonTaskProvision::STANDARD, + self::CONTRACT, self::ADDITIONAL_CONTRACT => SeasonTaskProvision::INDIVIDUAL, + self::SAAS_REGISTERED, self::MUNICIPALITY_REGISTERED => SeasonTaskProvision::NONE, + }; + } + + /** + * Whether the teamer has to send something back. This is what puts a task on the teamer's + * own page and into their weekly reminder; everything else is office-internal. + */ + public function requiresReturn(): bool + { + return match ($this) { + self::MASTER_DATA, self::VEHICLE, self::CONTRACT, self::ADDITIONAL_CONTRACT => true, + self::SAAS_REGISTERED, self::MUNICIPALITY_REGISTERED => false, + }; + } + + /** + * @return self[] + */ + public static function requiringReturn(): array + { + return array_values(array_filter( + self::cases(), + static fn (self $task): bool => true === $task->requiresReturn() + )); + } +} diff --git a/src/Config/SeasonTaskProvision.php b/src/Config/SeasonTaskProvision.php new file mode 100644 index 0000000..43b9b56 --- /dev/null +++ b/src/Config/SeasonTaskProvision.php @@ -0,0 +1,17 @@ +json($dates); } + + /** + * Season assignments are only ever made for all-season teamers, so their form must not be + * able to offer anybody else. + */ + #[Route('/admin/autocomplete/season-teamer', name: 'app_admin_autocomplete_season_teamer')] + #[IsGranted('ROLE_ADMINISTRATIVE')] + public function season(Request $request): JsonResponse + { + try { + $query = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR); + $queryString = $query['search']; + } catch (\JsonException $e) { + throw $this->createNotFoundException(); + } + + return $this->json($this->teamerRepository->getAutocompletionData($queryString, true)); + } } diff --git a/src/Controller/Admin/Teamer/MailingController.php b/src/Controller/Admin/Teamer/MailingController.php index 4962089..1637d29 100644 --- a/src/Controller/Admin/Teamer/MailingController.php +++ b/src/Controller/Admin/Teamer/MailingController.php @@ -57,7 +57,7 @@ class MailingController extends AbstractController )); } - $filterDto = $this->filterHandler->getFilterSettings(); + $filterDto = $this->filterHandler->setScope($request->query->get('scope'))->getFilterSettings(); // No redirect after the preview mail, the composed mail has to survive it. The // preview pane is rendered here so the page arrives complete; from then on the @@ -69,7 +69,8 @@ class MailingController extends AbstractController 'recipients' => $this->mailingService->resolveRecipients($filterDto), 'placeholders' => TeamerMailingService::PLACEHOLDERS, 'hasDraft' => $this->draftHandler->hasDraft(), - 'returnUrl' => $this->getReturnUrl($request, 'app_administrative_teamer_index'), + 'returnUrl' => $this->getReturnUrl($request, $this->getListRoute($request)), + 'scope' => $request->query->get('scope'), ]); } @@ -94,11 +95,13 @@ class MailingController extends AbstractController #[Route('/admin/teamer/mailing/discard', name: 'app_admin_teamer_mailing_discard')] #[IsGranted('ROLE_TEAM_ADMIN')] - public function discard(): Response + public function discard(Request $request): Response { $this->draftHandler->resetDraft(); - return $this->redirectToRoute('app_admin_teamer_mailing'); + return $this->redirectToRoute('app_admin_teamer_mailing', [ + 'scope' => $request->query->get('scope'), + ]); } #[Route('/admin/teamer/mailing/confirm', name: 'app_admin_teamer_mailing_confirm', methods: ['POST'])] @@ -106,7 +109,7 @@ class MailingController extends AbstractController public function confirm(Request $request): Response { $form = $this->createMailingForm($request); - $filterDto = $this->filterHandler->getFilterSettings(); + $filterDto = $this->filterHandler->setScope($request->query->get('scope'))->getFilterSettings(); // the composed mail is carried on in hidden fields so no half written mailing has // to be parked in the session between the confirmation and the send @@ -115,6 +118,7 @@ class MailingController extends AbstractController 'mailingDto' => $form->getData(), 'valid' => $form->isSubmitted() && $form->isValid(), 'recipients' => $this->mailingService->resolveRecipients($filterDto), + 'scope' => $request->query->get('scope'), ]); } @@ -127,10 +131,14 @@ class MailingController extends AbstractController if (false === $form->isSubmitted() || false === $form->isValid()) { $this->addFlash('error', 'Die Mail konnte nicht gesendet werden, Betreff oder Nachricht fehlen'); - return new HxRedirectResponse($this->generateUrl('app_admin_teamer_mailing')); + return new HxRedirectResponse($this->generateUrl('app_admin_teamer_mailing', [ + 'scope' => $request->query->get('scope'), + ])); } - $recipients = $this->mailingService->resolveRecipients($this->filterHandler->getFilterSettings()); + $recipients = $this->mailingService->resolveRecipients( + $this->filterHandler->setScope($request->query->get('scope'))->getFilterSettings() + ); $mailingDto = $form->getData(); // the recipients travel with the message, so the mailing reaches exactly the @@ -151,7 +159,18 @@ class MailingController extends AbstractController $recipients->getEligibleCount() )); - return new HxRedirectResponse($this->generateUrl('app_administrative_teamer_index')); + return new HxRedirectResponse($this->generateUrl($this->getListRoute($request))); + } + + /** + * The list this mailing was started from, so a send returns to it and an unscoped + * mailing never falls back to the wrong one. + */ + private function getListRoute(Request $request): string + { + return TeamerFilterHandler::SCOPE_ALL_SEASON === $request->query->get('scope') + ? 'app_administrative_season_teamer_index' + : 'app_administrative_teamer_index'; } /** diff --git a/src/Controller/Admin/Teamer/SkillsController.php b/src/Controller/Admin/Teamer/SkillsController.php index b2889e2..4ae4dcf 100644 --- a/src/Controller/Admin/Teamer/SkillsController.php +++ b/src/Controller/Admin/Teamer/SkillsController.php @@ -30,6 +30,8 @@ class SkillsController extends AbstractController { $trainings = $this->trainingRepository->getList(); + $allSeasonStatus = $teamer->isAllSeason(); + $form = $this->createStatusForm($teamer); $form->handleRequest($request); @@ -42,6 +44,15 @@ class SkillsController extends AbstractController 'teamer' => $teamer->getFullName(), ]); + if ($teamer->isAllSeason() !== $allSeasonStatus) { + $this->addFlash('info', 'Der Saisonteamer:innern-Status wurde '.($teamer->isAllSeason() ? 'hinzugefügt' : 'entfernt')); + + $logMessage = $teamer->isAllSeason() ? 'Assign all-season status' : 'Remove all-season status'; + $this->logger->info($logMessage, [ + 'teamer' => $teamer->getFullName(), + ]); + } + return $this->redirectToRoute('app_admin_teamer_skills', [ 'uuid' => $teamer->getUuid(), 'r' => $request->query->get('r'), @@ -62,10 +73,14 @@ class SkillsController extends AbstractController ->add('status', ChoiceType::class, [ 'label' => 'Status', 'choices' => [ - 'Neuteamer' => Teamer::STATUS_NEW, - 'Bestandsteamer' => Teamer::STATUS_EXISTING, + 'Neuteamer:in' => Teamer::STATUS_NEW, + 'Bestandsteamer:in' => Teamer::STATUS_EXISTING, ], ]) + ->add('allSeason', CheckboxType::class, [ + 'label' => 'Saisonteamer:in', + 'required' => false, + ]) ->add('allowOverlappingApplications', CheckboxType::class, [ 'label' => 'Überlappende Bewerbungen erlauben', 'required' => false, diff --git a/src/Controller/Administrative/SeasonAssignment/CreateController.php b/src/Controller/Administrative/SeasonAssignment/CreateController.php new file mode 100644 index 0000000..70041a6 --- /dev/null +++ b/src/Controller/Administrative/SeasonAssignment/CreateController.php @@ -0,0 +1,76 @@ +seasonCalendar->windowFor($this->seasonCalendar->currentOrUpcomingSeasonYear()); + $seasonAssignment + ->setDateFrom($windowStart->toDateTimeImmutable()) + ->setDateTo($windowEnd->toDateTimeImmutable()) + ; + $returnUrl = $this->getReturnUrl($request, 'app_administrative_season_assignment_index'); + + $form = $this->createForm(SeasonAssignmentType::class, $seasonAssignment); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->entityManager->persist($seasonAssignment); + $this->entityManager->flush(); + + // the checklist is what the office works with from here on, so it exists from the + // moment the assignment does + $this->taskSynchronizer->sync($seasonAssignment); + + $this->addFlash('success', 'Der Saisoneinsatz wurde angelegt'); + + $this->logger->info('Create season assignment', [ + 'season_assignment_id' => $seasonAssignment->getId(), + 'teamer' => $seasonAssignment->getTeamer()?->getFullName(), + 'season_assignment' => $this->labeler->label($seasonAssignment), + 'job_profile' => $seasonAssignment->getJobProfile()->getName(), + ]); + + return $this->redirect($returnUrl); + } + + return $this->render('administrative/season_assignment/create.html.twig', [ + 'form' => $form->createView(), + 'returnUrl' => $returnUrl, + ]); + } +} diff --git a/src/Controller/Administrative/SeasonAssignment/DeleteController.php b/src/Controller/Administrative/SeasonAssignment/DeleteController.php new file mode 100644 index 0000000..cb7336f --- /dev/null +++ b/src/Controller/Administrative/SeasonAssignment/DeleteController.php @@ -0,0 +1,50 @@ +isMethod('POST')) { + $seasonAssignment->setDeleted(); + + $this->entityManager->flush(); + + $this->logger->info('Delete season assignment', [ + 'season_assignment_id' => $seasonAssignment->getId(), + 'teamer' => $seasonAssignment->getTeamer()?->getFullName(), + 'season_assignment' => $this->labeler->label($seasonAssignment), + 'job_profile' => $seasonAssignment->getJobProfile()->getName(), + ]); + + $this->addFlash('success', 'Der Saisoneinsatz wurde gelöscht'); + + return new HxRedirectResponse($this->generateUrl('app_administrative_season_assignment_index')); + } + + return $this->render('administrative/season_assignment/modal_delete.html.twig', [ + 'seasonAssignment' => $seasonAssignment, + ]); + } +} diff --git a/src/Controller/Administrative/SeasonAssignment/DetailController.php b/src/Controller/Administrative/SeasonAssignment/DetailController.php new file mode 100644 index 0000000..338aaca --- /dev/null +++ b/src/Controller/Administrative/SeasonAssignment/DetailController.php @@ -0,0 +1,39 @@ +taskSynchronizer->sync($seasonAssignment); + + return $this->render('administrative/season_assignment/detail.html.twig', [ + 'seasonAssignment' => $seasonAssignment, + 'checklist' => $this->checklistBuilder->build($seasonAssignment), + 'returnUrl' => $this->getReturnUrl($request, 'app_administrative_season_assignment_index'), + ]); + } +} diff --git a/src/Controller/Administrative/SeasonAssignment/DocumentController.php b/src/Controller/Administrative/SeasonAssignment/DocumentController.php new file mode 100644 index 0000000..afbef7c --- /dev/null +++ b/src/Controller/Administrative/SeasonAssignment/DocumentController.php @@ -0,0 +1,156 @@ +createFormBuilder()->getForm(); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $count = $this->processUpload($seasonAssignment); + + $this->addFlash( + 'success', + 1 === $count ? 'Die Unterlage wurde hochgeladen' : 'Die Unterlagen wurden hochgeladen' + ); + + return new HxRedirectResponse($this->generateUrl('app_administrative_season_assignment_detail', [ + 'uuid' => $seasonAssignment->getUuid(), + 'r' => $request->query->get('r'), + ])); + } + + return $this->render('administrative/season_assignment/modal_document_upload.html.twig', [ + 'seasonAssignment' => $seasonAssignment, + 'form' => $form->createView(), + ]); + } + + #[Route( + path: '/administrative/season-assignment/document/{uuid}', + name: 'app_administrative_season_assignment_document_delete' + )] + #[IsGranted('ROLE_ADMINISTRATIVE')] + public function delete(Upload $document, Request $request): Response + { + if (null === $seasonAssignment = $document->getSeasonAssignment()) { + throw $this->createNotFoundException('Not a season assignment document'); + } + + if (true === $request->isMethod('POST')) { + // Doctrine's DeleteUploadListener removes the file from disk on preRemove. + $this->entityManager->remove($document); + $this->entityManager->flush(); + + $this->addFlash('success', 'Die Unterlage wurde gelöscht'); + + $this->logger->info('Delete season assignment document', [ + 'season_assignment_id' => $seasonAssignment->getId(), + 'teamer' => $seasonAssignment->getTeamer()?->getFullName(), + 'document_filename' => $document->getOriginalFilename(), + 'document_type' => $document->getType(), + ]); + + return new HxRedirectResponse($this->generateUrl('app_administrative_season_assignment_detail', [ + 'uuid' => $seasonAssignment->getUuid(), + 'r' => $request->query->get('r'), + ])); + } + + return $this->render('administrative/season_assignment/modal_document_delete.html.twig', [ + 'document' => $document, + ]); + } + + private function processUpload(SeasonAssignment $seasonAssignment): int + { + /** @var User $user */ + $user = $this->getUser(); + + $uploadSession = $this->uploadHandler->getUploadSession(); + + // Only this dropzone's files: the page may hold a second one, so the session must not be + // destroyed wholesale. + $uploadDtos = $uploadSession->getUploadsByType(Upload::TYPE_SEASON_DOCUMENT_OUT); + + $uploads = []; + + /** @var UploadDto $uploadDto */ + foreach ($uploadDtos as $uploadDto) { + $upload = Upload::fromUploadDto($uploadDto, $user, Upload::TYPE_SEASON_DOCUMENT_OUT); + + // No status: these are not checked or approved, they are handed out. UploadVoter + // grants CHECK only for "new" and "pending", so a null status keeps them out of the + // checking process by construction. + $upload->setStatus(null); + + $seasonAssignment->addDocument($upload); + $this->entityManager->persist($upload); + + $uploads[] = $upload; + } + + $this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_SEASON_DOCUMENT_OUT, $uploadSession); + $this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_SEASON_DOCUMENT_OUT); + + if (true === $this->featureManager->isActive('sanitize_uploads')) { + foreach ($uploads as $upload) { + $this->uploadHandler->ensurePdf($upload); + } + } + + $this->entityManager->flush(); + + $this->logger->info('Upload season assignment documents', [ + 'user' => $user->getUserIdentifier(), + 'season_assignment_id' => $seasonAssignment->getId(), + 'count' => count($uploads), + ]); + + return count($uploads); + } +} diff --git a/src/Controller/Administrative/SeasonAssignment/DoneTeamerController.php b/src/Controller/Administrative/SeasonAssignment/DoneTeamerController.php new file mode 100644 index 0000000..dc664fd --- /dev/null +++ b/src/Controller/Administrative/SeasonAssignment/DoneTeamerController.php @@ -0,0 +1,50 @@ +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/EditController.php b/src/Controller/Administrative/SeasonAssignment/EditController.php new file mode 100644 index 0000000..210f633 --- /dev/null +++ b/src/Controller/Administrative/SeasonAssignment/EditController.php @@ -0,0 +1,58 @@ +getReturnUrl($request, 'app_administrative_season_assignment_index'); + + $form = $this->createForm(SeasonAssignmentType::class, $seasonAssignment); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->entityManager->flush(); + + $this->addFlash('success', 'Der Saisoneinsatz wurde gespeichert'); + + $this->logger->info('Edit season assignment', [ + 'season_assignment_id' => $seasonAssignment->getId(), + 'teamer' => $seasonAssignment->getTeamer()?->getFullName(), + 'season_assignment' => $this->labeler->label($seasonAssignment), + 'job_profile' => $seasonAssignment->getJobProfile()->getName(), + ]); + + return $this->redirect($returnUrl); + } + + return $this->render('administrative/season_assignment/edit.html.twig', [ + 'form' => $form->createView(), + 'seasonAssignment' => $seasonAssignment, + 'returnUrl' => $returnUrl, + ]); + } +} diff --git a/src/Controller/Administrative/SeasonAssignment/IndexController.php b/src/Controller/Administrative/SeasonAssignment/IndexController.php new file mode 100644 index 0000000..0643bb2 --- /dev/null +++ b/src/Controller/Administrative/SeasonAssignment/IndexController.php @@ -0,0 +1,45 @@ +filterHandler->getFilterSettings(); + + $query = $this + ->seasonAssignmentRepository + ->getListQuery($filterDto) + ; + + $pagination = $this->paginator->paginate( + $query, + $request->query->getInt('page', 1), + $request->query->getInt('limit', 50) + ); + + return $this->render('administrative/season_assignment/index.html.twig', [ + 'pagination' => $pagination, + 'filterDto' => $filterDto, + ]); + } +} diff --git a/src/Controller/Administrative/SeasonAssignment/TaskDeadlineController.php b/src/Controller/Administrative/SeasonAssignment/TaskDeadlineController.php new file mode 100644 index 0000000..7142a6e --- /dev/null +++ b/src/Controller/Administrative/SeasonAssignment/TaskDeadlineController.php @@ -0,0 +1,69 @@ +getSeasonAssignment(); + + $returnUrl = $this->getReturnUrl($request, 'app_administrative_season_assignment_detail', [ + 'uuid' => $seasonAssignment?->getUuid(), + ]); + + $form = $this->createForm(SeasonTaskDeadlineType::class, $task, [ + 'action' => $this->generateUrl('app_administrative_season_assignment_task_deadline', [ + 'uuid' => $task->getUuid(), + 'r' => $returnUrl, + ]), + ]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->entityManager->flush(); + + $this->logger->info('Change season assignment task deadlines', [ + 'season_assignment_id' => $seasonAssignment?->getId(), + 'teamer' => $seasonAssignment?->getTeamer()?->getFullName(), + 'task' => $task->getTaskKey(), + 'deadline_override_1' => $task->getDeadlineOverride1()?->format('Y-m-d'), + 'deadline_override_2' => $task->getDeadlineOverride2()?->format('Y-m-d'), + ]); + + $this->addFlash('success', 'Die Fristen wurden gespeichert'); + + return new HxRedirectResponse($returnUrl); + } + + return $this->render('administrative/season_assignment/modal_task_deadline.html.twig', [ + 'form' => $form->createView(), + 'task' => $task, + ]); + } +} diff --git a/src/Controller/Administrative/SeasonAssignment/TaskToggleController.php b/src/Controller/Administrative/SeasonAssignment/TaskToggleController.php new file mode 100644 index 0000000..c8d8c57 --- /dev/null +++ b/src/Controller/Administrative/SeasonAssignment/TaskToggleController.php @@ -0,0 +1,78 @@ + 'done|uploaded'], + methods: ['POST'] + )] + #[IsGranted('ROLE_ADMINISTRATIVE')] + public function index(SeasonAssignmentTask $task, string $field): Response + { + // matched against the two names rather than turned into a property path: the field comes + // out of the URL + if (false === in_array($field, [self::FIELD_DONE, self::FIELD_UPLOADED], true)) { + throw $this->createNotFoundException(sprintf('Unknown checklist field "%s".', $field)); + } + + if (self::FIELD_DONE === $field) { + $done = false === $task->isDone(); + + $task + ->setDone($done) + // explicit, because Blameable's updatedBy is overwritten by any later edit and + // the office wants to see who closed the entry + ->setDoneAt(true === $done ? new \DateTimeImmutable() : null) + ->setDoneBy(true === $done ? $this->getUser()?->getUserIdentifier() : null) + ; + } else { + $task->setUploaded(false === $task->isUploaded()); + } + + $this->entityManager->flush(); + + $this->logger->info('Toggle season assignment task', [ + 'season_assignment_id' => $task->getSeasonAssignment()?->getId(), + 'teamer' => $task->getSeasonAssignment()?->getTeamer()?->getFullName(), + 'task' => $task->getTaskKey(), + 'field' => $field, + 'value' => self::FIELD_DONE === $field ? $task->isDone() : $task->isUploaded(), + ]); + + return $this->render('administrative/season_assignment/_task_row.html.twig', [ + 'row' => $this->checklistBuilder->buildRow($task), + ]); + } +} diff --git a/src/Controller/Administrative/System/SeasonTaskDocument/DeleteController.php b/src/Controller/Administrative/System/SeasonTaskDocument/DeleteController.php new file mode 100644 index 0000000..8bd241f --- /dev/null +++ b/src/Controller/Administrative/System/SeasonTaskDocument/DeleteController.php @@ -0,0 +1,53 @@ +isMethod('POST')) { + $taskKey = $seasonTaskDocument->getTaskKey(); + + $this->entityManager->remove($seasonTaskDocument); + $this->entityManager->flush(); + + $this->logger->info('Remove season task document', ['task' => $taskKey]); + + $this->addFlash('success', 'Die Zuordnung wurde entfernt'); + + return new HxRedirectResponse( + $this->generateUrl('app_administrative_system_season_task_document_index') + ); + } + + return $this->render('administrative/system/season_task_document/modal_delete.html.twig', [ + 'seasonTaskDocument' => $seasonTaskDocument, + ]); + } +} diff --git a/src/Controller/Administrative/System/SeasonTaskDocument/EditController.php b/src/Controller/Administrative/System/SeasonTaskDocument/EditController.php new file mode 100644 index 0000000..1a4aaac --- /dev/null +++ b/src/Controller/Administrative/System/SeasonTaskDocument/EditController.php @@ -0,0 +1,75 @@ +provides()) { + throw $this->createNotFoundException(sprintf('No standard document is handed out for "%s".', $taskKey)); + } + + $seasonTaskDocument = $this->seasonTaskDocumentRepository->findForTask($task) + ?? (new SeasonTaskDocument())->setTaskKey($task->value); + + $form = $this->createForm(SeasonTaskDocumentType::class, $seasonTaskDocument); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + if (null === $seasonTaskDocument->getId()) { + $this->entityManager->persist($seasonTaskDocument); + } + + $this->entityManager->flush(); + + $this->logger->info('Change season task document', [ + 'task' => $task->value, + 'document_id' => $seasonTaskDocument->getDocument()?->getId(), + ]); + + $this->addFlash('success', 'Das Dokument wurde hinterlegt'); + + return new HxRedirectResponse( + $this->generateUrl('app_administrative_system_season_task_document_index') + ); + } + + return $this->render('administrative/system/season_task_document/modal_edit.html.twig', [ + 'form' => $form->createView(), + 'task' => $task, + ]); + } +} diff --git a/src/Controller/Administrative/System/SeasonTaskDocument/IndexController.php b/src/Controller/Administrative/System/SeasonTaskDocument/IndexController.php new file mode 100644 index 0000000..e20b563 --- /dev/null +++ b/src/Controller/Administrative/System/SeasonTaskDocument/IndexController.php @@ -0,0 +1,49 @@ +seasonTaskDocumentRepository->findIndexedByTaskKey(); + + $rows = []; + + foreach (SeasonTask::cases() as $case) { + if (SeasonTaskProvision::STANDARD !== $case->provides()) { + continue; + } + + $rows[] = [ + 'task' => $case, + 'document' => $mapped[$case->value] ?? null, + ]; + } + + return $this->render('administrative/system/season_task_document/index.html.twig', [ + 'rows' => $rows, + ]); + } +} diff --git a/src/Controller/Administrative/Teamer/IndexController.php b/src/Controller/Administrative/Teamer/IndexController.php index 0ec3332..37401dd 100644 --- a/src/Controller/Administrative/Teamer/IndexController.php +++ b/src/Controller/Administrative/Teamer/IndexController.php @@ -17,7 +17,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; class IndexController extends AbstractController { public function __construct( - protected readonly TeamerFilterHandler $filterHandler, + private readonly TeamerFilterHandler $filterHandler, private readonly TeamerRepository $teamerRepository, private readonly PaginatorInterface $paginator, private readonly EntityManagerInterface $entityManager, @@ -28,7 +28,38 @@ class IndexController extends AbstractController #[IsGranted('ROLE_ADMINISTRATIVE')] public function index(Request $request): Response { - $filterDto = $this->filterHandler->getFilterSettings(); + return $this->renderList($request, 'administrative/teamer/index.html.twig', null); + } + + #[Route('/administrative/season-teamer', name: 'app_administrative_season_teamer_index')] + #[IsGranted('ROLE_ADMINISTRATIVE')] + public function season(Request $request): Response + { + return $this->renderList($request, 'administrative/teamer/season_index.html.twig', TeamerFilterHandler::SCOPE_ALL_SEASON); + } + + #[Route('/administrative/teamer/viewed/{uuid}', name: 'app_administrative_teamer_viewed', methods: ['POST'])] + #[IsGranted('ROLE_ADMINISTRATIVE')] + public function viewed(Teamer $teamer): Response + { + $teamer->setViewed(true); + $this->entityManager->flush(); + + return new HxRedirectResponse($this->generateUrl('app_administrative_teamer_index')); + } + + /** + * Both teamer lists differ only in the scope they run under, so they share one body. + * The scope is set explicitly on either path to keep the two independent of the order + * in which they are called within a request. + */ + private function renderList(Request $request, string $template, ?string $scope): Response + { + $filterDto = $this + ->filterHandler + ->setScope($scope) + ->getFilterSettings() + ; $query = $this ->teamerRepository @@ -41,19 +72,9 @@ class IndexController extends AbstractController $request->query->getInt('limit', 50) ); - return $this->render('administrative/teamer/index.html.twig', [ + return $this->render($template, [ 'pagination' => $pagination, 'filterDto' => $filterDto, ]); } - - #[Route('/administrative/teamer/viewed/{uuid}', name: 'app_administrative_teamer_viewed', methods: ['POST'])] - #[IsGranted('ROLE_ADMINISTRATIVE')] - public function viewed(Teamer $teamer): Response - { - $teamer->setViewed(true); - $this->entityManager->flush(); - - return new HxRedirectResponse($this->generateUrl('app_administrative_teamer_index')); - } } diff --git a/src/Controller/Common/SeasonAssignmentFilterController.php b/src/Controller/Common/SeasonAssignmentFilterController.php new file mode 100644 index 0000000..4901109 --- /dev/null +++ b/src/Controller/Common/SeasonAssignmentFilterController.php @@ -0,0 +1,53 @@ +filterHandler->getFilterSettings(); + $form = $this->createForm(SeasonAssignmentFilterType::class, $formData); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->filterHandler->handleRequest($form); + $returnUrl = $this->getReturnUrl($request, 'app_administrative_season_assignment_index'); + + return new HxRedirectResponse($returnUrl); + } + + return $this->render('common/modal_season_assignment_filter.html.twig', [ + 'filterForm' => $form->createView(), + 'filterDto' => $form->getData(), + ]); + } + + #[Route('/common/season-assignment/filter/reset', name: 'app_common_season_assignment_filter_reset')] + #[IsGranted('ROLE_ADMINISTRATIVE')] + public function reset(Request $request): Response + { + $this->filterHandler->resetFilterSettings(); + $returnUrl = $this->getReturnUrl($request, 'app_administrative_season_assignment_index'); + + return $this->redirect($returnUrl); + } +} diff --git a/src/Controller/Common/TeamerFilterController.php b/src/Controller/Common/TeamerFilterController.php index eb7c3c1..d290222 100644 --- a/src/Controller/Common/TeamerFilterController.php +++ b/src/Controller/Common/TeamerFilterController.php @@ -24,13 +24,15 @@ class TeamerFilterController extends AbstractController #[IsGranted('ROLE_USER')] public function index(Request $request): Response { + $this->applyScope($request); + $formData = $this->filterHandler->getFilterSettings(); $form = $this->createForm(TeamerFilterType::class, $formData); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $this->filterHandler->handleRequest($form); - $returnUrl = $this->getReturnUrl($request, 'app_administrative_teamer_index'); + $returnUrl = $this->getReturnUrl($request, $this->getDefaultRoute($request)); return new HxRedirectResponse($returnUrl); } @@ -45,9 +47,26 @@ class TeamerFilterController extends AbstractController #[IsGranted('ROLE_USER')] public function reset(Request $request): Response { + $this->applyScope($request); + $this->filterHandler->resetFilterSettings(); - $returnUrl = $this->getReturnUrl($request, 'app_administrative_teamer_index'); + $returnUrl = $this->getReturnUrl($request, $this->getDefaultRoute($request)); return $this->redirect($returnUrl); } + + /** + * The modal serves both teamer lists; the scope says whose filter is being edited. + */ + private function applyScope(Request $request): void + { + $this->filterHandler->setScope($request->query->get('scope')); + } + + private function getDefaultRoute(Request $request): string + { + return TeamerFilterHandler::SCOPE_ALL_SEASON === $request->query->get('scope') + ? 'app_administrative_season_teamer_index' + : 'app_administrative_teamer_index'; + } } diff --git a/src/Controller/Teamer/Season/IndexController.php b/src/Controller/Teamer/Season/IndexController.php new file mode 100644 index 0000000..791f8aa --- /dev/null +++ b/src/Controller/Teamer/Season/IndexController.php @@ -0,0 +1,54 @@ +getUser(); + + if (null === $teamer = $user->getTeamer()) { + throw $this->createNotFoundException('No teamer for this user'); + } + + if (null === $seasonAssignment = $this->seasonAssignmentResolver->resolveFor($teamer)) { + throw $this->createNotFoundException('No season assignment for this teamer'); + } + + $rows = array_filter( + $this->checklistBuilder->build($seasonAssignment), + static fn (array $row): bool => true === $row['definition']->requiresReturn() + ); + + return $this->render('teamer/season/index.html.twig', [ + 'seasonAssignment' => $seasonAssignment, + 'rows' => $rows, + 'documents' => $seasonAssignment->getDocuments(), + ]); + } +} diff --git a/src/Controller/Teamer/Season/UploadController.php b/src/Controller/Teamer/Season/UploadController.php new file mode 100644 index 0000000..fcc309b --- /dev/null +++ b/src/Controller/Teamer/Season/UploadController.php @@ -0,0 +1,111 @@ +getUser(); + + // the assignment is addressed by uuid, so this is what keeps one teamer out of another's + if ($user->getTeamer() !== $seasonAssignment->getTeamer()) { + throw $this->createNotFoundException('Not this teamer\'s season assignment'); + } + + // Dummy form without fields, as elsewhere: the files travel in the upload session. + $form = $this->createFormBuilder()->getForm(); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $count = $this->processUpload($seasonAssignment, $user); + + $this->addFlash( + 'success', + 1 === $count ? 'Das Dokument wurde hochgeladen' : 'Die Dokumente wurden hochgeladen' + ); + + return new HxRedirectResponse($this->generateUrl('app_teamer_season_index')); + } + + return $this->render('teamer/season/modal_upload.html.twig', [ + 'seasonAssignment' => $seasonAssignment, + 'form' => $form->createView(), + ]); + } + + private function processUpload(SeasonAssignment $seasonAssignment, User $user): int + { + $uploadSession = $this->uploadHandler->getUploadSession(); + + // Only this dropzone's files: the session is shared, so it must not be destroyed whole. + $uploadDtos = $uploadSession->getUploadsByType(Upload::TYPE_SEASON_DOCUMENT_IN); + + $uploads = []; + + /** @var UploadDto $uploadDto */ + foreach ($uploadDtos as $uploadDto) { + $upload = Upload::fromUploadDto($uploadDto, $user, Upload::TYPE_SEASON_DOCUMENT_IN); + + // No status: a returned document is not run through the checking process, the office + // ticks the checklist entry off by hand. + $upload->setStatus(null); + + $seasonAssignment->addDocument($upload); + $this->entityManager->persist($upload); + + $uploads[] = $upload; + } + + $this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_SEASON_DOCUMENT_IN, $uploadSession); + $this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_SEASON_DOCUMENT_IN); + + if (true === $this->featureManager->isActive('sanitize_uploads')) { + foreach ($uploads as $upload) { + $this->uploadHandler->ensurePdf($upload); + } + } + + $this->entityManager->flush(); + + $this->logger->info('Upload season documents', [ + 'user' => $user->getUserIdentifier(), + 'season_assignment_id' => $seasonAssignment->getId(), + 'count' => count($uploads), + ]); + + return count($uploads); + } +} diff --git a/src/Entity/SeasonAssignment.php b/src/Entity/SeasonAssignment.php new file mode 100644 index 0000000..20d16a6 --- /dev/null +++ b/src/Entity/SeasonAssignment.php @@ -0,0 +1,260 @@ + + */ + #[ORM\OneToMany(mappedBy: 'seasonAssignment', targetEntity: SeasonAssignmentTask::class, cascade: ['persist'])] + private Collection $tasks; + + /** + * @var Collection + */ + #[ORM\OneToMany(mappedBy: 'seasonAssignment', targetEntity: Upload::class)] + private Collection $documents; + + public function __construct() + { + $this->uuid = Uuid::v4(); + $this->tasks = new ArrayCollection(); + $this->documents = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getUuid(): string + { + return $this->uuid; + } + + public function getTeamer(): ?Teamer + { + return $this->teamer; + } + + public function setTeamer(?Teamer $teamer): static + { + $this->teamer = $teamer; + + return $this; + } + + public function getDateFrom(): ?\DateTimeImmutable + { + return $this->dateFrom; + } + + public function setDateFrom(?\DateTimeImmutable $dateFrom): static + { + $this->dateFrom = $dateFrom; + + return $this; + } + + public function getDateTo(): ?\DateTimeImmutable + { + return $this->dateTo; + } + + public function setDateTo(?\DateTimeImmutable $dateTo): static + { + $this->dateTo = $dateTo; + + return $this; + } + + public function getHotelCode(): ?string + { + return $this->hotelCode; + } + + public function setHotelCode(?string $hotelCode): static + { + $this->hotelCode = $hotelCode; + + return $this; + } + + public function getJobProfile(): ?JobProfile + { + return $this->jobProfile; + } + + public function setJobProfile(?JobProfile $jobProfile): static + { + $this->jobProfile = $jobProfile; + + return $this; + } + + public function getRemarks(): ?string + { + return $this->remarks; + } + + public function setRemarks(?string $remarks): static + { + $this->remarks = $remarks; + + return $this; + } + + public function isDoneTeamer(): bool + { + return $this->doneTeamer; + } + + public function setDoneTeamer(bool $doneTeamer): static + { + $this->doneTeamer = $doneTeamer; + + return $this; + } + + /** + * @return Collection + */ + public function getTasks(): Collection + { + return $this->tasks; + } + + public function addTask(SeasonAssignmentTask $task): static + { + if (!$this->tasks->contains($task)) { + $this->tasks->add($task); + $task->setSeasonAssignment($this); + } + + return $this; + } + + public function removeTask(SeasonAssignmentTask $task): static + { + if ($this->tasks->removeElement($task)) { + // set the owning side to null (unless already changed) + if ($task->getSeasonAssignment() === $this) { + $task->setSeasonAssignment(null); + } + } + + return $this; + } + + /** + * @return Collection + */ + public function getDocuments(): Collection + { + return $this->documents; + } + + public function addDocument(Upload $document): static + { + if (!$this->documents->contains($document)) { + $this->documents->add($document); + $document->setSeasonAssignment($this); + } + + return $this; + } + + public function removeDocument(Upload $document): static + { + if ($this->documents->removeElement($document)) { + // set the owning side to null (unless already changed) + if ($document->getSeasonAssignment() === $this) { + $document->setSeasonAssignment(null); + } + } + + return $this; + } +} diff --git a/src/Entity/SeasonAssignmentTask.php b/src/Entity/SeasonAssignmentTask.php new file mode 100644 index 0000000..c8787a5 --- /dev/null +++ b/src/Entity/SeasonAssignmentTask.php @@ -0,0 +1,204 @@ +uuid = Uuid::v4(); + } + + public function __toString(): string + { + return $this->getTask()?->label() ?? $this->taskKey; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getUuid(): string + { + return $this->uuid; + } + + public function getSeasonAssignment(): ?SeasonAssignment + { + return $this->seasonAssignment; + } + + public function setSeasonAssignment(?SeasonAssignment $seasonAssignment): static + { + $this->seasonAssignment = $seasonAssignment; + + return $this; + } + + public function getTaskKey(): string + { + return $this->taskKey; + } + + public function setTaskKey(string $taskKey): static + { + $this->taskKey = $taskKey; + + return $this; + } + + /** + * Null when the stored key no longer resolves, which is what happens to the rows of a case + * that was removed from the enum. Callers skip those rows instead of failing on them. + */ + public function getTask(): ?SeasonTask + { + return SeasonTask::tryFrom($this->taskKey); + } + + public function isDone(): bool + { + return $this->done; + } + + public function setDone(bool $done): static + { + $this->done = $done; + + return $this; + } + + public function getDoneAt(): ?\DateTimeImmutable + { + return $this->doneAt; + } + + public function setDoneAt(?\DateTimeImmutable $doneAt): static + { + $this->doneAt = $doneAt; + + return $this; + } + + public function getDoneBy(): ?string + { + return $this->doneBy; + } + + public function setDoneBy(?string $doneBy): static + { + $this->doneBy = $doneBy; + + return $this; + } + + public function isUploaded(): bool + { + return $this->uploaded; + } + + public function setUploaded(bool $uploaded): static + { + $this->uploaded = $uploaded; + + return $this; + } + + public function getDeadlineOverride1(): ?\DateTimeImmutable + { + return $this->deadlineOverride1; + } + + public function setDeadlineOverride1(?\DateTimeImmutable $deadlineOverride1): static + { + $this->deadlineOverride1 = $deadlineOverride1; + + return $this; + } + + public function getDeadlineOverride2(): ?\DateTimeImmutable + { + return $this->deadlineOverride2; + } + + public function setDeadlineOverride2(?\DateTimeImmutable $deadlineOverride2): static + { + $this->deadlineOverride2 = $deadlineOverride2; + + return $this; + } +} diff --git a/src/Entity/SeasonTaskDocument.php b/src/Entity/SeasonTaskDocument.php new file mode 100644 index 0000000..4fa3d56 --- /dev/null +++ b/src/Entity/SeasonTaskDocument.php @@ -0,0 +1,93 @@ +uuid = Uuid::v4(); + } + + public function __toString(): string + { + return $this->getTask()?->label() ?? $this->taskKey; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getUuid(): string + { + return $this->uuid; + } + + public function getTaskKey(): string + { + return $this->taskKey; + } + + public function setTaskKey(string $taskKey): static + { + $this->taskKey = $taskKey; + + return $this; + } + + public function getTask(): ?SeasonTask + { + return SeasonTask::tryFrom($this->taskKey); + } + + public function getDocument(): ?Upload + { + return $this->document; + } + + public function setDocument(?Upload $document): static + { + $this->document = $document; + + return $this; + } +} diff --git a/src/Entity/Teamer.php b/src/Entity/Teamer.php index ba94539..69c7db8 100644 --- a/src/Entity/Teamer.php +++ b/src/Entity/Teamer.php @@ -126,6 +126,9 @@ class Teamer implements TimestampableEntityInterface, SoftDeletableEntityInterfa #[ORM\OneToMany(mappedBy: 'teamer', targetEntity: Disposition::class, cascade: ['remove'])] private Collection $dispositions; + #[ORM\OneToMany(mappedBy: 'teamer', targetEntity: SeasonAssignment::class, cascade: ['remove'])] + private Collection $seasonAssignments; + #[ORM\OneToOne(mappedBy: 'teamer', fetch: 'EXTRA_LAZY')] private ?User $user = null; @@ -176,6 +179,9 @@ class Teamer implements TimestampableEntityInterface, SoftDeletableEntityInterfa #[ORM\Column(length: 8, nullable: true)] private ?string $driverLicenseReviewedBy = null; + #[ORM\Column] + private bool $allSeason = false; + public function __construct() { $this->uuid = Uuid::v4(); @@ -188,6 +194,7 @@ class Teamer implements TimestampableEntityInterface, SoftDeletableEntityInterfa $this->bookmarks = new ArrayCollection(); $this->applications = new ArrayCollection(); $this->dispositions = new ArrayCollection(); + $this->seasonAssignments = new ArrayCollection(); $this->customAvailabilities = new ArrayCollection(); } @@ -791,6 +798,36 @@ class Teamer implements TimestampableEntityInterface, SoftDeletableEntityInterfa return $this; } + /** + * @return Collection + */ + public function getSeasonAssignments(): Collection + { + return $this->seasonAssignments; + } + + public function addSeasonAssignment(SeasonAssignment $seasonAssignment): static + { + if (!$this->seasonAssignments->contains($seasonAssignment)) { + $this->seasonAssignments->add($seasonAssignment); + $seasonAssignment->setTeamer($this); + } + + return $this; + } + + public function removeSeasonAssignment(SeasonAssignment $seasonAssignment): static + { + if ($this->seasonAssignments->removeElement($seasonAssignment)) { + // set the owning side to null (unless already changed) + if ($seasonAssignment->getTeamer() === $this) { + $seasonAssignment->setTeamer(null); + } + } + + return $this; + } + public function getUser(): ?User { return $this->user; @@ -987,4 +1024,16 @@ class Teamer implements TimestampableEntityInterface, SoftDeletableEntityInterfa return $this; } + + public function isAllSeason(): bool + { + return $this->allSeason; + } + + public function setAllSeason(bool $allSeason): static + { + $this->allSeason = $allSeason; + + return $this; + } } diff --git a/src/Entity/Upload.php b/src/Entity/Upload.php index ec0492e..165342a 100644 --- a/src/Entity/Upload.php +++ b/src/Entity/Upload.php @@ -25,6 +25,17 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface public const TYPE_DRIVER_LICENSE = 'driver_license'; public const TYPE_RECEIPT = 'receipt'; + /** + * A document the office issues to one single teamer. Deliberately not TYPE_DOCUMENT: that type + * is the staff-wide library and UploadVoter grants every teamer access to all of it. + */ + public const TYPE_SEASON_DOCUMENT_OUT = 'season_document_out'; + + /** + * A signed document the teamer returns. These accumulate, like receipts. + */ + public const TYPE_SEASON_DOCUMENT_IN = 'season_document_in'; + public const STATUS_NEW = 'new'; public const STATUS_PENDING = 'pending'; public const STATUS_CHECKED = 'checked'; @@ -74,6 +85,9 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface #[ORM\ManyToOne(inversedBy: 'documents')] private ?Disposition $disposition = null; + #[ORM\ManyToOne(inversedBy: 'documents')] + private ?SeasonAssignment $seasonAssignment = null; + #[ORM\Column(type: Types::TEXT, nullable: true)] private ?string $comment = null; @@ -146,6 +160,8 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface self::TYPE_DOCUMENT => 'Info', self::TYPE_DRIVER_LICENSE => 'Führerschein', self::TYPE_RECEIPT => 'Beleg', + self::TYPE_SEASON_DOCUMENT_OUT => 'Saisonunterlage', + self::TYPE_SEASON_DOCUMENT_IN => 'Rückläufer Saison', default => 'Dokument', }; } @@ -268,6 +284,18 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface return $this; } + public function getSeasonAssignment(): ?SeasonAssignment + { + return $this->seasonAssignment; + } + + public function setSeasonAssignment(?SeasonAssignment $seasonAssignment): static + { + $this->seasonAssignment = $seasonAssignment; + + return $this; + } + public function getComment(): ?string { return $this->comment; diff --git a/src/Form/AssignmentType.php b/src/Form/AssignmentType.php index b63c703..a352877 100644 --- a/src/Form/AssignmentType.php +++ b/src/Form/AssignmentType.php @@ -122,13 +122,9 @@ class AssignmentType extends AbstractType ; }, ]) - ->add('remarks', TextareaType::class, [ + ->add('remarks', AutosizeTextareaType::class, [ 'label' => 'Anmerkungen intern', 'required' => false, - 'attr' => [ - 'data-controller' => 'textarea-autosize', - 'data-action' => 'textarea-autosize#resize', - ], ]) ->add('pickup', ChoiceType::class, [ 'label' => 'Busbegleitung ab', diff --git a/src/Form/AutosizeTextareaType.php b/src/Form/AutosizeTextareaType.php new file mode 100644 index 0000000..5580d49 --- /dev/null +++ b/src/Form/AutosizeTextareaType.php @@ -0,0 +1,41 @@ +setDefaults(['min_rows' => 1]); + $resolver->setAllowedTypes('min_rows', 'int'); + } + + public function buildView(FormView $view, FormInterface $form, array $options): void + { + // Merged rather than set, and the caller's own attributes win: a field that needs a + // second Stimulus controller on the same element composes the data-controller list + // itself, which is the only case this can collide on. + $view->vars['attr'] = array_merge([ + 'data-controller' => 'textarea-autosize', + 'data-action' => 'textarea-autosize#resize', + 'data-textarea-autosize-min-rows-value' => $options['min_rows'], + ], $view->vars['attr']); + } + + public function getParent(): string + { + return TextareaType::class; + } +} diff --git a/src/Form/DisableUserType.php b/src/Form/DisableUserType.php index 655969f..f47fb94 100644 --- a/src/Form/DisableUserType.php +++ b/src/Form/DisableUserType.php @@ -4,7 +4,6 @@ namespace App\Form; use App\Entity\User; use Symfony\Component\Form\AbstractType; -use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Validator\Constraints\NotBlank; @@ -14,23 +13,15 @@ class DisableUserType extends AbstractType public function buildForm(FormBuilderInterface $builder, array $options): void { $builder - ->add('disabledReason', TextareaType::class, [ + ->add('disabledReason', AutosizeTextareaType::class, [ 'label' => 'Begründung', - 'attr' => [ - 'data-controller' => 'textarea-autosize', - 'data-action' => 'textarea-autosize#resize', - ], 'constraints' => [ new NotBlank(message: 'Bitte gib die Begründung ein'), ], ]) - ->add('disabledReasonInternal', TextareaType::class, [ + ->add('disabledReasonInternal', AutosizeTextareaType::class, [ 'label' => 'Begründung intern', 'required' => false, - 'attr' => [ - 'data-controller' => 'textarea-autosize', - 'data-action' => 'textarea-autosize#resize', - ], ]) ; } diff --git a/src/Form/DispositionCallOffType.php b/src/Form/DispositionCallOffType.php index be4d3da..7ecd32a 100644 --- a/src/Form/DispositionCallOffType.php +++ b/src/Form/DispositionCallOffType.php @@ -6,7 +6,6 @@ use App\Entity\Disposition; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; -use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Validator\Constraints\NotBlank; @@ -23,12 +22,8 @@ class DispositionCallOffType extends AbstractType 'E&P' => Disposition::CALLED_OFF_BY_OFFICE, ], ]) - ->add('calledOffReason', TextareaType::class, [ + ->add('calledOffReason', AutosizeTextareaType::class, [ 'label' => 'Begründung', - 'attr' => [ - 'data-controller' => 'textarea-autosize', - 'data-action' => 'textarea-autosize#resize', - ], 'constraints' => [ new NotBlank(message: 'Bitte gib die Begründung ein'), ], diff --git a/src/Form/EmailTextType.php b/src/Form/EmailTextType.php index 238e82e..5ef8601 100644 --- a/src/Form/EmailTextType.php +++ b/src/Form/EmailTextType.php @@ -6,7 +6,6 @@ use App\Config\EmailTextDefinition; use App\Entity\EmailText; use App\Validator\KnownPlaceholders; use Symfony\Component\Form\AbstractType; -use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormInterface; @@ -31,13 +30,9 @@ class EmailTextType extends AbstractType 'required' => false, 'constraints' => [$placeholders], ]) - ->add('body', TextareaType::class, [ + ->add('body', AutosizeTextareaType::class, [ 'label' => 'Text', - 'attr' => [ - 'data-controller' => 'textarea-autosize', - 'data-action' => 'textarea-autosize#resize', - 'data-textarea-autosize-min-rows-value' => 12, - ], + 'min_rows' => 12, 'constraints' => [$placeholders], ]) ; diff --git a/src/Form/SeasonAssignmentFilterType.php b/src/Form/SeasonAssignmentFilterType.php new file mode 100644 index 0000000..6225950 --- /dev/null +++ b/src/Form/SeasonAssignmentFilterType.php @@ -0,0 +1,70 @@ +add('teamerName', TextType::class, [ + 'label' => 'Name', + 'required' => false, + ]) + // By code, not by name as the destination filters do: a season assignment stores + // the code itself, so this is an exact match rather than a substring guess. + ->add('hotelCodes', MultiselectType::class, [ + 'label' => 'Haus', + 'required' => false, + 'empty_label' => 'nicht filtern', + 'choices' => $this->houseCatalog->getCodeChoices(), + ]) + ->add('jobProfiles', MultiselectEntityType::class, [ + 'label' => 'Job-Profile', + 'class' => JobProfile::class, + 'required' => false, + 'choice_label' => 'name', + 'empty_label' => 'nicht filtern', + 'query_builder' => function (EntityRepository $repository) { + return $repository->createQueryBuilder('job_profile') + ->orderBy('job_profile.name', \SortDirection::Ascending); + }, + ]) + ->add('includeDeleted', CheckboxType::class, [ + 'label' => 'gelöschte Saisoneinsätze anzeigen', + 'required' => false, + ]) + // AbstractFilterHandler::handleRequest() branches on exactly these two names + ->add('apply', SubmitType::class, [ + 'label' => 'filtern', + ]) + ->add('reset', SubmitType::class, [ + 'label' => 'reset', + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver + ->setDefaults([ + 'data_class' => SeasonAssignmentFilterDto::class, + ]) + ; + } +} diff --git a/src/Form/SeasonAssignmentType.php b/src/Form/SeasonAssignmentType.php new file mode 100644 index 0000000..49f658c --- /dev/null +++ b/src/Form/SeasonAssignmentType.php @@ -0,0 +1,77 @@ +add('teamer', AutocompleteEntityType::class, [ + 'label' => 'Teamer:in', + 'class' => Teamer::class, + 'required' => false, + 'placeholder' => 'noch nicht zugewiesen', + 'label_property' => 'fullName', + 'label_function' => fn (Teamer $teamer) => (string) $teamer, + 'endpoint_route' => 'app_admin_autocomplete_season_teamer', + ]) + ->add('dateFrom', DatepickerType::class, [ + 'label' => 'Datum von', + ]) + ->add('dateTo', DatepickerType::class, [ + 'label' => 'Datum bis', + ]) + // The houses map is the whole list of places an all-season teamer can be placed, so + // a plain select over it needs neither an autocomplete endpoint nor a BusPro call. + ->add('hotelCode', ChoiceType::class, [ + 'label' => 'Haus', + 'placeholder' => 'Bitte auswählen', + 'choices' => $this->houseCatalog->getCodeChoices(), + ]) + ->add('jobProfile', EntityType::class, [ + 'label' => 'Jobprofil', + 'class' => JobProfile::class, + 'choice_label' => 'name', + 'placeholder' => 'Bitte auswählen', + 'query_builder' => function (EntityRepository $repository) { + return $repository->createQueryBuilder('job_profile') + ->where('job_profile.deletedAt IS NULL') + ->orderBy('job_profile.name', \SortDirection::Ascending); + }, + ]) + ->add('remarks', AutosizeTextareaType::class, [ + 'label' => 'Anmerkungen intern', + 'required' => false, + 'min_rows' => 4, + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver + ->setDefaults([ + 'data_class' => SeasonAssignment::class, + ]) + ; + } +} diff --git a/src/Form/SeasonTaskDeadlineType.php b/src/Form/SeasonTaskDeadlineType.php new file mode 100644 index 0000000..58e6a6b --- /dev/null +++ b/src/Form/SeasonTaskDeadlineType.php @@ -0,0 +1,39 @@ +add('deadlineOverride1', DatepickerType::class, [ + 'label' => 'Frist 1', + 'required' => false, + ]) + ->add('deadlineOverride2', DatepickerType::class, [ + 'label' => 'Frist 2', + 'required' => false, + 'help' => 'Ohne Angabe gilt Frist 1.', + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => SeasonAssignmentTask::class, + ]); + } +} diff --git a/src/Form/SeasonTaskDocumentType.php b/src/Form/SeasonTaskDocumentType.php new file mode 100644 index 0000000..b1bccb6 --- /dev/null +++ b/src/Form/SeasonTaskDocumentType.php @@ -0,0 +1,32 @@ +add('document', AutocompleteEntityType::class, [ + 'label' => 'Dokument', + 'class' => Upload::class, + 'label_property' => 'originalFilename', + 'label_function' => fn (Upload $upload) => $upload->getDisplayName() ?? $upload->getOriginalFilename(), + 'endpoint_route' => 'app_admin_autocomplete_document', + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => SeasonTaskDocument::class, + ]); + } +} diff --git a/src/Form/SpecialAgreementsType.php b/src/Form/SpecialAgreementsType.php index 81bf388..afae7f3 100644 --- a/src/Form/SpecialAgreementsType.php +++ b/src/Form/SpecialAgreementsType.php @@ -4,7 +4,6 @@ namespace App\Form; use App\Entity\Disposition; use Symfony\Component\Form\AbstractType; -use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -12,13 +11,9 @@ class SpecialAgreementsType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options): void { - $builder->add('specialAgreements', TextareaType::class, [ + $builder->add('specialAgreements', AutosizeTextareaType::class, [ 'label' => 'zusätzliche Absprache', 'required' => false, - 'attr' => [ - 'data-controller' => 'textarea-autosize', - 'data-action' => 'textarea-autosize#resize', - ], ]); } diff --git a/src/Form/TeamerMailingType.php b/src/Form/TeamerMailingType.php index 8d5408a..9ced363 100644 --- a/src/Form/TeamerMailingType.php +++ b/src/Form/TeamerMailingType.php @@ -4,7 +4,6 @@ namespace App\Form; use App\Model\TeamerMailingDto; use Symfony\Component\Form\AbstractType; -use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -17,13 +16,9 @@ class TeamerMailingType extends AbstractType ->add('subject', TextType::class, [ 'label' => 'Betreff', ]) - ->add('message', TextareaType::class, [ + ->add('message', AutosizeTextareaType::class, [ 'label' => 'Nachricht', - 'attr' => [ - 'data-controller' => 'textarea-autosize', - 'data-action' => 'textarea-autosize#resize', - 'data-textarea-autosize-min-rows-value' => 12, - ], + 'min_rows' => 12, ]) ; } diff --git a/src/Form/UserType.php b/src/Form/UserType.php index 492cfab..cb3e2ed 100644 --- a/src/Form/UserType.php +++ b/src/Form/UserType.php @@ -5,7 +5,6 @@ namespace App\Form; use App\Entity\User; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; -use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvents; @@ -30,23 +29,15 @@ class UserType extends AbstractType 'required' => false, 'help' => 'Gesperrte Benutzer:innen können sich nicht anmelden.', ]) - ->add('disabledReason', TextareaType::class, [ + ->add('disabledReason', AutosizeTextareaType::class, [ 'label' => 'Begründung', 'required' => false, 'help' => 'Wird bei der Anmeldung angezeigt.', - 'attr' => [ - 'data-controller' => 'textarea-autosize', - 'data-action' => 'textarea-autosize#resize', - ], ]) - ->add('disabledReasonInternal', TextareaType::class, [ + ->add('disabledReasonInternal', AutosizeTextareaType::class, [ 'label' => 'Begründung intern', 'required' => false, 'help' => 'Wird der Benutzer:in nicht angezeigt.', - 'attr' => [ - 'data-controller' => 'textarea-autosize', - 'data-action' => 'textarea-autosize#resize', - ], ]) ; diff --git a/src/Menu/AdminMenuBuilder.php b/src/Menu/AdminMenuBuilder.php index 89926ac..7f97ddc 100644 --- a/src/Menu/AdminMenuBuilder.php +++ b/src/Menu/AdminMenuBuilder.php @@ -116,6 +116,30 @@ class AdminMenuBuilder extends AbstractMenuBuilder ], ], ]); + $menu->addChild('Saisonteamer', [ + 'route' => 'app_administrative_season_teamer_index', + 'linkAttributes' => [ + 'title' => 'Saisonteamer', + ], + 'extras' => [ + 'icon' => 'users', + 'routes' => [ + ['pattern' => '/^app_administrative_season_teamer_/'], + ], + ], + ]); + $menu->addChild('Saisoneinsätze', [ + 'route' => 'app_administrative_season_assignment_index', + 'linkAttributes' => [ + 'title' => 'Saisoneinsätze', + ], + 'extras' => [ + 'icon' => 'calendar', + 'routes' => [ + ['pattern' => '/^app_administrative_season_assignment_/'], + ], + ], + ]); $menu->addChild('Verfügbarkeiten', [ 'route' => 'app_administrative_availability_index', 'linkAttributes' => [ @@ -272,6 +296,17 @@ class AdminMenuBuilder extends AbstractMenuBuilder ], ], ]); + $settingsMenu->addChild('Saisonteam-Dokumente', [ + 'route' => 'app_administrative_system_season_task_document_index', + 'linkAttributes' => [ + 'title' => 'Saisonteam-Dokumente', + ], + 'extras' => [ + 'routes' => [ + ['pattern' => '/^app_administrative_system_season_task_document_/'], + ], + ], + ]); $settingsMenu->addChild('Ansprechpersonen', [ 'route' => 'app_admin_system_contact_index', 'linkAttributes' => [ diff --git a/src/Menu/ManagerMenuBuilder.php b/src/Menu/ManagerMenuBuilder.php index d3367bf..4febc95 100644 --- a/src/Menu/ManagerMenuBuilder.php +++ b/src/Menu/ManagerMenuBuilder.php @@ -82,6 +82,30 @@ class ManagerMenuBuilder extends AbstractMenuBuilder ], ], ]); + $menu->addChild('Saisonteamer', [ + 'route' => 'app_administrative_season_teamer_index', + 'linkAttributes' => [ + 'title' => 'Saisonteamer', + ], + 'extras' => [ + 'icon' => 'users', + 'routes' => [ + ['pattern' => '/^app_administrative_season_teamer_/'], + ], + ], + ]); + $menu->addChild('Saisoneinsätze', [ + 'route' => 'app_administrative_season_assignment_index', + 'linkAttributes' => [ + 'title' => 'Saisoneinsätze', + ], + 'extras' => [ + 'icon' => 'calendar', + 'routes' => [ + ['pattern' => '/^app_administrative_season_assignment_/'], + ], + ], + ]); $menu->addChild('Feedbackübersicht', [ 'route' => 'app_administrative_feedback_index', 'linkAttributes' => [ @@ -175,6 +199,17 @@ class ManagerMenuBuilder extends AbstractMenuBuilder ], ], ]); + $settingsMenu->addChild('Saisonunterlagen', [ + 'route' => 'app_administrative_system_season_task_document_index', + 'linkAttributes' => [ + 'title' => 'Saisonunterlagen', + ], + 'extras' => [ + 'routes' => [ + ['pattern' => '/^app_administrative_system_season_task_document_/'], + ], + ], + ]); $this->addAdminItem($menu); $this->addTeamerItem($menu); diff --git a/src/Menu/TeamerMenuBuilder.php b/src/Menu/TeamerMenuBuilder.php index fcac434..439bdb3 100644 --- a/src/Menu/TeamerMenuBuilder.php +++ b/src/Menu/TeamerMenuBuilder.php @@ -2,10 +2,27 @@ namespace App\Menu; +use App\Entity\SeasonAssignment; +use App\Entity\User; +use App\Service\Season\SeasonAssignmentResolver; +use Knp\Menu\FactoryInterface; use Knp\Menu\ItemInterface; +use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Contracts\Translation\TranslatorInterface; class TeamerMenuBuilder extends AbstractMenuBuilder { + public function __construct( + FactoryInterface $factory, + Security $security, + RequestStack $requestStack, + TranslatorInterface $translator, + private readonly SeasonAssignmentResolver $seasonAssignmentResolver, + ) { + parent::__construct($factory, $security, $requestStack, $translator); + } + public function createMainMenu(array $options): ItemInterface { $menu = $this->createRootElement(); @@ -36,6 +53,23 @@ class TeamerMenuBuilder extends AbstractMenuBuilder 'icon' => 'profile', ], ]); + + // Only for an all-season teamer who actually has an assignment this season: the page + // would have nothing to show otherwise, and it throws a 404 in exactly that case. + if (null !== $this->resolveSeasonAssignment()) { + $profileMenu->addChild('Saison-Teamer:in', [ + 'route' => 'app_teamer_season_index', + 'linkAttributes' => [ + 'title' => 'Saison-Teamer:in', + ], + 'extras' => [ + 'routes' => [ + ['pattern' => '/^app_teamer_season_/'], + ], + ], + ]); + } + $profileMenu->addChild('Stammdaten & Foto', [ 'route' => 'app_teamer_profile_index', 'linkAttributes' => [ @@ -120,4 +154,15 @@ class TeamerMenuBuilder extends AbstractMenuBuilder return $menu; } + + private function resolveSeasonAssignment(): ?SeasonAssignment + { + $user = $this->security->getUser(); + + if (false === $user instanceof User || null === $teamer = $user->getTeamer()) { + return null; + } + + return $this->seasonAssignmentResolver->resolveFor($teamer); + } } diff --git a/src/Model/SeasonAssignmentFilterDto.php b/src/Model/SeasonAssignmentFilterDto.php new file mode 100644 index 0000000..3d7c4ef --- /dev/null +++ b/src/Model/SeasonAssignmentFilterDto.php @@ -0,0 +1,71 @@ +teamerName; + } + + public function setTeamerName(?string $teamerName): static + { + $this->teamerName = $teamerName; + + return $this; + } + + /** + * @return string[] + */ + public function getHotelCodes(): array + { + return $this->hotelCodes; + } + + /** + * @param string[] $hotelCodes + */ + public function setHotelCodes(array $hotelCodes): static + { + $this->hotelCodes = $hotelCodes; + + return $this; + } + + /** + * @return JobProfile[] + */ + public function getJobProfiles(): array + { + return $this->jobProfiles; + } + + public function setJobProfiles(array $jobProfiles): static + { + $this->jobProfiles = $jobProfiles; + + return $this; + } + + public function isIncludeDeleted(): bool + { + return $this->includeDeleted; + } + + public function setIncludeDeleted(bool $includeDeleted): static + { + $this->includeDeleted = $includeDeleted; + + return $this; + } +} diff --git a/src/Model/TeamerFilterDto.php b/src/Model/TeamerFilterDto.php index eb6cbfa..69cc392 100644 --- a/src/Model/TeamerFilterDto.php +++ b/src/Model/TeamerFilterDto.php @@ -15,6 +15,14 @@ class TeamerFilterDto extends AbstractFilterDto protected bool $includeInactive = false; protected bool $includeDeleted = false; + /** + * The scope of the list this filter belongs to, not a filter the user set. + * + * The leading underscore keeps it out of AbstractFilterDto::getActiveFiltersCount(), + * so the all-season list does not permanently claim to have one filter active. + */ + protected bool $_allSeason = false; + public function getName(): ?string { return $this->name; @@ -132,4 +140,16 @@ class TeamerFilterDto extends AbstractFilterDto return $this; } + + public function isAllSeason(): bool + { + return $this->_allSeason; + } + + public function setAllSeason(bool $allSeason): static + { + $this->_allSeason = $allSeason; + + return $this; + } } diff --git a/src/Repository/SeasonAssignmentRepository.php b/src/Repository/SeasonAssignmentRepository.php new file mode 100644 index 0000000..4539272 --- /dev/null +++ b/src/Repository/SeasonAssignmentRepository.php @@ -0,0 +1,162 @@ + + * + * @method SeasonAssignment|null find($id, $lockMode = null, $lockVersion = null) + * @method SeasonAssignment|null findOneBy(array $criteria, array $orderBy = null) + * @method SeasonAssignment[] findAll() + * @method SeasonAssignment[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class SeasonAssignmentRepository extends ServiceEntityRepository +{ + use QueryHelperTrait; + + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, SeasonAssignment::class); + } + + public function getListQuery(SeasonAssignmentFilterDto $filterDto): Query + { + $qb = $this->createQueryBuilder('season_assignment'); + + $qb + ->select('season_assignment', 'teamer', 'job_profile') + // an assignment waiting for a teamer has to stay in the list - that is exactly + // the state an administrative user is looking for when placing people + ->leftJoin('season_assignment.teamer', 'teamer') + ->innerJoin('season_assignment.jobProfile', 'job_profile') + ; + + $this->applyFilterSettings($filterDto, $qb); + + return $qb + ->addOrderBy('season_assignment.dateFrom', \SortDirection::Descending) + ->addOrderBy('teamer.lastName', \SortDirection::Ascending) + ->getQuery() + ; + } + + /** + * The assignments a teamer already has in one season, for the rule that allows only one. + * + * Bucketed by the assignment's own first day, which is exactly how + * App\Config\SeasonCalendar::seasonYearFor() decides what season something belongs to. + * Deliberately not SeasonPeriodFilter: that one also requires the period to end inside the + * window, and a placement running past the season's last day still belongs to it here. + */ + public function countInSeasonWindow( + Teamer $teamer, + \DateTimeImmutable $windowStart, + \DateTimeImmutable $windowEnd, + ?int $excludeId = null, + ): int { + return (int) $this + ->createInSeasonWindowQueryBuilder($teamer, $windowStart, $windowEnd, $excludeId) + ->getQuery() + ->getSingleScalarResult() + ; + } + + /** + * A teamer's assignments in one season, earliest first - what the teamer's own season page + * is about. Bucketed by the assignment's own first day, like countInSeasonWindow(). + * + * @return SeasonAssignment[] + */ + public function findForTeamerInSeason( + Teamer $teamer, + \DateTimeImmutable $windowStart, + \DateTimeImmutable $windowEnd, + ): array { + $qb = $this->createInSeasonWindowQueryBuilder($teamer, $windowStart, $windowEnd); + + return $qb + ->select('season_assignment') + ->addOrderBy('season_assignment.dateFrom', \SortDirection::Ascending) + ->getQuery() + ->getResult() + ; + } + + /** + * Extracted so the conditions can be pinned by a test compiling the DQL - the suite has no + * fixtures, so what is asserted is what the query says, not what it returns. + * + * The window is read off the assignment itself, so there is no second row that could be + * soft-deleted underneath a live placement and drop it out of the season it belongs to. + */ + public function createInSeasonWindowQueryBuilder( + Teamer $teamer, + \DateTimeImmutable $windowStart, + \DateTimeImmutable $windowEnd, + ?int $excludeId = null, + ): QueryBuilder { + $qb = $this->createQueryBuilder('season_assignment'); + + $qb + ->select($qb->expr()->count('season_assignment')) + ->where($qb->expr()->andX( + $qb->expr()->eq('season_assignment.teamer', ':teamer'), + $qb->expr()->isNull('season_assignment.deletedAt'), + $qb->expr()->gte('season_assignment.dateFrom', ':windowStart'), + $qb->expr()->lte('season_assignment.dateFrom', ':windowEnd') + )) + ->setParameter('teamer', $teamer) + ->setParameter('windowStart', $windowStart) + ->setParameter('windowEnd', $windowEnd) + ; + + if (null !== $excludeId) { + $qb + ->andWhere($qb->expr()->neq('season_assignment.id', ':excludeId')) + ->setParameter('excludeId', $excludeId) + ; + } + + return $qb; + } + + private function applyFilterSettings(SeasonAssignmentFilterDto $filterDto, QueryBuilder $qb): void + { + if (null !== $filterDto->getTeamerName()) { + $qb + ->andWhere($qb->expr()->orX( + $qb->expr()->like('teamer.firstName', ':teamerName'), + $qb->expr()->like('teamer.lastName', ':teamerName') + )) + ->setParameter('teamerName', '%'.$this->escapeLikeWildcards($filterDto->getTeamerName()).'%') + ; + } + + if (0 < count($filterDto->getHotelCodes())) { + $qb + ->andWhere($qb->expr()->in('season_assignment.hotelCode', ':hotelCodes')) + ->setParameter('hotelCodes', $filterDto->getHotelCodes()) + ; + } + + if (0 < count($filterDto->getJobProfiles())) { + $qb + ->andWhere($qb->expr()->in('season_assignment.jobProfile', ':jobProfiles')) + ->setParameter('jobProfiles', $filterDto->getJobProfiles()) + ; + } + + if (false === $filterDto->isIncludeDeleted()) { + $qb->andWhere($qb->expr()->isNull('season_assignment.deletedAt')); + } + } +} diff --git a/src/Repository/SeasonAssignmentTaskRepository.php b/src/Repository/SeasonAssignmentTaskRepository.php new file mode 100644 index 0000000..db25e5a --- /dev/null +++ b/src/Repository/SeasonAssignmentTaskRepository.php @@ -0,0 +1,88 @@ + + * + * @method SeasonAssignmentTask|null find($id, $lockMode = null, $lockVersion = null) + * @method SeasonAssignmentTask|null findOneBy(array $criteria, array $orderBy = null) + * @method SeasonAssignmentTask[] findAll() + * @method SeasonAssignmentTask[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class SeasonAssignmentTaskRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, SeasonAssignmentTask::class); + } + + /** + * Every open entry that could still be reminded about: not ticked off, on a live assignment + * that has a teamer on it. + * + * The deadlines themselves are not part of this query. They are computed from the + * assignment's first day by a relative offset, and month arithmetic is not invertible + * day by day - "minus one month" from the 31st and back again lands somewhere else - so a + * query built by inverting the offset would silently skip entries. The reminder resolves + * every row through SeasonTaskDeadlineResolver instead; the set is small, one assignment per + * all-season teamer per season. + * + * @return SeasonAssignmentTask[] + */ + public function findOpen(?\DateTimeImmutable $windowStart = null, ?\DateTimeImmutable $windowEnd = null): array + { + return $this + ->createOpenQueryBuilder($windowStart, $windowEnd) + ->getQuery() + ->getResult() + ; + } + + /** + * Extracted so the conditions can be pinned by a test compiling the DQL - the suite has no + * fixtures, so what is asserted is what the query says, not what it returns. + */ + public function createOpenQueryBuilder( + ?\DateTimeImmutable $windowStart = null, + ?\DateTimeImmutable $windowEnd = null, + ): QueryBuilder { + $qb = $this->createQueryBuilder('task'); + + $qb + ->select('task', 'season_assignment', 'teamer') + ->innerJoin('task.seasonAssignment', 'season_assignment') + // no teamer means nobody to remind and nobody to be late + ->innerJoin('season_assignment.teamer', 'teamer') + ->where($qb->expr()->andX( + $qb->expr()->eq('task.done', ':done'), + $qb->expr()->isNull('season_assignment.deletedAt'), + $qb->expr()->isNull('teamer.deletedAt') + )) + ->setParameter('done', false) + ->addOrderBy('teamer.lastName', \SortDirection::Ascending) + ->addOrderBy('teamer.firstName', \SortDirection::Ascending) + ; + + if (null !== $windowStart) { + $qb + ->andWhere($qb->expr()->gte('season_assignment.dateFrom', ':windowStart')) + ->setParameter('windowStart', $windowStart) + ; + } + + if (null !== $windowEnd) { + $qb + ->andWhere($qb->expr()->lte('season_assignment.dateFrom', ':windowEnd')) + ->setParameter('windowEnd', $windowEnd) + ; + } + + return $qb; + } +} diff --git a/src/Repository/SeasonTaskDocumentRepository.php b/src/Repository/SeasonTaskDocumentRepository.php new file mode 100644 index 0000000..972bfa4 --- /dev/null +++ b/src/Repository/SeasonTaskDocumentRepository.php @@ -0,0 +1,46 @@ + + * + * @method SeasonTaskDocument|null find($id, $lockMode = null, $lockVersion = null) + * @method SeasonTaskDocument|null findOneBy(array $criteria, array $orderBy = null) + * @method SeasonTaskDocument[] findAll() + * @method SeasonTaskDocument[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class SeasonTaskDocumentRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, SeasonTaskDocument::class); + } + + /** + * The mapping keyed by task, for views that render the whole checklist at once and would + * otherwise query per row. + * + * @return array + */ + public function findIndexedByTaskKey(): array + { + $indexed = []; + + foreach ($this->findAll() as $seasonTaskDocument) { + $indexed[$seasonTaskDocument->getTaskKey()] = $seasonTaskDocument; + } + + return $indexed; + } + + public function findForTask(SeasonTask $task): ?SeasonTaskDocument + { + return $this->findOneBy(['taskKey' => $task->value]); + } +} diff --git a/src/Repository/TeamerRepository.php b/src/Repository/TeamerRepository.php index 7717578..cfba4af 100644 --- a/src/Repository/TeamerRepository.php +++ b/src/Repository/TeamerRepository.php @@ -150,6 +150,13 @@ class TeamerRepository extends ServiceEntityRepository ; } + if (true === $filterDto->isAllSeason()) { + $qb + ->andWhere($qb->expr()->eq('teamer.allSeason', ':allSeason')) + ->setParameter('allSeason', true) + ; + } + if (false === $filterDto->isIncludeDeleted()) { $qb->andWhere($qb->expr()->isNull('teamer.deletedAt')); } @@ -167,11 +174,15 @@ class TeamerRepository extends ServiceEntityRepository return $qb; } - public function getAutocompletionData(string $search): array + /** + * @param bool $allSeasonOnly restricts the suggestions to all-season teamers, so a season + * assignment cannot be created for someone who works short term + */ + private function createAutocompletionQueryBuilder(string $search, bool $allSeasonOnly): QueryBuilder { $qb = $this->createQueryBuilder('teamer'); - $teamers = $qb + $qb ->where($qb->expr()->orX( $qb->expr()->like('teamer.lastName', ':search'), $qb->expr()->like('teamer.firstName', ':search'), @@ -180,6 +191,22 @@ class TeamerRepository extends ServiceEntityRepository ->orderBy('teamer.lastName', \SortDirection::Ascending) ->addOrderBy('teamer.firstName', \SortDirection::Ascending) ->setParameter('search', '%'.$this->escapeLikeWildcards($search).'%') + ; + + if (true === $allSeasonOnly) { + $qb + ->andWhere($qb->expr()->eq('teamer.allSeason', ':allSeason')) + ->setParameter('allSeason', true) + ; + } + + return $qb; + } + + public function getAutocompletionData(string $search, bool $allSeasonOnly = false): array + { + $teamers = $this + ->createAutocompletionQueryBuilder($search, $allSeasonOnly) ->getQuery() ->getResult() ; diff --git a/src/Repository/UploadRepository.php b/src/Repository/UploadRepository.php index ad91bd9..205f007 100644 --- a/src/Repository/UploadRepository.php +++ b/src/Repository/UploadRepository.php @@ -8,6 +8,7 @@ use App\Model\DocumentFilterDto; use App\Repository\Traits\QueryHelperTrait; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\Query; +use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; /** @@ -46,17 +47,17 @@ class UploadRepository extends ServiceEntityRepository ; } + /** + * The picker behind every "choose a document" field, restricted to the global library. + * + * Without the type condition it offers every upload in the system - Honorarverträge, + * Honorarnoten, Belege, Führerscheine - so picking one would have pointed a setting at a + * teamer's private file. + */ public function getAutocompletionData(string $search): array { - $qb = $this->createQueryBuilder('upload'); - - $documents = $qb - ->where($qb->expr()->orX( - $qb->expr()->like('upload.originalFilename', ':filename'), - $qb->expr()->like('upload.displayName', ':filename') - )) - ->setParameter('filename', '%'.$this->escapeLikeWildcards($search).'%') - ->orderBy('upload.originalFilename', \SortDirection::Ascending) + $documents = $this + ->createAutocompletionQueryBuilder($search) ->getQuery() ->getResult() ; @@ -79,6 +80,24 @@ class UploadRepository extends ServiceEntityRepository return $data; } + private function createAutocompletionQueryBuilder(string $search): QueryBuilder + { + $qb = $this->createQueryBuilder('upload'); + + return $qb + ->where($qb->expr()->andX( + $qb->expr()->eq('upload.type', ':type'), + $qb->expr()->orX( + $qb->expr()->like('upload.originalFilename', ':filename'), + $qb->expr()->like('upload.displayName', ':filename') + ) + )) + ->setParameter('type', Upload::TYPE_DOCUMENT) + ->setParameter('filename', '%'.$this->escapeLikeWildcards($search).'%') + ->orderBy('upload.originalFilename', \SortDirection::Ascending) + ; + } + public function getBatchDownloadListQuery( string $type = Upload::TYPE_INVOICE, string $status = Upload::STATUS_PAID, diff --git a/src/Security/Voter/UploadVoter.php b/src/Security/Voter/UploadVoter.php index e8e9ccf..a939f47 100644 --- a/src/Security/Voter/UploadVoter.php +++ b/src/Security/Voter/UploadVoter.php @@ -50,9 +50,30 @@ class UploadVoter extends Voter /** @var User $user */ $user = $token->getUser(); - return Upload::TYPE_DOCUMENT === $upload->getType() || $user === $upload->getOwner(); + if (Upload::TYPE_DOCUMENT === $upload->getType() || $user === $upload->getOwner()) { + return true; + } + + // A document the office issues for one single teamer is owned by the admin who + // uploaded it, so neither rule above reaches it - and it must not be TYPE_DOCUMENT, + // since that type is the staff-wide library every teamer may read. The teamer it was + // issued to is therefore let in through their own season assignment. + return static::DOWNLOAD === $attribute && $this->isOwnSeasonDocument($upload, $user); } return false; } + + private function isOwnSeasonDocument(Upload $upload, User $user): bool + { + if (Upload::TYPE_SEASON_DOCUMENT_OUT !== $upload->getType()) { + return false; + } + + if (null === $teamer = $upload->getSeasonAssignment()?->getTeamer()) { + return false; + } + + return $teamer === $user->getTeamer(); + } } diff --git a/src/Service/Common/SeasonAssignmentFilterHandler.php b/src/Service/Common/SeasonAssignmentFilterHandler.php new file mode 100644 index 0000000..9b9ca4f --- /dev/null +++ b/src/Service/Common/SeasonAssignmentFilterHandler.php @@ -0,0 +1,54 @@ +modelClass(); + + if (null === $data = $this->getSession()->get($this->namespace)) { + return $filterDto; + } + + $filterDto + ->setTeamerName($data['teamer_name']) + // ?? []: sessions written before the houses filter replaced the destination one + // still carry the old payload under this same namespace + ->setHotelCodes($data['hotel_codes'] ?? []) + ->setIncludeDeleted((bool) ($data['include_deleted'] ?? false)) + ; + + if (0 < count($data['job_profiles'])) { + $jobProfiles = $this + ->entityManager + ->getRepository(JobProfile::class) + ->findBy(['id' => $data['job_profiles']]) + ; + $filterDto->setJobProfiles($jobProfiles); + } + + return $filterDto; + } + + protected function saveFilterSettings(AbstractFilterDto $filterDto): void + { + /* @var SeasonAssignmentFilterDto $filterDto */ + $this->getSession()->set($this->namespace, [ + 'teamer_name' => $filterDto->getTeamerName(), + 'hotel_codes' => $filterDto->getHotelCodes(), + 'include_deleted' => $filterDto->isIncludeDeleted(), + 'job_profiles' => array_map(function (JobProfile $jobProfile) { + return $jobProfile->getId(); + }, $filterDto->getJobProfiles()), + ]); + } +} diff --git a/src/Service/Common/TeamerFilterHandler.php b/src/Service/Common/TeamerFilterHandler.php index 23a7861..44ae1b9 100644 --- a/src/Service/Common/TeamerFilterHandler.php +++ b/src/Service/Common/TeamerFilterHandler.php @@ -8,13 +8,43 @@ use App\Model\TeamerFilterDto; class TeamerFilterHandler extends AbstractFilterHandler { - protected string $namespace = 'filter:teamer'; + /** + * The value the teamer lists pass around as a 'scope' query parameter to say which + * of the two they are working on. + */ + public const SCOPE_ALL_SEASON = 'all_season'; + + public const NAMESPACE_DEFAULT = 'filter:teamer'; + + /** + * The all-season list keeps its own session bucket so filtering one teamer list + * never silently changes what the other one shows. + */ + public const NAMESPACE_ALL_SEASON = 'filter:teamer:all_season'; + + protected string $namespace = self::NAMESPACE_DEFAULT; protected string $modelClass = TeamerFilterDto::class; + /** + * Point the handler at the list the request belongs to. Every caller reading teamer + * filter settings has to do this first - the mailing included, otherwise it would + * resolve its recipients from the other list's filter. + */ + public function setScope(?string $scope): static + { + return $this->setNamespace(self::SCOPE_ALL_SEASON === $scope + ? self::NAMESPACE_ALL_SEASON + : self::NAMESPACE_DEFAULT); + } + public function getFilterSettings(): TeamerFilterDto { $filterDto = new $this->modelClass(); + // the bucket the settings live in is what makes a list an all-season list, so the + // scope is derived here rather than left to each caller to remember + $filterDto->setAllSeason(self::NAMESPACE_ALL_SEASON === $this->namespace); + if (null === $data = $this->getSession()->get($this->namespace)) { return $filterDto; } diff --git a/src/Service/Cron/SeasonTaskReminderService.php b/src/Service/Cron/SeasonTaskReminderService.php new file mode 100644 index 0000000..8ca2857 --- /dev/null +++ b/src/Service/Cron/SeasonTaskReminderService.php @@ -0,0 +1,189 @@ +setTime(0, 0); + + $violations = []; + + foreach ($this->seasonAssignmentTaskRepository->findOpen() as $task) { + if (null === $level = $this->levelDueOn($task, $today)) { + continue; + } + + $teamer = (string) $task->getSeasonAssignment()?->getTeamer(); + + $violations[$teamer][] = [ + 'task' => $task, + 'label' => $task->getTask()?->label() ?? $task->getTaskKey(), + 'level' => $level, + ]; + } + + if (0 === $count = count($violations)) { + $message = 'No season task deadlines reached today'; + $this->logger->info($message); + + return $message; + } + + // the two roles that carry ROLE_ADMINISTRATIVE: the repository matches roles as they are + // stored on the user, and nobody is ever stored with the hierarchy-granted one + foreach ($this->userRepository->getUsersByRoles(['ROLE_TEAM_ADMIN', 'ROLE_MANAGER']) as $user) { + if (true === $user->isMuteNotifications()) { + continue; + } + + $this->mailer->createAndSendEmail([ + 'violations' => $violations, + ], [ + 'to' => $user->getEmail(), + 'subject' => 'Fristen für Saisonunterlagen erreicht', + 'template' => 'email/season_deadlines.html.twig', + ]); + } + + $message = 'Sent the season task deadline summary for '.$count.' teamers'; + $this->logger->info($message); + + return $message; + } + + /** + * One mail per teamer, every Monday, listing what they still have to send us. It repeats + * until an administrative user ticks the entry off; it is not tied to a deadline at all. + */ + public function sendTeamerDigest(?\DateTimeImmutable $today = null): string + { + $today = ($today ?? new \DateTimeImmutable('today'))->setTime(0, 0); + + if (self::DIGEST_WEEKDAY !== (int) $today->format('N')) { + $message = 'Season task digests are only sent on mondays'; + $this->logger->info($message); + + return $message; + } + + // Scoped to the season being prepared, so a teamer is never chased about a season that + // is long over. + [$windowStart, $windowEnd] = $this->seasonCalendar->windowFor( + $this->seasonCalendar->currentOrUpcomingSeasonYear($today) + ); + + $open = $this->seasonAssignmentTaskRepository->findOpen( + $windowStart->toDateTimeImmutable(), + $windowEnd->toDateTimeImmutable() + ); + + $perTeamer = []; + + foreach ($open as $task) { + $seasonAssignment = $task->getSeasonAssignment(); + + if (null === $seasonAssignment) { + continue; + } + + // office-internal entries never reach the teamer + if (true !== $task->getTask()?->requiresReturn()) { + continue; + } + + $perTeamer[$seasonAssignment->getId()]['assignment'] = $seasonAssignment; + $perTeamer[$seasonAssignment->getId()]['labels'][] = $task->getTask()->label(); + } + + $count = 0; + + foreach ($perTeamer as $entry) { + $teamer = $entry['assignment']->getTeamer(); + $email = $teamer?->getCommunication()?->getEmail(); + + if (null === $email) { + continue; + } + + $this->mailer->createAndSendText(EmailTextKey::REMINDER_SEASON_TASKS, [ + // the placeholder keeps its name: admins can have edited this text in the + // database, and renaming it would blank it out in those stored versions + 'destination' => $this->labeler->label($entry['assignment']), + 'tasks' => implode(', ', $entry['labels']), + ], [ + 'to' => $email, + ]); + + ++$count; + } + + $message = 'Sent '.$count.' season task digests to teamers'; + $this->logger->info($message); + + return $message; + } + + /** + * Which deadline of this entry falls on $day, or null for none. Frist 1 wins when both do. + */ + private function levelDueOn(SeasonAssignmentTask $task, \DateTimeImmutable $day): ?int + { + if ($this->isSameDay($this->deadlineResolver->resolveFirst($task), $day)) { + return 1; + } + + if ($this->isSameDay($this->deadlineResolver->resolveSecond($task), $day)) { + return 2; + } + + return null; + } + + private function isSameDay(?\DateTimeImmutable $deadline, \DateTimeImmutable $day): bool + { + return null !== $deadline && $deadline->format('Y-m-d') === $day->format('Y-m-d'); + } +} diff --git a/src/Service/Season/SeasonAssignmentLabeler.php b/src/Service/Season/SeasonAssignmentLabeler.php new file mode 100644 index 0000000..1ad1066 --- /dev/null +++ b/src/Service/Season/SeasonAssignmentLabeler.php @@ -0,0 +1,48 @@ +seasonCalendar->labelFor($seasonAssignment->getDateFrom()), + $this->houseLabel($seasonAssignment->getHotelCode()) + ); + } + + /** + * A code that has since been dropped from the houses map still has to name itself, so it + * falls back to the raw code rather than rendering an empty cell. + */ + public function houseLabel(?string $hotelCode): string + { + if (null === $hotelCode) { + return '?'; + } + + return $this->houseCatalog->getName($hotelCode) ?? $hotelCode; + } +} diff --git a/src/Service/Season/SeasonAssignmentResolver.php b/src/Service/Season/SeasonAssignmentResolver.php new file mode 100644 index 0000000..c01531e --- /dev/null +++ b/src/Service/Season/SeasonAssignmentResolver.php @@ -0,0 +1,60 @@ +isAllSeason()) { + return null; + } + + [$windowStart, $windowEnd] = $this->seasonCalendar->windowFor( + $this->seasonCalendar->currentOrUpcomingSeasonYear() + ); + + $seasonAssignments = $this->seasonAssignmentRepository->findForTeamerInSeason( + $teamer, + $windowStart->toDateTimeImmutable(), + $windowEnd->toDateTimeImmutable() + ); + + if (0 === count($seasonAssignments)) { + return null; + } + + if (1 < count($seasonAssignments)) { + $this->logger->warning('Multiple season assignments resolved for one teamer', [ + 'teamer_id' => $teamer->getId(), + 'count' => count($seasonAssignments), + ]); + } + + return $seasonAssignments[0]; + } +} diff --git a/src/Service/Season/SeasonChecklistBuilder.php b/src/Service/Season/SeasonChecklistBuilder.php new file mode 100644 index 0000000..16bf9a2 --- /dev/null +++ b/src/Service/Season/SeasonChecklistBuilder.php @@ -0,0 +1,81 @@ +> + */ + public function build(SeasonAssignment $seasonAssignment): array + { + $tasks = []; + + foreach ($seasonAssignment->getTasks() as $task) { + if (null !== $definition = $task->getTask()) { + $tasks[$definition->value] = $task; + } + } + + $documents = $this->seasonTaskDocumentRepository->findIndexedByTaskKey(); + + $rows = []; + + foreach (SeasonTask::cases() as $case) { + if (false === isset($tasks[$case->value])) { + continue; + } + + $rows[] = $this->buildRow($tasks[$case->value], $documents[$case->value] ?? null); + } + + return $rows; + } + + /** + * @return array + */ + public function buildRow(SeasonAssignmentTask $task, ?SeasonTaskDocument $seasonTaskDocument = null): array + { + $definition = $task->getTask(); + + if (null === $definition) { + throw new \InvalidArgumentException(sprintf('Task key "%s" is not defined.', $task->getTaskKey())); + } + + if (null === $seasonTaskDocument && SeasonTaskProvision::STANDARD === $definition->provides()) { + $seasonTaskDocument = $this->seasonTaskDocumentRepository->findForTask($definition); + } + + return [ + 'task' => $task, + 'definition' => $definition, + 'deadline1' => $this->deadlineResolver->resolveFirst($task), + 'deadline2' => $this->deadlineResolver->resolveSecond($task), + 'document' => $seasonTaskDocument?->getDocument(), + ]; + } +} diff --git a/src/Service/Season/SeasonTaskDeadlineResolver.php b/src/Service/Season/SeasonTaskDeadlineResolver.php new file mode 100644 index 0000000..e668a9b --- /dev/null +++ b/src/Service/Season/SeasonTaskDeadlineResolver.php @@ -0,0 +1,55 @@ +getDeadlineOverride1()) { + return $override; + } + + return $this->defaultDeadline($task); + } + + public function resolveSecond(SeasonAssignmentTask $task): ?\DateTimeImmutable + { + if (null !== $override = $task->getDeadlineOverride2()) { + return $override; + } + + return $this->resolveFirst($task); + } + + /** + * Null only when the assignment has no start date yet, which the create form does not + * allow but a half-built object in memory can still be in. + */ + private function defaultDeadline(SeasonAssignmentTask $task): ?\DateTimeImmutable + { + $dateFrom = $task->getSeasonAssignment()?->getDateFrom(); + + return $dateFrom?->modify($this->seasonTaskDeadlineOffset); + } +} diff --git a/src/Service/Season/SeasonTaskSynchronizer.php b/src/Service/Season/SeasonTaskSynchronizer.php new file mode 100644 index 0000000..3b3f016 --- /dev/null +++ b/src/Service/Season/SeasonTaskSynchronizer.php @@ -0,0 +1,55 @@ +getTasks() as $task) { + $existing[$task->getTaskKey()] = true; + } + + $created = false; + + foreach (SeasonTask::cases() as $case) { + if (true === isset($existing[$case->value])) { + continue; + } + + $task = (new SeasonAssignmentTask())->setTaskKey($case->value); + + $seasonAssignment->addTask($task); + $this->entityManager->persist($task); + + $created = true; + } + + if (true === $created) { + $this->entityManager->flush(); + } + } +} diff --git a/src/Twig/AppExtension.php b/src/Twig/AppExtension.php index e7dcb37..ecb35af 100644 --- a/src/Twig/AppExtension.php +++ b/src/Twig/AppExtension.php @@ -19,6 +19,8 @@ class AppExtension extends AbstractExtension new TwigFilter('format_rating', [AppRuntime::class, 'formatRating'], ['is_safe' => ['html']]), new TwigFilter('license_label', [AppRuntime::class, 'licenseLabel']), new TwigFilter('teamer_status_label', [AppRuntime::class, 'teamerStatusLabel']), + new TwigFilter('season_assignment_label', [AppRuntime::class, 'seasonAssignmentLabel']), + new TwigFilter('house_label', [AppRuntime::class, 'houseLabel']), new TwigFilter('bpn_country_label', [AppRuntime::class, 'bpnCountryLabel']), new TwigFilter('bpn_pickup_label', [AppRuntime::class, 'bpnPickupLabel']), new TwigFilter('upload_status_badge', [AppRuntime::class, 'renderUploadStatusBadge'], ['is_safe' => ['html']]), diff --git a/src/Twig/AppRuntime.php b/src/Twig/AppRuntime.php index c0ce112..5d75ea1 100644 --- a/src/Twig/AppRuntime.php +++ b/src/Twig/AppRuntime.php @@ -4,8 +4,10 @@ namespace App\Twig; use App\BusProNet\DataProvider\CountryDataProvider; use App\BusProNet\DataProvider\PickupDataProvider; +use App\Entity\SeasonAssignment; use App\Entity\Teamer; use App\Entity\Upload; +use App\Service\Season\SeasonAssignmentLabeler; use App\Service\Teamer\PickupResolver; use Carbon\CarbonImmutable; use Symfony\Component\HttpFoundation\RequestStack; @@ -23,11 +25,43 @@ class AppRuntime implements RuntimeExtensionInterface private readonly CountryDataProvider $countries, private readonly PickupDataProvider $pickups, private readonly PickupResolver $pickupResolver, + private readonly SeasonAssignmentLabeler $seasonAssignmentLabeler, private readonly string $environment, ) { } - public function teamerStatusLabel(Teamer $teamer): string + public function seasonAssignmentLabel(SeasonAssignment $seasonAssignment): string + { + return $this->seasonAssignmentLabeler->label($seasonAssignment); + } + + /** + * The house name for a stored hotel code. Only the code is stored, so every template that + * shows a house resolves it here rather than freezing a name into the record. + */ + public function houseLabel(?string $hotelCode): string + { + return $this->seasonAssignmentLabeler->houseLabel($hotelCode); + } + + /** + * All-season is a flag on top of the status, not a status of its own - a teamer can be a + * Bestandsteamer:in and an all-season teamer at once. The general Teamübersicht is the only + * place that overlap is not otherwise visible, so the marker is appended there; the + * dedicated Saisonteamer list passes false, where repeating it on every row says nothing. + */ + public function teamerStatusLabel(Teamer $teamer, bool $includeAllSeason = true): string + { + $label = $this->baseTeamerStatusLabel($teamer); + + if (true === $includeAllSeason && true === $teamer->isAllSeason()) { + return sprintf('%s · Saison', $label); + } + + return $label; + } + + private function baseTeamerStatusLabel(Teamer $teamer): string { $labelItems = []; $user = $teamer->getUser(); diff --git a/src/Validator/Constraints/SeasonAssignment.php b/src/Validator/Constraints/SeasonAssignment.php new file mode 100644 index 0000000..8d12741 --- /dev/null +++ b/src/Validator/Constraints/SeasonAssignment.php @@ -0,0 +1,16 @@ +getTeamer()) { + return; + } + + if (null === $dateFrom = $seasonAssignment->getDateFrom()) { + return; + } + + // A placement starting outside any season window belongs to no season, so there is + // nothing to be the second of. + if (null === $seasonYear = $this->seasonCalendar->seasonYearFor($dateFrom)) { + return; + } + + [$windowStart, $windowEnd] = $this->seasonCalendar->windowFor($seasonYear); + + $existing = $this->seasonAssignmentRepository->countInSeasonWindow( + $teamer, + $windowStart->toDateTimeImmutable(), + $windowEnd->toDateTimeImmutable(), + $seasonAssignment->getId() + ); + + if (0 < $existing) { + $this + ->context + ->buildViolation($constraint->message) + ->addViolation() + ; + } + } +} diff --git a/templates/admin/teamer/mailing.html.twig b/templates/admin/teamer/mailing.html.twig index a1430d8..ffb8c0f 100644 --- a/templates/admin/teamer/mailing.html.twig +++ b/templates/admin/teamer/mailing.html.twig @@ -93,7 +93,7 @@ {% if recipients.eligibleCount > 0 %} {% if hasDraft %} - + Entwurf verwerfen {% endif %} diff --git a/templates/admin/teamer/modal_mailing_confirm.html.twig b/templates/admin/teamer/modal_mailing_confirm.html.twig index 3e259a0..025b607 100644 --- a/templates/admin/teamer/modal_mailing_confirm.html.twig +++ b/templates/admin/teamer/modal_mailing_confirm.html.twig @@ -41,4 +41,4 @@ {# without a complete mail there is nothing to confirm, the button only leads back to the draft #} {% block button_confirm %}{{ sendable ? 'Jetzt senden' : 'Zurück zum Entwurf' }}{% endblock %} -{% block button_attribute %}{% if sendable %}hx-post="{{ path('app_admin_teamer_mailing_send') }}" hx-include="#mailing-confirm-fields"{% else %}{{ stimulus_action('modal', 'close') }}{% endif %}{% endblock %} +{% block button_attribute %}{% if sendable %}hx-post="{{ path('app_admin_teamer_mailing_send', { 'scope': scope }) }}" hx-include="#mailing-confirm-fields"{% else %}{{ stimulus_action('modal', 'close') }}{% endif %}{% endblock %} diff --git a/templates/admin/teamer/skills.html.twig b/templates/admin/teamer/skills.html.twig index f97f827..07311b0 100644 --- a/templates/admin/teamer/skills.html.twig +++ b/templates/admin/teamer/skills.html.twig @@ -20,6 +20,9 @@
{{ form_widget(form.status) }}
+
+ {{ form_row(form.allSeason) }} +
{{ form_row(form.allowOverlappingApplications) }}
diff --git a/templates/administrative/season_assignment/_done_teamer.html.twig b/templates/administrative/season_assignment/_done_teamer.html.twig new file mode 100644 index 0000000..162a774 --- /dev/null +++ b/templates/administrative/season_assignment/_done_teamer.html.twig @@ -0,0 +1,17 @@ +{# Swapped in place by its own route, so the id and the outerHTML swap have to stay. #} +
+ + +
diff --git a/templates/administrative/season_assignment/_form.html.twig b/templates/administrative/season_assignment/_form.html.twig new file mode 100644 index 0000000..38bea50 --- /dev/null +++ b/templates/administrative/season_assignment/_form.html.twig @@ -0,0 +1,25 @@ +{{ form_start(form) }} +
+
+ {{ form_row(form.teamer) }} + {{ form_row(form.hotelCode) }} +
+
+ {{ form_row(form.dateFrom) }} + {{ form_row(form.dateTo) }} +
+
+ {{ form_row(form.jobProfile) }} + {{ form_row(form.remarks) }} +
+
+
+ + + Abbrechen + +
+{{ form_rest(form) }} +{{ form_end(form) }} diff --git a/templates/administrative/season_assignment/_task_row.html.twig b/templates/administrative/season_assignment/_task_row.html.twig new file mode 100644 index 0000000..a3f2e18 --- /dev/null +++ b/templates/administrative/season_assignment/_task_row.html.twig @@ -0,0 +1,63 @@ +{# 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. #} + + +
{{ row.definition.label }}
+ {% if row.definition.provides == constant('App\\Config\\SeasonTaskProvision::STANDARD') %} + {% if row.document is not null %} + + {{ row.document.displayName ?? row.document.originalFilename }} + + {% else %} +
kein Dokument hinterlegt
+ {% endif %} + {% elseif row.definition.provides == constant('App\\Config\\SeasonTaskProvision::INDIVIDUAL') %} +
individuelles Dokument
+ {% endif %} + + + {{ row.deadline1 is not null ? row.deadline1 | date('d.m.Y') : '-' }} + {% if row.task.deadlineOverride1 is not null %} + (angepasst) + {% endif %} + + + {{ row.deadline2 is not null ? row.deadline2 | date('d.m.Y') : '-' }} + {% if row.task.deadlineOverride2 is not null %} + (angepasst) + {% endif %} + + + + + + + {% if row.task.done and row.task.doneAt is not null %} +
+ {{ row.task.doneAt | date('d.m.Y') }}{% if row.task.doneBy is not null %}, {{ row.task.doneBy }}{% endif %} +
+ {% endif %} + + + + + diff --git a/templates/administrative/season_assignment/create.html.twig b/templates/administrative/season_assignment/create.html.twig new file mode 100644 index 0000000..1491fd0 --- /dev/null +++ b/templates/administrative/season_assignment/create.html.twig @@ -0,0 +1,10 @@ +{% extends 'administrative/layout.html.twig' %} + +{% block title %}Saisoneinsatz anlegen{% endblock %} + +{% block content %} +

+ Saisoneinsatz anlegen +

+ {% include 'administrative/season_assignment/_form.html.twig' %} +{% endblock %} diff --git a/templates/administrative/season_assignment/detail.html.twig b/templates/administrative/season_assignment/detail.html.twig new file mode 100644 index 0000000..f90a05e --- /dev/null +++ b/templates/administrative/season_assignment/detail.html.twig @@ -0,0 +1,161 @@ +{% extends 'administrative/layout.html.twig' %} + +{% block title %}Saisoneinsatz{% endblock %} + +{% block content %} +
+

+ Saisoneinsatz + + {{ seasonAssignment | season_assignment_label }} + +

+ +
+ + {% set teamer = seasonAssignment.teamer %} + +
+
+
+

Teamer:in

+ {% if teamer is not null %} + + {% else %} + noch nicht zugewiesen + {% endif %} +
+
    +
  • +

    Jobprofil

    + {{ seasonAssignment.jobProfile.name }} +
  • +
  • +

    Haus

    + {{ seasonAssignment.hotelCode | house_label }} +
  • +
  • +

    Zeitraum

    + {{ seasonAssignment.dateFrom | date('d.m.Y') }} - {{ seasonAssignment.dateTo | date('d.m.Y') }} +
  • +
+
+ {% if teamer is not null and teamer.photo is not null %} + + {{ teamer.firstName }} + + {% endif %} +
+ +
+

+ 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' %} +
+
+ +
+
+

+ Dokumente +

+ +
+ {% if seasonAssignment.documents | length > 0 %} +
    + {% for document in seasonAssignment.documents %} +
  • +
    + + {{ document.originalFilename }} + +
    + {{ document.typeLabel }}, hochgeladen am {{ document.createdAt | date('d.m.Y') }} +
    +
    + +
  • + {% endfor %} +
+ {% else %} +
keine Dokumente vorhanden
+ {% endif %} +
+ +
+

Anmerkungen intern

+ {% if seasonAssignment.remarks is not empty %} +
{{ seasonAssignment.remarks }}
+ {% else %} +
-
+ {% endif %} +
+{% endblock %} diff --git a/templates/administrative/season_assignment/edit.html.twig b/templates/administrative/season_assignment/edit.html.twig new file mode 100644 index 0000000..d1bcce7 --- /dev/null +++ b/templates/administrative/season_assignment/edit.html.twig @@ -0,0 +1,10 @@ +{% extends 'administrative/layout.html.twig' %} + +{% block title %}Saisoneinsatz bearbeiten{% endblock %} + +{% block content %} +

+ Saisoneinsatz bearbeiten +

+ {% include 'administrative/season_assignment/_form.html.twig' %} +{% endblock %} diff --git a/templates/administrative/season_assignment/index.html.twig b/templates/administrative/season_assignment/index.html.twig new file mode 100644 index 0000000..3e8abfa --- /dev/null +++ b/templates/administrative/season_assignment/index.html.twig @@ -0,0 +1,136 @@ +{% extends 'administrative/layout.html.twig' %} + +{% block title %}Saisoneinsätze{% endblock %} + +{% block content %} +
+

+ Saisoneinsätze +

+
+ + {% if filterDto.active %} + + Reset + + {% endif %} +
+
+ +
+
+ + + + + + + + + + + + + {% for seasonAssignment in pagination %} + + {# an assignment that has not been staffed yet still belongs in the list #} + {% if seasonAssignment.teamer is not null %} + + + {% else %} + + {% endif %} + + + + + + {% else %} + + + + {% endfor %} + +
+ {{ knp_pagination_sortable(pagination, 'Name', 'teamer.lastName') }} + + Vorname + + {{ knp_pagination_sortable(pagination, 'Haus', 'season_assignment.hotelCode') }} + + {{ knp_pagination_sortable(pagination, 'Zeitraum', 'season_assignment.dateFrom') }} + + {{ knp_pagination_sortable(pagination, 'Jobprofil', 'job_profile.name') }} +
+ + {{ seasonAssignment.teamer.lastName }} + + + + {{ seasonAssignment.teamer.firstName }} + + + + noch nicht zugewiesen + + + {{ seasonAssignment.hotelCode | house_label }} + + + {{ seasonAssignment.dateFrom | date('d.m.Y') }} - {{ seasonAssignment.dateTo | date('d.m.Y') }} + + + {{ seasonAssignment.jobProfile.name }} + + + + Details + + + bearbeiten + + + + {% if seasonAssignment.deleted %} +
+ gel. am {{ seasonAssignment.deletedAt | date('d.m.Y') }} +
+ {% endif %} +
+ Keine Daten... +
+ {{ knp_pagination_render(pagination) }} +
+
+ + + Neu + +{% endblock %} diff --git a/templates/administrative/season_assignment/modal_delete.html.twig b/templates/administrative/season_assignment/modal_delete.html.twig new file mode 100644 index 0000000..fa43c45 --- /dev/null +++ b/templates/administrative/season_assignment/modal_delete.html.twig @@ -0,0 +1,13 @@ +{% extends 'htmx_confirmation_modal.html.twig' %} + +{% block content %} +
+ {% if seasonAssignment.teamer is not null %} + Möchtest du den Saisoneinsatz von {{ seasonAssignment.teamer }} + in {{ seasonAssignment | season_assignment_label }} wirklich löschen? + {% else %} + Möchtest du den noch nicht zugewiesenen Saisoneinsatz + in {{ seasonAssignment | season_assignment_label }} wirklich löschen? + {% endif %} +
+{% endblock %} diff --git a/templates/administrative/season_assignment/modal_document_delete.html.twig b/templates/administrative/season_assignment/modal_document_delete.html.twig new file mode 100644 index 0000000..c109a0d --- /dev/null +++ b/templates/administrative/season_assignment/modal_document_delete.html.twig @@ -0,0 +1,7 @@ +{% extends 'htmx_confirmation_modal.html.twig' %} + +{% block content %} +
+ Möchtest du das Dokument {{ document.originalFilename }} wirklich löschen? +
+{% endblock %} diff --git a/templates/administrative/season_assignment/modal_document_upload.html.twig b/templates/administrative/season_assignment/modal_document_upload.html.twig new file mode 100644 index 0000000..081e77e --- /dev/null +++ b/templates/administrative/season_assignment/modal_document_upload.html.twig @@ -0,0 +1,26 @@ +{% extends 'htmx_modal.html.twig' %} + +{% block title %}Dokumente hochladen{% endblock %} + +{% block content %} + {% 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()) }) }} +
+ Lade hier die Dokumente hoch, die + {% if seasonAssignment.teamer is not null %}{{ seasonAssignment.teamer }}{% else %}der:die Teamer:in{% endif %} + für diesen Saisoneinsatz erhält. Sie sind nur für diese Person sichtbar. +
+
+ {% include '_partials/_upload_collection_form.html.twig' with { + 'endpoint_upload': path('_uploader_upload_season_document_out'), + 'max_filesize': 5, + 'max_files': 10, + 'accepted_files': 'image/jpg,image/jpeg,application/pdf', + } %} +
+ + {{ form_rest(form) }} + {{ form_end(form) }} +{% endblock %} diff --git a/templates/administrative/season_assignment/modal_task_deadline.html.twig b/templates/administrative/season_assignment/modal_task_deadline.html.twig new file mode 100644 index 0000000..31c2739 --- /dev/null +++ b/templates/administrative/season_assignment/modal_task_deadline.html.twig @@ -0,0 +1,19 @@ +{% extends 'htmx_modal.html.twig' %} + +{% block title %}Fristen: {{ task.task.label }}{% endblock %} + +{% block content %} + {{ form_start(form, { 'attr': { 'hx-post': app.request.uri, 'hx-target': '#htmx-modal', 'hx-swap': 'outerHTML' } }) }} +
+

+ Ohne Eintrag gilt die Frist, die sich aus dem Beginn des Saisoneinsatzes ergibt. +

+ {{ form_row(form.deadlineOverride1) }} + {{ form_row(form.deadlineOverride2) }} +
+ + {{ form_rest(form) }} + {{ form_end(form) }} +{% endblock %} diff --git a/templates/administrative/system/season_task_document/index.html.twig b/templates/administrative/system/season_task_document/index.html.twig new file mode 100644 index 0000000..1ee9e13 --- /dev/null +++ b/templates/administrative/system/season_task_document/index.html.twig @@ -0,0 +1,60 @@ +{% extends 'administrative/layout.html.twig' %} + +{% block title %}Saisonteam-Dokumente{% endblock %} + +{% block content %} +

+ Saisonteam-Dokumente +

+

+ Auswahl der Standarddokumente für das Saisonteam. +

+
+
+ + + + + + + + + + {% for row in rows %} + + + + + + {% endfor %} + +
ToDoDokument
{{ row.task.label }} + {% if row.document is not null %} + + {{ row.document.document.displayName ?? row.document.document.originalFilename }} + + {% else %} + kein Dokument hinterlegt + {% endif %} + +
+ {% if row.document is not null %} + + {% endif %} + +
+
+
+
+{% endblock %} diff --git a/templates/administrative/system/season_task_document/modal_delete.html.twig b/templates/administrative/system/season_task_document/modal_delete.html.twig new file mode 100644 index 0000000..413a81e --- /dev/null +++ b/templates/administrative/system/season_task_document/modal_delete.html.twig @@ -0,0 +1,8 @@ +{% extends 'htmx_confirmation_modal.html.twig' %} + +{% block content %} +
+ Möchtest du die Zuordnung für {{ seasonTaskDocument.task ? seasonTaskDocument.task.label : seasonTaskDocument.taskKey }} + wirklich entfernen? Das Dokument selbst bleibt erhalten. +
+{% endblock %} diff --git a/templates/administrative/system/season_task_document/modal_edit.html.twig b/templates/administrative/system/season_task_document/modal_edit.html.twig new file mode 100644 index 0000000..0a102bc --- /dev/null +++ b/templates/administrative/system/season_task_document/modal_edit.html.twig @@ -0,0 +1,15 @@ +{% extends 'htmx_modal.html.twig' %} + +{% 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' } }) }} +
+ {{ form_row(form.document) }} +
+ + {{ form_rest(form) }} + {{ form_end(form) }} +{% endblock %} diff --git a/templates/administrative/teamer/_header.html.twig b/templates/administrative/teamer/_header.html.twig new file mode 100644 index 0000000..f6709d8 --- /dev/null +++ b/templates/administrative/teamer/_header.html.twig @@ -0,0 +1,36 @@ +{# Shared by both teamer lists. `scope` selects which list's filter the modal edits and + which list a mailing resolves its recipients from; it is null on the general one. #} +{% set scope = scope|default(null) %} +
+

+ {{ title }} +

+
+ + {% if filterDto.active %} + + Reset + + {% endif %} + {% if is_granted('ROLE_TEAM_ADMIN') %} + + {{ icon('mail', 'w-4 h-4 shrink-0') }} + Mailing + + {% endif %} +
+
diff --git a/templates/administrative/teamer/_table.html.twig b/templates/administrative/teamer/_table.html.twig new file mode 100644 index 0000000..1f67abe --- /dev/null +++ b/templates/administrative/teamer/_table.html.twig @@ -0,0 +1,155 @@ +
+
+ + + + + + + + + + + + + + {% for teamer in pagination %} + + + + + + + + + + {% else %} + + {% endfor %} + +
+ {{ knp_pagination_sortable(pagination, 'Name', 'teamer.lastName') }} + + Vorname + + {{ knp_pagination_sortable(pagination, 'Status', 'teamer.status') }} + + {{ knp_pagination_sortable(pagination, 'E-Mail', 'teamer.communication.email') }} + + {{ knp_pagination_sortable(pagination, 'Letzter Login', 'user.lastLoginAt') }} +
+
+ {% if teamer.photo %} + {{ teamer.firstName }} + {% else %} +
+ {{ icon('user', 'w-10 h-10 text-gray-200') }} +
+ {% endif %} + +
+
+ + {{ teamer.lastName }} + + + + {{ teamer.firstName }} + + + {{ teamer|teamer_status_label(showAllSeason|default(true)) }} + + {{ teamer.communication.email }} + + {% if teamer.user is not null and teamer.user.lastLoginAt is not null %} + {{ teamer.user.lastLoginAt | date('d.m.Y') }} + {% else %} + - + {% endif %} + +
+ {% if teamer.driverLicenseVerified %} + {{ icon('car', 'w-5 h-5 text-green-500') }} + {% endif %} + {% if not teamer.viewed %} + + {% endif %} + {% if teamer.deleted %} + {% if is_granted('ROLE_TEAM_ADMIN') %} + + {% endif %} + {% elseif teamer.user is not null and teamer.user.disabled %} + {% if is_granted('ROLE_TEAM_ADMIN') %} + + {% endif %} + {% else %} + {% if is_granted('ROLE_TEAM_ADMIN') %} + + {% endif %} + {% if is_granted('CAN_IMPERSONATE', teamer.user) %} + + {{ icon('mask') }} + + {% endif %} + {% if is_granted('ROLE_TEAM_ADMIN') %} + + {% endif %} + {% endif %} + + {{ icon('user') }} + +
+ {% if teamer.deleted %} +
+ gel. am {{ teamer.deletedAt | date('d.m.Y') }} +
+ {% endif %} + {% if teamer.user is not null and teamer.user.disabled %} +
+ gesp. am {{ teamer.user.disabledAt | date('d.m.Y') }} +
+ {% endif %} +
+ Keine Daten... +
+ {{ knp_pagination_render(pagination) }} +
+
diff --git a/templates/administrative/teamer/index.html.twig b/templates/administrative/teamer/index.html.twig index fb44e17..c242ed6 100644 --- a/templates/administrative/teamer/index.html.twig +++ b/templates/administrative/teamer/index.html.twig @@ -3,192 +3,6 @@ {% block title %}Teamübersicht{% endblock %} {% block content %} -
-

- Teamübersicht -

-
- - {% if filterDto.active %} - - Reset - - {% endif %} - {% if is_granted('ROLE_TEAM_ADMIN') %} - - {{ icon('mail', 'w-4 h-4 shrink-0') }} - Mailing - - {% endif %} -
-
-
-
- - - - - - - - - - - - - - {% for teamer in pagination %} - - - - - - - - - - {% else %} - - {% endfor %} - -
- {{ knp_pagination_sortable(pagination, 'Name', 'teamer.lastName') }} - - Vorname - - {{ knp_pagination_sortable(pagination, 'Status', 'teamer.status') }} - - {{ knp_pagination_sortable(pagination, 'E-Mail', 'teamer.communication.email') }} - - {{ knp_pagination_sortable(pagination, 'Letzter Login', 'user.lastLoginAt') }} -
-
- {% if teamer.photo %} - {{ teamer.firstName }} - {% else %} -
- {{ icon('user', 'w-10 h-10 text-gray-200') }} -
- {% endif %} - -
-
- - {{ teamer.lastName }} - - - - {{ teamer.firstName }} - - - {{ teamer|teamer_status_label }} - - {{ teamer.communication.email }} - - {% if teamer.user is not null and teamer.user.lastLoginAt is not null %} - {{ teamer.user.lastLoginAt | date('d.m.Y') }} - {% else %} - - - {% endif %} - -
- {% if teamer.driverLicenseVerified %} - {{ icon('car', 'w-5 h-5 text-green-500') }} - {% endif %} - {% if not teamer.viewed %} - - {% endif %} - {% if teamer.deleted %} - {% if is_granted('ROLE_TEAM_ADMIN') %} - - {% endif %} - {% elseif teamer.user is not null and teamer.user.disabled %} - {% if is_granted('ROLE_TEAM_ADMIN') %} - - {% endif %} - {% else %} - {% if is_granted('ROLE_TEAM_ADMIN') %} - - {% endif %} - {% if is_granted('CAN_IMPERSONATE', teamer.user) %} - - {{ icon('mask') }} - - {% endif %} - {% if is_granted('ROLE_TEAM_ADMIN') %} - - {% endif %} - {% endif %} - - {{ icon('user') }} - -
- {% if teamer.deleted %} -
- gel. am {{ teamer.deletedAt | date('d.m.Y') }} -
- {% endif %} - {% if teamer.user is not null and teamer.user.disabled %} -
- gesp. am {{ teamer.user.disabledAt | date('d.m.Y') }} -
- {% endif %} -
- Keine Daten... -
- {{ knp_pagination_render(pagination) }} -
-
+ {% include 'administrative/teamer/_header.html.twig' with { 'title': 'Teamübersicht' } %} + {% include 'administrative/teamer/_table.html.twig' %} {% endblock %} diff --git a/templates/administrative/teamer/profile.html.twig b/templates/administrative/teamer/profile.html.twig index 1757136..bb63f1e 100644 --- a/templates/administrative/teamer/profile.html.twig +++ b/templates/administrative/teamer/profile.html.twig @@ -244,6 +244,31 @@ + {% if teamer.allSeason %} +
+

+ Saisoneinsätze +

+
    + {% for seasonAssignment in teamer.seasonAssignments|filter(seasonAssignment => not seasonAssignment.deleted) %} +
  • + + {{ seasonAssignment.hotelCode | house_label }} + + + {{ seasonAssignment.dateFrom | date('d.m.Y') }} - {{ seasonAssignment.dateTo | date('d.m.Y') }}, + {{ seasonAssignment.jobProfile.name }} + +
  • + {% else %} +
  • + Keine Daten +
  • + {% endfor %} +
+
+ {% endif %} + diff --git a/templates/administrative/teamer/season_index.html.twig b/templates/administrative/teamer/season_index.html.twig new file mode 100644 index 0000000..f308ef0 --- /dev/null +++ b/templates/administrative/teamer/season_index.html.twig @@ -0,0 +1,12 @@ +{% extends 'administrative/layout.html.twig' %} + +{% block title %}Saisonteamer{% endblock %} + +{% block content %} + {% include 'administrative/teamer/_header.html.twig' with { + 'title': 'Saisonteamer', + 'scope': constant('App\\Service\\Common\\TeamerFilterHandler::SCOPE_ALL_SEASON') + } %} + {# every row here is an all-season teamer, so the marker would only add noise #} + {% include 'administrative/teamer/_table.html.twig' with { 'showAllSeason': false } %} +{% endblock %} diff --git a/templates/common/modal_season_assignment_filter.html.twig b/templates/common/modal_season_assignment_filter.html.twig new file mode 100644 index 0000000..f985725 --- /dev/null +++ b/templates/common/modal_season_assignment_filter.html.twig @@ -0,0 +1,19 @@ +{% extends 'htmx_modal.html.twig' %} + +{% block title %}Saisoneinsätze filtern{% endblock %} + +{% block content %} + {{ form_start(filterForm, { 'attr': { 'hx-post': app.request.uri, 'hx-target': '#htmx-modal', 'hx-swap': 'outerHTML'} }) }} +
+ {{ form_row(filterForm.teamerName) }} + {{ form_row(filterForm.hotelCodes) }} + {{ form_row(filterForm.jobProfiles) }} + {{ form_row(filterForm.includeDeleted) }} +
+
+ {{ form_widget(filterForm.apply, { 'attr': { 'class': 'btn' } }) }} + {{ form_widget(filterForm.reset, { 'attr': { 'class': 'btn btn--secondary' } }) }} +
+ {{ form_rest(filterForm) }} + {{ form_end(filterForm) }} +{% endblock %} diff --git a/templates/email/season_deadlines.html.twig b/templates/email/season_deadlines.html.twig new file mode 100644 index 0000000..1333161 --- /dev/null +++ b/templates/email/season_deadlines.html.twig @@ -0,0 +1,18 @@ +{% extends 'email/layout.html.twig' %} + +{% block body %} +

+ Fristen für Saison-Teamer:innen-Dokumente erreicht +

+

+ Für die folgenden Teamer:innen ist heute die Einreichungsfrist für Dokument erreicht: +

+ {% for teamer, entries in violations %} +

+ {{ teamer }}
+ {% for entry in entries %} + Frist {{ entry.level }}: {{ entry.label }}
+ {% endfor %} +

+ {% endfor %} +{% endblock %} diff --git a/templates/forms.html.twig b/templates/forms.html.twig index 6a05e10..117b35b 100644 --- a/templates/forms.html.twig +++ b/templates/forms.html.twig @@ -200,6 +200,7 @@
    +

    + Saison-Teamer:in +

    +

    + Für deinen Einsatz im Haus {{ seasonAssignment.hotelCode | house_label }} + ({{ seasonAssignment.dateFrom | date('d.m.Y') }} - + {{ seasonAssignment.dateTo | date('d.m.Y') }}) + brauchen wir die folgenden Dokumente von dir. +

    + +

    + Allgemeine Dokumente +

    + +
    + {% for row in rows %} +
    +
    + {{ row.definition.label }} +
    + {% if row.deadline1 is not null %} + bis {{ row.deadline1 | date('d.m.Y') }} + {% endif %} +
    +
    +
    + {% if row.task.done %} + erledigt + {% else %} + offen + {% endif %} + {% if row.document is not null %} + + {{ icon('download') }} + Vorlage herunterladen + + {% elseif row.definition.provides == constant('App\\Config\\SeasonTaskProvision::STANDARD') %} +
    kein Dokument hinterlegt
    + {% endif %} +
    +
    + {% endfor %} +
    + +
    +

    + Persönliche Dokumente +

    + {% set personalDocuments = documents | filter(document => document.type == constant('App\\Entity\\Upload::TYPE_SEASON_DOCUMENT_OUT')) %} + {% if personalDocuments | length > 0 %} +
    + {% for document in personalDocuments %} +
    +
    {{ document.displayName | default(document.originalFilename) }}
    + + {{ icon('download') }} + Download + +
    + {% endfor %} +
    + {% else %} +
    Zurzeit liegen keine persönlichen Dokumente für dich bereit.
    + {% endif %} +
    + +
    +

    + Deine Uploads +

    + {% set returnedDocuments = documents | filter(document => document.type == constant('App\\Entity\\Upload::TYPE_SEASON_DOCUMENT_IN')) %} + {% if returnedDocuments | length > 0 %} +
    + {% for document in returnedDocuments %} +
    +
    {{ document.originalFilename }}
    +
    {{ document.createdAt | date('d.m.Y') }}
    +
    + {% endfor %} +
    + {% else %} +
    Du hast noch nichts hochgeladen.
    + {% endif %} + + +
    + +{% endblock %} diff --git a/templates/teamer/season/modal_upload.html.twig b/templates/teamer/season/modal_upload.html.twig new file mode 100644 index 0000000..5fc97ef --- /dev/null +++ b/templates/teamer/season/modal_upload.html.twig @@ -0,0 +1,24 @@ +{% extends 'htmx_modal.html.twig' %} + +{% block title %}Dokumente hochladen{% endblock %} + +{% block content %} + {% 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()) }) }} +
    + Lade hier die von dir unterschriebenen Dokumente hoch. Wir prüfen sie und haken die Punkte anschließend ab. +
    +
    + {% include '_partials/_upload_collection_form.html.twig' with { + 'endpoint_upload': path('_uploader_upload_season_document_in'), + 'max_filesize': 5, + 'max_files': 10, + 'accepted_files': 'image/jpg,image/jpeg,application/pdf', + } %} +
    + + {{ form_rest(form) }} + {{ form_end(form) }} +{% endblock %} diff --git a/tests/Config/SeasonCalendarTest.php b/tests/Config/SeasonCalendarTest.php index fec4e28..fa8aa6b 100644 --- a/tests/Config/SeasonCalendarTest.php +++ b/tests/Config/SeasonCalendarTest.php @@ -71,6 +71,52 @@ class SeasonCalendarTest extends TestCase (new SeasonCalendar('xx-yy', '04-01'))->seasonYearFor(new \DateTimeImmutable('2025-01-01')); } + /** + * @dataProvider currentOrUpcomingDates + */ + public function testCurrentOrUpcomingSeasonYear(string $date, int $expected): void + { + $this->assertSame( + $expected, + $this->winter()->currentOrUpcomingSeasonYear(new \DateTimeImmutable($date)) + ); + } + + public static function currentOrUpcomingDates(): iterable + { + yield 'inside the season' => ['2024-12-20', 2024]; + yield 'last day of the season' => ['2025-04-01', 2024]; + yield 'day after the season ends falls forward' => ['2025-04-02', 2025]; + yield 'high summer falls forward' => ['2025-07-15', 2025]; + yield 'day before the next season starts' => ['2025-10-31', 2025]; + yield 'first day of the next season' => ['2025-11-01', 2025]; + yield 'off-season in january is impossible for this window' => ['2025-01-05', 2024]; + } + + public function testCurrentOrUpcomingSeasonYearFallsForwardAcrossTheTurnOfTheYear(): void + { + // A window that ends before it starts in the same calendar year is the wrapping case; + // one that does not wrap has its off-season on both sides of the window instead. + $summer = new SeasonCalendar('05-01', '09-30'); + + $this->assertSame(2025, $summer->currentOrUpcomingSeasonYear(new \DateTimeImmutable('2025-03-01'))); + $this->assertSame(2025, $summer->currentOrUpcomingSeasonYear(new \DateTimeImmutable('2025-06-01'))); + $this->assertSame(2026, $summer->currentOrUpcomingSeasonYear(new \DateTimeImmutable('2025-11-20'))); + } + + public function testWindowFor(): void + { + [$start, $end] = $this->winter()->windowFor(2024); + + $this->assertSame('2024-11-01', $start->format('Y-m-d')); + $this->assertSame('2025-04-01', $end->format('Y-m-d')); + + [$start, $end] = (new SeasonCalendar('05-01', '09-30'))->windowFor(2025); + + $this->assertSame('2025-05-01', $start->format('Y-m-d')); + $this->assertSame('2025-09-30', $end->format('Y-m-d')); + } + /** * @dataProvider provideUpcomingSeasonDates */ diff --git a/tests/Config/SeasonTaskTest.php b/tests/Config/SeasonTaskTest.php new file mode 100644 index 0000000..fd61c2c --- /dev/null +++ b/tests/Config/SeasonTaskTest.php @@ -0,0 +1,81 @@ +assertNotSame('', $case->label(), sprintf('%s has no label', $case->name)); + + $keys[] = $case->value; + $labels[] = $case->label(); + } + + $this->assertSame($keys, array_unique($keys)); + $this->assertSame($labels, array_unique($labels)); + } + + /** + * @dataProvider entries + */ + public function testTheAgreedEntryList( + SeasonTask $task, + string $label, + SeasonTaskProvision $provides, + bool $requiresReturn, + ): void { + $this->assertSame($label, $task->label()); + $this->assertSame($provides, $task->provides()); + $this->assertSame($requiresReturn, $task->requiresReturn()); + } + + public static function entries(): iterable + { + yield [SeasonTask::MASTER_DATA, 'Stammdaten', SeasonTaskProvision::STANDARD, true]; + yield [SeasonTask::VEHICLE, 'KfZ', SeasonTaskProvision::STANDARD, true]; + yield [SeasonTask::CONTRACT, 'Vertrag', SeasonTaskProvision::INDIVIDUAL, true]; + yield [SeasonTask::ADDITIONAL_CONTRACT, 'Zusatzvertrag', SeasonTaskProvision::INDIVIDUAL, true]; + yield [SeasonTask::SAAS_REGISTERED, 'SaaS angemeldet', SeasonTaskProvision::NONE, false]; + yield [SeasonTask::MUNICIPALITY_REGISTERED, 'Gemeinde angemeldet', SeasonTaskProvision::NONE, false]; + } + + public function testAnEntryThatHandsNothingOutNeverExpectsSomethingBack(): void + { + // the two are independent properties, but "nothing out and something back" would be a + // task the teamer is reminded about without ever being given anything to return + foreach (SeasonTask::cases() as $case) { + if (SeasonTaskProvision::NONE === $case->provides()) { + $this->assertFalse($case->requiresReturn(), sprintf('%s expects a return', $case->name)); + } + } + } + + public function testRequiringReturnIsTheTeamerFacingSubsetInEnumOrder(): void + { + $this->assertSame( + [ + SeasonTask::MASTER_DATA, + SeasonTask::VEHICLE, + SeasonTask::CONTRACT, + SeasonTask::ADDITIONAL_CONTRACT, + ], + SeasonTask::requiringReturn() + ); + } +} diff --git a/tests/Entity/SeasonAssignmentTest.php b/tests/Entity/SeasonAssignmentTest.php new file mode 100644 index 0000000..313d373 --- /dev/null +++ b/tests/Entity/SeasonAssignmentTest.php @@ -0,0 +1,103 @@ +assertMatchesRegularExpression('/^[0-9a-f-]{36}$/', $first->getUuid()); + $this->assertNotSame($first->getUuid(), $second->getUuid()); + } + + public function testANewSeasonAssignmentIsNotDeleted(): void + { + $this->assertFalse((new SeasonAssignment())->isDeleted()); + } + + /** + * The list only ever hides soft-deleted records, so deleting must not detach the teamer - + * the placement has to stay readable for whoever looks at it afterwards. + */ + public function testSoftDeletingKeepsTheRecordIntact(): void + { + $teamer = (new Teamer())->setFirstName('Ada')->setLastName('Musterfrau'); + $seasonAssignment = (new SeasonAssignment())->setTeamer($teamer); + + $seasonAssignment->setDeleted(); + + $this->assertTrue($seasonAssignment->isDeleted()); + $this->assertSame($teamer, $seasonAssignment->getTeamer()); + } + + public function testTheInverseSideIsKeptInSync(): void + { + $teamer = new Teamer(); + $seasonAssignment = new SeasonAssignment(); + + $teamer->addSeasonAssignment($seasonAssignment); + + $this->assertCount(1, $teamer->getSeasonAssignments()); + $this->assertSame($teamer, $seasonAssignment->getTeamer()); + + // adding the same one twice must not duplicate it + $teamer->addSeasonAssignment($seasonAssignment); + $this->assertCount(1, $teamer->getSeasonAssignments()); + + $teamer->removeSeasonAssignment($seasonAssignment); + $this->assertCount(0, $teamer->getSeasonAssignments()); + $this->assertNull($seasonAssignment->getTeamer()); + } + + /** + * Administrative users lay the season's assignments out first and place teamers on them + * later, so an assignment without a teamer is a normal state rather than a half built one. + */ + public function testAnAssignmentStartsWithoutATeamer(): void + { + $seasonAssignment = new SeasonAssignment(); + + $this->assertNull($seasonAssignment->getTeamer()); + } + + public function testATeamerCanBePlacedAndRemovedAgain(): void + { + $teamer = (new Teamer())->setFirstName('Ada')->setLastName('Musterfrau'); + $seasonAssignment = new SeasonAssignment(); + + $seasonAssignment->setTeamer($teamer); + $this->assertSame($teamer, $seasonAssignment->getTeamer()); + + $seasonAssignment->setTeamer(null); + $this->assertNull($seasonAssignment->getTeamer()); + } + + /** + * The period and the house live on the assignment itself - it is not cut from a travel, so + * there is no Destination to read them from. + */ + public function testItCarriesItsOwnPeriodAndHouse(): void + { + $seasonAssignment = (new SeasonAssignment()) + ->setTeamer((new Teamer())->setFirstName('Ada')->setLastName('Musterfrau')) + ->setDateFrom(new \DateTimeImmutable('2026-11-01')) + ->setDateTo(new \DateTimeImmutable('2027-04-30')) + ->setHotelCode('AGR') + ->setJobProfile((new JobProfile())->setName('Guide')) + ; + + $this->assertSame('2026-11-01', $seasonAssignment->getDateFrom()->format('Y-m-d')); + $this->assertSame('2027-04-30', $seasonAssignment->getDateTo()->format('Y-m-d')); + $this->assertSame('AGR', $seasonAssignment->getHotelCode()); + } +} diff --git a/tests/Form/AutosizeTextareaTypeTest.php b/tests/Form/AutosizeTextareaTypeTest.php new file mode 100644 index 0000000..7c650c7 --- /dev/null +++ b/tests/Form/AutosizeTextareaTypeTest.php @@ -0,0 +1,61 @@ +attr(); + + $this->assertSame('textarea-autosize', $attr['data-controller']); + $this->assertSame('textarea-autosize#resize', $attr['data-action']); + } + + /** + * One row is what the controller itself defaults to, so a field that says nothing + * renders exactly as it did before this type existed. + */ + public function testTheFloorDefaultsToASingleRow(): void + { + $this->assertSame(1, $this->attr()['data-textarea-autosize-min-rows-value']); + } + + public function testMinRowsReachesTheController(): void + { + $this->assertSame(12, $this->attr(['min_rows' => 12])['data-textarea-autosize-min-rows-value']); + } + + /** + * A field needing a second Stimulus controller on the same element has to compose the + * list itself, so its own attributes must win over the defaults. + */ + public function testTheCallersOwnAttributesWin(): void + { + $attr = $this->attr(['attr' => ['data-controller' => 'textarea-autosize other', 'placeholder' => 'x']]); + + $this->assertSame('textarea-autosize other', $attr['data-controller']); + $this->assertSame('x', $attr['placeholder']); + $this->assertSame('textarea-autosize#resize', $attr['data-action']); + } + + /** + * @return array + */ + private function attr(array $options = []): array + { + return $this->factory->create(AutosizeTextareaType::class, null, $options)->createView()->vars['attr']; + } +} diff --git a/tests/Model/TeamerFilterDtoTest.php b/tests/Model/TeamerFilterDtoTest.php new file mode 100644 index 0000000..5e3f857 --- /dev/null +++ b/tests/Model/TeamerFilterDtoTest.php @@ -0,0 +1,42 @@ +setAllSeason(true); + + $this->assertTrue($filterDto->isAllSeason()); + $this->assertSame(0, $filterDto->getActiveFiltersCount()); + $this->assertFalse($filterDto->isActive()); + } + + public function testAFilterOnTopOfTheScopeStillCounts(): void + { + $filterDto = (new TeamerFilterDto()) + ->setAllSeason(true) + ->setName('Fromme') + ; + + $this->assertSame(1, $filterDto->getActiveFiltersCount()); + $this->assertTrue($filterDto->isActive()); + } + + public function testTheListIsUnscopedByDefault(): void + { + $this->assertFalse((new TeamerFilterDto())->isAllSeason()); + } +} diff --git a/tests/Repository/SeasonAssignmentRepositoryTest.php b/tests/Repository/SeasonAssignmentRepositoryTest.php new file mode 100644 index 0000000..e67b9b5 --- /dev/null +++ b/tests/Repository/SeasonAssignmentRepositoryTest.php @@ -0,0 +1,70 @@ +createInSeasonWindowQueryBuilder()->getQuery()->getDQL(); + + // both bounds on dateFrom: a placement running past the last day of the season still + // belongs to that season, so dateTo must not be part of the test + $this->assertStringContainsString('season_assignment.dateFrom >= :windowStart', $dql); + $this->assertStringContainsString('season_assignment.dateFrom <= :windowEnd', $dql); + $this->assertStringNotContainsString('dateTo', $dql); + } + + /** + * The period is the assignment's own, so there is no second row left to join - and none that + * could be soft-deleted underneath a live placement. + */ + public function testTheSeasonWindowQueryNeedsNoJoin(): void + { + $dql = $this->createInSeasonWindowQueryBuilder()->getQuery()->getDQL(); + + $this->assertStringContainsString('season_assignment.deletedAt IS NULL', $dql); + $this->assertStringNotContainsString('destination', $dql); + } + + public function testTheSeasonWindowQueryOnlyExcludesARowWhenEditingOne(): void + { + $this->assertStringNotContainsString( + 'season_assignment.id <> :excludeId', + $this->createInSeasonWindowQueryBuilder()->getQuery()->getDQL() + ); + + $this->assertStringContainsString( + 'season_assignment.id <> :excludeId', + $this->createInSeasonWindowQueryBuilder(42)->getQuery()->getDQL() + ); + } + + private function createInSeasonWindowQueryBuilder(?int $excludeId = null): \Doctrine\ORM\QueryBuilder + { + /** @var EntityManagerInterface $entityManager */ + $entityManager = static::getContainer()->get(EntityManagerInterface::class); + + /** @var SeasonAssignmentRepository $repository */ + $repository = $entityManager->getRepository(SeasonAssignment::class); + + return $repository->createInSeasonWindowQueryBuilder( + new Teamer(), + new \DateTimeImmutable('2026-11-01'), + new \DateTimeImmutable('2027-04-01'), + $excludeId + ); + } +} diff --git a/tests/Repository/SeasonAssignmentTaskRepositoryTest.php b/tests/Repository/SeasonAssignmentTaskRepositoryTest.php new file mode 100644 index 0000000..bf36784 --- /dev/null +++ b/tests/Repository/SeasonAssignmentTaskRepositoryTest.php @@ -0,0 +1,72 @@ +createOpenQueryBuilder()->getQuery()->getDQL(); + + $this->assertStringContainsString('task.done = :done', $dql); + $this->assertStringContainsString('season_assignment.deletedAt IS NULL', $dql); + $this->assertStringContainsString('teamer.deletedAt IS NULL', $dql); + // the period is the assignment's own, so there is no destination left to join + $this->assertStringNotContainsString('destination', $dql); + } + + /** + * An inner join, not a left one: an assignment the office has laid out but not staffed yet + * has nobody to remind and nobody who could be late. + */ + public function testAnUnstaffedAssignmentIsLeftOut(): void + { + $dql = $this->createOpenQueryBuilder()->getQuery()->getDQL(); + + $this->assertStringContainsString('INNER JOIN season_assignment.teamer teamer', $dql); + $this->assertStringNotContainsString('LEFT JOIN', $dql); + } + + public function testTheSeasonWindowIsOptional(): void + { + $unscoped = $this->createOpenQueryBuilder()->getQuery()->getDQL(); + $scoped = $this + ->createOpenQueryBuilder(new \DateTimeImmutable('2026-11-01'), new \DateTimeImmutable('2027-04-01')) + ->getQuery() + ->getDQL() + ; + + $this->assertStringNotContainsString('windowStart', $unscoped); + $this->assertStringContainsString('season_assignment.dateFrom >= :windowStart', $scoped); + $this->assertStringContainsString('season_assignment.dateFrom <= :windowEnd', $scoped); + } + + private function createOpenQueryBuilder( + ?\DateTimeImmutable $windowStart = null, + ?\DateTimeImmutable $windowEnd = null, + ): QueryBuilder { + /** @var EntityManagerInterface $entityManager */ + $entityManager = static::getContainer()->get(EntityManagerInterface::class); + + /** @var SeasonAssignmentTaskRepository $repository */ + $repository = $entityManager->getRepository(SeasonAssignmentTask::class); + + return $repository->createOpenQueryBuilder($windowStart, $windowEnd); + } +} diff --git a/tests/Repository/TeamerRepositoryTest.php b/tests/Repository/TeamerRepositoryTest.php new file mode 100644 index 0000000..1d22882 --- /dev/null +++ b/tests/Repository/TeamerRepositoryTest.php @@ -0,0 +1,82 @@ +getRepository()->getListQuery((new TeamerFilterDto())->setAllSeason(true)); + + $this->assertStringContainsString('teamer.allSeason = :allSeason', $query->getDQL()); + $this->assertTrue($query->getParameter('allSeason')->getValue()); + } + + public function testTheGeneralListIsNotRestricted(): void + { + $query = $this->getRepository()->getListQuery(new TeamerFilterDto()); + + $this->assertStringNotContainsString('teamer.allSeason', $query->getDQL()); + $this->assertNull($query->getParameter('allSeason')); + } + + /** + * The mailing resolves its recipients through the same builder, so a mailing started from + * the all-season list must not be able to reach the rest of the team. + */ + public function testTheMailingRecipientsCarryTheSameScope(): void + { + $repository = $this->getRepository(); + + $reflection = new \ReflectionMethod($repository, 'createListQueryBuilder'); + $queryBuilder = $reflection->invoke($repository, (new TeamerFilterDto())->setAllSeason(true)); + + $this->assertStringContainsString('teamer.allSeason = :allSeason', $queryBuilder->getDQL()); + } + + /** + * The season assignment form picks its teamer through this endpoint, so an unrestricted + * suggestion list would let an admin place a short term teamer on a season assignment. + */ + public function testTheAutocompleteCanBeRestrictedToAllSeasonTeamers(): void + { + $dql = $this->createAutocompletionQueryBuilder(true)->getDQL(); + + $this->assertStringContainsString('teamer.allSeason = :allSeason', $dql); + } + + public function testTheAutocompleteIsUnrestrictedByDefault(): void + { + $dql = $this->createAutocompletionQueryBuilder(false)->getDQL(); + + $this->assertStringNotContainsString('teamer.allSeason', $dql); + $this->assertStringContainsString('teamer.deletedAt IS NULL', $dql); + } + + private function createAutocompletionQueryBuilder(bool $allSeasonOnly): QueryBuilder + { + $repository = $this->getRepository(); + $reflection = new \ReflectionMethod($repository, 'createAutocompletionQueryBuilder'); + + return $reflection->invoke($repository, '', $allSeasonOnly); + } + + private function getRepository(): TeamerRepository + { + self::bootKernel(); + + return self::getContainer()->get(TeamerRepository::class); + } +} diff --git a/tests/Repository/UploadRepositoryTest.php b/tests/Repository/UploadRepositoryTest.php new file mode 100644 index 0000000..96d92e9 --- /dev/null +++ b/tests/Repository/UploadRepositoryTest.php @@ -0,0 +1,51 @@ +createAutocompletionQueryBuilder(); + + $this->assertStringContainsString('upload.type = :type', $qb->getDQL()); + $this->assertSame( + Upload::TYPE_DOCUMENT, + $qb->getQuery()->getParameter('type')->getValue() + ); + } + + public function testTheAutocompleteStillSearchesBothNames(): void + { + $dql = $this->createAutocompletionQueryBuilder()->getDQL(); + + $this->assertStringContainsString('upload.originalFilename LIKE :filename', $dql); + $this->assertStringContainsString('upload.displayName LIKE :filename', $dql); + } + + private function createAutocompletionQueryBuilder(): QueryBuilder + { + /** @var EntityManagerInterface $entityManager */ + $entityManager = static::getContainer()->get(EntityManagerInterface::class); + + /** @var UploadRepository $repository */ + $repository = $entityManager->getRepository(Upload::class); + + $reflection = new \ReflectionMethod($repository, 'createAutocompletionQueryBuilder'); + + return $reflection->invoke($repository, ''); + } +} diff --git a/tests/Security/Voter/UploadVoterTest.php b/tests/Security/Voter/UploadVoterTest.php new file mode 100644 index 0000000..33e75bc --- /dev/null +++ b/tests/Security/Voter/UploadVoterTest.php @@ -0,0 +1,132 @@ +security = $this->createMock(Security::class); + $this->security + ->method('isGranted') + ->willReturnCallback(static fn (string $role): bool => 'ROLE_TEAMER' === $role) + ; + } + + public function testATeamerMayDownloadASeasonDocumentIssuedToThem(): void + { + $teamer = new Teamer(); + + $this->assertSame( + VoterInterface::ACCESS_GRANTED, + $this->vote(UploadVoter::DOWNLOAD, $this->seasonDocument($teamer), $teamer) + ); + } + + public function testATeamerMayNotDownloadAnotherTeamersSeasonDocument(): void + { + $this->assertSame( + VoterInterface::ACCESS_DENIED, + $this->vote(UploadVoter::DOWNLOAD, $this->seasonDocument(new Teamer()), new Teamer()) + ); + } + + public function testASeasonDocumentOnAnUnstaffedAssignmentReachesNoTeamer(): void + { + $upload = (new Upload()) + ->setType(Upload::TYPE_SEASON_DOCUMENT_OUT) + ->setOwner(new User()) + ; + $upload->setSeasonAssignment(new SeasonAssignment()); + + $this->assertSame( + VoterInterface::ACCESS_DENIED, + $this->vote(UploadVoter::DOWNLOAD, $upload, new Teamer()) + ); + } + + /** + * Downloading is all the new rule grants: the file stays the office's, so the teamer it was + * issued to may not delete it or reach it through any other attribute. + * + * @dataProvider otherAttributes + */ + public function testTheNewRuleGrantsNothingButDownloading(string $attribute): void + { + $teamer = new Teamer(); + + $this->assertSame( + VoterInterface::ACCESS_DENIED, + $this->vote($attribute, $this->seasonDocument($teamer), $teamer) + ); + } + + /** + * @return array + */ + public static function otherAttributes(): array + { + return [ + 'viewing' => [UploadVoter::VIEW], + 'deleting' => [UploadVoter::DELETE], + ]; + } + + /** + * The library is deliberately open to all staff, which is exactly why an individually issued + * document must never carry its type - so the blanket rule has to stay untouched. + */ + public function testTheLibraryStaysReadableByEveryTeamer(): void + { + $upload = (new Upload()) + ->setType(Upload::TYPE_DOCUMENT) + ->setOwner(new User()) + ; + + $this->assertSame( + VoterInterface::ACCESS_GRANTED, + $this->vote(UploadVoter::DOWNLOAD, $upload, new Teamer()) + ); + } + + private function seasonDocument(Teamer $teamer): Upload + { + $seasonAssignment = (new SeasonAssignment())->setTeamer($teamer); + + $upload = (new Upload()) + ->setType(Upload::TYPE_SEASON_DOCUMENT_OUT) + ->setOwner(new User()) + ; + $upload->setSeasonAssignment($seasonAssignment); + + return $upload; + } + + private function vote(string $attribute, Upload $upload, Teamer $teamer): int + { + $token = $this->createMock(TokenInterface::class); + $token->method('getUser')->willReturn((new User())->setTeamer($teamer)); + + return (new UploadVoter($this->security))->vote($token, $upload, [$attribute]); + } +} diff --git a/tests/Service/Common/TeamerFilterHandlerTest.php b/tests/Service/Common/TeamerFilterHandlerTest.php new file mode 100644 index 0000000..e1678d1 --- /dev/null +++ b/tests/Service/Common/TeamerFilterHandlerTest.php @@ -0,0 +1,92 @@ +createHandler(); + + $this->save($handler->setScope(null), 'Fromme'); + $this->save($handler->setScope(TeamerFilterHandler::SCOPE_ALL_SEASON), 'Musterfrau'); + + $this->assertSame('Fromme', $handler->setScope(null)->getFilterSettings()->getName()); + $this->assertSame( + 'Musterfrau', + $handler->setScope(TeamerFilterHandler::SCOPE_ALL_SEASON)->getFilterSettings()->getName() + ); + } + + public function testResettingOneListLeavesTheOtherAlone(): void + { + $handler = $this->createHandler(); + + $this->save($handler->setScope(null), 'Fromme'); + $this->save($handler->setScope(TeamerFilterHandler::SCOPE_ALL_SEASON), 'Musterfrau'); + + $handler->setScope(TeamerFilterHandler::SCOPE_ALL_SEASON)->resetFilterSettings(); + + $this->assertSame('Fromme', $handler->setScope(null)->getFilterSettings()->getName()); + $this->assertNull( + $handler->setScope(TeamerFilterHandler::SCOPE_ALL_SEASON)->getFilterSettings()->getName() + ); + } + + /** + * The bucket is what makes a list an all-season list, so every reader of the settings gets + * the scope without having to remember to apply it - the mailing above all. + */ + public function testTheScopeTravelsWithTheSettings(): void + { + $handler = $this->createHandler(); + + $this->assertFalse($handler->setScope(null)->getFilterSettings()->isAllSeason()); + $this->assertTrue( + $handler->setScope(TeamerFilterHandler::SCOPE_ALL_SEASON)->getFilterSettings()->isAllSeason() + ); + } + + public function testAnUnknownScopeFallsBackToTheGeneralList(): void + { + $handler = $this->createHandler(); + + $this->save($handler->setScope(null), 'Fromme'); + + $this->assertSame('Fromme', $handler->setScope('nonsense')->getFilterSettings()->getName()); + $this->assertFalse($handler->setScope('nonsense')->getFilterSettings()->isAllSeason()); + } + + private function save(TeamerFilterHandler $handler, string $name): void + { + $reflection = new \ReflectionMethod($handler, 'saveFilterSettings'); + $reflection->invoke($handler, (new TeamerFilterDto())->setName($name)); + } + + private function createHandler(): TeamerFilterHandler + { + $request = new Request(); + $request->setSession(new Session(new MockArraySessionStorage())); + + $requestStack = new RequestStack(); + $requestStack->push($request); + + return new TeamerFilterHandler($requestStack, $this->createMock(EntityManagerInterface::class)); + } +} diff --git a/tests/Service/Cron/SeasonTaskReminderServiceTest.php b/tests/Service/Cron/SeasonTaskReminderServiceTest.php new file mode 100644 index 0000000..12bc46f --- /dev/null +++ b/tests/Service/Cron/SeasonTaskReminderServiceTest.php @@ -0,0 +1,229 @@ +repository = $this->createMock(SeasonAssignmentTaskRepository::class); + $this->userRepository = $this->createMock(UserRepository::class); + $this->mailer = $this->createMock(Mailer::class); + + $this->service = new SeasonTaskReminderService( + $this->repository, + new SeasonTaskDeadlineResolver('-1 month'), + new SeasonAssignmentLabeler( + new SeasonCalendar('11-01', '04-01'), + new HouseCatalog(['AGR' => 'Rotbach']) + ), + new SeasonCalendar('11-01', '04-01'), + $this->userRepository, + $this->mailer, + new NullLogger(), + ); + } + + public function testTheSummaryFiresOnTheDeadlineDay(): void + { + $this->repository->method('findOpen')->willReturn([$this->task('2026-11-01')]); + $this->userRepository->method('getUsersByRoles')->willReturn([$this->admin()]); + + $this->mailer + ->expects($this->once()) + ->method('createAndSendEmail') + ->with( + $this->callback(static function (array $context): bool { + $entries = reset($context['violations']); + + return 1 === count($entries) && 1 === $entries[0]['level']; + }), + $this->anything() + ) + ; + + $this->service->sendDeadlineSummary(new \DateTimeImmutable('2026-10-01')); + } + + public function testTheSummaryDoesNotFireTheDayAfter(): void + { + $this->repository->method('findOpen')->willReturn([$this->task('2026-11-01')]); + + $this->mailer->expects($this->never())->method('createAndSendEmail'); + + $this->service->sendDeadlineSummary(new \DateTimeImmutable('2026-10-02')); + } + + /** + * Frist 2 defaults to Frist 1, so the two coincide unless the office moves one. The entry is + * then reported once and as Frist 1 - a second, identical line would be pure noise. + */ + public function testCoincidingDeadlinesAreReportedOnceAsTheFirst(): void + { + $this->repository->method('findOpen')->willReturn([$this->task('2026-11-01')]); + $this->userRepository->method('getUsersByRoles')->willReturn([$this->admin()]); + + $levels = []; + + $this->mailer + ->method('createAndSendEmail') + ->willReturnCallback(static function (array $context) use (&$levels): void { + foreach (reset($context['violations']) as $entry) { + $levels[] = $entry['level']; + } + }) + ; + + $this->service->sendDeadlineSummary(new \DateTimeImmutable('2026-10-01')); + + $this->assertSame([1], $levels); + } + + public function testAPushedOutSecondDeadlineGetsItsOwnDay(): void + { + $task = $this->task('2026-11-01')->setDeadlineOverride2(new \DateTimeImmutable('2026-10-15')); + + $this->repository->method('findOpen')->willReturn([$task]); + $this->userRepository->method('getUsersByRoles')->willReturn([$this->admin()]); + + $levels = []; + + $this->mailer + ->method('createAndSendEmail') + ->willReturnCallback(static function (array $context) use (&$levels): void { + foreach (reset($context['violations']) as $entry) { + $levels[] = $entry['level']; + } + }) + ; + + $this->service->sendDeadlineSummary(new \DateTimeImmutable('2026-10-15')); + + $this->assertSame([2], $levels); + } + + public function testAnAdminMutingNotificationsIsSkipped(): void + { + $this->repository->method('findOpen')->willReturn([$this->task('2026-11-01')]); + $this->userRepository->method('getUsersByRoles')->willReturn([ + $this->admin()->setMuteNotifications(true), + ]); + + $this->mailer->expects($this->never())->method('createAndSendEmail'); + + $this->service->sendDeadlineSummary(new \DateTimeImmutable('2026-10-01')); + } + + public function testTheDigestOnlyGoesOutOnMondays(): void + { + $this->repository->expects($this->never())->method('findOpen'); + $this->mailer->expects($this->never())->method('createAndSendText'); + + // 2026-10-06 is a tuesday + $this->service->sendTeamerDigest(new \DateTimeImmutable('2026-10-06')); + } + + public function testTheDigestListsTheOpenReturnEntries(): void + { + $this->repository->method('findOpen')->willReturn([$this->task('2026-11-01')]); + + $this->mailer + ->expects($this->once()) + ->method('createAndSendText') + ->with( + EmailTextKey::REMINDER_SEASON_TASKS, + $this->callback(static fn (array $placeholders): bool => SeasonTask::CONTRACT->label() === $placeholders['tasks']), + $this->anything() + ) + ; + + $this->service->sendTeamerDigest($this->monday()); + } + + public function testAnOfficeOnlyEntryNeverReachesTheTeamer(): void + { + $this->repository + ->method('findOpen') + ->willReturn([$this->task('2026-11-01', SeasonTask::SAAS_REGISTERED)]) + ; + + $this->mailer->expects($this->never())->method('createAndSendText'); + + $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 + return new \DateTimeImmutable('2026-10-05'); + } + + private function task(string $dateFrom, SeasonTask $definition = SeasonTask::CONTRACT): SeasonAssignmentTask + { + $teamer = new Teamer(); + $teamer->setCommunication((new Communication())->setEmail('teamer@example.org')); + + $seasonAssignment = (new SeasonAssignment()) + ->setTeamer($teamer) + ->setDateFrom(new \DateTimeImmutable($dateFrom)) + ->setDateTo(new \DateTimeImmutable($dateFrom.' +5 months')) + ->setHotelCode('AGR') + ; + + $task = (new SeasonAssignmentTask())->setTaskKey($definition->value); + $seasonAssignment->addTask($task); + + return $task; + } + + private function admin(): User + { + return (new User())->setEmail('office@example.org'); + } +} diff --git a/tests/Service/Season/SeasonAssignmentLabelerTest.php b/tests/Service/Season/SeasonAssignmentLabelerTest.php new file mode 100644 index 0000000..f4ea770 --- /dev/null +++ b/tests/Service/Season/SeasonAssignmentLabelerTest.php @@ -0,0 +1,73 @@ +assertSame( + 'Saison 2026/27 - Rotbach', + $this->labeler()->label($this->seasonAssignment('2026-11-01', 'AGR')) + ); + } + + /** + * The season window is 01.11.-01.04., so a placement starting in July belongs to no season. + * It still has to name itself rather than render blank. + */ + public function testAPlacementOutsideAnySeasonSaysSo(): void + { + $this->assertSame( + 'außerhalb der Saison - Rotbach', + $this->labeler()->label($this->seasonAssignment('2027-07-15', 'AGR')) + ); + } + + public function testAHalfFilledRecordStillNamesItself(): void + { + $this->assertSame('? - ?', $this->labeler()->label(new SeasonAssignment())); + } + + /** + * The houses map is curated and can lose an entry; a placement that named that house falls + * back to the raw code rather than losing its name. + */ + public function testACodeThatLeftTheMapFallsBackToTheCode(): void + { + $this->assertSame( + 'Saison 2026/27 - XYZ', + $this->labeler()->label($this->seasonAssignment('2026-11-01', 'XYZ')) + ); + } + + private function labeler(): SeasonAssignmentLabeler + { + return new SeasonAssignmentLabeler( + new SeasonCalendar('11-01', '04-01'), + new HouseCatalog(['AGR' => 'Rotbach']) + ); + } + + private function seasonAssignment(string $dateFrom, string $hotelCode): SeasonAssignment + { + return (new SeasonAssignment()) + ->setDateFrom(new \DateTimeImmutable($dateFrom)) + ->setDateTo(new \DateTimeImmutable($dateFrom.' +5 months')) + ->setHotelCode($hotelCode) + ; + } +} diff --git a/tests/Service/Season/SeasonTaskDeadlineResolverTest.php b/tests/Service/Season/SeasonTaskDeadlineResolverTest.php new file mode 100644 index 0000000..e36f7b8 --- /dev/null +++ b/tests/Service/Season/SeasonTaskDeadlineResolverTest.php @@ -0,0 +1,99 @@ +task('2026-11-01'); + + $this->assertSame('2026-10-01', $this->resolver()->resolveFirst($task)?->format('Y-m-d')); + } + + public function testTheOffsetComesFromTheParameter(): void + { + $task = $this->task('2026-11-01'); + + $this->assertSame( + '2026-10-18', + $this->resolver('-14 days')->resolveFirst($task)?->format('Y-m-d') + ); + } + + public function testAnOverriddenFirstDeadlineWins(): void + { + $task = $this->task('2026-11-01')->setDeadlineOverride1(new \DateTimeImmutable('2026-09-15')); + + $this->assertSame('2026-09-15', $this->resolver()->resolveFirst($task)?->format('Y-m-d')); + } + + public function testTheSecondDeadlineDefaultsToTheFirst(): void + { + $task = $this->task('2026-11-01'); + + $this->assertSame('2026-10-01', $this->resolver()->resolveSecond($task)?->format('Y-m-d')); + } + + public function testTheSecondDeadlineFollowsAnOverriddenFirstOne(): void + { + $task = $this->task('2026-11-01')->setDeadlineOverride1(new \DateTimeImmutable('2026-09-15')); + + $this->assertSame('2026-09-15', $this->resolver()->resolveSecond($task)?->format('Y-m-d')); + } + + public function testAnOverriddenSecondDeadlineWinsOverBoth(): void + { + $task = $this + ->task('2026-11-01') + ->setDeadlineOverride1(new \DateTimeImmutable('2026-09-15')) + ->setDeadlineOverride2(new \DateTimeImmutable('2026-10-20')) + ; + + $this->assertSame('2026-09-15', $this->resolver()->resolveFirst($task)?->format('Y-m-d')); + $this->assertSame('2026-10-20', $this->resolver()->resolveSecond($task)?->format('Y-m-d')); + } + + public function testAnAssignmentWithoutAStartDateHasNoDeadlines(): void + { + $task = (new SeasonAssignmentTask()) + ->setTaskKey(SeasonTask::CONTRACT->value) + ->setSeasonAssignment(new SeasonAssignment()) + ; + + $this->assertNull($this->resolver()->resolveFirst($task)); + $this->assertNull($this->resolver()->resolveSecond($task)); + } + + private function resolver(string $offset = '-1 month'): SeasonTaskDeadlineResolver + { + return new SeasonTaskDeadlineResolver($offset); + } + + private function task(string $dateFrom): SeasonAssignmentTask + { + $seasonAssignment = (new SeasonAssignment()) + ->setDateFrom(new \DateTimeImmutable($dateFrom)) + ->setDateTo(new \DateTimeImmutable($dateFrom.' +5 months')) + ->setHotelCode('AGR') + ; + + $task = (new SeasonAssignmentTask())->setTaskKey(SeasonTask::CONTRACT->value); + $seasonAssignment->addTask($task); + + return $task; + } +} diff --git a/tests/Service/Season/SeasonTaskSynchronizerTest.php b/tests/Service/Season/SeasonTaskSynchronizerTest.php new file mode 100644 index 0000000..f571e12 --- /dev/null +++ b/tests/Service/Season/SeasonTaskSynchronizerTest.php @@ -0,0 +1,102 @@ +entityManager = $this->createMock(EntityManagerInterface::class); + $this->synchronizer = new SeasonTaskSynchronizer($this->entityManager); + } + + public function testItCreatesOneRowPerEntryInEnumOrder(): void + { + $seasonAssignment = new SeasonAssignment(); + + $this->entityManager->expects($this->exactly(count(SeasonTask::cases())))->method('persist'); + $this->entityManager->expects($this->once())->method('flush'); + + $this->synchronizer->sync($seasonAssignment); + + $this->assertSame( + array_map(static fn (SeasonTask $task): string => $task->value, SeasonTask::cases()), + $this->keysOf($seasonAssignment) + ); + } + + public function testItOnlyCreatesWhatIsMissing(): void + { + $seasonAssignment = new SeasonAssignment(); + $seasonAssignment->addTask((new SeasonAssignmentTask())->setTaskKey(SeasonTask::CONTRACT->value)); + + $this->entityManager->expects($this->exactly(count(SeasonTask::cases()) - 1))->method('persist'); + + $this->synchronizer->sync($seasonAssignment); + + $this->assertCount(count(SeasonTask::cases()), $seasonAssignment->getTasks()); + $this->assertSame( + 1, + count(array_filter( + $this->keysOf($seasonAssignment), + static fn (string $key): bool => SeasonTask::CONTRACT->value === $key + )) + ); + } + + public function testASyncedAssignmentIsNotFlushedAgain(): void + { + $seasonAssignment = new SeasonAssignment(); + + foreach (SeasonTask::cases() as $case) { + $seasonAssignment->addTask((new SeasonAssignmentTask())->setTaskKey($case->value)); + } + + $this->entityManager->expects($this->never())->method('persist'); + $this->entityManager->expects($this->never())->method('flush'); + + $this->synchronizer->sync($seasonAssignment); + } + + public function testARowOfAnUnknownEntryIsKept(): void + { + $seasonAssignment = new SeasonAssignment(); + $seasonAssignment->addTask((new SeasonAssignmentTask())->setTaskKey('entry_removed_from_the_enum')); + + $this->entityManager->expects($this->never())->method('remove'); + + $this->synchronizer->sync($seasonAssignment); + + $this->assertCount(count(SeasonTask::cases()) + 1, $seasonAssignment->getTasks()); + $this->assertContains('entry_removed_from_the_enum', $this->keysOf($seasonAssignment)); + } + + /** + * @return string[] + */ + private function keysOf(SeasonAssignment $seasonAssignment): array + { + return array_map( + static fn (SeasonAssignmentTask $task): string => $task->getTaskKey(), + $seasonAssignment->getTasks()->toArray() + ); + } +} diff --git a/tests/Twig/TeamerStatusLabelTest.php b/tests/Twig/TeamerStatusLabelTest.php new file mode 100644 index 0000000..baa18b5 --- /dev/null +++ b/tests/Twig/TeamerStatusLabelTest.php @@ -0,0 +1,87 @@ +createTeamer(Teamer::STATUS_EXISTING, true); + + $this->assertSame('Bestandsteamer:in · Saison', $this->label($teamer)); + } + + public function testANewTeamerCanAlsoBeAllSeason(): void + { + $teamer = $this->createTeamer(Teamer::STATUS_NEW, true); + + $this->assertSame('Neuteamer:in · Saison', $this->label($teamer)); + } + + public function testAnOrdinaryTeamerIsUnmarked(): void + { + $this->assertSame('Bestandsteamer:in', $this->label($this->createTeamer(Teamer::STATUS_EXISTING, false))); + $this->assertSame('Neuteamer:in', $this->label($this->createTeamer(Teamer::STATUS_NEW, false))); + } + + /** + * A role replaces the status label entirely, but the flag is orthogonal to both - an admin + * who is also an all-season teamer has to stay recognisable as one. + */ + public function testARoleLabelStillCarriesTheMarker(): void + { + $teamer = $this->createTeamer(Teamer::STATUS_EXISTING, true); + $teamer->getUser()->setRoles(['ROLE_MANAGER']); + + $this->assertSame('Reisemanagement · Saison', $this->label($teamer)); + } + + /** + * The dedicated Saisonteamer list is entirely all-season, so repeating it on every row + * would say nothing. + */ + public function testTheMarkerCanBeSuppressed(): void + { + $teamer = $this->createTeamer(Teamer::STATUS_EXISTING, true); + + $this->assertSame('Bestandsteamer:in', $this->label($teamer, false)); + } + + private function label(Teamer $teamer, bool $includeAllSeason = true): string + { + return $this->createRuntime()->teamerStatusLabel($teamer, $includeAllSeason); + } + + private function createTeamer(string $status, bool $allSeason): Teamer + { + $teamer = (new Teamer()) + ->setStatus($status) + ->setAllSeason($allSeason) + ; + + // the label reads roles off the user, so every teamer needs one + (new User())->setTeamer($teamer); + + return $teamer; + } + + private function createRuntime(): AppRuntime + { + $reflection = new \ReflectionClass(AppRuntime::class); + + return $reflection->newInstanceWithoutConstructor(); + } +} diff --git a/tests/Validator/SeasonAssignmentValidatorTest.php b/tests/Validator/SeasonAssignmentValidatorTest.php new file mode 100644 index 0000000..4da4a6a --- /dev/null +++ b/tests/Validator/SeasonAssignmentValidatorTest.php @@ -0,0 +1,128 @@ +repository = $this->createMock(SeasonAssignmentRepository::class); + + return new SeasonAssignmentValidator($this->repository, new SeasonCalendar('11-01', '04-01')); + } + + public function testASecondAssignmentInTheSameSeasonIsRefused(): void + { + $this->repository->method('countInSeasonWindow')->willReturn(1); + + $this->validator->validate($this->seasonAssignment('2026-12-20'), $this->constraint()); + + $this->buildViolation('Für diese:n Teamer:in existiert in dieser Saison bereits ein Saisoneinsatz') + ->assertRaised() + ; + } + + public function testTheFirstAssignmentOfASeasonPasses(): void + { + $this->repository->method('countInSeasonWindow')->willReturn(0); + + $this->validator->validate($this->seasonAssignment('2026-12-20'), $this->constraint()); + + $this->assertNoViolation(); + } + + public function testItCountsAgainstTheSeasonTheAssignmentStartsIn(): void + { + // 20.02.2027 belongs to the season that began on 01.11.2026, so the window asked for has + // to be that one and not the calendar year the date happens to fall in + $this->repository + ->expects($this->once()) + ->method('countInSeasonWindow') + ->with( + $this->isInstanceOf(Teamer::class), + $this->callback(static fn (\DateTimeImmutable $start): bool => '2026-11-01' === $start->format('Y-m-d')), + $this->callback(static fn (\DateTimeImmutable $end): bool => '2027-04-01' === $end->format('Y-m-d')), + null + ) + ->willReturn(0) + ; + + $this->validator->validate($this->seasonAssignment('2027-02-20'), $this->constraint()); + + $this->assertNoViolation(); + } + + public function testEditingAnAssignmentDoesNotCountItAgainstItself(): void + { + $seasonAssignment = $this->seasonAssignment('2026-12-20'); + + // the id is never set by hand in the app; it is what tells the query to leave the row + // being edited out, so an edit that changes nothing cannot refuse itself + $id = new \ReflectionProperty(SeasonAssignment::class, 'id'); + $id->setValue($seasonAssignment, 42); + + $this->repository + ->expects($this->once()) + ->method('countInSeasonWindow') + ->with($this->anything(), $this->anything(), $this->anything(), 42) + ->willReturn(0) + ; + + $this->validator->validate($seasonAssignment, $this->constraint()); + + $this->assertNoViolation(); + } + + public function testAnAssignmentWithoutATeamerPasses(): void + { + $this->repository->expects($this->never())->method('countInSeasonWindow'); + + $seasonAssignment = $this->seasonAssignment('2026-12-20')->setTeamer(null); + + $this->validator->validate($seasonAssignment, $this->constraint()); + + $this->assertNoViolation(); + } + + public function testAnAssignmentOutsideAnySeasonWindowPasses(): void + { + $this->repository->expects($this->never())->method('countInSeasonWindow'); + + $this->validator->validate($this->seasonAssignment('2027-07-15'), $this->constraint()); + + $this->assertNoViolation(); + } + + private function constraint(): SeasonAssignmentConstraint + { + return new SeasonAssignmentConstraint(); + } + + private function seasonAssignment(string $dateFrom): SeasonAssignment + { + return (new SeasonAssignment()) + ->setTeamer(new Teamer()) + ->setDateFrom(new \DateTimeImmutable($dateFrom)) + ->setDateTo(new \DateTimeImmutable($dateFrom.' +6 months')) + ->setHotelCode('AGR') + ; + } +}