feat: all-season assignments
This commit is contained in:
1 parent
675cec1b69
commit
cf876ed224
119 files changed
+6194
-294
No files matched your search
@@ -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"
|
||||
|
||||
|
||||
@@ -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'
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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()
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
],
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
|
||||
@@ -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],
|
||||
])
|
||||
;
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
])
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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',
|
||||
],
|
||||
])
|
||||
;
|
||||
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -2,10 +2,27 @@
|
||||
|
||||
namespace App\Menu;
|
||||
|
||||
use App\Entity\SeasonAssignment;
|
||||
use App\Entity\User;
|
||||
use App\Service\Season\SeasonAssignmentResolver;
|
||||
use Knp\Menu\FactoryInterface;
|
||||
use Knp\Menu\ItemInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class TeamerMenuBuilder extends AbstractMenuBuilder
|
||||
{
|
||||
public function __construct(
|
||||
FactoryInterface $factory,
|
||||
Security $security,
|
||||
RequestStack $requestStack,
|
||||
TranslatorInterface $translator,
|
||||
private readonly SeasonAssignmentResolver $seasonAssignmentResolver,
|
||||
) {
|
||||
parent::__construct($factory, $security, $requestStack, $translator);
|
||||
}
|
||||
|
||||
public function createMainMenu(array $options): ItemInterface
|
||||
{
|
||||
$menu = $this->createRootElement();
|
||||
@@ -36,6 +53,23 @@ class TeamerMenuBuilder extends AbstractMenuBuilder
|
||||
'icon' => 'profile',
|
||||
],
|
||||
]);
|
||||
|
||||
// Only for an all-season teamer who actually has an assignment this season: the page
|
||||
// would have nothing to show otherwise, and it throws a 404 in exactly that case.
|
||||
if (null !== $this->resolveSeasonAssignment()) {
|
||||
$profileMenu->addChild('Saison-Teamer:in', [
|
||||
'route' => 'app_teamer_season_index',
|
||||
'linkAttributes' => [
|
||||
'title' => 'Saison-Teamer:in',
|
||||
],
|
||||
'extras' => [
|
||||
'routes' => [
|
||||
['pattern' => '/^app_teamer_season_/'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$profileMenu->addChild('Stammdaten & Foto', [
|
||||
'route' => 'app_teamer_profile_index',
|
||||
'linkAttributes' => [
|
||||
@@ -120,4 +154,15 @@ class TeamerMenuBuilder extends AbstractMenuBuilder
|
||||
|
||||
return $menu;
|
||||
}
|
||||
|
||||
private function resolveSeasonAssignment(): ?SeasonAssignment
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
|
||||
if (false === $user instanceof User || null === $teamer = $user->getTeamer()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->seasonAssignmentResolver->resolveFor($teamer);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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
Reference in new issue
Block a user