feat: all-season assignments

This commit is contained in:
Björn Fromme authored and Björn Fromme committed 2026-09-26 12:13:16 +02:00
1 parent 675cec1b69
commit cf876ed224
119 files changed
+6194 -294

No files matched your search

+1
View File
@@ -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"
+15
View File
@@ -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'
+7
View File
@@ -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:
+7 -1
View File
@@ -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
+15
View File
@@ -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:
+7
View File
@@ -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
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260824124850 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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');
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Adds the season_assignment table: the placement of an all-season teamer at a destination.
*
* Written by hand rather than taken from doctrine:migrations:diff - the diff against this
* database also carries a large amount of pre-existing drift (datetime and JSON column
* declarations across nearly every table) that has nothing to do with this feature.
*/
final class Version20260909124625 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add season_assignment table';
}
public function up(Schema $schema): void
{
$this->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');
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Adds the season assignment checklist: one row per checklist entry per assignment, the binding
* of an entry to a document from the global library, and the two links a season assignment needs
* to carry its own files.
*
* Written by hand rather than taken from doctrine:migrations:diff, like Version20260909124625 -
* the diff against this database also carries the pre-existing drift in datetime and JSON column
* declarations, which has nothing to do with this feature.
*/
final class Version20260915103000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add season assignment tasks, task document mapping and season uploads';
}
public function up(Schema $schema): void
{
$this->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');
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Replaces the season assignment's Destination relation with its own period and house.
*
* A Destination is one travel with one departure window; an all-season placement runs a whole
* season at one house, so the relation only ever meant "borrow three scalars from an arbitrary
* travel row". The house is now a key of the configured "houses" map, which is keyed by the
* normalized hotel code - hence the SER stripping in the backfill, mirroring
* App\Entity\Destination::getHotelCodeNormalized().
*
* Written by hand rather than taken from doctrine:migrations:diff, like Version20260909124625 -
* the diff against this database also carries the pre-existing drift in datetime and JSON column
* declarations, which has nothing to do with this feature.
*/
final class Version20260916160000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Move season assignment period and house onto the assignment itself';
}
public function up(Schema $schema): void
{
// nullable first, so the existing rows can be backfilled before the columns are tightened
$this->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');
}
}
+8
View File
@@ -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;
}
}
+1
View File
@@ -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';
}
+48 -10
View File
@@ -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));
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Config;
/**
* The formalities the office works through for every season assignment.
*
* This enum is the single source of truth: the entries themselves, their German labels, their
* order and both of their properties live here and nowhere else. Rows in season_assignment_task
* only carry state, and App\Service\Season\SeasonTaskSynchronizer creates the ones an assignment
* is missing - so adding a case here is all it takes to add an entry everywhere, including on
* assignments that already exist.
*
* Removing a case leaves its rows behind on purpose. Every read resolves a stored key through
* tryFrom() and skips what no longer exists, which keeps the history recoverable instead of
* deleting it.
*/
enum SeasonTask: string
{
case MASTER_DATA = 'master_data';
case VEHICLE = 'vehicle';
case CONTRACT = 'contract';
case ADDITIONAL_CONTRACT = 'additional_contract';
case SAAS_REGISTERED = 'saas_registered';
case MUNICIPALITY_REGISTERED = 'municipality_registered';
public function label(): string
{
return match ($this) {
self::MASTER_DATA => 'Stammdaten',
self::VEHICLE => 'KfZ',
self::CONTRACT => 'Vertrag',
self::ADDITIONAL_CONTRACT => 'Zusatzvertrag',
self::SAAS_REGISTERED => 'SaaS angemeldet',
self::MUNICIPALITY_REGISTERED => 'Gemeinde angemeldet',
};
}
public function provides(): SeasonTaskProvision
{
return match ($this) {
self::MASTER_DATA, self::VEHICLE => SeasonTaskProvision::STANDARD,
self::CONTRACT, self::ADDITIONAL_CONTRACT => SeasonTaskProvision::INDIVIDUAL,
self::SAAS_REGISTERED, self::MUNICIPALITY_REGISTERED => SeasonTaskProvision::NONE,
};
}
/**
* Whether the teamer has to send something back. This is what puts a task on the teamer's
* own page and into their weekly reminder; everything else is office-internal.
*/
public function requiresReturn(): bool
{
return match ($this) {
self::MASTER_DATA, self::VEHICLE, self::CONTRACT, self::ADDITIONAL_CONTRACT => true,
self::SAAS_REGISTERED, self::MUNICIPALITY_REGISTERED => false,
};
}
/**
* @return self[]
*/
public static function requiringReturn(): array
{
return array_values(array_filter(
self::cases(),
static fn (self $task): bool => true === $task->requiresReturn()
));
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Config;
/**
* What the office hands out for a season task.
*
* NONE nothing is issued, the task is either a pure return or an internal check
* INDIVIDUAL a document produced for this one teamer and uploaded onto their season assignment
* STANDARD the same document for everyone, taken from the global document library
*/
enum SeasonTaskProvision: string
{
case NONE = 'none';
case INDIVIDUAL = 'individual';
case STANDARD = 'standard';
}
@@ -30,4 +30,22 @@ class TeamerController extends AbstractController
return $this->json($dates);
}
/**
* Season assignments are only ever made for all-season teamers, so their form must not be
* able to offer anybody else.
*/
#[Route('/admin/autocomplete/season-teamer', name: 'app_admin_autocomplete_season_teamer')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function season(Request $request): JsonResponse
{
try {
$query = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
$queryString = $query['search'];
} catch (\JsonException $e) {
throw $this->createNotFoundException();
}
return $this->json($this->teamerRepository->getAutocompletionData($queryString, true));
}
}
@@ -57,7 +57,7 @@ class MailingController extends AbstractController
));
}
$filterDto = $this->filterHandler->getFilterSettings();
$filterDto = $this->filterHandler->setScope($request->query->get('scope'))->getFilterSettings();
// No redirect after the preview mail, the composed mail has to survive it. The
// preview pane is rendered here so the page arrives complete; from then on the
@@ -69,7 +69,8 @@ class MailingController extends AbstractController
'recipients' => $this->mailingService->resolveRecipients($filterDto),
'placeholders' => TeamerMailingService::PLACEHOLDERS,
'hasDraft' => $this->draftHandler->hasDraft(),
'returnUrl' => $this->getReturnUrl($request, 'app_administrative_teamer_index'),
'returnUrl' => $this->getReturnUrl($request, $this->getListRoute($request)),
'scope' => $request->query->get('scope'),
]);
}
@@ -94,11 +95,13 @@ class MailingController extends AbstractController
#[Route('/admin/teamer/mailing/discard', name: 'app_admin_teamer_mailing_discard')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function discard(): Response
public function discard(Request $request): Response
{
$this->draftHandler->resetDraft();
return $this->redirectToRoute('app_admin_teamer_mailing');
return $this->redirectToRoute('app_admin_teamer_mailing', [
'scope' => $request->query->get('scope'),
]);
}
#[Route('/admin/teamer/mailing/confirm', name: 'app_admin_teamer_mailing_confirm', methods: ['POST'])]
@@ -106,7 +109,7 @@ class MailingController extends AbstractController
public function confirm(Request $request): Response
{
$form = $this->createMailingForm($request);
$filterDto = $this->filterHandler->getFilterSettings();
$filterDto = $this->filterHandler->setScope($request->query->get('scope'))->getFilterSettings();
// the composed mail is carried on in hidden fields so no half written mailing has
// to be parked in the session between the confirmation and the send
@@ -115,6 +118,7 @@ class MailingController extends AbstractController
'mailingDto' => $form->getData(),
'valid' => $form->isSubmitted() && $form->isValid(),
'recipients' => $this->mailingService->resolveRecipients($filterDto),
'scope' => $request->query->get('scope'),
]);
}
@@ -127,10 +131,14 @@ class MailingController extends AbstractController
if (false === $form->isSubmitted() || false === $form->isValid()) {
$this->addFlash('error', 'Die Mail konnte nicht gesendet werden, Betreff oder Nachricht fehlen');
return new HxRedirectResponse($this->generateUrl('app_admin_teamer_mailing'));
return new HxRedirectResponse($this->generateUrl('app_admin_teamer_mailing', [
'scope' => $request->query->get('scope'),
]));
}
$recipients = $this->mailingService->resolveRecipients($this->filterHandler->getFilterSettings());
$recipients = $this->mailingService->resolveRecipients(
$this->filterHandler->setScope($request->query->get('scope'))->getFilterSettings()
);
$mailingDto = $form->getData();
// the recipients travel with the message, so the mailing reaches exactly the
@@ -151,7 +159,18 @@ class MailingController extends AbstractController
$recipients->getEligibleCount()
));
return new HxRedirectResponse($this->generateUrl('app_administrative_teamer_index'));
return new HxRedirectResponse($this->generateUrl($this->getListRoute($request)));
}
/**
* The list this mailing was started from, so a send returns to it and an unscoped
* mailing never falls back to the wrong one.
*/
private function getListRoute(Request $request): string
{
return TeamerFilterHandler::SCOPE_ALL_SEASON === $request->query->get('scope')
? 'app_administrative_season_teamer_index'
: 'app_administrative_teamer_index';
}
/**
@@ -30,6 +30,8 @@ class SkillsController extends AbstractController
{
$trainings = $this->trainingRepository->getList();
$allSeasonStatus = $teamer->isAllSeason();
$form = $this->createStatusForm($teamer);
$form->handleRequest($request);
@@ -42,6 +44,15 @@ class SkillsController extends AbstractController
'teamer' => $teamer->getFullName(),
]);
if ($teamer->isAllSeason() !== $allSeasonStatus) {
$this->addFlash('info', 'Der Saisonteamer:innern-Status wurde '.($teamer->isAllSeason() ? 'hinzugefügt' : 'entfernt'));
$logMessage = $teamer->isAllSeason() ? 'Assign all-season status' : 'Remove all-season status';
$this->logger->info($logMessage, [
'teamer' => $teamer->getFullName(),
]);
}
return $this->redirectToRoute('app_admin_teamer_skills', [
'uuid' => $teamer->getUuid(),
'r' => $request->query->get('r'),
@@ -62,10 +73,14 @@ class SkillsController extends AbstractController
->add('status', ChoiceType::class, [
'label' => 'Status',
'choices' => [
'Neuteamer' => Teamer::STATUS_NEW,
'Bestandsteamer' => Teamer::STATUS_EXISTING,
'Neuteamer:in' => Teamer::STATUS_NEW,
'Bestandsteamer:in' => Teamer::STATUS_EXISTING,
],
])
->add('allSeason', CheckboxType::class, [
'label' => 'Saisonteamer:in',
'required' => false,
])
->add('allowOverlappingApplications', CheckboxType::class, [
'label' => 'Überlappende Bewerbungen erlauben',
'required' => false,
@@ -0,0 +1,76 @@
<?php
namespace App\Controller\Administrative\SeasonAssignment;
use App\Config\SeasonCalendar;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\SeasonAssignment;
use App\Form\SeasonAssignmentType;
use App\Service\Season\SeasonAssignmentLabeler;
use App\Service\Season\SeasonTaskSynchronizer;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class CreateController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly SeasonTaskSynchronizer $taskSynchronizer,
private readonly SeasonAssignmentLabeler $labeler,
private readonly SeasonCalendar $seasonCalendar,
private readonly LoggerInterface $logger,
) {
}
#[Route('/administrative/season-assignment/create', name: 'app_administrative_season_assignment_create')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(Request $request): Response
{
$seasonAssignment = new SeasonAssignment();
// Almost every assignment laid out runs the full season, so the form opens on the
// season that is running today - or, off-season, the one that starts next, the same
// window the teamer's season page already falls forward to.
[$windowStart, $windowEnd] = $this->seasonCalendar->windowFor($this->seasonCalendar->currentOrUpcomingSeasonYear());
$seasonAssignment
->setDateFrom($windowStart->toDateTimeImmutable())
->setDateTo($windowEnd->toDateTimeImmutable())
;
$returnUrl = $this->getReturnUrl($request, 'app_administrative_season_assignment_index');
$form = $this->createForm(SeasonAssignmentType::class, $seasonAssignment);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->persist($seasonAssignment);
$this->entityManager->flush();
// the checklist is what the office works with from here on, so it exists from the
// moment the assignment does
$this->taskSynchronizer->sync($seasonAssignment);
$this->addFlash('success', 'Der Saisoneinsatz wurde angelegt');
$this->logger->info('Create season assignment', [
'season_assignment_id' => $seasonAssignment->getId(),
'teamer' => $seasonAssignment->getTeamer()?->getFullName(),
'season_assignment' => $this->labeler->label($seasonAssignment),
'job_profile' => $seasonAssignment->getJobProfile()->getName(),
]);
return $this->redirect($returnUrl);
}
return $this->render('administrative/season_assignment/create.html.twig', [
'form' => $form->createView(),
'returnUrl' => $returnUrl,
]);
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Controller\Administrative\SeasonAssignment;
use App\Entity\SeasonAssignment;
use App\Htmx\HxRedirectResponse;
use App\Service\Season\SeasonAssignmentLabeler;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class DeleteController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly SeasonAssignmentLabeler $labeler,
private readonly LoggerInterface $logger,
) {
}
#[Route('/administrative/season-assignment/delete/{uuid}', name: 'app_administrative_season_assignment_delete')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(SeasonAssignment $seasonAssignment, Request $request): Response
{
if (true === $request->isMethod('POST')) {
$seasonAssignment->setDeleted();
$this->entityManager->flush();
$this->logger->info('Delete season assignment', [
'season_assignment_id' => $seasonAssignment->getId(),
'teamer' => $seasonAssignment->getTeamer()?->getFullName(),
'season_assignment' => $this->labeler->label($seasonAssignment),
'job_profile' => $seasonAssignment->getJobProfile()->getName(),
]);
$this->addFlash('success', 'Der Saisoneinsatz wurde gelöscht');
return new HxRedirectResponse($this->generateUrl('app_administrative_season_assignment_index'));
}
return $this->render('administrative/season_assignment/modal_delete.html.twig', [
'seasonAssignment' => $seasonAssignment,
]);
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Controller\Administrative\SeasonAssignment;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\SeasonAssignment;
use App\Service\Season\SeasonChecklistBuilder;
use App\Service\Season\SeasonTaskSynchronizer;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class DetailController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(
private readonly SeasonTaskSynchronizer $taskSynchronizer,
private readonly SeasonChecklistBuilder $checklistBuilder,
) {
}
#[Route('/administrative/season-assignment/detail/{uuid}', name: 'app_administrative_season_assignment_detail')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(SeasonAssignment $seasonAssignment, Request $request): Response
{
// one of the two write paths the synchronizer runs on, so an entry added to the enum
// later shows up on assignments that already exist
$this->taskSynchronizer->sync($seasonAssignment);
return $this->render('administrative/season_assignment/detail.html.twig', [
'seasonAssignment' => $seasonAssignment,
'checklist' => $this->checklistBuilder->build($seasonAssignment),
'returnUrl' => $this->getReturnUrl($request, 'app_administrative_season_assignment_index'),
]);
}
}
@@ -0,0 +1,156 @@
<?php
namespace App\Controller\Administrative\SeasonAssignment;
use App\Entity\SeasonAssignment;
use App\Entity\Upload;
use App\Entity\User;
use App\Htmx\HxRedirectResponse;
use App\Model\UploadDto;
use App\Service\Upload\UploadHandler;
use Doctrine\ORM\EntityManagerInterface;
use Flagception\Manager\FeatureManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The documents the office issues for one season assignment.
*
* Deliberately not Upload::TYPE_DOCUMENT: that type is the staff-wide library, which UploadVoter
* opens to every teamer - an individually issued contract in it would be readable by everyone.
* These carry TYPE_SEASON_DOCUMENT_OUT instead, and the voter lets exactly the teamer of this
* assignment download them.
*
* There is no relation between a file and a checklist entry on purpose: the office looks at the
* documents, decides which entry a file satisfies and ticks it off by hand.
*/
class DocumentController extends AbstractController
{
public function __construct(
private readonly UploadHandler $uploadHandler,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly FeatureManagerInterface $featureManager,
) {
}
#[Route(
path: '/administrative/season-assignment/{uuid}/document',
name: 'app_administrative_season_assignment_document_upload'
)]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function upload(SeasonAssignment $seasonAssignment, Request $request): Response
{
// Dummy form without fields, as elsewhere: the files travel in the upload session, not in
// the form itself.
$form = $this->createFormBuilder()->getForm();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$count = $this->processUpload($seasonAssignment);
$this->addFlash(
'success',
1 === $count ? 'Die Unterlage wurde hochgeladen' : 'Die Unterlagen wurden hochgeladen'
);
return new HxRedirectResponse($this->generateUrl('app_administrative_season_assignment_detail', [
'uuid' => $seasonAssignment->getUuid(),
'r' => $request->query->get('r'),
]));
}
return $this->render('administrative/season_assignment/modal_document_upload.html.twig', [
'seasonAssignment' => $seasonAssignment,
'form' => $form->createView(),
]);
}
#[Route(
path: '/administrative/season-assignment/document/{uuid}',
name: 'app_administrative_season_assignment_document_delete'
)]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function delete(Upload $document, Request $request): Response
{
if (null === $seasonAssignment = $document->getSeasonAssignment()) {
throw $this->createNotFoundException('Not a season assignment document');
}
if (true === $request->isMethod('POST')) {
// Doctrine's DeleteUploadListener removes the file from disk on preRemove.
$this->entityManager->remove($document);
$this->entityManager->flush();
$this->addFlash('success', 'Die Unterlage wurde gelöscht');
$this->logger->info('Delete season assignment document', [
'season_assignment_id' => $seasonAssignment->getId(),
'teamer' => $seasonAssignment->getTeamer()?->getFullName(),
'document_filename' => $document->getOriginalFilename(),
'document_type' => $document->getType(),
]);
return new HxRedirectResponse($this->generateUrl('app_administrative_season_assignment_detail', [
'uuid' => $seasonAssignment->getUuid(),
'r' => $request->query->get('r'),
]));
}
return $this->render('administrative/season_assignment/modal_document_delete.html.twig', [
'document' => $document,
]);
}
private function processUpload(SeasonAssignment $seasonAssignment): int
{
/** @var User $user */
$user = $this->getUser();
$uploadSession = $this->uploadHandler->getUploadSession();
// Only this dropzone's files: the page may hold a second one, so the session must not be
// destroyed wholesale.
$uploadDtos = $uploadSession->getUploadsByType(Upload::TYPE_SEASON_DOCUMENT_OUT);
$uploads = [];
/** @var UploadDto $uploadDto */
foreach ($uploadDtos as $uploadDto) {
$upload = Upload::fromUploadDto($uploadDto, $user, Upload::TYPE_SEASON_DOCUMENT_OUT);
// No status: these are not checked or approved, they are handed out. UploadVoter
// grants CHECK only for "new" and "pending", so a null status keeps them out of the
// checking process by construction.
$upload->setStatus(null);
$seasonAssignment->addDocument($upload);
$this->entityManager->persist($upload);
$uploads[] = $upload;
}
$this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_SEASON_DOCUMENT_OUT, $uploadSession);
$this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_SEASON_DOCUMENT_OUT);
if (true === $this->featureManager->isActive('sanitize_uploads')) {
foreach ($uploads as $upload) {
$this->uploadHandler->ensurePdf($upload);
}
}
$this->entityManager->flush();
$this->logger->info('Upload season assignment documents', [
'user' => $user->getUserIdentifier(),
'season_assignment_id' => $seasonAssignment->getId(),
'count' => count($uploads),
]);
return count($uploads);
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Controller\Administrative\SeasonAssignment;
use App\Entity\SeasonAssignment;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Toggles the office's note that it considers this teamer done.
*
* Informative only: it silences nothing. The weekly teamer digest and the office deadline alerts
* both keep running while a return-required checklist entry is open. Administrative users only -
* there is no teamer-side write path.
*/
class DoneTeamerController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route(
path: '/administrative/season-assignment/{uuid}/done-teamer',
name: 'app_administrative_season_assignment_done_teamer',
methods: ['POST']
)]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(SeasonAssignment $seasonAssignment): Response
{
$seasonAssignment->setDoneTeamer(false === $seasonAssignment->isDoneTeamer());
$this->entityManager->flush();
$this->logger->info('Toggle season assignment done teamer', [
'season_assignment_id' => $seasonAssignment->getId(),
'teamer' => $seasonAssignment->getTeamer()?->getFullName(),
'value' => $seasonAssignment->isDoneTeamer(),
]);
return $this->render('administrative/season_assignment/_done_teamer.html.twig', [
'seasonAssignment' => $seasonAssignment,
]);
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Controller\Administrative\SeasonAssignment;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\SeasonAssignment;
use App\Form\SeasonAssignmentType;
use App\Service\Season\SeasonAssignmentLabeler;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class EditController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly SeasonAssignmentLabeler $labeler,
private readonly LoggerInterface $logger,
) {
}
#[Route('/administrative/season-assignment/edit/{uuid}', name: 'app_administrative_season_assignment_edit')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(SeasonAssignment $seasonAssignment, Request $request): Response
{
$returnUrl = $this->getReturnUrl($request, 'app_administrative_season_assignment_index');
$form = $this->createForm(SeasonAssignmentType::class, $seasonAssignment);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->flush();
$this->addFlash('success', 'Der Saisoneinsatz wurde gespeichert');
$this->logger->info('Edit season assignment', [
'season_assignment_id' => $seasonAssignment->getId(),
'teamer' => $seasonAssignment->getTeamer()?->getFullName(),
'season_assignment' => $this->labeler->label($seasonAssignment),
'job_profile' => $seasonAssignment->getJobProfile()->getName(),
]);
return $this->redirect($returnUrl);
}
return $this->render('administrative/season_assignment/edit.html.twig', [
'form' => $form->createView(),
'seasonAssignment' => $seasonAssignment,
'returnUrl' => $returnUrl,
]);
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Controller\Administrative\SeasonAssignment;
use App\Repository\SeasonAssignmentRepository;
use App\Service\Common\SeasonAssignmentFilterHandler;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class IndexController extends AbstractController
{
public function __construct(
private readonly SeasonAssignmentFilterHandler $filterHandler,
private readonly SeasonAssignmentRepository $seasonAssignmentRepository,
private readonly PaginatorInterface $paginator,
) {
}
#[Route('/administrative/season-assignment', name: 'app_administrative_season_assignment_index')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(Request $request): Response
{
$filterDto = $this->filterHandler->getFilterSettings();
$query = $this
->seasonAssignmentRepository
->getListQuery($filterDto)
;
$pagination = $this->paginator->paginate(
$query,
$request->query->getInt('page', 1),
$request->query->getInt('limit', 50)
);
return $this->render('administrative/season_assignment/index.html.twig', [
'pagination' => $pagination,
'filterDto' => $filterDto,
]);
}
}
@@ -0,0 +1,69 @@
<?php
namespace App\Controller\Administrative\SeasonAssignment;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\SeasonAssignmentTask;
use App\Form\SeasonTaskDeadlineType;
use App\Htmx\HxRedirectResponse;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class TaskDeadlineController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route(
path: '/administrative/season-assignment/task/{uuid}/deadline',
name: 'app_administrative_season_assignment_task_deadline'
)]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(SeasonAssignmentTask $task, Request $request): Response
{
$seasonAssignment = $task->getSeasonAssignment();
$returnUrl = $this->getReturnUrl($request, 'app_administrative_season_assignment_detail', [
'uuid' => $seasonAssignment?->getUuid(),
]);
$form = $this->createForm(SeasonTaskDeadlineType::class, $task, [
'action' => $this->generateUrl('app_administrative_season_assignment_task_deadline', [
'uuid' => $task->getUuid(),
'r' => $returnUrl,
]),
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->flush();
$this->logger->info('Change season assignment task deadlines', [
'season_assignment_id' => $seasonAssignment?->getId(),
'teamer' => $seasonAssignment?->getTeamer()?->getFullName(),
'task' => $task->getTaskKey(),
'deadline_override_1' => $task->getDeadlineOverride1()?->format('Y-m-d'),
'deadline_override_2' => $task->getDeadlineOverride2()?->format('Y-m-d'),
]);
$this->addFlash('success', 'Die Fristen wurden gespeichert');
return new HxRedirectResponse($returnUrl);
}
return $this->render('administrative/season_assignment/modal_task_deadline.html.twig', [
'form' => $form->createView(),
'task' => $task,
]);
}
}
@@ -0,0 +1,78 @@
<?php
namespace App\Controller\Administrative\SeasonAssignment;
use App\Entity\SeasonAssignmentTask;
use App\Service\Season\SeasonChecklistBuilder;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Ticks one checklist entry, re-rendering that single row.
*
* Deliberately one request per row rather than one form for the whole checklist: the office
* works through the list one line at a time while reading a document, and a whole-form submit
* would let two people working the same assignment overwrite each other's ticks.
*/
class TaskToggleController extends AbstractController
{
private const FIELD_DONE = 'done';
private const FIELD_UPLOADED = 'uploaded';
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly SeasonChecklistBuilder $checklistBuilder,
private readonly LoggerInterface $logger,
) {
}
#[Route(
path: '/administrative/season-assignment/task/{uuid}/{field}',
name: 'app_administrative_season_assignment_task_toggle',
// pinned in the route as well as checked below, so this can never shadow the sibling
// .../task/{uuid}/deadline route whichever order the two are loaded in
requirements: ['field' => 'done|uploaded'],
methods: ['POST']
)]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(SeasonAssignmentTask $task, string $field): Response
{
// matched against the two names rather than turned into a property path: the field comes
// out of the URL
if (false === in_array($field, [self::FIELD_DONE, self::FIELD_UPLOADED], true)) {
throw $this->createNotFoundException(sprintf('Unknown checklist field "%s".', $field));
}
if (self::FIELD_DONE === $field) {
$done = false === $task->isDone();
$task
->setDone($done)
// explicit, because Blameable's updatedBy is overwritten by any later edit and
// the office wants to see who closed the entry
->setDoneAt(true === $done ? new \DateTimeImmutable() : null)
->setDoneBy(true === $done ? $this->getUser()?->getUserIdentifier() : null)
;
} else {
$task->setUploaded(false === $task->isUploaded());
}
$this->entityManager->flush();
$this->logger->info('Toggle season assignment task', [
'season_assignment_id' => $task->getSeasonAssignment()?->getId(),
'teamer' => $task->getSeasonAssignment()?->getTeamer()?->getFullName(),
'task' => $task->getTaskKey(),
'field' => $field,
'value' => self::FIELD_DONE === $field ? $task->isDone() : $task->isUploaded(),
]);
return $this->render('administrative/season_assignment/_task_row.html.twig', [
'row' => $this->checklistBuilder->buildRow($task),
]);
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Controller\Administrative\System\SeasonTaskDocument;
use App\Entity\SeasonTaskDocument;
use App\Htmx\HxRedirectResponse;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Removes the mapping, not the document: the library file itself stays where it is and is
* managed under Dokumente.
*/
class DeleteController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route(
path: '/administrative/system/season-task-document/delete/{uuid}',
name: 'app_administrative_system_season_task_document_delete'
)]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(SeasonTaskDocument $seasonTaskDocument, Request $request): Response
{
if (true === $request->isMethod('POST')) {
$taskKey = $seasonTaskDocument->getTaskKey();
$this->entityManager->remove($seasonTaskDocument);
$this->entityManager->flush();
$this->logger->info('Remove season task document', ['task' => $taskKey]);
$this->addFlash('success', 'Die Zuordnung wurde entfernt');
return new HxRedirectResponse(
$this->generateUrl('app_administrative_system_season_task_document_index')
);
}
return $this->render('administrative/system/season_task_document/modal_delete.html.twig', [
'seasonTaskDocument' => $seasonTaskDocument,
]);
}
}
@@ -0,0 +1,75 @@
<?php
namespace App\Controller\Administrative\System\SeasonTaskDocument;
use App\Config\SeasonTask;
use App\Config\SeasonTaskProvision;
use App\Entity\SeasonTaskDocument;
use App\Form\SeasonTaskDocumentType;
use App\Htmx\HxRedirectResponse;
use App\Repository\SeasonTaskDocumentRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Sets the library document a checklist entry hands out. Addressed by the entry rather than by a
* record: the mapping may not exist yet, and there is at most one per entry.
*/
class EditController extends AbstractController
{
public function __construct(
private readonly SeasonTaskDocumentRepository $seasonTaskDocumentRepository,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route(
path: '/administrative/system/season-task-document/edit/{taskKey}',
name: 'app_administrative_system_season_task_document_edit'
)]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(string $taskKey, Request $request): Response
{
$task = SeasonTask::tryFrom($taskKey);
if (null === $task || SeasonTaskProvision::STANDARD !== $task->provides()) {
throw $this->createNotFoundException(sprintf('No standard document is handed out for "%s".', $taskKey));
}
$seasonTaskDocument = $this->seasonTaskDocumentRepository->findForTask($task)
?? (new SeasonTaskDocument())->setTaskKey($task->value);
$form = $this->createForm(SeasonTaskDocumentType::class, $seasonTaskDocument);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
if (null === $seasonTaskDocument->getId()) {
$this->entityManager->persist($seasonTaskDocument);
}
$this->entityManager->flush();
$this->logger->info('Change season task document', [
'task' => $task->value,
'document_id' => $seasonTaskDocument->getDocument()?->getId(),
]);
$this->addFlash('success', 'Das Dokument wurde hinterlegt');
return new HxRedirectResponse(
$this->generateUrl('app_administrative_system_season_task_document_index')
);
}
return $this->render('administrative/system/season_task_document/modal_edit.html.twig', [
'form' => $form->createView(),
'task' => $task,
]);
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Controller\Administrative\System\SeasonTaskDocument;
use App\Config\SeasonTask;
use App\Config\SeasonTaskProvision;
use App\Repository\SeasonTaskDocumentRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Which document from the global library a checklist entry hands out.
*
* The rows are the enum's entries, not database records: only the entries that hand out a
* standard document can carry a mapping at all, and one without a mapping is a valid - if
* incomplete - state that the teamer's page has to show as "kein Dokument hinterlegt".
*/
class IndexController extends AbstractController
{
public function __construct(private readonly SeasonTaskDocumentRepository $seasonTaskDocumentRepository)
{
}
#[Route('/administrative/system/season-task-document', name: 'app_administrative_system_season_task_document_index')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(): Response
{
$mapped = $this->seasonTaskDocumentRepository->findIndexedByTaskKey();
$rows = [];
foreach (SeasonTask::cases() as $case) {
if (SeasonTaskProvision::STANDARD !== $case->provides()) {
continue;
}
$rows[] = [
'task' => $case,
'document' => $mapped[$case->value] ?? null,
];
}
return $this->render('administrative/system/season_task_document/index.html.twig', [
'rows' => $rows,
]);
}
}
@@ -17,7 +17,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class IndexController extends AbstractController
{
public function __construct(
protected readonly TeamerFilterHandler $filterHandler,
private readonly TeamerFilterHandler $filterHandler,
private readonly TeamerRepository $teamerRepository,
private readonly PaginatorInterface $paginator,
private readonly EntityManagerInterface $entityManager,
@@ -28,7 +28,38 @@ class IndexController extends AbstractController
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(Request $request): Response
{
$filterDto = $this->filterHandler->getFilterSettings();
return $this->renderList($request, 'administrative/teamer/index.html.twig', null);
}
#[Route('/administrative/season-teamer', name: 'app_administrative_season_teamer_index')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function season(Request $request): Response
{
return $this->renderList($request, 'administrative/teamer/season_index.html.twig', TeamerFilterHandler::SCOPE_ALL_SEASON);
}
#[Route('/administrative/teamer/viewed/{uuid}', name: 'app_administrative_teamer_viewed', methods: ['POST'])]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function viewed(Teamer $teamer): Response
{
$teamer->setViewed(true);
$this->entityManager->flush();
return new HxRedirectResponse($this->generateUrl('app_administrative_teamer_index'));
}
/**
* Both teamer lists differ only in the scope they run under, so they share one body.
* The scope is set explicitly on either path to keep the two independent of the order
* in which they are called within a request.
*/
private function renderList(Request $request, string $template, ?string $scope): Response
{
$filterDto = $this
->filterHandler
->setScope($scope)
->getFilterSettings()
;
$query = $this
->teamerRepository
@@ -41,19 +72,9 @@ class IndexController extends AbstractController
$request->query->getInt('limit', 50)
);
return $this->render('administrative/teamer/index.html.twig', [
return $this->render($template, [
'pagination' => $pagination,
'filterDto' => $filterDto,
]);
}
#[Route('/administrative/teamer/viewed/{uuid}', name: 'app_administrative_teamer_viewed', methods: ['POST'])]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function viewed(Teamer $teamer): Response
{
$teamer->setViewed(true);
$this->entityManager->flush();
return new HxRedirectResponse($this->generateUrl('app_administrative_teamer_index'));
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Controller\Common;
use App\Controller\Traits\ReturnUrlTrait;
use App\Form\SeasonAssignmentFilterType;
use App\Htmx\HxRedirectResponse;
use App\Service\Common\SeasonAssignmentFilterHandler;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class SeasonAssignmentFilterController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(private readonly SeasonAssignmentFilterHandler $filterHandler)
{
}
#[Route('/common/season-assignment/filter', name: 'app_common_season_assignment_filter')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(Request $request): Response
{
$formData = $this->filterHandler->getFilterSettings();
$form = $this->createForm(SeasonAssignmentFilterType::class, $formData);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->filterHandler->handleRequest($form);
$returnUrl = $this->getReturnUrl($request, 'app_administrative_season_assignment_index');
return new HxRedirectResponse($returnUrl);
}
return $this->render('common/modal_season_assignment_filter.html.twig', [
'filterForm' => $form->createView(),
'filterDto' => $form->getData(),
]);
}
#[Route('/common/season-assignment/filter/reset', name: 'app_common_season_assignment_filter_reset')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function reset(Request $request): Response
{
$this->filterHandler->resetFilterSettings();
$returnUrl = $this->getReturnUrl($request, 'app_administrative_season_assignment_index');
return $this->redirect($returnUrl);
}
}
@@ -24,13 +24,15 @@ class TeamerFilterController extends AbstractController
#[IsGranted('ROLE_USER')]
public function index(Request $request): Response
{
$this->applyScope($request);
$formData = $this->filterHandler->getFilterSettings();
$form = $this->createForm(TeamerFilterType::class, $formData);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->filterHandler->handleRequest($form);
$returnUrl = $this->getReturnUrl($request, 'app_administrative_teamer_index');
$returnUrl = $this->getReturnUrl($request, $this->getDefaultRoute($request));
return new HxRedirectResponse($returnUrl);
}
@@ -45,9 +47,26 @@ class TeamerFilterController extends AbstractController
#[IsGranted('ROLE_USER')]
public function reset(Request $request): Response
{
$this->applyScope($request);
$this->filterHandler->resetFilterSettings();
$returnUrl = $this->getReturnUrl($request, 'app_administrative_teamer_index');
$returnUrl = $this->getReturnUrl($request, $this->getDefaultRoute($request));
return $this->redirect($returnUrl);
}
/**
* The modal serves both teamer lists; the scope says whose filter is being edited.
*/
private function applyScope(Request $request): void
{
$this->filterHandler->setScope($request->query->get('scope'));
}
private function getDefaultRoute(Request $request): string
{
return TeamerFilterHandler::SCOPE_ALL_SEASON === $request->query->get('scope')
? 'app_administrative_season_teamer_index'
: 'app_administrative_teamer_index';
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\Controller\Teamer\Season;
use App\Entity\User;
use App\Service\Season\SeasonAssignmentResolver;
use App\Service\Season\SeasonChecklistBuilder;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* What an all-season teamer has to download, sign and return.
*
* Only the entries expecting something back are listed, whether or not the office issues a
* document for them first; the office-internal checks never appear here. Frist 2 does not either:
* it is an internal escalation.
*/
class IndexController extends AbstractController
{
public function __construct(
private readonly SeasonAssignmentResolver $seasonAssignmentResolver,
private readonly SeasonChecklistBuilder $checklistBuilder,
) {
}
#[Route('/teamer/season', name: 'app_teamer_season_index')]
#[IsGranted('ROLE_TEAMER')]
public function index(): Response
{
/** @var User $user */
$user = $this->getUser();
if (null === $teamer = $user->getTeamer()) {
throw $this->createNotFoundException('No teamer for this user');
}
if (null === $seasonAssignment = $this->seasonAssignmentResolver->resolveFor($teamer)) {
throw $this->createNotFoundException('No season assignment for this teamer');
}
$rows = array_filter(
$this->checklistBuilder->build($seasonAssignment),
static fn (array $row): bool => true === $row['definition']->requiresReturn()
);
return $this->render('teamer/season/index.html.twig', [
'seasonAssignment' => $seasonAssignment,
'rows' => $rows,
'documents' => $seasonAssignment->getDocuments(),
]);
}
}
@@ -0,0 +1,111 @@
<?php
namespace App\Controller\Teamer\Season;
use App\Entity\SeasonAssignment;
use App\Entity\Upload;
use App\Entity\User;
use App\Htmx\HxRedirectResponse;
use App\Model\UploadDto;
use App\Service\Upload\UploadHandler;
use Doctrine\ORM\EntityManagerInterface;
use Flagception\Manager\FeatureManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The signed documents a teamer returns for their season assignment.
*
* They accumulate, like Belege: there is no relation between a file and a checklist entry, and
* nothing here closes one - only an administrative tick on "erledigt" does that.
*/
class UploadController extends AbstractController
{
public function __construct(
private readonly UploadHandler $uploadHandler,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly FeatureManagerInterface $featureManager,
) {
}
#[Route('/teamer/season/upload/{uuid}', name: 'app_teamer_season_upload')]
#[IsGranted('ROLE_TEAMER')]
public function index(SeasonAssignment $seasonAssignment, Request $request): Response
{
/** @var User $user */
$user = $this->getUser();
// the assignment is addressed by uuid, so this is what keeps one teamer out of another's
if ($user->getTeamer() !== $seasonAssignment->getTeamer()) {
throw $this->createNotFoundException('Not this teamer\'s season assignment');
}
// Dummy form without fields, as elsewhere: the files travel in the upload session.
$form = $this->createFormBuilder()->getForm();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$count = $this->processUpload($seasonAssignment, $user);
$this->addFlash(
'success',
1 === $count ? 'Das Dokument wurde hochgeladen' : 'Die Dokumente wurden hochgeladen'
);
return new HxRedirectResponse($this->generateUrl('app_teamer_season_index'));
}
return $this->render('teamer/season/modal_upload.html.twig', [
'seasonAssignment' => $seasonAssignment,
'form' => $form->createView(),
]);
}
private function processUpload(SeasonAssignment $seasonAssignment, User $user): int
{
$uploadSession = $this->uploadHandler->getUploadSession();
// Only this dropzone's files: the session is shared, so it must not be destroyed whole.
$uploadDtos = $uploadSession->getUploadsByType(Upload::TYPE_SEASON_DOCUMENT_IN);
$uploads = [];
/** @var UploadDto $uploadDto */
foreach ($uploadDtos as $uploadDto) {
$upload = Upload::fromUploadDto($uploadDto, $user, Upload::TYPE_SEASON_DOCUMENT_IN);
// No status: a returned document is not run through the checking process, the office
// ticks the checklist entry off by hand.
$upload->setStatus(null);
$seasonAssignment->addDocument($upload);
$this->entityManager->persist($upload);
$uploads[] = $upload;
}
$this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_SEASON_DOCUMENT_IN, $uploadSession);
$this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_SEASON_DOCUMENT_IN);
if (true === $this->featureManager->isActive('sanitize_uploads')) {
foreach ($uploads as $upload) {
$this->uploadHandler->ensurePdf($upload);
}
}
$this->entityManager->flush();
$this->logger->info('Upload season documents', [
'user' => $user->getUserIdentifier(),
'season_assignment_id' => $seasonAssignment->getId(),
'count' => count($uploads),
]);
return count($uploads);
}
}
+260
View File
@@ -0,0 +1,260 @@
<?php
namespace App\Entity;
use App\Entity\Traits\BlameableEntity;
use App\Entity\Traits\SoftDeletableEntity;
use App\Entity\Traits\TimestampableEntity;
use App\Repository\SeasonAssignmentRepository;
use App\Validator\Constraints as AppAssert;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints as Assert;
/**
* The placement of an all-season teamer, assigned directly by an administrative user.
*
* It carries its own period and house rather than pointing at a Destination: a Destination is one
* travel with one departure window, while an all-season teamer sits at one house for the whole
* season, so the relation only ever meant "borrow three scalars from an arbitrary travel row".
*
* Deliberately not an Assignment plus a Disposition: those two model the short term process,
* where an assignment is published, teamers apply and an admin turns an application into a
* placement. All-season teamers never apply, so there is nothing to publish, nothing to staff
* and no contract workflow to run - Disposition cannot even be constructed without an
* Application. One record carries the whole placement instead.
*/
#[ORM\Entity(repositoryClass: SeasonAssignmentRepository::class)]
#[AppAssert\SeasonAssignment]
class SeasonAssignment implements BlameableEntityInterface, TimestampableEntityInterface, SoftDeletableEntityInterface
{
use BlameableEntity;
use TimestampableEntity;
use SoftDeletableEntity;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
/**
* Left open on purpose: administrative users lay the season's assignments out first and
* only place teamers on them later, so an unassigned assignment is a normal working state
* rather than an incomplete record.
*/
#[ORM\ManyToOne(inversedBy: 'seasonAssignments')]
private ?Teamer $teamer = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
#[Assert\NotNull(message: 'Bitte gib das Startdatum an')]
private ?\DateTimeImmutable $dateFrom = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
#[Assert\NotNull(message: 'Bitte gib das Enddatum an')]
#[Assert\GreaterThan(propertyPath: 'dateFrom', message: 'Das Enddatum muss nach dem Beginndatum liegen')]
private ?\DateTimeImmutable $dateTo = null;
/**
* A key of the configured "houses" map, which App\Config\HouseCatalog turns into a name for
* display. Only the code is stored: a house renamed in config must not leave old placements
* showing the old name. Deliberately no Assert\Choice - the map is curated and can lose an
* entry, and a record that named that house has to stay editable.
*/
#[ORM\Column(length: 32)]
#[Assert\NotBlank(message: 'Bitte gib das Haus an')]
private ?string $hotelCode = null;
#[ORM\ManyToOne]
#[Assert\NotNull(message: 'Bitte gib das Jobprofil an')]
private ?JobProfile $jobProfile = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $remarks = null;
/**
* The office's note that it considers this teamer done. Informative only: it changes no mail
* and no query - the teamer keeps being reminded while a return-required checklist entry is
* open. Administrative users only; there is no teamer-side write path.
*/
#[ORM\Column]
private bool $doneTeamer = false;
/**
* @var Collection<int, SeasonAssignmentTask>
*/
#[ORM\OneToMany(mappedBy: 'seasonAssignment', targetEntity: SeasonAssignmentTask::class, cascade: ['persist'])]
private Collection $tasks;
/**
* @var Collection<int, Upload>
*/
#[ORM\OneToMany(mappedBy: 'seasonAssignment', targetEntity: Upload::class)]
private Collection $documents;
public function __construct()
{
$this->uuid = Uuid::v4();
$this->tasks = new ArrayCollection();
$this->documents = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getUuid(): string
{
return $this->uuid;
}
public function getTeamer(): ?Teamer
{
return $this->teamer;
}
public function setTeamer(?Teamer $teamer): static
{
$this->teamer = $teamer;
return $this;
}
public function getDateFrom(): ?\DateTimeImmutable
{
return $this->dateFrom;
}
public function setDateFrom(?\DateTimeImmutable $dateFrom): static
{
$this->dateFrom = $dateFrom;
return $this;
}
public function getDateTo(): ?\DateTimeImmutable
{
return $this->dateTo;
}
public function setDateTo(?\DateTimeImmutable $dateTo): static
{
$this->dateTo = $dateTo;
return $this;
}
public function getHotelCode(): ?string
{
return $this->hotelCode;
}
public function setHotelCode(?string $hotelCode): static
{
$this->hotelCode = $hotelCode;
return $this;
}
public function getJobProfile(): ?JobProfile
{
return $this->jobProfile;
}
public function setJobProfile(?JobProfile $jobProfile): static
{
$this->jobProfile = $jobProfile;
return $this;
}
public function getRemarks(): ?string
{
return $this->remarks;
}
public function setRemarks(?string $remarks): static
{
$this->remarks = $remarks;
return $this;
}
public function isDoneTeamer(): bool
{
return $this->doneTeamer;
}
public function setDoneTeamer(bool $doneTeamer): static
{
$this->doneTeamer = $doneTeamer;
return $this;
}
/**
* @return Collection<int, SeasonAssignmentTask>
*/
public function getTasks(): Collection
{
return $this->tasks;
}
public function addTask(SeasonAssignmentTask $task): static
{
if (!$this->tasks->contains($task)) {
$this->tasks->add($task);
$task->setSeasonAssignment($this);
}
return $this;
}
public function removeTask(SeasonAssignmentTask $task): static
{
if ($this->tasks->removeElement($task)) {
// set the owning side to null (unless already changed)
if ($task->getSeasonAssignment() === $this) {
$task->setSeasonAssignment(null);
}
}
return $this;
}
/**
* @return Collection<int, Upload>
*/
public function getDocuments(): Collection
{
return $this->documents;
}
public function addDocument(Upload $document): static
{
if (!$this->documents->contains($document)) {
$this->documents->add($document);
$document->setSeasonAssignment($this);
}
return $this;
}
public function removeDocument(Upload $document): static
{
if ($this->documents->removeElement($document)) {
// set the owning side to null (unless already changed)
if ($document->getSeasonAssignment() === $this) {
$document->setSeasonAssignment(null);
}
}
return $this;
}
}
+204
View File
@@ -0,0 +1,204 @@
<?php
namespace App\Entity;
use App\Config\SeasonTask;
use App\Entity\Traits\BlameableEntity;
use App\Entity\Traits\TimestampableEntity;
use App\Repository\SeasonAssignmentTaskRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* The state of one checklist entry on one season assignment.
*
* The entries themselves are defined by App\Config\SeasonTask; this table only records what the
* office has done with them. State lives in a row rather than in a JSON column because the nightly
* reminder asks "every entry whose deadline is today and that is not done" across all assignments,
* which JSON would turn into a full scan filtered in PHP.
*
* Deliberately not soft-deletable, unlike almost everything else in this app: rows are derived
* from the enum and synced, never deleted by a user, and a soft-deleted row would silently drop
* out of the reminder queries. Upload sets the same precedent.
*/
#[ORM\Entity(repositoryClass: SeasonAssignmentTaskRepository::class)]
#[ORM\UniqueConstraint(name: 'season_assignment_task_unique', columns: ['season_assignment_id', 'task_key'])]
class SeasonAssignmentTask implements BlameableEntityInterface, TimestampableEntityInterface
{
use BlameableEntity;
use TimestampableEntity;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(inversedBy: 'tasks')]
#[ORM\JoinColumn(nullable: false)]
private ?SeasonAssignment $seasonAssignment = null;
#[ORM\Column(length: 64)]
private string $taskKey;
/**
* The only thing that closes a task and the only thing that stops a reminder.
*/
#[ORM\Column]
private bool $done = false;
/**
* Explicit rather than leaning on Blameable's updatedBy, which any later edit overwrites:
* the office wants to see who ticked the entry off and when.
*/
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $doneAt = null;
#[ORM\Column(nullable: true)]
private ?string $doneBy = null;
/**
* Purely informational: it records that a usable file arrived and gates nothing at all.
*/
#[ORM\Column]
private bool $uploaded = false;
/**
* Null means "not overridden" - the deadlines are computed on read by
* App\Service\Season\SeasonTaskDeadlineResolver. Storing the computed value would leave them
* stale as soon as the assignment's dates move.
*/
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $deadlineOverride1 = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $deadlineOverride2 = null;
public function __construct()
{
$this->uuid = Uuid::v4();
}
public function __toString(): string
{
return $this->getTask()?->label() ?? $this->taskKey;
}
public function getId(): ?int
{
return $this->id;
}
public function getUuid(): string
{
return $this->uuid;
}
public function getSeasonAssignment(): ?SeasonAssignment
{
return $this->seasonAssignment;
}
public function setSeasonAssignment(?SeasonAssignment $seasonAssignment): static
{
$this->seasonAssignment = $seasonAssignment;
return $this;
}
public function getTaskKey(): string
{
return $this->taskKey;
}
public function setTaskKey(string $taskKey): static
{
$this->taskKey = $taskKey;
return $this;
}
/**
* Null when the stored key no longer resolves, which is what happens to the rows of a case
* that was removed from the enum. Callers skip those rows instead of failing on them.
*/
public function getTask(): ?SeasonTask
{
return SeasonTask::tryFrom($this->taskKey);
}
public function isDone(): bool
{
return $this->done;
}
public function setDone(bool $done): static
{
$this->done = $done;
return $this;
}
public function getDoneAt(): ?\DateTimeImmutable
{
return $this->doneAt;
}
public function setDoneAt(?\DateTimeImmutable $doneAt): static
{
$this->doneAt = $doneAt;
return $this;
}
public function getDoneBy(): ?string
{
return $this->doneBy;
}
public function setDoneBy(?string $doneBy): static
{
$this->doneBy = $doneBy;
return $this;
}
public function isUploaded(): bool
{
return $this->uploaded;
}
public function setUploaded(bool $uploaded): static
{
$this->uploaded = $uploaded;
return $this;
}
public function getDeadlineOverride1(): ?\DateTimeImmutable
{
return $this->deadlineOverride1;
}
public function setDeadlineOverride1(?\DateTimeImmutable $deadlineOverride1): static
{
$this->deadlineOverride1 = $deadlineOverride1;
return $this;
}
public function getDeadlineOverride2(): ?\DateTimeImmutable
{
return $this->deadlineOverride2;
}
public function setDeadlineOverride2(?\DateTimeImmutable $deadlineOverride2): static
{
$this->deadlineOverride2 = $deadlineOverride2;
return $this;
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
namespace App\Entity;
use App\Config\SeasonTask;
use App\Entity\Traits\BlameableEntity;
use App\Entity\Traits\TimestampableEntity;
use App\Repository\SeasonTaskDocumentRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* Binds a checklist entry that hands out a standard document to the library upload that document
* actually is.
*
* One row per task key at most; no row means no document is deposited, which the views have to
* show as such rather than fail on. The mapping points at a library Upload instead of holding a
* file of its own, so replacing the library file through the existing ReplaceController keeps this
* reference intact.
*
* This is a task-type to configuration relation. It is unrelated to the files on a season
* assignment, between which and the checklist there is deliberately no relation at all.
*/
#[ORM\Entity(repositoryClass: SeasonTaskDocumentRepository::class)]
class SeasonTaskDocument implements BlameableEntityInterface, TimestampableEntityInterface
{
use BlameableEntity;
use TimestampableEntity;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(length: 64, unique: true)]
private string $taskKey;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
private ?Upload $document = null;
public function __construct()
{
$this->uuid = Uuid::v4();
}
public function __toString(): string
{
return $this->getTask()?->label() ?? $this->taskKey;
}
public function getId(): ?int
{
return $this->id;
}
public function getUuid(): string
{
return $this->uuid;
}
public function getTaskKey(): string
{
return $this->taskKey;
}
public function setTaskKey(string $taskKey): static
{
$this->taskKey = $taskKey;
return $this;
}
public function getTask(): ?SeasonTask
{
return SeasonTask::tryFrom($this->taskKey);
}
public function getDocument(): ?Upload
{
return $this->document;
}
public function setDocument(?Upload $document): static
{
$this->document = $document;
return $this;
}
}
+49
View File
@@ -126,6 +126,9 @@ class Teamer implements TimestampableEntityInterface, SoftDeletableEntityInterfa
#[ORM\OneToMany(mappedBy: 'teamer', targetEntity: Disposition::class, cascade: ['remove'])]
private Collection $dispositions;
#[ORM\OneToMany(mappedBy: 'teamer', targetEntity: SeasonAssignment::class, cascade: ['remove'])]
private Collection $seasonAssignments;
#[ORM\OneToOne(mappedBy: 'teamer', fetch: 'EXTRA_LAZY')]
private ?User $user = null;
@@ -176,6 +179,9 @@ class Teamer implements TimestampableEntityInterface, SoftDeletableEntityInterfa
#[ORM\Column(length: 8, nullable: true)]
private ?string $driverLicenseReviewedBy = null;
#[ORM\Column]
private bool $allSeason = false;
public function __construct()
{
$this->uuid = Uuid::v4();
@@ -188,6 +194,7 @@ class Teamer implements TimestampableEntityInterface, SoftDeletableEntityInterfa
$this->bookmarks = new ArrayCollection();
$this->applications = new ArrayCollection();
$this->dispositions = new ArrayCollection();
$this->seasonAssignments = new ArrayCollection();
$this->customAvailabilities = new ArrayCollection();
}
@@ -791,6 +798,36 @@ class Teamer implements TimestampableEntityInterface, SoftDeletableEntityInterfa
return $this;
}
/**
* @return Collection<int, SeasonAssignment>
*/
public function getSeasonAssignments(): Collection
{
return $this->seasonAssignments;
}
public function addSeasonAssignment(SeasonAssignment $seasonAssignment): static
{
if (!$this->seasonAssignments->contains($seasonAssignment)) {
$this->seasonAssignments->add($seasonAssignment);
$seasonAssignment->setTeamer($this);
}
return $this;
}
public function removeSeasonAssignment(SeasonAssignment $seasonAssignment): static
{
if ($this->seasonAssignments->removeElement($seasonAssignment)) {
// set the owning side to null (unless already changed)
if ($seasonAssignment->getTeamer() === $this) {
$seasonAssignment->setTeamer(null);
}
}
return $this;
}
public function getUser(): ?User
{
return $this->user;
@@ -987,4 +1024,16 @@ class Teamer implements TimestampableEntityInterface, SoftDeletableEntityInterfa
return $this;
}
public function isAllSeason(): bool
{
return $this->allSeason;
}
public function setAllSeason(bool $allSeason): static
{
$this->allSeason = $allSeason;
return $this;
}
}
+28
View File
@@ -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;
+1 -5
View File
@@ -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',
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* A textarea that grows with its content, wiring up assets/controllers/textarea_autosize_controller.js.
*
* The controller overwrites the rows attribute on connect, so `rows` never had an effect on
* these fields - `min_rows` is the floor it actually reads.
*/
class AutosizeTextareaType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(['min_rows' => 1]);
$resolver->setAllowedTypes('min_rows', 'int');
}
public function buildView(FormView $view, FormInterface $form, array $options): void
{
// Merged rather than set, and the caller's own attributes win: a field that needs a
// second Stimulus controller on the same element composes the data-controller list
// itself, which is the only case this can collide on.
$view->vars['attr'] = array_merge([
'data-controller' => 'textarea-autosize',
'data-action' => 'textarea-autosize#resize',
'data-textarea-autosize-min-rows-value' => $options['min_rows'],
], $view->vars['attr']);
}
public function getParent(): string
{
return TextareaType::class;
}
}
+2 -11
View File
@@ -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',
],
])
;
}
+1 -6
View File
@@ -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'),
],
+2 -7
View File
@@ -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],
])
;
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Form;
use App\Config\HouseCatalog;
use App\Entity\JobProfile;
use App\Model\SeasonAssignmentFilterDto;
use Doctrine\ORM\EntityRepository;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class SeasonAssignmentFilterType extends AbstractType
{
public function __construct(private readonly HouseCatalog $houseCatalog)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('teamerName', TextType::class, [
'label' => 'Name',
'required' => false,
])
// By code, not by name as the destination filters do: a season assignment stores
// the code itself, so this is an exact match rather than a substring guess.
->add('hotelCodes', MultiselectType::class, [
'label' => 'Haus',
'required' => false,
'empty_label' => 'nicht filtern',
'choices' => $this->houseCatalog->getCodeChoices(),
])
->add('jobProfiles', MultiselectEntityType::class, [
'label' => 'Job-Profile',
'class' => JobProfile::class,
'required' => false,
'choice_label' => 'name',
'empty_label' => 'nicht filtern',
'query_builder' => function (EntityRepository $repository) {
return $repository->createQueryBuilder('job_profile')
->orderBy('job_profile.name', \SortDirection::Ascending);
},
])
->add('includeDeleted', CheckboxType::class, [
'label' => 'gelöschte Saisoneinsätze anzeigen',
'required' => false,
])
// AbstractFilterHandler::handleRequest() branches on exactly these two names
->add('apply', SubmitType::class, [
'label' => 'filtern',
])
->add('reset', SubmitType::class, [
'label' => 'reset',
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver
->setDefaults([
'data_class' => SeasonAssignmentFilterDto::class,
])
;
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
namespace App\Form;
use App\Config\HouseCatalog;
use App\Entity\JobProfile;
use App\Entity\SeasonAssignment;
use App\Entity\Teamer;
use Doctrine\ORM\EntityRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class SeasonAssignmentType extends AbstractType
{
public function __construct(private readonly HouseCatalog $houseCatalog)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
// Optional: assignments are laid out first and staffed later. The endpoint only
// ever answers with all-season teamers, so a short term teamer cannot be picked
// here even by typing their full name.
->add('teamer', AutocompleteEntityType::class, [
'label' => 'Teamer:in',
'class' => Teamer::class,
'required' => false,
'placeholder' => 'noch nicht zugewiesen',
'label_property' => 'fullName',
'label_function' => fn (Teamer $teamer) => (string) $teamer,
'endpoint_route' => 'app_admin_autocomplete_season_teamer',
])
->add('dateFrom', DatepickerType::class, [
'label' => 'Datum von',
])
->add('dateTo', DatepickerType::class, [
'label' => 'Datum bis',
])
// The houses map is the whole list of places an all-season teamer can be placed, so
// a plain select over it needs neither an autocomplete endpoint nor a BusPro call.
->add('hotelCode', ChoiceType::class, [
'label' => 'Haus',
'placeholder' => 'Bitte auswählen',
'choices' => $this->houseCatalog->getCodeChoices(),
])
->add('jobProfile', EntityType::class, [
'label' => 'Jobprofil',
'class' => JobProfile::class,
'choice_label' => 'name',
'placeholder' => 'Bitte auswählen',
'query_builder' => function (EntityRepository $repository) {
return $repository->createQueryBuilder('job_profile')
->where('job_profile.deletedAt IS NULL')
->orderBy('job_profile.name', \SortDirection::Ascending);
},
])
->add('remarks', AutosizeTextareaType::class, [
'label' => 'Anmerkungen intern',
'required' => false,
'min_rows' => 4,
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver
->setDefaults([
'data_class' => SeasonAssignment::class,
])
;
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Form;
use App\Entity\SeasonAssignmentTask;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* The two deadline overrides of one checklist entry.
*
* Both are optional: an empty field means "not overridden", and the deadline falls back to what
* App\Service\Season\SeasonTaskDeadlineResolver computes from the destination's travel start.
*/
class SeasonTaskDeadlineType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('deadlineOverride1', DatepickerType::class, [
'label' => 'Frist 1',
'required' => false,
])
->add('deadlineOverride2', DatepickerType::class, [
'label' => 'Frist 2',
'required' => false,
'help' => 'Ohne Angabe gilt Frist 1.',
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => SeasonAssignmentTask::class,
]);
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Form;
use App\Entity\SeasonTaskDocument;
use App\Entity\Upload;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class SeasonTaskDocumentType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('document', AutocompleteEntityType::class, [
'label' => 'Dokument',
'class' => Upload::class,
'label_property' => 'originalFilename',
'label_function' => fn (Upload $upload) => $upload->getDisplayName() ?? $upload->getOriginalFilename(),
'endpoint_route' => 'app_admin_autocomplete_document',
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => SeasonTaskDocument::class,
]);
}
}
+1 -6
View File
@@ -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',
],
]);
}
+2 -7
View File
@@ -4,7 +4,6 @@ namespace App\Form;
use App\Model\TeamerMailingDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -17,13 +16,9 @@ class TeamerMailingType extends AbstractType
->add('subject', TextType::class, [
'label' => 'Betreff',
])
->add('message', TextareaType::class, [
->add('message', AutosizeTextareaType::class, [
'label' => 'Nachricht',
'attr' => [
'data-controller' => 'textarea-autosize',
'data-action' => 'textarea-autosize#resize',
'data-textarea-autosize-min-rows-value' => 12,
],
'min_rows' => 12,
])
;
}
+2 -11
View File
@@ -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',
],
])
;
+35
View File
@@ -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' => [
+35
View File
@@ -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);
+45
View File
@@ -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 &amp; 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);
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace App\Model;
use App\Entity\JobProfile;
class SeasonAssignmentFilterDto extends AbstractFilterDto
{
protected ?string $teamerName = null;
/** @var string[] keys of the configured "houses" map */
protected array $hotelCodes = [];
protected array $jobProfiles = [];
protected bool $includeDeleted = false;
public function getTeamerName(): ?string
{
return $this->teamerName;
}
public function setTeamerName(?string $teamerName): static
{
$this->teamerName = $teamerName;
return $this;
}
/**
* @return string[]
*/
public function getHotelCodes(): array
{
return $this->hotelCodes;
}
/**
* @param string[] $hotelCodes
*/
public function setHotelCodes(array $hotelCodes): static
{
$this->hotelCodes = $hotelCodes;
return $this;
}
/**
* @return JobProfile[]
*/
public function getJobProfiles(): array
{
return $this->jobProfiles;
}
public function setJobProfiles(array $jobProfiles): static
{
$this->jobProfiles = $jobProfiles;
return $this;
}
public function isIncludeDeleted(): bool
{
return $this->includeDeleted;
}
public function setIncludeDeleted(bool $includeDeleted): static
{
$this->includeDeleted = $includeDeleted;
return $this;
}
}
+20
View File
@@ -15,6 +15,14 @@ class TeamerFilterDto extends AbstractFilterDto
protected bool $includeInactive = false;
protected bool $includeDeleted = false;
/**
* The scope of the list this filter belongs to, not a filter the user set.
*
* The leading underscore keeps it out of AbstractFilterDto::getActiveFiltersCount(),
* so the all-season list does not permanently claim to have one filter active.
*/
protected bool $_allSeason = false;
public function getName(): ?string
{
return $this->name;
@@ -132,4 +140,16 @@ class TeamerFilterDto extends AbstractFilterDto
return $this;
}
public function isAllSeason(): bool
{
return $this->_allSeason;
}
public function setAllSeason(bool $allSeason): static
{
$this->_allSeason = $allSeason;
return $this;
}
}
@@ -0,0 +1,162 @@
<?php
namespace App\Repository;
use App\Entity\SeasonAssignment;
use App\Entity\Teamer;
use App\Model\SeasonAssignmentFilterDto;
use App\Repository\Traits\QueryHelperTrait;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<SeasonAssignment>
*
* @method SeasonAssignment|null find($id, $lockMode = null, $lockVersion = null)
* @method SeasonAssignment|null findOneBy(array $criteria, array $orderBy = null)
* @method SeasonAssignment[] findAll()
* @method SeasonAssignment[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class SeasonAssignmentRepository extends ServiceEntityRepository
{
use QueryHelperTrait;
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, SeasonAssignment::class);
}
public function getListQuery(SeasonAssignmentFilterDto $filterDto): Query
{
$qb = $this->createQueryBuilder('season_assignment');
$qb
->select('season_assignment', 'teamer', 'job_profile')
// an assignment waiting for a teamer has to stay in the list - that is exactly
// the state an administrative user is looking for when placing people
->leftJoin('season_assignment.teamer', 'teamer')
->innerJoin('season_assignment.jobProfile', 'job_profile')
;
$this->applyFilterSettings($filterDto, $qb);
return $qb
->addOrderBy('season_assignment.dateFrom', \SortDirection::Descending)
->addOrderBy('teamer.lastName', \SortDirection::Ascending)
->getQuery()
;
}
/**
* The assignments a teamer already has in one season, for the rule that allows only one.
*
* Bucketed by the assignment's own first day, which is exactly how
* App\Config\SeasonCalendar::seasonYearFor() decides what season something belongs to.
* Deliberately not SeasonPeriodFilter: that one also requires the period to end inside the
* window, and a placement running past the season's last day still belongs to it here.
*/
public function countInSeasonWindow(
Teamer $teamer,
\DateTimeImmutable $windowStart,
\DateTimeImmutable $windowEnd,
?int $excludeId = null,
): int {
return (int) $this
->createInSeasonWindowQueryBuilder($teamer, $windowStart, $windowEnd, $excludeId)
->getQuery()
->getSingleScalarResult()
;
}
/**
* A teamer's assignments in one season, earliest first - what the teamer's own season page
* is about. Bucketed by the assignment's own first day, like countInSeasonWindow().
*
* @return SeasonAssignment[]
*/
public function findForTeamerInSeason(
Teamer $teamer,
\DateTimeImmutable $windowStart,
\DateTimeImmutable $windowEnd,
): array {
$qb = $this->createInSeasonWindowQueryBuilder($teamer, $windowStart, $windowEnd);
return $qb
->select('season_assignment')
->addOrderBy('season_assignment.dateFrom', \SortDirection::Ascending)
->getQuery()
->getResult()
;
}
/**
* Extracted so the conditions can be pinned by a test compiling the DQL - the suite has no
* fixtures, so what is asserted is what the query says, not what it returns.
*
* The window is read off the assignment itself, so there is no second row that could be
* soft-deleted underneath a live placement and drop it out of the season it belongs to.
*/
public function createInSeasonWindowQueryBuilder(
Teamer $teamer,
\DateTimeImmutable $windowStart,
\DateTimeImmutable $windowEnd,
?int $excludeId = null,
): QueryBuilder {
$qb = $this->createQueryBuilder('season_assignment');
$qb
->select($qb->expr()->count('season_assignment'))
->where($qb->expr()->andX(
$qb->expr()->eq('season_assignment.teamer', ':teamer'),
$qb->expr()->isNull('season_assignment.deletedAt'),
$qb->expr()->gte('season_assignment.dateFrom', ':windowStart'),
$qb->expr()->lte('season_assignment.dateFrom', ':windowEnd')
))
->setParameter('teamer', $teamer)
->setParameter('windowStart', $windowStart)
->setParameter('windowEnd', $windowEnd)
;
if (null !== $excludeId) {
$qb
->andWhere($qb->expr()->neq('season_assignment.id', ':excludeId'))
->setParameter('excludeId', $excludeId)
;
}
return $qb;
}
private function applyFilterSettings(SeasonAssignmentFilterDto $filterDto, QueryBuilder $qb): void
{
if (null !== $filterDto->getTeamerName()) {
$qb
->andWhere($qb->expr()->orX(
$qb->expr()->like('teamer.firstName', ':teamerName'),
$qb->expr()->like('teamer.lastName', ':teamerName')
))
->setParameter('teamerName', '%'.$this->escapeLikeWildcards($filterDto->getTeamerName()).'%')
;
}
if (0 < count($filterDto->getHotelCodes())) {
$qb
->andWhere($qb->expr()->in('season_assignment.hotelCode', ':hotelCodes'))
->setParameter('hotelCodes', $filterDto->getHotelCodes())
;
}
if (0 < count($filterDto->getJobProfiles())) {
$qb
->andWhere($qb->expr()->in('season_assignment.jobProfile', ':jobProfiles'))
->setParameter('jobProfiles', $filterDto->getJobProfiles())
;
}
if (false === $filterDto->isIncludeDeleted()) {
$qb->andWhere($qb->expr()->isNull('season_assignment.deletedAt'));
}
}
}
@@ -0,0 +1,88 @@
<?php
namespace App\Repository;
use App\Entity\SeasonAssignmentTask;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<SeasonAssignmentTask>
*
* @method SeasonAssignmentTask|null find($id, $lockMode = null, $lockVersion = null)
* @method SeasonAssignmentTask|null findOneBy(array $criteria, array $orderBy = null)
* @method SeasonAssignmentTask[] findAll()
* @method SeasonAssignmentTask[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class SeasonAssignmentTaskRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, SeasonAssignmentTask::class);
}
/**
* Every open entry that could still be reminded about: not ticked off, on a live assignment
* that has a teamer on it.
*
* The deadlines themselves are not part of this query. They are computed from the
* assignment's first day by a relative offset, and month arithmetic is not invertible
* day by day - "minus one month" from the 31st and back again lands somewhere else - so a
* query built by inverting the offset would silently skip entries. The reminder resolves
* every row through SeasonTaskDeadlineResolver instead; the set is small, one assignment per
* all-season teamer per season.
*
* @return SeasonAssignmentTask[]
*/
public function findOpen(?\DateTimeImmutable $windowStart = null, ?\DateTimeImmutable $windowEnd = null): array
{
return $this
->createOpenQueryBuilder($windowStart, $windowEnd)
->getQuery()
->getResult()
;
}
/**
* Extracted so the conditions can be pinned by a test compiling the DQL - the suite has no
* fixtures, so what is asserted is what the query says, not what it returns.
*/
public function createOpenQueryBuilder(
?\DateTimeImmutable $windowStart = null,
?\DateTimeImmutable $windowEnd = null,
): QueryBuilder {
$qb = $this->createQueryBuilder('task');
$qb
->select('task', 'season_assignment', 'teamer')
->innerJoin('task.seasonAssignment', 'season_assignment')
// no teamer means nobody to remind and nobody to be late
->innerJoin('season_assignment.teamer', 'teamer')
->where($qb->expr()->andX(
$qb->expr()->eq('task.done', ':done'),
$qb->expr()->isNull('season_assignment.deletedAt'),
$qb->expr()->isNull('teamer.deletedAt')
))
->setParameter('done', false)
->addOrderBy('teamer.lastName', \SortDirection::Ascending)
->addOrderBy('teamer.firstName', \SortDirection::Ascending)
;
if (null !== $windowStart) {
$qb
->andWhere($qb->expr()->gte('season_assignment.dateFrom', ':windowStart'))
->setParameter('windowStart', $windowStart)
;
}
if (null !== $windowEnd) {
$qb
->andWhere($qb->expr()->lte('season_assignment.dateFrom', ':windowEnd'))
->setParameter('windowEnd', $windowEnd)
;
}
return $qb;
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Repository;
use App\Config\SeasonTask;
use App\Entity\SeasonTaskDocument;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<SeasonTaskDocument>
*
* @method SeasonTaskDocument|null find($id, $lockMode = null, $lockVersion = null)
* @method SeasonTaskDocument|null findOneBy(array $criteria, array $orderBy = null)
* @method SeasonTaskDocument[] findAll()
* @method SeasonTaskDocument[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class SeasonTaskDocumentRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, SeasonTaskDocument::class);
}
/**
* The mapping keyed by task, for views that render the whole checklist at once and would
* otherwise query per row.
*
* @return array<string, SeasonTaskDocument>
*/
public function findIndexedByTaskKey(): array
{
$indexed = [];
foreach ($this->findAll() as $seasonTaskDocument) {
$indexed[$seasonTaskDocument->getTaskKey()] = $seasonTaskDocument;
}
return $indexed;
}
public function findForTask(SeasonTask $task): ?SeasonTaskDocument
{
return $this->findOneBy(['taskKey' => $task->value]);
}
}
+29 -2
View File
@@ -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()
;
+28 -9
View File
@@ -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,
+22 -1
View File
@@ -50,9 +50,30 @@ class UploadVoter extends Voter
/** @var User $user */
$user = $token->getUser();
return Upload::TYPE_DOCUMENT === $upload->getType() || $user === $upload->getOwner();
if (Upload::TYPE_DOCUMENT === $upload->getType() || $user === $upload->getOwner()) {
return true;
}
// A document the office issues for one single teamer is owned by the admin who
// uploaded it, so neither rule above reaches it - and it must not be TYPE_DOCUMENT,
// since that type is the staff-wide library every teamer may read. The teamer it was
// issued to is therefore let in through their own season assignment.
return static::DOWNLOAD === $attribute && $this->isOwnSeasonDocument($upload, $user);
}
return false;
}
private function isOwnSeasonDocument(Upload $upload, User $user): bool
{
if (Upload::TYPE_SEASON_DOCUMENT_OUT !== $upload->getType()) {
return false;
}
if (null === $teamer = $upload->getSeasonAssignment()?->getTeamer()) {
return false;
}
return $teamer === $user->getTeamer();
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\Service\Common;
use App\Entity\JobProfile;
use App\Model\AbstractFilterDto;
use App\Model\SeasonAssignmentFilterDto;
class SeasonAssignmentFilterHandler extends AbstractFilterHandler
{
protected string $namespace = 'filter:season_assignment';
protected string $modelClass = SeasonAssignmentFilterDto::class;
public function getFilterSettings(): SeasonAssignmentFilterDto
{
$filterDto = new $this->modelClass();
if (null === $data = $this->getSession()->get($this->namespace)) {
return $filterDto;
}
$filterDto
->setTeamerName($data['teamer_name'])
// ?? []: sessions written before the houses filter replaced the destination one
// still carry the old payload under this same namespace
->setHotelCodes($data['hotel_codes'] ?? [])
->setIncludeDeleted((bool) ($data['include_deleted'] ?? false))
;
if (0 < count($data['job_profiles'])) {
$jobProfiles = $this
->entityManager
->getRepository(JobProfile::class)
->findBy(['id' => $data['job_profiles']])
;
$filterDto->setJobProfiles($jobProfiles);
}
return $filterDto;
}
protected function saveFilterSettings(AbstractFilterDto $filterDto): void
{
/* @var SeasonAssignmentFilterDto $filterDto */
$this->getSession()->set($this->namespace, [
'teamer_name' => $filterDto->getTeamerName(),
'hotel_codes' => $filterDto->getHotelCodes(),
'include_deleted' => $filterDto->isIncludeDeleted(),
'job_profiles' => array_map(function (JobProfile $jobProfile) {
return $jobProfile->getId();
}, $filterDto->getJobProfiles()),
]);
}
}
+31 -1
View File
@@ -8,13 +8,43 @@ use App\Model\TeamerFilterDto;
class TeamerFilterHandler extends AbstractFilterHandler
{
protected string $namespace = 'filter:teamer';
/**
* The value the teamer lists pass around as a 'scope' query parameter to say which
* of the two they are working on.
*/
public const SCOPE_ALL_SEASON = 'all_season';
public const NAMESPACE_DEFAULT = 'filter:teamer';
/**
* The all-season list keeps its own session bucket so filtering one teamer list
* never silently changes what the other one shows.
*/
public const NAMESPACE_ALL_SEASON = 'filter:teamer:all_season';
protected string $namespace = self::NAMESPACE_DEFAULT;
protected string $modelClass = TeamerFilterDto::class;
/**
* Point the handler at the list the request belongs to. Every caller reading teamer
* filter settings has to do this first - the mailing included, otherwise it would
* resolve its recipients from the other list's filter.
*/
public function setScope(?string $scope): static
{
return $this->setNamespace(self::SCOPE_ALL_SEASON === $scope
? self::NAMESPACE_ALL_SEASON
: self::NAMESPACE_DEFAULT);
}
public function getFilterSettings(): TeamerFilterDto
{
$filterDto = new $this->modelClass();
// the bucket the settings live in is what makes a list an all-season list, so the
// scope is derived here rather than left to each caller to remember
$filterDto->setAllSeason(self::NAMESPACE_ALL_SEASON === $this->namespace);
if (null === $data = $this->getSession()->get($this->namespace)) {
return $filterDto;
}
@@ -0,0 +1,189 @@
<?php
namespace App\Service\Cron;
use App\Config\EmailTextKey;
use App\Config\SeasonCalendar;
use App\Email\Mailer;
use App\Entity\SeasonAssignmentTask;
use App\Repository\SeasonAssignmentTaskRepository;
use App\Repository\UserRepository;
use App\Service\Season\SeasonAssignmentLabeler;
use App\Service\Season\SeasonTaskDeadlineResolver;
use Psr\Log\LoggerInterface;
/**
* The two reminders around the season checklist: one to the office when a deadline is reached,
* one to the teamer while they still owe us something.
*
* Both are idempotent without recording anything, like UploadReminderService: the office summary
* picks out exactly one day per deadline, and the teamer digest picks out exactly one weekday.
*
* Only "done" stops either of them. An arriving file does not, and neither does the "uploaded"
* tick, which is a note for the office and gates nothing. Neither does doneTeamer on the
* assignment: that flag is informative only.
*/
class SeasonTaskReminderService
{
private const DIGEST_WEEKDAY = 1;
public function __construct(
private readonly SeasonAssignmentTaskRepository $seasonAssignmentTaskRepository,
private readonly SeasonTaskDeadlineResolver $deadlineResolver,
private readonly SeasonAssignmentLabeler $labeler,
private readonly SeasonCalendar $seasonCalendar,
private readonly UserRepository $userRepository,
private readonly Mailer $mailer,
private readonly LoggerInterface $logger,
) {
}
/**
* One mail to the office listing every entry whose deadline is reached today.
*
* Frist 2 defaults to Frist 1, so the two normally fall on the same day. A row is reported
* once in that case and reads as Frist 1: a second, identical line would be noise, and the
* office only pushes the second deadline out deliberately.
*/
public function sendDeadlineSummary(?\DateTimeImmutable $today = null): string
{
$today = ($today ?? new \DateTimeImmutable('today'))->setTime(0, 0);
$violations = [];
foreach ($this->seasonAssignmentTaskRepository->findOpen() as $task) {
if (null === $level = $this->levelDueOn($task, $today)) {
continue;
}
$teamer = (string) $task->getSeasonAssignment()?->getTeamer();
$violations[$teamer][] = [
'task' => $task,
'label' => $task->getTask()?->label() ?? $task->getTaskKey(),
'level' => $level,
];
}
if (0 === $count = count($violations)) {
$message = 'No season task deadlines reached today';
$this->logger->info($message);
return $message;
}
// the two roles that carry ROLE_ADMINISTRATIVE: the repository matches roles as they are
// stored on the user, and nobody is ever stored with the hierarchy-granted one
foreach ($this->userRepository->getUsersByRoles(['ROLE_TEAM_ADMIN', 'ROLE_MANAGER']) as $user) {
if (true === $user->isMuteNotifications()) {
continue;
}
$this->mailer->createAndSendEmail([
'violations' => $violations,
], [
'to' => $user->getEmail(),
'subject' => 'Fristen für Saisonunterlagen erreicht',
'template' => 'email/season_deadlines.html.twig',
]);
}
$message = 'Sent the season task deadline summary for '.$count.' teamers';
$this->logger->info($message);
return $message;
}
/**
* One mail per teamer, every Monday, listing what they still have to send us. It repeats
* until an administrative user ticks the entry off; it is not tied to a deadline at all.
*/
public function sendTeamerDigest(?\DateTimeImmutable $today = null): string
{
$today = ($today ?? new \DateTimeImmutable('today'))->setTime(0, 0);
if (self::DIGEST_WEEKDAY !== (int) $today->format('N')) {
$message = 'Season task digests are only sent on mondays';
$this->logger->info($message);
return $message;
}
// Scoped to the season being prepared, so a teamer is never chased about a season that
// is long over.
[$windowStart, $windowEnd] = $this->seasonCalendar->windowFor(
$this->seasonCalendar->currentOrUpcomingSeasonYear($today)
);
$open = $this->seasonAssignmentTaskRepository->findOpen(
$windowStart->toDateTimeImmutable(),
$windowEnd->toDateTimeImmutable()
);
$perTeamer = [];
foreach ($open as $task) {
$seasonAssignment = $task->getSeasonAssignment();
if (null === $seasonAssignment) {
continue;
}
// office-internal entries never reach the teamer
if (true !== $task->getTask()?->requiresReturn()) {
continue;
}
$perTeamer[$seasonAssignment->getId()]['assignment'] = $seasonAssignment;
$perTeamer[$seasonAssignment->getId()]['labels'][] = $task->getTask()->label();
}
$count = 0;
foreach ($perTeamer as $entry) {
$teamer = $entry['assignment']->getTeamer();
$email = $teamer?->getCommunication()?->getEmail();
if (null === $email) {
continue;
}
$this->mailer->createAndSendText(EmailTextKey::REMINDER_SEASON_TASKS, [
// the placeholder keeps its name: admins can have edited this text in the
// database, and renaming it would blank it out in those stored versions
'destination' => $this->labeler->label($entry['assignment']),
'tasks' => implode(', ', $entry['labels']),
], [
'to' => $email,
]);
++$count;
}
$message = 'Sent '.$count.' season task digests to teamers';
$this->logger->info($message);
return $message;
}
/**
* Which deadline of this entry falls on $day, or null for none. Frist 1 wins when both do.
*/
private function levelDueOn(SeasonAssignmentTask $task, \DateTimeImmutable $day): ?int
{
if ($this->isSameDay($this->deadlineResolver->resolveFirst($task), $day)) {
return 1;
}
if ($this->isSameDay($this->deadlineResolver->resolveSecond($task), $day)) {
return 2;
}
return null;
}
private function isSameDay(?\DateTimeImmutable $deadline, \DateTimeImmutable $day): bool
{
return null !== $deadline && $deadline->format('Y-m-d') === $day->format('Y-m-d');
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Service\Season;
use App\Config\HouseCatalog;
use App\Config\SeasonCalendar;
use App\Entity\SeasonAssignment;
/**
* How a season assignment names itself - "Saison 2026/27 - Rotbach".
*
* Not SeasonAssignment::__toString(): the name needs the configured season window and the
* configured houses map, neither of which an entity can reach. Everything that shows or logs a
* placement goes through here, so the list, the delete dialog, the audit log and the teamer mail
* cannot drift apart.
*/
class SeasonAssignmentLabeler
{
public function __construct(
private readonly SeasonCalendar $seasonCalendar,
private readonly HouseCatalog $houseCatalog,
) {
}
public function label(SeasonAssignment $seasonAssignment): string
{
return sprintf(
'%s - %s',
$this->seasonCalendar->labelFor($seasonAssignment->getDateFrom()),
$this->houseLabel($seasonAssignment->getHotelCode())
);
}
/**
* A code that has since been dropped from the houses map still has to name itself, so it
* falls back to the raw code rather than rendering an empty cell.
*/
public function houseLabel(?string $hotelCode): string
{
if (null === $hotelCode) {
return '?';
}
return $this->houseCatalog->getName($hotelCode) ?? $hotelCode;
}
}
@@ -0,0 +1,60 @@
<?php
namespace App\Service\Season;
use App\Config\SeasonCalendar;
use App\Entity\SeasonAssignment;
use App\Entity\Teamer;
use App\Repository\SeasonAssignmentRepository;
use Psr\Log\LoggerInterface;
/**
* The season assignment a teamer's own page is about.
*
* Resolved by season rather than by date range, using SeasonCalendar - the same vocabulary the
* statistics screens use - and falling forward to the next season when today is off-season, so
* the page stays reachable while the documents for the coming season are already being chased.
*
* App\Validator\Constraints\SeasonAssignment allows only one per teamer and season, so at most
* one is expected here; the earliest is taken and a warning logged for rows that predate the
* rule.
*/
class SeasonAssignmentResolver
{
public function __construct(
private readonly SeasonAssignmentRepository $seasonAssignmentRepository,
private readonly SeasonCalendar $seasonCalendar,
private readonly LoggerInterface $logger,
) {
}
public function resolveFor(Teamer $teamer): ?SeasonAssignment
{
if (false === $teamer->isAllSeason()) {
return null;
}
[$windowStart, $windowEnd] = $this->seasonCalendar->windowFor(
$this->seasonCalendar->currentOrUpcomingSeasonYear()
);
$seasonAssignments = $this->seasonAssignmentRepository->findForTeamerInSeason(
$teamer,
$windowStart->toDateTimeImmutable(),
$windowEnd->toDateTimeImmutable()
);
if (0 === count($seasonAssignments)) {
return null;
}
if (1 < count($seasonAssignments)) {
$this->logger->warning('Multiple season assignments resolved for one teamer', [
'teamer_id' => $teamer->getId(),
'count' => count($seasonAssignments),
]);
}
return $seasonAssignments[0];
}
}
@@ -0,0 +1,81 @@
<?php
namespace App\Service\Season;
use App\Config\SeasonTask;
use App\Config\SeasonTaskProvision;
use App\Entity\SeasonAssignment;
use App\Entity\SeasonAssignmentTask;
use App\Entity\SeasonTaskDocument;
use App\Repository\SeasonTaskDocumentRepository;
/**
* Puts one checklist row together: the stored state, the entry it belongs to, both resolved
* deadlines and - for an entry handing out a standard document - the library file it points at.
*
* Shared by the detail page and the per-row htmx toggles, which re-render a single row and have
* to arrive at exactly the same one.
*/
class SeasonChecklistBuilder
{
public function __construct(
private readonly SeasonTaskDeadlineResolver $deadlineResolver,
private readonly SeasonTaskDocumentRepository $seasonTaskDocumentRepository,
) {
}
/**
* Rows in enum order, which is the order the office works through them. Rows whose key no
* longer resolves are left out: the entry they belong to was removed from the enum.
*
* @return array<int, array<string, mixed>>
*/
public function build(SeasonAssignment $seasonAssignment): array
{
$tasks = [];
foreach ($seasonAssignment->getTasks() as $task) {
if (null !== $definition = $task->getTask()) {
$tasks[$definition->value] = $task;
}
}
$documents = $this->seasonTaskDocumentRepository->findIndexedByTaskKey();
$rows = [];
foreach (SeasonTask::cases() as $case) {
if (false === isset($tasks[$case->value])) {
continue;
}
$rows[] = $this->buildRow($tasks[$case->value], $documents[$case->value] ?? null);
}
return $rows;
}
/**
* @return array<string, mixed>
*/
public function buildRow(SeasonAssignmentTask $task, ?SeasonTaskDocument $seasonTaskDocument = null): array
{
$definition = $task->getTask();
if (null === $definition) {
throw new \InvalidArgumentException(sprintf('Task key "%s" is not defined.', $task->getTaskKey()));
}
if (null === $seasonTaskDocument && SeasonTaskProvision::STANDARD === $definition->provides()) {
$seasonTaskDocument = $this->seasonTaskDocumentRepository->findForTask($definition);
}
return [
'task' => $task,
'definition' => $definition,
'deadline1' => $this->deadlineResolver->resolveFirst($task),
'deadline2' => $this->deadlineResolver->resolveSecond($task),
'document' => $seasonTaskDocument?->getDocument(),
];
}
}
@@ -0,0 +1,55 @@
<?php
namespace App\Service\Season;
use App\Entity\SeasonAssignmentTask;
/**
* The two deadlines of a checklist entry.
*
* Neither is stored: both are computed from the assignment's first day, so moving the placement's
* dates moves the deadlines with them instead of leaving them stale. A task row only carries the
* overrides, which is what "null" means there.
*
* Frist 2 defaults to Frist 1 and therefore follows an overridden Frist 1 as well - the office
* pushes the second deadline out deliberately or not at all.
*
* Reminders resolve through this class in PHP rather than reproducing the offset in DQL:
* "-1 month" is not invertible day by day (31.03. minus a month is 03.03., and that plus a
* month is 03.04.), so a query built by inverting the offset would silently miss tasks.
*/
class SeasonTaskDeadlineResolver
{
public function __construct(private readonly string $seasonTaskDeadlineOffset)
{
}
public function resolveFirst(SeasonAssignmentTask $task): ?\DateTimeImmutable
{
if (null !== $override = $task->getDeadlineOverride1()) {
return $override;
}
return $this->defaultDeadline($task);
}
public function resolveSecond(SeasonAssignmentTask $task): ?\DateTimeImmutable
{
if (null !== $override = $task->getDeadlineOverride2()) {
return $override;
}
return $this->resolveFirst($task);
}
/**
* Null only when the assignment has no start date yet, which the create form does not
* allow but a half-built object in memory can still be in.
*/
private function defaultDeadline(SeasonAssignmentTask $task): ?\DateTimeImmutable
{
$dateFrom = $task->getSeasonAssignment()?->getDateFrom();
return $dateFrom?->modify($this->seasonTaskDeadlineOffset);
}
}
@@ -0,0 +1,55 @@
<?php
namespace App\Service\Season;
use App\Config\SeasonTask;
use App\Entity\SeasonAssignment;
use App\Entity\SeasonAssignmentTask;
use Doctrine\ORM\EntityManagerInterface;
/**
* Brings a season assignment's checklist rows in line with the entries defined in
* App\Config\SeasonTask.
*
* Called on write paths only - after an assignment is created, and at the top of the
* administrative detail controller before it renders - never lazily during an arbitrary GET, so
* no read path ends up flushing.
*
* It only ever creates. A case removed from the enum leaves its row behind: every read resolves
* the stored key through SeasonTask::tryFrom() and skips what no longer exists, which keeps a
* removed entry from erroring out and keeps its history recoverable.
*/
class SeasonTaskSynchronizer
{
public function __construct(private readonly EntityManagerInterface $entityManager)
{
}
public function sync(SeasonAssignment $seasonAssignment): void
{
$existing = [];
foreach ($seasonAssignment->getTasks() as $task) {
$existing[$task->getTaskKey()] = true;
}
$created = false;
foreach (SeasonTask::cases() as $case) {
if (true === isset($existing[$case->value])) {
continue;
}
$task = (new SeasonAssignmentTask())->setTaskKey($case->value);
$seasonAssignment->addTask($task);
$this->entityManager->persist($task);
$created = true;
}
if (true === $created) {
$this->entityManager->flush();
}
}
}
+2
View File
@@ -19,6 +19,8 @@ class AppExtension extends AbstractExtension
new TwigFilter('format_rating', [AppRuntime::class, 'formatRating'], ['is_safe' => ['html']]),
new TwigFilter('license_label', [AppRuntime::class, 'licenseLabel']),
new TwigFilter('teamer_status_label', [AppRuntime::class, 'teamerStatusLabel']),
new TwigFilter('season_assignment_label', [AppRuntime::class, 'seasonAssignmentLabel']),
new TwigFilter('house_label', [AppRuntime::class, 'houseLabel']),
new TwigFilter('bpn_country_label', [AppRuntime::class, 'bpnCountryLabel']),
new TwigFilter('bpn_pickup_label', [AppRuntime::class, 'bpnPickupLabel']),
new TwigFilter('upload_status_badge', [AppRuntime::class, 'renderUploadStatusBadge'], ['is_safe' => ['html']]),
+35 -1
View File
@@ -4,8 +4,10 @@ namespace App\Twig;
use App\BusProNet\DataProvider\CountryDataProvider;
use App\BusProNet\DataProvider\PickupDataProvider;
use App\Entity\SeasonAssignment;
use App\Entity\Teamer;
use App\Entity\Upload;
use App\Service\Season\SeasonAssignmentLabeler;
use App\Service\Teamer\PickupResolver;
use Carbon\CarbonImmutable;
use Symfony\Component\HttpFoundation\RequestStack;
@@ -23,11 +25,43 @@ class AppRuntime implements RuntimeExtensionInterface
private readonly CountryDataProvider $countries,
private readonly PickupDataProvider $pickups,
private readonly PickupResolver $pickupResolver,
private readonly SeasonAssignmentLabeler $seasonAssignmentLabeler,
private readonly string $environment,
) {
}
public function teamerStatusLabel(Teamer $teamer): string
public function seasonAssignmentLabel(SeasonAssignment $seasonAssignment): string
{
return $this->seasonAssignmentLabeler->label($seasonAssignment);
}
/**
* The house name for a stored hotel code. Only the code is stored, so every template that
* shows a house resolves it here rather than freezing a name into the record.
*/
public function houseLabel(?string $hotelCode): string
{
return $this->seasonAssignmentLabeler->houseLabel($hotelCode);
}
/**
* All-season is a flag on top of the status, not a status of its own - a teamer can be a
* Bestandsteamer:in and an all-season teamer at once. The general Teamübersicht is the only
* place that overlap is not otherwise visible, so the marker is appended there; the
* dedicated Saisonteamer list passes false, where repeating it on every row says nothing.
*/
public function teamerStatusLabel(Teamer $teamer, bool $includeAllSeason = true): string
{
$label = $this->baseTeamerStatusLabel($teamer);
if (true === $includeAllSeason && true === $teamer->isAllSeason()) {
return sprintf('%s · Saison', $label);
}
return $label;
}
private function baseTeamerStatusLabel(Teamer $teamer): string
{
$labelItems = [];
$user = $teamer->getUser();
@@ -0,0 +1,16 @@
<?php
namespace App\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
#[\Attribute]
class SeasonAssignment extends Constraint
{
public string $message = 'Für diese:n Teamer:in existiert in dieser Saison bereits ein Saisoneinsatz';
public function getTargets(): array|string
{
return static::CLASS_CONSTRAINT;
}
}
@@ -0,0 +1,63 @@
<?php
namespace App\Validator\Constraints;
use App\Config\SeasonCalendar;
use App\Repository\SeasonAssignmentRepository;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
/**
* One season assignment per teamer per season.
*
* "Once a year" was a stated fact rather than an enforced one; this is what enforces it, on
* create and on edit alike, so the teamer's season view can rely on resolving a single
* assignment.
*/
class SeasonAssignmentValidator extends ConstraintValidator
{
public function __construct(
private readonly SeasonAssignmentRepository $seasonAssignmentRepository,
private readonly SeasonCalendar $seasonCalendar,
) {
}
public function validate(mixed $value, Constraint $constraint): void
{
/** @var \App\Entity\SeasonAssignment $seasonAssignment */
$seasonAssignment = $value;
// An assignment the office has laid out but not staffed yet is a normal working state,
// and a missing start date is the create form's own problem to report.
if (null === $teamer = $seasonAssignment->getTeamer()) {
return;
}
if (null === $dateFrom = $seasonAssignment->getDateFrom()) {
return;
}
// A placement starting outside any season window belongs to no season, so there is
// nothing to be the second of.
if (null === $seasonYear = $this->seasonCalendar->seasonYearFor($dateFrom)) {
return;
}
[$windowStart, $windowEnd] = $this->seasonCalendar->windowFor($seasonYear);
$existing = $this->seasonAssignmentRepository->countInSeasonWindow(
$teamer,
$windowStart->toDateTimeImmutable(),
$windowEnd->toDateTimeImmutable(),
$seasonAssignment->getId()
);
if (0 < $existing) {
$this
->context
->buildViolation($constraint->message)
->addViolation()
;
}
}
}
+2 -2
View File
@@ -93,7 +93,7 @@
{% if recipients.eligibleCount > 0 %}
<button type="button"
class="btn"
hx-post="{{ path('app_admin_teamer_mailing_confirm') }}"
hx-post="{{ path('app_admin_teamer_mailing_confirm', { 'scope': scope }) }}"
hx-include="closest form"
hx-target="body"
hx-swap="beforeend">
@@ -105,7 +105,7 @@
Vorschau an mich senden
</button>
{% if hasDraft %}
<a href="{{ path('app_admin_teamer_mailing_discard') }}" class="btn btn--secondary">
<a href="{{ path('app_admin_teamer_mailing_discard', { 'scope': scope }) }}" class="btn btn--secondary">
Entwurf verwerfen
</a>
{% endif %}
@@ -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 %}
+3
View File
@@ -20,6 +20,9 @@
<div class="pb-4">
{{ form_widget(form.status) }}
</div>
<div class="pb-4">
{{ form_row(form.allSeason) }}
</div>
<div class="pb-4">
{{ form_row(form.allowOverlappingApplications) }}
</div>
@@ -0,0 +1,17 @@
{# Swapped in place by its own route, so the id and the outerHTML swap have to stay. #}
<div id="season-done-teamer" class="flex items-start space-x-2">
<input type="checkbox"
id="season-done-teamer-input"
class="mt-1 text-primary"
{{ seasonAssignment.doneTeamer ? 'checked' : '' }}
hx-post="{{ path('app_administrative_season_assignment_done_teamer', { 'uuid': seasonAssignment.uuid }) }}"
hx-target="#season-done-teamer"
hx-swap="outerHTML">
<label for="season-done-teamer-input">
<span class="font-bold">Teamer:in erledigt</span>
<span class="block text-sm text-gray-500">
Reiner Vermerk, die wöchentliche Erinnerung läuft weiter,
solange ein Punkt der Checkliste mit Rückgabe offen ist.
</span>
</label>
</div>
@@ -0,0 +1,25 @@
{{ form_start(form) }}
<div class="flex flex-col space-y-4 pb-8">
<div class="grid lg:grid-cols-2 gap-x-8 gap-y-4">
{{ form_row(form.teamer) }}
{{ form_row(form.hotelCode) }}
</div>
<div class="grid lg:grid-cols-2 gap-x-8 gap-y-4">
{{ form_row(form.dateFrom) }}
{{ form_row(form.dateTo) }}
</div>
<div class="grid lg:grid-cols-2 gap-x-8 gap-y-4">
{{ form_row(form.jobProfile) }}
{{ form_row(form.remarks) }}
</div>
</div>
<div class="flex items-center space-x-2">
<button type="submit" class="btn">
Speichern
</button>
<a href="{{ returnUrl }}" class="btn btn--secondary">
Abbrechen
</a>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
@@ -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. #}
<tr id="season-task-{{ row.task.uuid }}" class="border-b border-gray-200">
<td class="py-2 pr-4 align-top">
<div class="font-bold">{{ row.definition.label }}</div>
{% if row.definition.provides == constant('App\\Config\\SeasonTaskProvision::STANDARD') %}
{% if row.document is not null %}
<a href="{{ path('app_common_download', { 'uuid': row.document.uuid }) }}"
class="text-sm underline">
{{ row.document.displayName ?? row.document.originalFilename }}
</a>
{% else %}
<div class="text-sm text-gray-500">kein Dokument hinterlegt</div>
{% endif %}
{% elseif row.definition.provides == constant('App\\Config\\SeasonTaskProvision::INDIVIDUAL') %}
<div class="text-sm text-gray-500">individuelles Dokument</div>
{% endif %}
</td>
<td class="py-2 pr-4 align-top whitespace-nowrap">
{{ row.deadline1 is not null ? row.deadline1 | date('d.m.Y') : '-' }}
{% if row.task.deadlineOverride1 is not null %}
<span class="text-xs text-gray-500">(angepasst)</span>
{% endif %}
</td>
<td class="py-2 pr-4 align-top whitespace-nowrap">
{{ row.deadline2 is not null ? row.deadline2 | date('d.m.Y') : '-' }}
{% if row.task.deadlineOverride2 is not null %}
<span class="text-xs text-gray-500">(angepasst)</span>
{% endif %}
</td>
<td class="py-2 pr-4 align-top text-center">
<input type="checkbox"
class="text-primary"
{{ row.task.uploaded ? 'checked' : '' }}
aria-label="Dokument erhalten"
hx-post="{{ path('app_administrative_season_assignment_task_toggle', { 'uuid': row.task.uuid, 'field': 'uploaded' }) }}"
hx-target="#season-task-{{ row.task.uuid }}"
hx-swap="outerHTML">
</td>
<td class="py-2 pr-4 align-top text-center">
<input type="checkbox"
class="text-primary"
{{ row.task.done ? 'checked' : '' }}
aria-label="Erledigt"
hx-post="{{ path('app_administrative_season_assignment_task_toggle', { 'uuid': row.task.uuid, 'field': 'done' }) }}"
hx-target="#season-task-{{ row.task.uuid }}"
hx-swap="outerHTML">
{% if row.task.done and row.task.doneAt is not null %}
<div class="text-xs text-gray-500 whitespace-nowrap">
{{ row.task.doneAt | date('d.m.Y') }}{% if row.task.doneBy is not null %}, {{ row.task.doneBy }}{% endif %}
</div>
{% endif %}
</td>
<td class="py-2 align-top text-right">
<button type="button"
class="text-sm underline"
hx-get="{{ path('app_administrative_season_assignment_task_deadline', { 'uuid': row.task.uuid, 'r': return_url() }) }}"
hx-target="body"
hx-swap="beforeend">
Fristen
</button>
</td>
</tr>
@@ -0,0 +1,10 @@
{% extends 'administrative/layout.html.twig' %}
{% block title %}Saisoneinsatz anlegen{% endblock %}
{% block content %}
<h1 class="text-2xl font-bold pb-8">
Saisoneinsatz anlegen
</h1>
{% include 'administrative/season_assignment/_form.html.twig' %}
{% endblock %}
@@ -0,0 +1,161 @@
{% extends 'administrative/layout.html.twig' %}
{% block title %}Saisoneinsatz{% endblock %}
{% block content %}
<div class="flex items-start justify-between pb-4">
<h1 class="text-2xl font-bold">
Saisoneinsatz
<span class="block text-base font-normal text-gray-500">
{{ seasonAssignment | season_assignment_label }}
</span>
</h1>
<div class="flex flex-col items-end space-y-2 md:flex-row md:items-center md:space-x-2 md:space-y-0">
<a href="{{ path('app_administrative_season_assignment_edit', { 'uuid': seasonAssignment.uuid, 'r': return_url() }) }}" class="btn btn--small">
bearbeiten
</a>
<a href="{{ returnUrl }}" class="btn btn--small btn--secondary">
zurück
</a>
</div>
</div>
{% set teamer = seasonAssignment.teamer %}
<div class="flex flex-col lg:flex-row lg:space-x-8">
<div class="flex-1 grid lg:grid-cols-2 gap-x-8 gap-y-4 pb-8">
<div>
<h4 class="font-bold pb-2">Teamer:in</h4>
{% if teamer is not null %}
<ul class="space-y-2">
<li>
<a href="{{ path('app_administrative_teamer_profile', { 'uuid': teamer.uuid, 'r': return_url() }) }}">
{{ teamer }}
</a>
</li>
{% if teamer.communication.email %}
<li>
<a href="mailto:{{ teamer.communication.email }}" class="flex items-center space-x-2">
{{ icon('mail', 'w-4 h-4') }}
<span class="flex-0 underline">{{ teamer.communication.email }}</span>
</a>
</li>
{% endif %}
{% if teamer.communication.mobile %}
<li>
<a href="tel:{{ teamer.communication.mobile }}" class="flex items-center space-x-2">
{{ icon('phone', 'w-4 h-4') }}
<span class="flex-0 underline">{{ teamer.communication.mobile }}</span>
</a>
</li>
{% endif %}
</ul>
{% else %}
<span class="text-gray-500">noch nicht zugewiesen</span>
{% endif %}
</div>
<ul class="space-y-4">
<li>
<h4 class="font-bold pb-2">Jobprofil</h4>
{{ seasonAssignment.jobProfile.name }}
</li>
<li>
<h4 class="font-bold pb-2">Haus</h4>
{{ seasonAssignment.hotelCode | house_label }}
</li>
<li>
<h4 class="font-bold pb-2">Zeitraum</h4>
{{ seasonAssignment.dateFrom | date('d.m.Y') }} - {{ seasonAssignment.dateTo | date('d.m.Y') }}
</li>
</ul>
</div>
{% if teamer is not null and teamer.photo is not null %}
<a href="{{ path('app_administrative_teamer_profile', { 'uuid': teamer.uuid, 'r': return_url() }) }}">
<img src="{{ asset(teamer.photo | upload_filename_full | imagine_filter('profile')) }}"
class="w-48 h-auto border-2 border-primary"
alt="{{ teamer.firstName }}">
</a>
{% endif %}
</div>
<div class="pb-8">
<h2 class="text-xl font-bold pb-2">
Checkliste
</h2>
<div class="data-table-wrapper overflow-x-auto">
<table class="w-full text-left">
<thead>
<tr class="border-b-2 border-gray-300">
<th class="py-2 pr-4">Dokument/ToDo</th>
<th class="py-2 pr-4">Frist 1</th>
<th class="py-2 pr-4">Frist 2</th>
<th class="py-2 pr-4 text-center">erhalten</th>
<th class="py-2 pr-4 text-center">erledigt</th>
<th class="py-2"></th>
</tr>
</thead>
<tbody>
{% for row in checklist %}
{% include 'administrative/season_assignment/_task_row.html.twig' with { 'row': row } only %}
{% endfor %}
</tbody>
</table>
</div>
<p class="pt-2 text-sm text-gray-500">
Nur <em>erledigt</em> schließt einen Punkt ab und beendet die Erinnerungen.
<em>erhalten</em> ist ein reiner Vermerk.
</p>
<div class="pt-4">
{% include 'administrative/season_assignment/_done_teamer.html.twig' %}
</div>
</div>
<div class="pb-8">
<div class="flex items-center justify-between pb-2">
<h2 class="text-xl font-bold pb-2">
Dokumente
</h2>
<button type="button"
class="btn btn--small"
hx-get="{{ path('app_administrative_season_assignment_document_upload', { 'uuid': seasonAssignment.uuid, 'r': return_url() }) }}"
hx-target="body"
hx-swap="beforeend">
hochladen
</button>
</div>
{% if seasonAssignment.documents | length > 0 %}
<ul class="divide-y divide-gray-200">
{% for document in seasonAssignment.documents %}
<li class="flex items-center justify-between py-2">
<div>
<a href="{{ path('app_common_download', { 'uuid': document.uuid }) }}" class="underline">
{{ document.originalFilename }}
</a>
<div class="text-sm text-gray-500">
{{ document.typeLabel }}, hochgeladen am {{ document.createdAt | date('d.m.Y') }}
</div>
</div>
<button type="button"
class="text-sm text-red-500"
hx-get="{{ path('app_administrative_season_assignment_document_delete', { 'uuid': document.uuid, 'r': return_url() }) }}"
hx-target="body"
hx-swap="beforeend">
löschen
</button>
</li>
{% endfor %}
</ul>
{% else %}
<div class="text-gray-500">keine Dokumente vorhanden</div>
{% endif %}
</div>
<div class="pb-8">
<h4 class="font-bold pb-2">Anmerkungen intern</h4>
{% if seasonAssignment.remarks is not empty %}
<div class="whitespace-pre-line">{{ seasonAssignment.remarks }}</div>
{% else %}
<div class="text-gray-500">-</div>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,10 @@
{% extends 'administrative/layout.html.twig' %}
{% block title %}Saisoneinsatz bearbeiten{% endblock %}
{% block content %}
<h1 class="text-2xl font-bold pb-8">
Saisoneinsatz bearbeiten
</h1>
{% include 'administrative/season_assignment/_form.html.twig' %}
{% endblock %}
@@ -0,0 +1,136 @@
{% extends 'administrative/layout.html.twig' %}
{% block title %}Saisoneinsätze{% endblock %}
{% block content %}
<div class="flex items-start justify-between pb-4">
<h1 class="text-2xl font-bold">
Saisoneinsätze
</h1>
<div class="flex flex-col items-end space-y-2 md:flex-row md:items-center md:space-x-2 md:space-y-0">
<button type="button"
class="{{ html_classes('btn btn--small', { 'btn--secondary': filterDto.active }) }}"
title="Saisoneinsätze filtern"
hx-get="{{ path('app_common_season_assignment_filter', { 'r': return_url() }) }}"
hx-target="body"
hx-swap="beforeend">
{{ icon('filter', 'w-4 h-4 shrink-0') }}
{% if filterDto.active %}
<span class="whitespace-nowrap">{{ filterDto.activeFiltersCount }} Filter aktiv</span>
{% else %}
<span>Filter</span>
{% endif %}
</button>
{% if filterDto.active %}
<a href="{{ path('app_common_season_assignment_filter_reset', { 'r': return_url() }) }}" class="btn btn--small btn--secondary">
Reset
</a>
{% endif %}
</div>
</div>
<div class="data-table-wrapper">
<div class="data-table-wrapper__inner">
<table class="data-table">
<thead>
<tr>
<th>
{{ knp_pagination_sortable(pagination, 'Name', 'teamer.lastName') }}
</th>
<th>
Vorname
</th>
<th>
{{ knp_pagination_sortable(pagination, 'Haus', 'season_assignment.hotelCode') }}
</th>
<th>
{{ knp_pagination_sortable(pagination, 'Zeitraum', 'season_assignment.dateFrom') }}
</th>
<th>
{{ knp_pagination_sortable(pagination, 'Jobprofil', 'job_profile.name') }}
</th>
<th></th>
</tr>
</thead>
<tbody>
{% for seasonAssignment in pagination %}
<tr>
{# an assignment that has not been staffed yet still belongs in the list #}
{% if seasonAssignment.teamer is not null %}
<td>
<a href="{{ path('app_administrative_season_assignment_detail', { 'uuid': seasonAssignment.uuid, 'r': return_url() }) }}">
<span class="whitespace-nowrap">{{ seasonAssignment.teamer.lastName }}</span>
</a>
</td>
<td>
<a href="{{ path('app_administrative_season_assignment_detail', { 'uuid': seasonAssignment.uuid, 'r': return_url() }) }}" class="whitespace-nowrap">
{{ seasonAssignment.teamer.firstName }}
</a>
</td>
{% else %}
<td colspan="2">
<a href="{{ path('app_administrative_season_assignment_detail', { 'uuid': seasonAssignment.uuid, 'r': return_url() }) }}"
class="whitespace-nowrap text-gray-500">
noch nicht zugewiesen
</a>
</td>
{% endif %}
<td>
{{ seasonAssignment.hotelCode | house_label }}
</td>
<td>
<span class="whitespace-nowrap">
{{ seasonAssignment.dateFrom | date('d.m.Y') }} - {{ seasonAssignment.dateTo | date('d.m.Y') }}
</span>
</td>
<td>
{{ seasonAssignment.jobProfile.name }}
</td>
<td>
<twig:DropDown>
<a href="{{ path('app_administrative_season_assignment_detail', { 'uuid': seasonAssignment.uuid, 'r': return_url() }) }}"
class="text-gray-700 hover:text-primary block px-4 py-2 text-sm"
role="menuitem"
tabindex="-1">
Details
</a>
<a href="{{ path('app_administrative_season_assignment_edit', { 'uuid': seasonAssignment.uuid, 'r': return_url() }) }}"
class="text-gray-700 hover:text-primary block px-4 py-2 text-sm"
role="menuitem"
tabindex="-1">
bearbeiten
</a>
<button type="button"
class="text-red-500 hover:text-primary block px-4 py-2 text-sm w-full text-left"
role="menuitem"
tabindex="-1"
hx-get="{{ path('app_administrative_season_assignment_delete', { 'uuid': seasonAssignment.uuid }) }}"
hx-target="body"
hx-swap="beforeend">
löschen
</button>
</twig:DropDown>
{% if seasonAssignment.deleted %}
<div class="py-1 px-2 mt-2 text-xs bg-gray-500 text-white">
gel. am {{ seasonAssignment.deletedAt | date('d.m.Y') }}
</div>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="6">
Keine Daten...
</td>
</tr>
{% endfor %}
</tbody>
</table>
{{ knp_pagination_render(pagination) }}
</div>
</div>
<a href="{{ path('app_administrative_season_assignment_create', { 'r': return_url() }) }}" class="btn">
Neu
</a>
{% endblock %}
@@ -0,0 +1,13 @@
{% extends 'htmx_confirmation_modal.html.twig' %}
{% block content %}
<div>
{% if seasonAssignment.teamer is not null %}
Möchtest du den Saisoneinsatz von <em>{{ seasonAssignment.teamer }}</em>
in <em>{{ seasonAssignment | season_assignment_label }}</em> wirklich löschen?
{% else %}
Möchtest du den noch nicht zugewiesenen Saisoneinsatz
in <em>{{ seasonAssignment | season_assignment_label }}</em> wirklich löschen?
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,7 @@
{% extends 'htmx_confirmation_modal.html.twig' %}
{% block content %}
<div>
Möchtest du das Dokument <em>{{ document.originalFilename }}</em> wirklich löschen?
</div>
{% endblock %}
@@ -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()) }) }}
<div class="pb-4">
Lade hier die Dokumente hoch, die
{% if seasonAssignment.teamer is not null %}<em>{{ seasonAssignment.teamer }}</em>{% else %}der:die Teamer:in{% endif %}
für diesen Saisoneinsatz erhält. Sie sind nur für diese Person sichtbar.
</div>
<div class="pb-4">
{% 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',
} %}
</div>
<button type="submit" class="btn" {{ stimulus_target('form-upload-guard', 'submit') }}>
Dokumente speichern
</button>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
@@ -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' } }) }}
<div class="flex flex-col space-y-4 pb-8">
<p class="text-sm text-gray-500">
Ohne Eintrag gilt die Frist, die sich aus dem Beginn des Saisoneinsatzes ergibt.
</p>
{{ form_row(form.deadlineOverride1) }}
{{ form_row(form.deadlineOverride2) }}
</div>
<button type="submit" class="btn">
Speichern
</button>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
@@ -0,0 +1,60 @@
{% extends 'administrative/layout.html.twig' %}
{% block title %}Saisonteam-Dokumente{% endblock %}
{% block content %}
<h1 class="text-2xl font-bold pb-2">
Saisonteam-Dokumente
</h1>
<p class="pb-8 text-gray-500">
Auswahl der Standarddokumente für das Saisonteam.
</p>
<div class="data-table-wrapper">
<div class="data-table-wrapper__inner">
<table class="data-table">
<thead>
<tr>
<th>ToDo</th>
<th>Dokument</th>
<th></th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td>{{ row.task.label }}</td>
<td>
{% if row.document is not null %}
<a href="{{ path('app_common_download', { 'uuid': row.document.document.uuid }) }}" class="underline">
{{ row.document.document.displayName ?? row.document.document.originalFilename }}
</a>
{% else %}
<span class="text-gray-500">kein Dokument hinterlegt</span>
{% endif %}
</td>
<td>
<div class="flex items-center space-x-2 justify-end">
{% if row.document is not null %}
<button type="button"
class="text-red-500"
hx-get="{{ path('app_administrative_system_season_task_document_delete', { 'uuid': row.document.uuid }) }}"
hx-target="body"
hx-swap="beforeend">
{{ icon('delete') }}
</button>
{% endif %}
<button type="button"
hx-get="{{ path('app_administrative_system_season_task_document_edit', { 'taskKey': row.task.value }) }}"
hx-target="body"
hx-swap="beforeend">
{{ icon('edit') }}
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
@@ -0,0 +1,8 @@
{% extends 'htmx_confirmation_modal.html.twig' %}
{% block content %}
<div>
Möchtest du die Zuordnung für <em>{{ seasonTaskDocument.task ? seasonTaskDocument.task.label : seasonTaskDocument.taskKey }}</em>
wirklich entfernen? Das Dokument selbst bleibt erhalten.
</div>
{% endblock %}
@@ -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' } }) }}
<div class="flex flex-col space-y-4 pb-8">
{{ form_row(form.document) }}
</div>
<button type="submit" class="btn">
Speichern
</button>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
@@ -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) %}
<div class="flex items-start justify-between pb-4">
<h1 class="text-2xl font-bold">
{{ title }}
</h1>
<div class="flex flex-col items-end space-y-2 md:flex-row md:items-center md:space-x-2 md:space-y-0">
<button type="button"
class="{{ html_classes('btn btn--small', { 'btn--secondary': filterDto.active }) }}"
title="Team filtern"
hx-get="{{ path('app_common_teamer_filter', { 'r': return_url(), 'scope': scope }) }}"
hx-target="body"
hx-swap="beforeend">
{{ icon('filter', 'w-4 h-4 shrink-0') }}
{% if filterDto.active %}
<span class="whitespace-nowrap">{{ filterDto.activeFiltersCount }} Filter aktiv</span>
{% else %}
<span>Filter</span>
{% endif %}
</button>
{% if filterDto.active %}
<a href="{{ path('app_common_teamer_filter_reset', { 'r': return_url(), 'scope': scope }) }}" class="btn btn--small btn--secondary">
Reset
</a>
{% endif %}
{% if is_granted('ROLE_TEAM_ADMIN') %}
<a href="{{ path('app_admin_teamer_mailing', { 'r': return_url(), 'scope': scope }) }}"
class="btn btn--small"
title="Mail an die gefilterte Liste schreiben">
{{ icon('mail', 'w-4 h-4 shrink-0') }}
<span>Mailing</span>
</a>
{% endif %}
</div>
</div>
@@ -0,0 +1,155 @@
<div class="data-table-wrapper">
<div class="data-table-wrapper__inner">
<table class="data-table">
<thead>
<tr>
<th></th>
<th>
{{ knp_pagination_sortable(pagination, 'Name', 'teamer.lastName') }}
</th>
<th>
Vorname
</th>
<th>
{{ knp_pagination_sortable(pagination, 'Status', 'teamer.status') }}
</th>
<th>
{{ knp_pagination_sortable(pagination, 'E-Mail', 'teamer.communication.email') }}
</th>
<th>
{{ knp_pagination_sortable(pagination, 'Letzter Login', 'user.lastLoginAt') }}
</th>
<th></th>
</tr>
</thead>
<tbody>
{% for teamer in pagination %}
<tr>
<td>
<div class="flex flex-col items-center space-y-2">
{% if teamer.photo %}
<img src="{{ asset(teamer.photo | upload_filename_full | imagine_filter('thumbnail')) }}"
class="object-cover w-12 h-12 rounded-full"
alt="{{ teamer.firstName }}">
{% else %}
<div class="flex flex-col items-center justify-center w-12 h-12 border-2 border-gray-200 rounded-full">
{{ icon('user', 'w-10 h-10 text-gray-200') }}
</div>
{% endif %}
<twig:RatingStars rating="{{ teamer.averageRating }}" :teamer="teamer" />
</div>
</td>
<td>
<a href="{{ path('app_administrative_teamer_profile', { 'uuid': teamer.uuid, 'r': return_url() }) }}">
<span class="whitespace-nowrap">{{ teamer.lastName }}</span>
</a>
</td>
<td>
<a href="{{ path('app_administrative_teamer_profile', { 'uuid': teamer.uuid, 'r': return_url() }) }}" class="whitespace-nowrap">
{{ teamer.firstName }}
</a>
</td>
<td>
{{ teamer|teamer_status_label(showAllSeason|default(true)) }}
</td>
<td>
{{ teamer.communication.email }}
</td>
<td>
{% if teamer.user is not null and teamer.user.lastLoginAt is not null %}
{{ teamer.user.lastLoginAt | date('d.m.Y') }}
{% else %}
-
{% endif %}
</td>
<td>
<div class="flex items-center space-x-1 justify-end">
{% if teamer.driverLicenseVerified %}
{{ icon('car', 'w-5 h-5 text-green-500') }}
{% endif %}
{% if not teamer.viewed %}
<button type="button"
hx-post="{{ path('app_administrative_teamer_viewed', { 'uuid': teamer.uuid }) }}"
title="Teamer als gesichtet markieren">
{{ icon('check') }}
</button>
{% endif %}
{% if teamer.deleted %}
{% if is_granted('ROLE_TEAM_ADMIN') %}
<button type="button"
role="menuitem"
tabindex="-1"
hx-get="{{ path('app_admin_teamer_restore_account', { 'uuid': teamer.uuid }) }}"
hx-target="body"
hx-swap="beforeend"
title="Account wiederherstellen">
{{ icon('refresh') }}
</button>
{% endif %}
{% elseif teamer.user is not null and teamer.user.disabled %}
{% if is_granted('ROLE_TEAM_ADMIN') %}
<button type="button"
role="menuitem"
tabindex="-1"
hx-get="{{ path('app_admin_teamer_enable_user', { 'uuid': teamer.uuid }) }}"
hx-target="body"
hx-swap="beforeend">
{{ icon('unlocked') }}
</button>
{% endif %}
{% else %}
{% if is_granted('ROLE_TEAM_ADMIN') %}
<button type="button"
class="text-red-500"
role="menuitem"
tabindex="-1"
hx-get="{{ path('app_admin_teamer_disable_user', { 'uuid': teamer.uuid }) }}"
hx-target="body"
hx-swap="beforeend">
{{ icon('locked') }}
</button>
{% endif %}
{% if is_granted('CAN_IMPERSONATE', teamer.user) %}
<a href="{{ path('app_teamer_index', { '_switch_user': teamer.user.email }) }}" title="Als Teamer:in maskieren">
{{ icon('mask') }}
</a>
{% endif %}
{% if is_granted('ROLE_TEAM_ADMIN') %}
<button type="button"
class="text-red-500"
role="menuitem"
tabindex="-1"
hx-get="{{ path('app_admin_teamer_delete_account', { 'uuid': teamer.uuid }) }}"
hx-target="body"
hx-swap="beforeend"
title="Account löschen">
{{ icon('delete') }}
</button>
{% endif %}
{% endif %}
<a href="{{ path('app_administrative_teamer_profile', { 'uuid': teamer.uuid, 'r': return_url() }) }}" title="Teamer:innenprofil {{ teamer }}">
{{ icon('user') }}
</a>
</div>
{% if teamer.deleted %}
<div class="py-1 px-2 mt-2 text-xs bg-gray-500 text-white">
gel. am {{ teamer.deletedAt | date('d.m.Y') }}
</div>
{% endif %}
{% if teamer.user is not null and teamer.user.disabled %}
<div class="py-1 px-2 mt-2 text-xs bg-red-500 text-white">
gesp. am {{ teamer.user.disabledAt | date('d.m.Y') }}
</div>
{% endif %}
</td>
</tr>
{% else %}
<td colspan="7">
Keine Daten...
</td>
{% endfor %}
</tbody>
</table>
{{ knp_pagination_render(pagination) }}
</div>
</div>
+2 -188
View File
@@ -3,192 +3,6 @@
{% block title %}Teamübersicht{% endblock %}
{% block content %}
<div class="flex items-start justify-between pb-4">
<h1 class="text-2xl font-bold">
Teamübersicht
</h1>
<div class="flex flex-col items-end space-y-2 md:flex-row md:items-center md:space-x-2 md:space-y-0">
<button type="button"
class="{{ html_classes('btn btn--small', { 'btn--secondary': filterDto.active }) }}"
title="Team filtern"
hx-get="{{ path('app_common_teamer_filter', { 'r': return_url() }) }}"
hx-target="body"
hx-swap="beforeend">
{{ icon('filter', 'w-4 h-4 shrink-0') }}
{% if filterDto.active %}
<span class="whitespace-nowrap">{{ filterDto.activeFiltersCount }} Filter aktiv</span>
{% else %}
<span>Filter</span>
{% endif %}
</button>
{% if filterDto.active %}
<a href="{{ path('app_common_teamer_filter_reset', { 'r': return_url() }) }}" class="btn btn--small btn--secondary">
Reset
</a>
{% endif %}
{% if is_granted('ROLE_TEAM_ADMIN') %}
<a href="{{ path('app_admin_teamer_mailing', { 'r': return_url() }) }}"
class="btn btn--small"
title="Mail an die gefilterte Liste schreiben">
{{ icon('mail', 'w-4 h-4 shrink-0') }}
<span>Mailing</span>
</a>
{% endif %}
</div>
</div>
<div class="data-table-wrapper">
<div class="data-table-wrapper__inner">
<table class="data-table">
<thead>
<tr>
<th></th>
<th>
{{ knp_pagination_sortable(pagination, 'Name', 'teamer.lastName') }}
</th>
<th>
Vorname
</th>
<th>
{{ knp_pagination_sortable(pagination, 'Status', 'teamer.status') }}
</th>
<th>
{{ knp_pagination_sortable(pagination, 'E-Mail', 'teamer.communication.email') }}
</th>
<th>
{{ knp_pagination_sortable(pagination, 'Letzter Login', 'user.lastLoginAt') }}
</th>
<th></th>
</tr>
</thead>
<tbody>
{% for teamer in pagination %}
<tr>
<td>
<div class="flex flex-col items-center space-y-2">
{% if teamer.photo %}
<img src="{{ asset(teamer.photo | upload_filename_full | imagine_filter('profile')) }}"
class="w-12 h-auto rounded-full"
alt="{{ teamer.firstName }}">
{% else %}
<div class="flex flex-col items-center justify-center w-12 h-12 border-2 border-gray-200 rounded-full">
{{ icon('user', 'w-10 h-10 text-gray-200') }}
</div>
{% endif %}
<twig:RatingStars rating="{{ teamer.averageRating }}" :teamer="teamer" />
</div>
</td>
<td>
<a href="{{ path('app_administrative_teamer_profile', { 'uuid': teamer.uuid, 'r': return_url() }) }}">
<span class="whitespace-nowrap">{{ teamer.lastName }}</span>
</a>
</td>
<td>
<a href="{{ path('app_administrative_teamer_profile', { 'uuid': teamer.uuid, 'r': return_url() }) }}" class="whitespace-nowrap">
{{ teamer.firstName }}
</a>
</td>
<td>
{{ teamer|teamer_status_label }}
</td>
<td>
{{ teamer.communication.email }}
</td>
<td>
{% if teamer.user is not null and teamer.user.lastLoginAt is not null %}
{{ teamer.user.lastLoginAt | date('d.m.Y') }}
{% else %}
-
{% endif %}
</td>
<td>
<div class="flex items-center space-x-1 justify-end">
{% if teamer.driverLicenseVerified %}
{{ icon('car', 'w-5 h-5 text-green-500') }}
{% endif %}
{% if not teamer.viewed %}
<button type="button"
hx-post="{{ path('app_administrative_teamer_viewed', { 'uuid': teamer.uuid }) }}"
title="Teamer als gesichtet markieren">
{{ icon('check') }}
</button>
{% endif %}
{% if teamer.deleted %}
{% if is_granted('ROLE_TEAM_ADMIN') %}
<button type="button"
role="menuitem"
tabindex="-1"
hx-get="{{ path('app_admin_teamer_restore_account', { 'uuid': teamer.uuid }) }}"
hx-target="body"
hx-swap="beforeend"
title="Account wiederherstellen">
{{ icon('refresh') }}
</button>
{% endif %}
{% elseif teamer.user is not null and teamer.user.disabled %}
{% if is_granted('ROLE_TEAM_ADMIN') %}
<button type="button"
role="menuitem"
tabindex="-1"
hx-get="{{ path('app_admin_teamer_enable_user', { 'uuid': teamer.uuid }) }}"
hx-target="body"
hx-swap="beforeend">
{{ icon('unlocked') }}
</button>
{% endif %}
{% else %}
{% if is_granted('ROLE_TEAM_ADMIN') %}
<button type="button"
class="text-red-500"
role="menuitem"
tabindex="-1"
hx-get="{{ path('app_admin_teamer_disable_user', { 'uuid': teamer.uuid }) }}"
hx-target="body"
hx-swap="beforeend">
{{ icon('locked') }}
</button>
{% endif %}
{% if is_granted('CAN_IMPERSONATE', teamer.user) %}
<a href="{{ path('app_teamer_index', { '_switch_user': teamer.user.email }) }}" title="Als Teamer:in maskieren">
{{ icon('mask') }}
</a>
{% endif %}
{% if is_granted('ROLE_TEAM_ADMIN') %}
<button type="button"
class="text-red-500"
role="menuitem"
tabindex="-1"
hx-get="{{ path('app_admin_teamer_delete_account', { 'uuid': teamer.uuid }) }}"
hx-target="body"
hx-swap="beforeend"
title="Account löschen">
{{ icon('delete') }}
</button>
{% endif %}
{% endif %}
<a href="{{ path('app_administrative_teamer_profile', { 'uuid': teamer.uuid, 'r': return_url() }) }}" title="Teamer:innenprofil {{ teamer }}">
{{ icon('user') }}
</a>
</div>
{% if teamer.deleted %}
<div class="py-1 px-2 mt-2 text-xs bg-gray-500 text-white">
gel. am {{ teamer.deletedAt | date('d.m.Y') }}
</div>
{% endif %}
{% if teamer.user is not null and teamer.user.disabled %}
<div class="py-1 px-2 mt-2 text-xs bg-red-500 text-white">
gesp. am {{ teamer.user.disabledAt | date('d.m.Y') }}
</div>
{% endif %}
</td>
</tr>
{% else %}
<td colspan="7">
Keine Daten...
</td>
{% endfor %}
</tbody>
</table>
{{ knp_pagination_render(pagination) }}
</div>
</div>
{% include 'administrative/teamer/_header.html.twig' with { 'title': 'Teamübersicht' } %}
{% include 'administrative/teamer/_table.html.twig' %}
{% endblock %}
@@ -244,6 +244,31 @@
</div>
</div>
{% if teamer.allSeason %}
<div>
<h2 class="text-lg font-bold pb-2">
Saisoneinsätze
</h2>
<ul class="list-disc pl-4">
{% for seasonAssignment in teamer.seasonAssignments|filter(seasonAssignment => not seasonAssignment.deleted) %}
<li>
<a href="{{ path('app_administrative_season_assignment_detail', { 'uuid': seasonAssignment.uuid, 'r': return_url() }) }}">
{{ seasonAssignment.hotelCode | house_label }}
</a>
<span class="text-sm block">
{{ seasonAssignment.dateFrom | date('d.m.Y') }} - {{ seasonAssignment.dateTo | date('d.m.Y') }},
{{ seasonAssignment.jobProfile.name }}
</span>
</li>
{% else %}
<li>
<span class="text-sm">Keine Daten</span>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
</div>
</div>
@@ -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 %}
@@ -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'} }) }}
<div class="flex flex-col space-y-2 pb-2">
{{ form_row(filterForm.teamerName) }}
{{ form_row(filterForm.hotelCodes) }}
{{ form_row(filterForm.jobProfiles) }}
{{ form_row(filterForm.includeDeleted) }}
</div>
<div class="flex items-center space-x-2">
{{ form_widget(filterForm.apply, { 'attr': { 'class': 'btn' } }) }}
{{ form_widget(filterForm.reset, { 'attr': { 'class': 'btn btn--secondary' } }) }}
</div>
{{ form_rest(filterForm) }}
{{ form_end(filterForm) }}
{% endblock %}
@@ -0,0 +1,18 @@
{% extends 'email/layout.html.twig' %}
{% block body %}
<h1>
Fristen für Saison-Teamer:innen-Dokumente erreicht
</h1>
<p>
Für die folgenden Teamer:innen ist heute die Einreichungsfrist für Dokument erreicht:
</p>
{% for teamer, entries in violations %}
<p>
<strong>{{ teamer }}</strong><br>
{% for entry in entries %}
Frist {{ entry.level }}: {{ entry.label }}<br>
{% endfor %}
</p>
{% endfor %}
{% endblock %}
+1
View File
@@ -200,6 +200,7 @@
<input type="text"
class="{{ attr.class }}"
value="{{ form.vars.initial_label }}"
{% if form.vars.placeholder %}placeholder="{{ form.vars.placeholder }}"{% endif %}
{{ stimulus_target('autocomplete', 'input') }}>
<ul class="autocomplete-result-list"></ul>
<input type="hidden"
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more