Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8da9af46f5 | ||
|
|
ca4a73eb4f | ||
|
|
1a7c272414 | ||
|
|
7cf39850e6 | ||
|
|
4cab57d136 | ||
|
|
80c6016da3 | ||
|
|
69b8e549ee | ||
|
|
77986776c2 | ||
|
|
cf876ed224 |
No files matched your search
+4
-10
@@ -1,4 +1,4 @@
|
||||
type: php
|
||||
type: symfony
|
||||
docroot: public
|
||||
php_version: "8.4"
|
||||
webserver_type: apache-fpm
|
||||
@@ -9,15 +9,8 @@ database:
|
||||
type: mariadb
|
||||
version: "11.8"
|
||||
hooks:
|
||||
# The agent hooks in config.codex-claude.yaml are merged with these, not replaced by them.
|
||||
post-start:
|
||||
# ddev creates "db" alone, so the test database has to be created and granted here.
|
||||
# Without it the first test run after a fresh start fails with "Access denied for user
|
||||
# 'db'@'%' to database 'db_test'".
|
||||
- exec: mysql -uroot -proot -hdb -e "CREATE DATABASE IF NOT EXISTS db_test CHARACTER SET utf8mb4; GRANT ALL ON db_test.* TO 'db'@'%';"
|
||||
# Schema from the entity mapping, never from the migrations: the squashed history is not
|
||||
# replayable from an empty database (Version20260824124850 alters a table that
|
||||
# Version20260901090029 only creates later). No fixtures - a test persists its own rows.
|
||||
- exec: php bin/console doctrine:schema:update --force --env=test --no-interaction
|
||||
omit_containers: [ddev-ssh-agent]
|
||||
use_dns_when_possible: true
|
||||
@@ -27,8 +20,6 @@ web_environment: []
|
||||
nodejs_version: "18"
|
||||
corepack_enable: false
|
||||
disable_upload_dirs_warning: true
|
||||
xhgui_https_port: "8142"
|
||||
xhgui_http_port: "8143"
|
||||
|
||||
# Key features of DDEV's config.yaml:
|
||||
|
||||
@@ -317,3 +308,6 @@ xhgui_http_port: "8143"
|
||||
# information on the commands that can be extended and the tasks you can define
|
||||
# for them. Example:
|
||||
#hooks:
|
||||
## Un-comment to consume async message.
|
||||
# post-start:
|
||||
# - exec: symfony run --daemon --watch=config,src,templates,vendor bin/console messenger:consume async -vv
|
||||
@@ -70,6 +70,14 @@ APP_BPN_CRM_ID_TEAMER=1070
|
||||
APP_DEFAULT_EMAIL_FROM=[email protected]
|
||||
APP_DEFAULT_EMAIL_TO=[email protected]
|
||||
|
||||
# Regular expressions of the only recipients a mail may actually reach. Everything else is
|
||||
# stripped from the envelope and a mail left without a recipient is not sent at all, see
|
||||
# App\EventListener\EmailRecipientWhitelistSubscriber. Empty means no filtering, which is what
|
||||
# production runs with. Set this on staging: it runs the real transports against real teamer
|
||||
# addresses. The patterns have to match the full address.
|
||||
# Example: APP_MAIL_RECIPIENT_WHITELIST='.*@ep-reisen\.de,qa@example\.com'
|
||||
APP_MAIL_RECIPIENT_WHITELIST=
|
||||
|
||||
SFTP_XML_EXPORT_HOST=
|
||||
SFTP_XML_EXPORT_PORT=22
|
||||
SFTP_XML_EXPORT_USER=
|
||||
@@ -89,6 +97,12 @@ APPLICATION_FORM_API_KEY=
|
||||
# an invitation with a dead booking link is worse than none.
|
||||
APPLICATION_FORM_BOOKING_URL=
|
||||
|
||||
# SaaS time tracking. Season teamers clock their hours here; app:saas:work-hours imports them.
|
||||
# The API key travels in the URL path, so it never belongs in the committed file - set both
|
||||
# values in .env.local / the server environment.
|
||||
APP_SAAS_BASE_URL=
|
||||
APP_SAAS_API_KEY=
|
||||
|
||||
###> symfony/mailjet-mailer ###
|
||||
# Bulk teamer mailings only, everything else goes out over MAILER_DSN. Set the real
|
||||
# credentials in .env.local - the sending domain has to be verified in Mailjet (SPF,
|
||||
@@ -114,8 +128,7 @@ FEATURE_SANITIZE_UPLOADS=false
|
||||
FEATURE_STAMP_INVOICES=false
|
||||
FEATURE_DATEV_EMAIL=false
|
||||
FEATURE_MYEP_LOGIN=false
|
||||
|
||||
UPLOAD_REPLACEMENT_FILE=assets/pdf/chicken.pdf
|
||||
FEATURE_UPLOAD_REPLACEMENT=false
|
||||
|
||||
MAINTENANCE_MODE_ENABLED=false
|
||||
MAINTENANCE_MODE_IP_WHITELIST=188.245.62.21
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"doctrine/doctrine-bundle": "^2.15",
|
||||
"doctrine/doctrine-migrations-bundle": "^3.2",
|
||||
"doctrine/orm": "^3.6",
|
||||
"dompdf/dompdf": "^3.1",
|
||||
"flagception/flagception-bundle": "^6.0",
|
||||
"knplabs/knp-menu-bundle": "^3.2",
|
||||
"knplabs/knp-paginator-bundle": "^6.2",
|
||||
|
||||
Generated
+338
-107
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "e4ae4ad86c4c01e26d770c2ab3e0fda4",
|
||||
"content-hash": "3c22c1470cd1883d7c10f2e9800431e0",
|
||||
"packages": [
|
||||
{
|
||||
"name": "beberlei/doctrineextensions",
|
||||
@@ -1074,27 +1074,25 @@
|
||||
},
|
||||
{
|
||||
"name": "doctrine/lexer",
|
||||
"version": "3.0.1",
|
||||
"version": "3.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/doctrine/lexer.git",
|
||||
"reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd"
|
||||
"reference": "e96fe45e92a54233726014a7cc7340abf29bb14c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd",
|
||||
"reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd",
|
||||
"url": "https://api.github.com/repos/doctrine/lexer/zipball/e96fe45e92a54233726014a7cc7340abf29bb14c",
|
||||
"reference": "e96fe45e92a54233726014a7cc7340abf29bb14c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/coding-standard": "^12",
|
||||
"phpstan/phpstan": "^1.10",
|
||||
"phpunit/phpunit": "^10.5",
|
||||
"psalm/plugin-phpunit": "^0.18.3",
|
||||
"vimeo/psalm": "^5.21"
|
||||
"doctrine/coding-standard": "^14",
|
||||
"phpstan/phpstan": "^2",
|
||||
"phpunit/phpunit": "^10.5.58 || ^12.5.4"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
@@ -1131,7 +1129,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/doctrine/lexer/issues",
|
||||
"source": "https://github.com/doctrine/lexer/tree/3.0.1"
|
||||
"source": "https://github.com/doctrine/lexer/tree/3.0.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -1147,7 +1145,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-02-05T11:56:58+00:00"
|
||||
"time": "2026-06-14T20:44:06+00:00"
|
||||
},
|
||||
{
|
||||
"name": "doctrine/migrations",
|
||||
@@ -1254,16 +1252,16 @@
|
||||
},
|
||||
{
|
||||
"name": "doctrine/orm",
|
||||
"version": "3.7.1",
|
||||
"version": "3.7.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/doctrine/orm.git",
|
||||
"reference": "037807a39697890dcede8b766c5efe8a1b4855fe"
|
||||
"reference": "6b47a02cd1d7119ef4d8f90ca6d49651accef1a3"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/doctrine/orm/zipball/037807a39697890dcede8b766c5efe8a1b4855fe",
|
||||
"reference": "037807a39697890dcede8b766c5efe8a1b4855fe",
|
||||
"url": "https://api.github.com/repos/doctrine/orm/zipball/6b47a02cd1d7119ef4d8f90ca6d49651accef1a3",
|
||||
"reference": "6b47a02cd1d7119ef4d8f90ca6d49651accef1a3",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -1287,7 +1285,7 @@
|
||||
"doctrine/coding-standard": "^14.0",
|
||||
"phpbench/phpbench": "^1.0",
|
||||
"phpstan/extension-installer": "^1.4",
|
||||
"phpstan/phpstan": "2.1.23",
|
||||
"phpstan/phpstan": "2.2.14",
|
||||
"phpstan/phpstan-deprecation-rules": "^2",
|
||||
"phpunit/phpunit": "^10.5.0 || ^11.5",
|
||||
"psr/log": "^1 || ^2 || ^3",
|
||||
@@ -1338,9 +1336,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/doctrine/orm/issues",
|
||||
"source": "https://github.com/doctrine/orm/tree/3.7.1"
|
||||
"source": "https://github.com/doctrine/orm/tree/3.7.2"
|
||||
},
|
||||
"time": "2026-09-14T05:45:59+00:00"
|
||||
"time": "2026-09-23T05:28:00+00:00"
|
||||
},
|
||||
{
|
||||
"name": "doctrine/persistence",
|
||||
@@ -1491,6 +1489,161 @@
|
||||
},
|
||||
"time": "2026-02-08T16:21:46+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dompdf/dompdf",
|
||||
"version": "v3.1.6",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dompdf/dompdf.git",
|
||||
"reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/6d4b4eb8500f7a786da8868ba463a71b725a4005",
|
||||
"reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"dompdf/php-font-lib": "^1.0.0",
|
||||
"dompdf/php-svg-lib": "^1.0.0",
|
||||
"ext-dom": "*",
|
||||
"ext-mbstring": "*",
|
||||
"masterminds/html5": "^2.0",
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"ext-gd": "*",
|
||||
"ext-json": "*",
|
||||
"ext-zip": "*",
|
||||
"mockery/mockery": "^1.3",
|
||||
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11",
|
||||
"squizlabs/php_codesniffer": "^3.5",
|
||||
"symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-gd": "Needed to process images",
|
||||
"ext-gmagick": "Improves image processing performance",
|
||||
"ext-imagick": "Improves image processing performance",
|
||||
"ext-zlib": "Needed for pdf stream compression"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Dompdf\\": "src/"
|
||||
},
|
||||
"classmap": [
|
||||
"lib/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"LGPL-2.1"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "The Dompdf Community",
|
||||
"homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md"
|
||||
}
|
||||
],
|
||||
"description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter",
|
||||
"homepage": "https://github.com/dompdf/dompdf",
|
||||
"support": {
|
||||
"issues": "https://github.com/dompdf/dompdf/issues",
|
||||
"source": "https://github.com/dompdf/dompdf/tree/v3.1.6"
|
||||
},
|
||||
"time": "2026-07-20T12:29:38+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dompdf/php-font-lib",
|
||||
"version": "1.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dompdf/php-font-lib.git",
|
||||
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a",
|
||||
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"FontLib\\": "src/FontLib"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"LGPL-2.1-or-later"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "The FontLib Community",
|
||||
"homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md"
|
||||
}
|
||||
],
|
||||
"description": "A library to read, parse, export and make subsets of different types of font files.",
|
||||
"homepage": "https://github.com/dompdf/php-font-lib",
|
||||
"support": {
|
||||
"issues": "https://github.com/dompdf/php-font-lib/issues",
|
||||
"source": "https://github.com/dompdf/php-font-lib/tree/1.0.2"
|
||||
},
|
||||
"time": "2026-01-20T14:10:26+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dompdf/php-svg-lib",
|
||||
"version": "1.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dompdf/php-svg-lib.git",
|
||||
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1",
|
||||
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"php": "^7.1 || ^8.0",
|
||||
"sabberworm/php-css-parser": "^8.4 || ^9.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Svg\\": "src/Svg"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"LGPL-3.0-or-later"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "The SvgLib Community",
|
||||
"homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md"
|
||||
}
|
||||
],
|
||||
"description": "A library to read, parse and export to PDF SVG files.",
|
||||
"homepage": "https://github.com/dompdf/php-svg-lib",
|
||||
"support": {
|
||||
"issues": "https://github.com/dompdf/php-svg-lib/issues",
|
||||
"source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2"
|
||||
},
|
||||
"time": "2026-01-02T16:01:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dragonmantank/cron-expression",
|
||||
"version": "v3.6.0",
|
||||
@@ -2454,16 +2607,16 @@
|
||||
},
|
||||
{
|
||||
"name": "league/commonmark",
|
||||
"version": "2.10.1",
|
||||
"version": "2.10.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/commonmark.git",
|
||||
"reference": "9d489ab67a02960fd8ffe624d93f751daf95439e"
|
||||
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/9d489ab67a02960fd8ffe624d93f751daf95439e",
|
||||
"reference": "9d489ab67a02960fd8ffe624d93f751daf95439e",
|
||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6efbd9c472b91db0a3350fcd601c8332c2382e1f",
|
||||
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2557,7 +2710,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-09-07T13:44:26+00:00"
|
||||
"time": "2026-09-21T13:07:34+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/config",
|
||||
@@ -3307,6 +3460,73 @@
|
||||
},
|
||||
"time": "2022-12-02T22:17:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "masterminds/html5",
|
||||
"version": "2.11.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Masterminds/html5-php.git",
|
||||
"reference": "a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7",
|
||||
"reference": "a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-dom": "*",
|
||||
"php": ">=7.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^6 || ^7 || ^8 || ^9 || ^10"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.7-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Masterminds\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Matt Butcher",
|
||||
"email": "[email protected]"
|
||||
},
|
||||
{
|
||||
"name": "Matt Farina",
|
||||
"email": "[email protected]"
|
||||
},
|
||||
{
|
||||
"name": "Asmir Mustafic",
|
||||
"email": "[email protected]"
|
||||
}
|
||||
],
|
||||
"description": "An HTML5 parser and serializer.",
|
||||
"homepage": "http://masterminds.github.io/html5-php",
|
||||
"keywords": [
|
||||
"HTML5",
|
||||
"dom",
|
||||
"html",
|
||||
"parser",
|
||||
"querypath",
|
||||
"serializer",
|
||||
"xml"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Masterminds/html5-php/issues",
|
||||
"source": "https://github.com/Masterminds/html5-php/tree/2.11.0"
|
||||
},
|
||||
"time": "2026-08-18T06:18:41+00:00"
|
||||
},
|
||||
{
|
||||
"name": "monolog/monolog",
|
||||
"version": "3.12.0",
|
||||
@@ -4920,6 +5140,84 @@
|
||||
},
|
||||
"time": "2021-10-29T13:26:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sabberworm/php-css-parser",
|
||||
"version": "v9.5.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
|
||||
"reference": "f284e63b6e891e0c28631e54ba06c3ed102a9ef3"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/f284e63b6e891e0c28631e54ba06c3ed102a9ef3",
|
||||
"reference": "f284e63b6e891e0c28631e54ba06c3ed102a9ef3",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-iconv": "*",
|
||||
"php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"php-parallel-lint/php-parallel-lint": "1.4.0",
|
||||
"phpstan/extension-installer": "1.4.3",
|
||||
"phpstan/phpstan": "1.12.33 || 2.2.9",
|
||||
"phpstan/phpstan-phpunit": "1.4.2 || 2.0.18",
|
||||
"phpstan/phpstan-strict-rules": "1.6.2 || 2.0.12",
|
||||
"phpunit/phpunit": "8.5.54",
|
||||
"rawr/phpunit-data-provider": "3.3.1",
|
||||
"rector/rector": "1.2.10 || 2.6.2",
|
||||
"rector/type-perfect": "1.0.0 || 2.1.4",
|
||||
"squizlabs/php_codesniffer": "4.0.4"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-mbstring": "for parsing UTF-8 CSS"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-main": "9.6.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/Rule/Rule.php",
|
||||
"src/RuleSet/RuleContainer.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Sabberworm\\CSS\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Raphael Schweikert"
|
||||
},
|
||||
{
|
||||
"name": "Oliver Klee",
|
||||
"email": "[email protected]"
|
||||
},
|
||||
{
|
||||
"name": "Jake Hotson",
|
||||
"email": "[email protected]"
|
||||
}
|
||||
],
|
||||
"description": "Parser for CSS Files written in PHP",
|
||||
"homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
|
||||
"keywords": [
|
||||
"css",
|
||||
"parser",
|
||||
"stylesheet"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
|
||||
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.5.0"
|
||||
},
|
||||
"time": "2026-09-20T15:02:00+00:00"
|
||||
},
|
||||
{
|
||||
"name": "scienta/doctrine-json-functions",
|
||||
"version": "6.5.0",
|
||||
@@ -12708,16 +13006,16 @@
|
||||
},
|
||||
{
|
||||
"name": "friendsofphp/php-cs-fixer",
|
||||
"version": "v3.95.26",
|
||||
"version": "v3.95.27",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
|
||||
"reference": "11839dfaf25718e1617c522c3dde01ebf96c875f"
|
||||
"reference": "1d0c68ee5f37446d8ad5970b82384cd1bb0a3317"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/11839dfaf25718e1617c522c3dde01ebf96c875f",
|
||||
"reference": "11839dfaf25718e1617c522c3dde01ebf96c875f",
|
||||
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/1d0c68ee5f37446d8ad5970b82384cd1bb0a3317",
|
||||
"reference": "1d0c68ee5f37446d8ad5970b82384cd1bb0a3317",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -12800,7 +13098,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues",
|
||||
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.26"
|
||||
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.27"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -12808,7 +13106,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-09-19T19:04:32+00:00"
|
||||
"time": "2026-09-22T09:54:55+00:00"
|
||||
},
|
||||
{
|
||||
"name": "marcocesarato/php-conventional-changelog",
|
||||
@@ -12888,73 +13186,6 @@
|
||||
},
|
||||
"time": "2026-09-06T14:25:24+00:00"
|
||||
},
|
||||
{
|
||||
"name": "masterminds/html5",
|
||||
"version": "2.11.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Masterminds/html5-php.git",
|
||||
"reference": "a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7",
|
||||
"reference": "a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-dom": "*",
|
||||
"php": ">=7.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^6 || ^7 || ^8 || ^9 || ^10"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.7-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Masterminds\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Matt Butcher",
|
||||
"email": "[email protected]"
|
||||
},
|
||||
{
|
||||
"name": "Matt Farina",
|
||||
"email": "[email protected]"
|
||||
},
|
||||
{
|
||||
"name": "Asmir Mustafic",
|
||||
"email": "[email protected]"
|
||||
}
|
||||
],
|
||||
"description": "An HTML5 parser and serializer.",
|
||||
"homepage": "http://masterminds.github.io/html5-php",
|
||||
"keywords": [
|
||||
"HTML5",
|
||||
"dom",
|
||||
"html",
|
||||
"parser",
|
||||
"querypath",
|
||||
"serializer",
|
||||
"xml"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Masterminds/html5-php/issues",
|
||||
"source": "https://github.com/Masterminds/html5-php/tree/2.11.0"
|
||||
},
|
||||
"time": "2026-08-18T06:18:41+00:00"
|
||||
},
|
||||
{
|
||||
"name": "myclabs/deep-copy",
|
||||
"version": "1.14.0",
|
||||
@@ -13192,11 +13423,11 @@
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpstan",
|
||||
"version": "2.2.14",
|
||||
"version": "2.2.15",
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac",
|
||||
"reference": "9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac",
|
||||
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/b158556ffd26825cf615a1c1f72fb5157a2301b7",
|
||||
"reference": "b158556ffd26825cf615a1c1f72fb5157a2301b7",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -13252,7 +13483,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-09-12T21:39:33+00:00"
|
||||
"time": "2026-09-23T12:23:07+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpunit/php-code-coverage",
|
||||
@@ -13575,16 +13806,16 @@
|
||||
},
|
||||
{
|
||||
"name": "phpunit/phpunit",
|
||||
"version": "9.6.36",
|
||||
"version": "9.6.37",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
||||
"reference": "abab27ed286d3e1246fbbfe6b56bfd732d945ec9"
|
||||
"reference": "aceaa623912caf8e80bffea22fc4103a0a01565f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/abab27ed286d3e1246fbbfe6b56bfd732d945ec9",
|
||||
"reference": "abab27ed286d3e1246fbbfe6b56bfd732d945ec9",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/aceaa623912caf8e80bffea22fc4103a0a01565f",
|
||||
"reference": "aceaa623912caf8e80bffea22fc4103a0a01565f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -13658,7 +13889,7 @@
|
||||
"support": {
|
||||
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
||||
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
|
||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.36"
|
||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.37"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -13666,7 +13897,7 @@
|
||||
"type": "other"
|
||||
}
|
||||
],
|
||||
"time": "2026-08-11T06:25:15+00:00"
|
||||
"time": "2026-09-23T09:59:59+00:00"
|
||||
},
|
||||
{
|
||||
"name": "react/cache",
|
||||
|
||||
@@ -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']
|
||||
@@ -214,6 +228,32 @@ texts:
|
||||
|
||||
# Descriptions shown next to the form fields. They describe the placeholder itself rather
|
||||
# than any one mail, so they are shared: a name means the same thing wherever it appears.
|
||||
# Daily, for as long as a work month has no signed Arbeitszeitnachweis on it. The month
|
||||
# is named rather than a deadline: there is none, the office simply wants it back.
|
||||
reminder_work_month_upload:
|
||||
label: 'Erinnerung: Arbeitszeitnachweis hochladen'
|
||||
placeholders: ['workMonth']
|
||||
subject: 'Reminder: Fehlender Arbeitszeitnachweis'
|
||||
headline: 'Hallo aus Köln,'
|
||||
body: |-
|
||||
bitte denke daran, deinen unterschriebenen Arbeitszeitnachweis hochzuladen. Ohne ihn können wir den Monat nicht abrechnen.
|
||||
|
||||
**{workMonth}**
|
||||
|
||||
Du findest ihn im Portal unter "Mein Profil - Arbeitszeiten": dort herunterladen, unterschreiben und wieder hochladen.
|
||||
|
||||
work_month_rejected:
|
||||
label: 'Arbeitszeitnachweis abgelehnt'
|
||||
placeholders: ['workMonth', 'comment']
|
||||
subject: 'Dein Arbeitszeitnachweis wurde abgelehnt'
|
||||
headline: 'Hallo aus Köln,'
|
||||
body: |-
|
||||
leider können wir deinen Arbeitszeitnachweis für **{workMonth}** so nicht annehmen.
|
||||
|
||||
Begründung: {comment}
|
||||
|
||||
Bitte lade ihn im Portal unter "Mein Profil - Arbeitszeiten" noch einmal hoch.
|
||||
|
||||
placeholders:
|
||||
bookingUrl: 'Buchungslink für den Team-Infocall, kommt aus der Konfiguration (APPLICATION_FORM_BOOKING_URL)'
|
||||
season: 'Kommende Saison in Kurzform, z. B. "26/27" - wird automatisch eingesetzt'
|
||||
@@ -232,3 +272,5 @@ placeholders:
|
||||
dateFrom: 'Beginn des Einsatzes'
|
||||
dateTo: 'Ende des Einsatzes'
|
||||
diffInDays: 'Tage bis zum Beginn des Einsatzes'
|
||||
tasks: 'Noch offene Punkte der Saison-Checkliste, kommagetrennt'
|
||||
workMonth: 'Monat der Arbeitszeiten, z. B. "März 2026"'
|
||||
@@ -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:
|
||||
|
||||
@@ -13,3 +13,6 @@ flagception:
|
||||
myep_login:
|
||||
default: false
|
||||
env: FEATURE_MYEP_LOGIN
|
||||
upload_replacement:
|
||||
default: false
|
||||
env: FEATURE_UPLOAD_REPLACEMENT
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,7 @@ monolog:
|
||||
channels:
|
||||
- deprecation
|
||||
- bpn
|
||||
- saas
|
||||
- audit
|
||||
- api
|
||||
|
||||
@@ -12,7 +13,7 @@ monolog:
|
||||
handler: exceptions
|
||||
excluded_http_codes: [404, 405]
|
||||
buffer_size: 50
|
||||
channels: ["!bpn", "!audit", "!api"]
|
||||
channels: ["!bpn", "!saas", "!audit", "!api"]
|
||||
exceptions:
|
||||
type: rotating_file
|
||||
path: "%kernel.logs_dir%/framework.%kernel.environment%.log"
|
||||
@@ -24,6 +25,12 @@ monolog:
|
||||
path: '%kernel.logs_dir%/bpn.%kernel.environment%.log'
|
||||
max_files: 5
|
||||
level: info
|
||||
saas:
|
||||
channels: ["saas"]
|
||||
type: rotating_file
|
||||
path: '%kernel.logs_dir%/saas.%kernel.environment%.log'
|
||||
max_files: 5
|
||||
level: info
|
||||
audit:
|
||||
channels: [ "audit" ]
|
||||
type: rotating_file
|
||||
@@ -45,7 +52,7 @@ monolog:
|
||||
console:
|
||||
type: console
|
||||
process_psr_3_messages: false
|
||||
channels: ["!event", "!bpn", "!api", "!doctrine", "!console"]
|
||||
channels: ["!event", "!bpn", "!saas", "!api", "!doctrine", "!console"]
|
||||
|
||||
when@dev:
|
||||
monolog:
|
||||
@@ -82,7 +89,7 @@ when@prod:
|
||||
handler: exceptions
|
||||
excluded_http_codes: [404, 405]
|
||||
buffer_size: 50
|
||||
channels: ["!bpn", "!audit", "!api"]
|
||||
channels: ["!bpn", "!saas", "!audit", "!api"]
|
||||
exceptions:
|
||||
type: rotating_file
|
||||
path: "%kernel.logs_dir%/framework.%kernel.environment%.log"
|
||||
@@ -94,6 +101,12 @@ when@prod:
|
||||
path: '%kernel.logs_dir%/bpn.%kernel.environment%.log'
|
||||
max_files: 5
|
||||
level: info
|
||||
saas:
|
||||
channels: ["saas"]
|
||||
type: rotating_file
|
||||
path: '%kernel.logs_dir%/saas.%kernel.environment%.log'
|
||||
max_files: 5
|
||||
level: info
|
||||
audit:
|
||||
channels: [ "audit" ]
|
||||
type: rotating_file
|
||||
@@ -113,4 +126,4 @@ when@prod:
|
||||
console:
|
||||
type: console
|
||||
process_psr_3_messages: false
|
||||
channels: ["!event", "!doctrine", "!bpn", "!audit", "!api"]
|
||||
channels: ["!event", "!doctrine", "!bpn", "!saas", "!audit", "!api"]
|
||||
@@ -45,6 +45,29 @@ 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'
|
||||
# Signed Arbeitszeitnachweise coming back from a teamer. Same rule again: the key and
|
||||
# the directory are exactly Upload::TYPE_WORK_MONTH.
|
||||
work_month:
|
||||
frontend: dropzone
|
||||
use_orphanage: true
|
||||
namer: app.upload_namer
|
||||
storage:
|
||||
directory: '%kernel.project_dir%/uploads/work_month'
|
||||
chunks:
|
||||
maxage: 86400
|
||||
storage:
|
||||
|
||||
@@ -27,6 +27,10 @@ when@prod:
|
||||
frequency: '30 1 * * *'
|
||||
description: "Updates status of teamers depending on their disposition"
|
||||
|
||||
- task: app:saas:work-hours
|
||||
frequency: '0 4 10 * *'
|
||||
description: "Imports last month's working hours of season teamers from SaaS"
|
||||
|
||||
- task: oneup:uploader:clear-orphans
|
||||
frequency: '30 * * * *'
|
||||
description: "Removes orphaned uploads"
|
||||
@@ -53,6 +57,10 @@ when@staging:
|
||||
frequency: '30 1 * * *'
|
||||
description: "Updates status of teamers depending on their disposition"
|
||||
|
||||
- task: app:saas:work-hours
|
||||
frequency: '0 4 10 * *'
|
||||
description: "Imports last month's working hours of season teamers from SaaS"
|
||||
|
||||
- task: oneup:uploader:clear-orphans
|
||||
frequency: '30 * * * *'
|
||||
description: "Removes orphaned uploads"
|
||||
|
||||
+54
-2
@@ -48,8 +48,19 @@ parameters:
|
||||
default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%'
|
||||
default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%'
|
||||
|
||||
# Per deployment and not per environment: staging and production run the same compiled
|
||||
# environment and differ only in their .env.local. The csv processor turns an unset
|
||||
# variable into an empty list, which switches the filtering off - so production is
|
||||
# unaffected without anything having to be remembered at deploy time.
|
||||
mail_recipient_whitelist: '%env(csv:APP_MAIL_RECIPIENT_WHITELIST)%'
|
||||
|
||||
teamer_inactive_period: '-2 years'
|
||||
|
||||
# The stand-in PDF served instead of real uploads while the upload_replacement feature is
|
||||
# on. Relative to the project dir. Which file is a fixed fact of the repo; whether it is
|
||||
# used at all is the feature flag's job alone.
|
||||
upload_replacement_file: 'assets/pdf/chicken.pdf'
|
||||
|
||||
# The contract upload period, counted from the creation of the disposition. This is the
|
||||
# single definition: the reminder mail, the "Honorarvertrag fällig" flag on the teamer
|
||||
# dashboard, the admin list of overdue contracts and the wording of the mails all read it,
|
||||
@@ -79,6 +90,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 +141,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
|
||||
@@ -185,6 +203,20 @@ services:
|
||||
bpn_crm_house_manager_ids: '%bpn_crm_house_manager_ids%'
|
||||
bpn_default_hotel_code: '%bpn_default_hotel_code%'
|
||||
|
||||
App\Saas\ApiClient:
|
||||
arguments:
|
||||
$logger: '@monolog.logger.saas'
|
||||
$options:
|
||||
base_url: '%env(APP_SAAS_BASE_URL)%'
|
||||
api_key: '%env(APP_SAAS_API_KEY)%'
|
||||
|
||||
App\Saas\DataProvider\EmployeeDataProvider:
|
||||
arguments:
|
||||
$logger: '@monolog.logger.saas'
|
||||
# The roster is slow and barely changes; six hours means one fetch per run and a
|
||||
# new starter is picked up the same day.
|
||||
$ttl: 21600
|
||||
|
||||
App\Twig\AppRuntime:
|
||||
arguments:
|
||||
$intlExtension: '@twig.extension.intl'
|
||||
@@ -196,6 +228,15 @@ services:
|
||||
tags:
|
||||
- name: monolog.processor
|
||||
|
||||
# Every SaaS url carries the api key as a path segment, so the request lines Symfony's
|
||||
# http client writes - and the exception messages that repeat those urls - are redacted
|
||||
# on the way into the log rather than suppressed.
|
||||
App\Logging\RedactSaasCredentialsProcessor:
|
||||
arguments:
|
||||
$apiKey: '%env(APP_SAAS_API_KEY)%'
|
||||
tags:
|
||||
- name: monolog.processor
|
||||
|
||||
App\Logging\RequestInfoProcessor:
|
||||
arguments:
|
||||
- '@request_stack'
|
||||
@@ -255,8 +296,7 @@ services:
|
||||
arguments:
|
||||
$orphanageManager: '@oneup_uploader.orphanage_manager'
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
$environment: '%kernel.environment%'
|
||||
$uploadReplacementFile: '%env(default::UPLOAD_REPLACEMENT_FILE)%'
|
||||
$uploadReplacementFile: '%upload_replacement_file%'
|
||||
|
||||
App\Service\Pdf\Sanitizer:
|
||||
arguments:
|
||||
@@ -329,6 +369,11 @@ services:
|
||||
$options:
|
||||
project_dir: '%kernel.project_dir%'
|
||||
|
||||
App\Service\Pdf\WorkMonthRenderer:
|
||||
arguments:
|
||||
$options:
|
||||
project_dir: '%kernel.project_dir%'
|
||||
|
||||
App\Email\Mailer:
|
||||
arguments:
|
||||
$defaults:
|
||||
@@ -351,6 +396,13 @@ services:
|
||||
$logger: '@monolog.logger.api'
|
||||
tags: [ 'controller.service_arguments' ]
|
||||
|
||||
App\EventListener\EmailRecipientWhitelistSubscriber:
|
||||
arguments:
|
||||
$allowedRecipients: '%mail_recipient_whitelist%'
|
||||
# Not the audit channel this argument is bound to by default: a mail dropped by a
|
||||
# staging guard is a technical event and has no place in the application log table.
|
||||
$logger: '@logger'
|
||||
|
||||
App\Security\OAuth2\MyEpClient:
|
||||
arguments:
|
||||
$options:
|
||||
|
||||
@@ -46,6 +46,7 @@ $rsyncOptions = [
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
'*.http',
|
||||
'http-client*.json',
|
||||
'postcss.config.js',
|
||||
'phpunit.xml.dist',
|
||||
'public/.htaccess',
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"prod": {
|
||||
"saas_base_url": "https://desktop.saas.de/rest"
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Drops the season assignment's done_teamer flag.
|
||||
*
|
||||
* It was a note the office set by hand and it gated nothing - neither the weekly teamer digest
|
||||
* nor the office deadline summary ever read it. Both stages it was meant to express follow from
|
||||
* the checklist rows, so App\Service\Season\SeasonChecklistBuilder derives them instead.
|
||||
*
|
||||
* Written by hand rather than taken from doctrine:migrations:diff, like Version20260916160000:
|
||||
* the diff against this database also carries the pre-existing drift in datetime and JSON column
|
||||
* declarations, which has nothing to do with this change.
|
||||
*/
|
||||
final class Version20260917120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Drop the informative done_teamer flag from season assignments';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE season_assignment DROP done_teamer');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// The hand-set notes themselves cannot be recovered, so the column comes back unset.
|
||||
$this->addSql('ALTER TABLE season_assignment ADD done_teamer TINYINT(1) NOT NULL');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Renames the checklist entry's uploaded flag to office_done.
|
||||
*
|
||||
* The flag is the office's own step on an entry, and that step differs per entry - filing a
|
||||
* returned contract is not the same act as registering someone with the municipality. Named
|
||||
* after a file arriving, it read as a second kind of "done" everywhere else, next to the done
|
||||
* flag that actually closes the entry.
|
||||
*
|
||||
* A rename, not a new column: the ticks themselves stay.
|
||||
*/
|
||||
final class Version20260917123000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Rename season_assignment_task.uploaded to office_done';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE season_assignment_task CHANGE uploaded office_done TINYINT(1) NOT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE season_assignment_task CHANGE office_done uploaded TINYINT(1) NOT NULL');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Copies the library document onto the checklist row.
|
||||
*
|
||||
* The library holds what the next assignments get and is replaced from season to season, so
|
||||
* resolving it on read rewrote what an assignment that already exists - a finished one included -
|
||||
* hands out. The row carries its own reference from now on.
|
||||
*
|
||||
* The backfill maps every existing row to what it resolves to today, so nothing changes for the
|
||||
* rows that are already out there. ON DELETE SET NULL keeps a row readable when the library file
|
||||
* itself is deleted: "kein Dokument hinterlegt" is a state every view already handles.
|
||||
*/
|
||||
final class Version20260917140000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Give each season checklist row its own document reference';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE season_assignment_task ADD document_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE season_assignment_task ADD CONSTRAINT FK_BEEB9D8DC33F7837 FOREIGN KEY (document_id) REFERENCES upload (id) ON DELETE SET NULL');
|
||||
$this->addSql('CREATE INDEX IDX_BEEB9D8DC33F7837 ON season_assignment_task (document_id)');
|
||||
|
||||
$this->addSql('UPDATE season_assignment_task t JOIN season_task_document d ON d.task_key = t.task_key SET t.document_id = d.document_id');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE season_assignment_task DROP FOREIGN KEY FK_BEEB9D8DC33F7837');
|
||||
$this->addSql('DROP INDEX IDX_BEEB9D8DC33F7837 ON season_assignment_task');
|
||||
$this->addSql('ALTER TABLE season_assignment_task DROP document_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Work months imported from the SaaS time tracking, and the signed Arbeitszeitnachweis that
|
||||
* belongs to each of them.
|
||||
*
|
||||
* Hand-trimmed: doctrine:migrations:diff also produced a long tail of datetime, JSON and
|
||||
* TINYINT redeclarations across almost every table. That is the long-standing drift between
|
||||
* the dev database and the mapping, not anything this feature changed, so none of it is here.
|
||||
*/
|
||||
final class Version20260921122743 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Adds teamer_work_month, the SaaS employee id on teamer and the work month relation on upload';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE teamer_work_month (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, year SMALLINT NOT NULL, month SMALLINT NOT NULL, days JSON NOT NULL, total_minutes INT NOT NULL, worked_days INT NOT NULL, absence_days INT NOT NULL, source_ist_minutes INT NOT NULL, status VARCHAR(255) NOT NULL, submitted_at DATETIME DEFAULT NULL, reviewed_at DATETIME DEFAULT NULL, reviewed_by VARCHAR(8) DEFAULT NULL, review_comment LONGTEXT DEFAULT NULL, synced_at DATETIME NOT NULL, warnings JSON DEFAULT NULL, mismatch_detected_at DATETIME DEFAULT NULL, mismatch_total_minutes INT DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, teamer_id INT NOT NULL, UNIQUE INDEX UNIQ_28857572D17F50A6 (uuid), UNIQUE INDEX teamer_work_month_unique (teamer_id, year, month), INDEX IDX_288575724302FF75 (teamer_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||
$this->addSql('ALTER TABLE teamer_work_month ADD CONSTRAINT FK_288575724302FF75 FOREIGN KEY (teamer_id) REFERENCES teamer (id)');
|
||||
|
||||
$this->addSql('ALTER TABLE teamer ADD saas_employee_uuid VARCHAR(36) DEFAULT NULL');
|
||||
|
||||
$this->addSql('ALTER TABLE upload ADD teamer_work_month_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE upload ADD CONSTRAINT FK_17BDE61FFEAE833A FOREIGN KEY (teamer_work_month_id) REFERENCES teamer_work_month (id)');
|
||||
$this->addSql('CREATE INDEX IDX_17BDE61FFEAE833A ON upload (teamer_work_month_id)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE upload DROP FOREIGN KEY FK_17BDE61FFEAE833A');
|
||||
$this->addSql('DROP INDEX IDX_17BDE61FFEAE833A ON upload');
|
||||
$this->addSql('ALTER TABLE upload DROP teamer_work_month_id');
|
||||
|
||||
$this->addSql('ALTER TABLE teamer DROP saas_employee_uuid');
|
||||
|
||||
$this->addSql('ALTER TABLE teamer_work_month DROP FOREIGN KEY FK_288575724302FF75');
|
||||
$this->addSql('DROP TABLE teamer_work_month');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* The monthly target and the month's total break time, both shown in the head of the
|
||||
* Arbeitszeitnachweis next to the hours actually worked.
|
||||
*
|
||||
* Hand-trimmed, like Version20260921122743: doctrine:migrations:diff sweeps in the
|
||||
* long-standing datetime and JSON drift of the dev database, none of which belongs here.
|
||||
*
|
||||
* The per-day start, end and break times that go with these live in the existing "days" JSON
|
||||
* column, so they need no schema change. Rows imported before this migration simply carry
|
||||
* neither, and are refreshed by the next import of a month that is not yet signed.
|
||||
*/
|
||||
final class Version20260921160000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Adds the monthly target and total break time to teamer_work_month';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE teamer_work_month ADD target_minutes INT DEFAULT 0 NOT NULL, ADD break_minutes INT DEFAULT 0 NOT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE teamer_work_month DROP target_minutes, DROP break_minutes');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* The employee's department, shown in the head of the Arbeitszeitnachweis.
|
||||
*
|
||||
* Hand-trimmed like its two predecessors: doctrine:migrations:diff sweeps in the long-standing
|
||||
* datetime and JSON drift of the dev database, none of which belongs here.
|
||||
*
|
||||
* Nullable, and not only because existing rows have none: a month made up entirely of holiday
|
||||
* carries no booking to read a department from.
|
||||
*/
|
||||
final class Version20260921170000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Adds the department to teamer_work_month';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE teamer_work_month ADD department VARCHAR(255) DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE teamer_work_month DROP department');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
### GET all employees
|
||||
GET {{saas_base_url}}/api/v2/employees/{{saas_api_key}}
|
||||
|
||||
### GET all departments
|
||||
GET {{saas_base_url}}/api/v2/employee/departments/{{saas_api_key}}
|
||||
|
||||
### GET single time account
|
||||
GET {{saas_base_url}}/api/v2/time/account/{{saas_api_key}}/{{saas_employee_email}}/2026/2
|
||||
|
||||
@@ -6,7 +6,9 @@ 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 App\Service\Cron\WorkMonthReminderService;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
@@ -25,6 +27,8 @@ class CronCommand extends Command
|
||||
private readonly FeedbackReminderService $feedbackReminderService,
|
||||
private readonly DispositionStatusService $dispositionStatusService,
|
||||
private readonly DispositionReminderService $dispositionReminderService,
|
||||
private readonly SeasonTaskReminderService $seasonTaskReminderService,
|
||||
private readonly WorkMonthReminderService $workMonthReminderService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -55,6 +59,15 @@ 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);
|
||||
|
||||
$message = $this->workMonthReminderService->sendReminders();
|
||||
$io->info($message);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Teamer;
|
||||
use App\Enum\WorkHoursSyncOutcome;
|
||||
use App\Model\WorkHoursSyncResult;
|
||||
use App\Service\Saas\WorkHoursSynchronizer;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:saas:work-hours',
|
||||
description: "Imports season teamers' working hours of one month from the SaaS",
|
||||
)]
|
||||
class SaasWorkHoursCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly WorkHoursSynchronizer $synchronizer,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument(
|
||||
'month',
|
||||
InputArgument::OPTIONAL,
|
||||
'The month to import as YYYY-MM. Defaults to the previous month, which is what the schedule wants.',
|
||||
)
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report what would be written without writing it')
|
||||
->addOption(
|
||||
'teamer',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Limit the run to one teamer, by uuid or email address. Use this to try the API out.',
|
||||
)
|
||||
->addOption(
|
||||
'limit',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Stop after this many teamers, in the order the list comes back',
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduled for the 10th of each month, importing the month before.
|
||||
*
|
||||
* A teamer that cannot be imported does not stop the others: only genuine failures
|
||||
* (the request or the payload) make the command fail, because the scheduler mails on a
|
||||
* non-zero exit and an incomplete profile would otherwise mail every month forever.
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
|
||||
try {
|
||||
[$year, $month] = $this->resolveMonth($input->getArgument('month'));
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$io->error($e->getMessage());
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
$io->title(sprintf('Arbeitszeiten %04d-%02d', $year, $month));
|
||||
|
||||
if ($dryRun) {
|
||||
$io->note('Dry run - nothing is written.');
|
||||
}
|
||||
|
||||
$teamers = $this->synchronizer->findEligibleTeamers($year, $month);
|
||||
|
||||
if (null !== $only = $input->getOption('teamer')) {
|
||||
$teamers = $this->onlyThisTeamer($teamers, (string) $only);
|
||||
|
||||
if ([] === $teamers) {
|
||||
$io->error(sprintf('"%s" matches no season teamer placed in this month.', $only));
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== $limit = $input->getOption('limit')) {
|
||||
$teamers = array_slice($teamers, 0, max(1, (int) $limit));
|
||||
}
|
||||
|
||||
if ([] === $teamers) {
|
||||
$io->warning('No season teamer is placed in this month.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->writeln(sprintf('%d teamer(s) to fetch, one request each.', count($teamers)));
|
||||
|
||||
$io->progressStart(count($teamers));
|
||||
|
||||
/** @var WorkHoursSyncResult[] $results */
|
||||
$results = [];
|
||||
|
||||
foreach ($teamers as $teamer) {
|
||||
$results[] = $this->synchronizer->syncTeamer($teamer, $year, $month, $dryRun);
|
||||
$io->progressAdvance();
|
||||
}
|
||||
|
||||
$io->progressFinish();
|
||||
|
||||
$this->report($io, $results);
|
||||
|
||||
return $this->hasFailures($results) ? Command::FAILURE : Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param WorkHoursSyncResult[] $results
|
||||
*/
|
||||
private function report(SymfonyStyle $io, array $results): void
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
foreach ($results as $result) {
|
||||
if (WorkHoursSyncOutcome::IMPORTED === $result->getOutcome() && [] === $result->getWarnings()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
$result->getTeamer()->getFullName(),
|
||||
$result->getOutcome()->value,
|
||||
$result->getMessage() ?? implode(' | ', $result->getWarnings()),
|
||||
];
|
||||
}
|
||||
|
||||
if ([] !== $rows) {
|
||||
$io->section('Needs a look');
|
||||
$io->table(['Teamer', 'Outcome', 'Detail'], $rows);
|
||||
}
|
||||
|
||||
$counts = array_count_values(array_map(
|
||||
static fn (WorkHoursSyncResult $result) => $result->getOutcome()->value,
|
||||
$results,
|
||||
));
|
||||
|
||||
$summary = [];
|
||||
|
||||
foreach (WorkHoursSyncOutcome::cases() as $outcome) {
|
||||
$summary[] = sprintf('%s: %d', $outcome->value, $counts[$outcome->value] ?? 0);
|
||||
}
|
||||
|
||||
$minutes = array_sum(array_map(
|
||||
static fn (WorkHoursSyncResult $result) => WorkHoursSyncOutcome::IMPORTED === $result->getOutcome()
|
||||
? (int) $result->getTotalMinutes()
|
||||
: 0,
|
||||
$results,
|
||||
));
|
||||
|
||||
$io->writeln(implode(' ', $summary));
|
||||
$io->writeln(sprintf('Imported %d minutes in total.', $minutes));
|
||||
|
||||
if ($this->hasFailures($results)) {
|
||||
$io->error('At least one teamer could not be imported.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$io->success(sprintf('%d teamer(s) processed.', count($results)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Teamer[] $teamers
|
||||
*
|
||||
* @return Teamer[]
|
||||
*/
|
||||
private function onlyThisTeamer(array $teamers, string $needle): array
|
||||
{
|
||||
$needle = strtolower(trim($needle));
|
||||
|
||||
return array_values(array_filter($teamers, static function (Teamer $teamer) use ($needle) {
|
||||
return strtolower($teamer->getUuid()) === $needle
|
||||
|| strtolower((string) $teamer->getCommunication()?->getEmail()) === $needle;
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param WorkHoursSyncResult[] $results
|
||||
*/
|
||||
private function hasFailures(array $results): bool
|
||||
{
|
||||
foreach ($results as $result) {
|
||||
if ($result->isFailure()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{int, int}
|
||||
*/
|
||||
private function resolveMonth(?string $argument): array
|
||||
{
|
||||
if (null === $argument) {
|
||||
$previous = (new \DateTimeImmutable('first day of this month'))->modify('-1 month');
|
||||
|
||||
return [(int) $previous->format('Y'), (int) $previous->format('n')];
|
||||
}
|
||||
|
||||
if (1 !== preg_match('/^(\d{4})-(\d{2})$/', $argument, $matches)) {
|
||||
throw new \InvalidArgumentException(sprintf('"%s" is not a month; expected YYYY-MM.', $argument));
|
||||
}
|
||||
|
||||
$month = (int) $matches[2];
|
||||
|
||||
if ($month < 1 || $month > 12) {
|
||||
throw new \InvalidArgumentException(sprintf('"%s" is not a month; expected YYYY-MM.', $argument));
|
||||
}
|
||||
|
||||
return [(int) $matches[1], $month];
|
||||
}
|
||||
}
|
||||
@@ -22,4 +22,7 @@ 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';
|
||||
case REMINDER_WORK_MONTH_UPLOAD = 'reminder_work_month_upload';
|
||||
case WORK_MONTH_REJECTED = 'work_month_rejected';
|
||||
}
|
||||
@@ -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,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Config;
|
||||
|
||||
/**
|
||||
* How far the office has worked through one season assignment's checklist.
|
||||
*
|
||||
* Derived from the rows by App\Service\Season\SeasonChecklistBuilder, never stored: the two
|
||||
* completed stages follow from the entries themselves, so a column would only be a second,
|
||||
* hand-maintained copy of what the rows already say.
|
||||
*/
|
||||
enum SeasonChecklistStatus: string
|
||||
{
|
||||
case OPEN = 'open';
|
||||
|
||||
/**
|
||||
* Everything the teamer has to send back has arrived and been ticked off, so the weekly
|
||||
* digest has nothing left to chase. Office-internal entries can still be open.
|
||||
*/
|
||||
case RETURNS_COMPLETE = 'returns_complete';
|
||||
|
||||
case COMPLETE = 'complete';
|
||||
}
|
||||
@@ -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,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Admin\WorkMonth;
|
||||
|
||||
use App\Repository\TeamerWorkMonthRepository;
|
||||
use App\Service\Common\WorkMonthFilterHandler;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Every imported work month with the state of its Arbeitszeitnachweis.
|
||||
*
|
||||
* Deliberately not the existing document list: UploadRepository::getUploadListQuery() inner
|
||||
* joins a disposition, so a document hanging off a month can never appear there, and it is
|
||||
* the month rather than the file that carries the status anyway.
|
||||
*/
|
||||
class IndexController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TeamerWorkMonthRepository $workMonthRepository,
|
||||
private readonly WorkMonthFilterHandler $filterHandler,
|
||||
private readonly PaginatorInterface $paginator,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/work-month', name: 'app_admin_work_month_index')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$filterDto = $this->filterHandler->getFilterSettings();
|
||||
|
||||
$pagination = $this->paginator->paginate(
|
||||
$this->workMonthRepository->getListQuery($filterDto),
|
||||
$request->query->getInt('page', 1),
|
||||
50,
|
||||
);
|
||||
|
||||
return $this->render('admin/work_month/index.html.twig', [
|
||||
'pagination' => $pagination,
|
||||
'filterDto' => $filterDto,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Admin\WorkMonth;
|
||||
|
||||
use App\Entity\TeamerWorkMonth;
|
||||
use App\Service\Pdf\WorkMonthRenderer;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* The same sheet the teamer gets, for the office.
|
||||
*
|
||||
* This is what a month's hours look like in full: there is no separate day-by-day screen,
|
||||
* because it would only restate the document that actually gets signed.
|
||||
*/
|
||||
class PdfController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly WorkMonthRenderer $renderer,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/work-month/pdf/{uuid}', name: 'app_admin_work_month_pdf')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(TeamerWorkMonth $workMonth): Response
|
||||
{
|
||||
$this->logger->info('Download work month pdf', [
|
||||
'work_month' => $workMonth->getUuid(),
|
||||
'teamer' => $workMonth->getTeamer()?->getFullName(),
|
||||
]);
|
||||
|
||||
$response = new Response($this->renderer->render($workMonth));
|
||||
$response->headers->set('Content-Type', 'application/pdf');
|
||||
$response->headers->set('Content-Disposition', $response->headers->makeDisposition(
|
||||
ResponseHeaderBag::DISPOSITION_INLINE,
|
||||
$this->renderer->filename($workMonth),
|
||||
));
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Admin\WorkMonth;
|
||||
|
||||
use App\Config\EmailTextKey;
|
||||
use App\Controller\Traits\ReturnUrlTrait;
|
||||
use App\Email\Mailer;
|
||||
use App\Entity\TeamerWorkMonth;
|
||||
use App\Entity\Upload;
|
||||
use App\Entity\User;
|
||||
use App\Enum\WorkMonthStatus;
|
||||
use App\Form\WorkMonthReviewType;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use App\Model\WorkMonthReviewDto;
|
||||
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;
|
||||
|
||||
/**
|
||||
* The office's verdict on a signed Arbeitszeitnachweis.
|
||||
*
|
||||
* A rejection does not undo the upload: the file stays on the month and the teamer uploads a
|
||||
* new one over it, which puts the month back into "eingereicht".
|
||||
*/
|
||||
class ReviewController extends AbstractController
|
||||
{
|
||||
use ReturnUrlTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly Mailer $mailer,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/work-month/review/{uuid}', name: 'app_admin_work_month_review')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(TeamerWorkMonth $workMonth, Request $request): Response
|
||||
{
|
||||
if (WorkMonthStatus::SUBMITTED !== $workMonth->getStatus()) {
|
||||
throw $this->createNotFoundException('This work month is not waiting for a review');
|
||||
}
|
||||
|
||||
$returnUrl = $this->getReturnUrl($request, 'app_admin_work_month_index');
|
||||
$formData = new WorkMonthReviewDto($workMonth);
|
||||
$form = $this->createForm(WorkMonthReviewType::class, $formData);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
$status = $formData->getStatus();
|
||||
|
||||
$workMonth
|
||||
->setStatus($status)
|
||||
->setReviewedAt(new \DateTimeImmutable())
|
||||
->setReviewedBy($user->getInitials())
|
||||
->setReviewComment($formData->getComment())
|
||||
;
|
||||
|
||||
// Keep the file's own status in step, so the existing upload badge and the
|
||||
// UploadVoter's CHECK rule stay meaningful for it.
|
||||
$workMonth->getCurrentDocument()?->setStatus(
|
||||
WorkMonthStatus::APPROVED === $status ? Upload::STATUS_CHECKED : Upload::STATUS_REJECTED
|
||||
);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
if (WorkMonthStatus::REJECTED === $status) {
|
||||
$this->notifyRejection($workMonth, (string) $formData->getComment());
|
||||
}
|
||||
|
||||
$this->addFlash(
|
||||
'success',
|
||||
WorkMonthStatus::APPROVED === $status
|
||||
? 'Der Arbeitszeitnachweis wurde bestätigt'
|
||||
: 'Der Arbeitszeitnachweis wurde abgelehnt'
|
||||
);
|
||||
|
||||
$this->logger->info('Review work month', [
|
||||
'work_month' => $workMonth->getUuid(),
|
||||
'teamer' => $workMonth->getTeamer()?->getFullName(),
|
||||
'period' => sprintf('%04d-%02d', $workMonth->getYear(), $workMonth->getMonth()),
|
||||
'status' => $status->value,
|
||||
'by' => $user->getInitials(),
|
||||
]);
|
||||
|
||||
return new HxRedirectResponse($returnUrl);
|
||||
}
|
||||
|
||||
return $this->render('admin/work_month/modal_review.html.twig', [
|
||||
'workMonth' => $workMonth,
|
||||
'form' => $form->createView(),
|
||||
'returnUrl' => $returnUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
private function notifyRejection(TeamerWorkMonth $workMonth, string $comment): void
|
||||
{
|
||||
$email = $workMonth->getTeamer()?->getCommunication()?->getEmail();
|
||||
|
||||
if (null === $email || '' === trim($email)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->mailer->createAndSendText(EmailTextKey::WORK_MONTH_REJECTED, [
|
||||
'workMonth' => $workMonth->getPeriodLabel(),
|
||||
'comment' => $comment,
|
||||
], [
|
||||
'to' => $email,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Admin\WorkMonth;
|
||||
|
||||
use App\Controller\Traits\ReturnUrlTrait;
|
||||
use App\Entity\TeamerWorkMonth;
|
||||
use App\Enum\WorkHoursSyncOutcome;
|
||||
use App\Service\Saas\WorkHoursSynchronizer;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Fetching one month again by hand, for a correction the schedule has already passed by.
|
||||
*
|
||||
* A month the teamer has signed is frozen, so re-fetching it only records that the SaaS now
|
||||
* disagrees. Releasing it is a separate, deliberate step: it throws away the signature and
|
||||
* sends the teamer back to the start.
|
||||
*/
|
||||
class SyncController extends AbstractController
|
||||
{
|
||||
use ReturnUrlTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly WorkHoursSynchronizer $synchronizer,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/work-month/sync/{uuid}', name: 'app_admin_work_month_sync', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function sync(TeamerWorkMonth $workMonth, Request $request): Response
|
||||
{
|
||||
if (null === $teamer = $workMonth->getTeamer()) {
|
||||
throw $this->createNotFoundException('No teamer on this work month');
|
||||
}
|
||||
|
||||
$result = $this->synchronizer->syncTeamer($teamer, $workMonth->getYear(), $workMonth->getMonth());
|
||||
|
||||
match ($result->getOutcome()) {
|
||||
WorkHoursSyncOutcome::IMPORTED => $this->addFlash('success', 'Die Arbeitszeiten wurden aktualisiert'),
|
||||
WorkHoursSyncOutcome::LOCKED => $this->addFlash('success', $result->getMessage() ?? 'Der Monat ist bereits eingereicht'),
|
||||
WorkHoursSyncOutcome::SKIPPED => $this->addFlash('error', $result->getMessage() ?? 'Der Abruf war nicht möglich'),
|
||||
WorkHoursSyncOutcome::FAILED => $this->addFlash('error', 'Der Abruf ist fehlgeschlagen: '.$result->getMessage()),
|
||||
};
|
||||
|
||||
return $this->redirect($this->getReturnUrl($request, 'app_admin_work_month_index'));
|
||||
}
|
||||
|
||||
#[Route('/admin/work-month/unlock/{uuid}', name: 'app_admin_work_month_unlock', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function unlock(TeamerWorkMonth $workMonth, Request $request): Response
|
||||
{
|
||||
$this->synchronizer->unlock($workMonth);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->addFlash('success', 'Der Monat wurde wieder geöffnet und muss neu unterschrieben werden');
|
||||
|
||||
$this->logger->info('Unlock work month', [
|
||||
'work_month' => $workMonth->getUuid(),
|
||||
'teamer' => $workMonth->getTeamer()?->getFullName(),
|
||||
'period' => sprintf('%04d-%02d', $workMonth->getYear(), $workMonth->getMonth()),
|
||||
]);
|
||||
|
||||
return $this->redirect($this->getReturnUrl($request, 'app_admin_work_month_index'));
|
||||
}
|
||||
}
|
||||
@@ -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,40 @@
|
||||
<?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),
|
||||
'checklistStatus' => $this->checklistBuilder->status($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,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,88 @@
|
||||
<?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 and re-renders the checklist.
|
||||
*
|
||||
* Deliberately one request per tick rather than one form for the whole checklist: the office
|
||||
* works through the list one line at a time while reading a document, and a whole-form submit
|
||||
* would let two people working the same assignment overwrite each other's ticks. The answer
|
||||
* still carries the whole checklist, because the status box below the table is derived from
|
||||
* every row at once - and a tick re-rendered from the database also picks up what a colleague
|
||||
* has ticked in the meantime.
|
||||
*/
|
||||
class TaskToggleController extends AbstractController
|
||||
{
|
||||
private const FIELD_DONE = 'done';
|
||||
private const FIELD_OFFICE_DONE = 'office_done';
|
||||
|
||||
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|office_done'],
|
||||
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_OFFICE_DONE], 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->setOfficeDone(false === $task->isOfficeDone());
|
||||
}
|
||||
|
||||
$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->isOfficeDone(),
|
||||
]);
|
||||
|
||||
$seasonAssignment = $task->getSeasonAssignment();
|
||||
|
||||
if (null === $seasonAssignment) {
|
||||
throw $this->createNotFoundException('The checklist entry has no season assignment.');
|
||||
}
|
||||
|
||||
return $this->render('administrative/season_assignment/_checklist.html.twig', [
|
||||
'checklist' => $this->checklistBuilder->build($seasonAssignment),
|
||||
'checklistStatus' => $this->checklistBuilder->status($seasonAssignment),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,145 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Administrative\System\SeasonTaskDocument;
|
||||
|
||||
use App\Config\SeasonTask;
|
||||
use App\Config\SeasonTaskProvision;
|
||||
use App\Entity\SeasonTaskDocument;
|
||||
use App\Entity\Upload;
|
||||
use App\Entity\User;
|
||||
use App\Form\SeasonTaskDocumentType;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use App\Repository\SeasonTaskDocumentRepository;
|
||||
use App\Service\Upload\UploadHandler;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Flagception\Manager\FeatureManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Form\FormError;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Sets the library document a checklist entry hands out. Addressed by the entry rather than by a
|
||||
* record: the mapping may not exist yet, and there is at most one per entry.
|
||||
*
|
||||
* The file can be picked from the library or uploaded here, in which case it joins the library as
|
||||
* an ordinary Upload::TYPE_DOCUMENT - so next season it can simply be picked. An uploaded file
|
||||
* wins over the pick: the picker already shows the mapping's current document, so a new file is
|
||||
* always the more recent statement of intent.
|
||||
*
|
||||
* Changing the mapping changes what the *next* assignments get. It never reaches an assignment
|
||||
* that already exists - that is what the propagate action is for.
|
||||
*/
|
||||
class EditController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SeasonTaskDocumentRepository $seasonTaskDocumentRepository,
|
||||
private readonly UploadHandler $uploadHandler,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly FeatureManagerInterface $featureManager,
|
||||
) {
|
||||
}
|
||||
|
||||
#[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);
|
||||
|
||||
$uploaded = null;
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$dropped = $this->uploadHandler->getUploadSession()->getUploadsByType(Upload::TYPE_DOCUMENT);
|
||||
|
||||
// A dropped file wins over the pick. Editing an existing mapping shows its current
|
||||
// document in the picker, so having to clear that first to replace it would be a
|
||||
// detour through a field the user is not talking about.
|
||||
if (false === $dropped->isEmpty()) {
|
||||
$uploaded = $this->storeUploadedDocument($form);
|
||||
|
||||
$seasonTaskDocument->setDocument($uploaded);
|
||||
} elseif (null === $seasonTaskDocument->getDocument()) {
|
||||
$form->addError(new FormError('Bitte wähle ein Dokument aus oder lade eine Datei hoch'));
|
||||
}
|
||||
}
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
if (null === $seasonTaskDocument->getId()) {
|
||||
$this->entityManager->persist($seasonTaskDocument);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->logger->info('Change season task document', [
|
||||
'task' => $task->value,
|
||||
'document_id' => $seasonTaskDocument->getDocument()?->getId(),
|
||||
'uploaded' => null !== $uploaded,
|
||||
]);
|
||||
|
||||
$this->addFlash('success', 'Das Dokument wurde hinterlegt');
|
||||
|
||||
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,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The dropped file, turned into a library document. It carries Upload::TYPE_DOCUMENT like
|
||||
* everything under Dokumente, so next season it is simply picked from the autocomplete.
|
||||
*
|
||||
* The dropzone takes one file, and a second one would be a user error rather than a case to
|
||||
* model, so only the first is kept and the rest of the session entry is dropped with it.
|
||||
*/
|
||||
private function storeUploadedDocument(FormInterface $form): Upload
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
$uploadSession = $this->uploadHandler->getUploadSession();
|
||||
|
||||
$upload = Upload::fromUploadDto(
|
||||
$uploadSession->getUploadsByType(Upload::TYPE_DOCUMENT)->first(),
|
||||
$user,
|
||||
Upload::TYPE_DOCUMENT
|
||||
);
|
||||
|
||||
if (null !== $displayName = $form->get('displayName')->getData()) {
|
||||
$upload->setDisplayName($displayName);
|
||||
}
|
||||
|
||||
$this->entityManager->persist($upload);
|
||||
|
||||
$this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_DOCUMENT, $uploadSession);
|
||||
$this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_DOCUMENT);
|
||||
|
||||
if (true === $this->featureManager->isActive('sanitize_uploads')) {
|
||||
$this->uploadHandler->ensurePdf($upload);
|
||||
}
|
||||
|
||||
return $upload;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Administrative\System\SeasonTaskDocument;
|
||||
|
||||
use App\Entity\SeasonTaskDocument;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use App\Repository\SeasonAssignmentTaskRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Copies the mapped document onto the checklist rows of assignments that already exist.
|
||||
*
|
||||
* A checklist row carries the document it was given when it was created, so depositing a new file
|
||||
* changes nothing for a season that was already laid out. That is deliberate for finished work
|
||||
* and unhelpful for a season still being prepared - this action closes exactly that gap, by hand
|
||||
* and with the count shown before it runs.
|
||||
*
|
||||
* Scoped to entries that are not ticked off, on assignments that have not ended: what an entry
|
||||
* already handed out is history and is not rewritten.
|
||||
*
|
||||
* officeDone is left alone. It is the office's own note, an untick is deliberate, and a document
|
||||
* changing underneath is not an office action.
|
||||
*/
|
||||
class PropagateController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SeasonAssignmentTaskRepository $seasonAssignmentTaskRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route(
|
||||
path: '/administrative/system/season-task-document/propagate/{uuid}',
|
||||
name: 'app_administrative_system_season_task_document_propagate'
|
||||
)]
|
||||
#[IsGranted('ROLE_ADMINISTRATIVE')]
|
||||
public function index(SeasonTaskDocument $seasonTaskDocument, Request $request): Response
|
||||
{
|
||||
$tasks = $this->seasonAssignmentTaskRepository->findOpenForTask(
|
||||
$seasonTaskDocument->getTaskKey(),
|
||||
new \DateTimeImmutable('today')
|
||||
);
|
||||
|
||||
if (true === $request->isMethod('POST')) {
|
||||
foreach ($tasks as $task) {
|
||||
$task->setDocument($seasonTaskDocument->getDocument());
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->logger->info('Propagate season task document', [
|
||||
'task' => $seasonTaskDocument->getTaskKey(),
|
||||
'document_id' => $seasonTaskDocument->getDocument()?->getId(),
|
||||
'count' => count($tasks),
|
||||
]);
|
||||
|
||||
$this->addFlash(
|
||||
'success',
|
||||
1 === count($tasks)
|
||||
? 'Das Dokument wurde auf einen offenen Einsatz übertragen'
|
||||
: sprintf('Das Dokument wurde auf %d offene Einsätze übertragen', count($tasks))
|
||||
);
|
||||
|
||||
return new HxRedirectResponse(
|
||||
$this->generateUrl('app_administrative_system_season_task_document_index')
|
||||
);
|
||||
}
|
||||
|
||||
return $this->render('administrative/system/season_task_document/modal_propagate.html.twig', [
|
||||
'seasonTaskDocument' => $seasonTaskDocument,
|
||||
'tasks' => $tasks,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Common;
|
||||
|
||||
use App\Controller\Traits\ReturnUrlTrait;
|
||||
use App\Form\WorkMonthFilterType;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use App\Service\Common\WorkMonthFilterHandler;
|
||||
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 WorkMonthFilterController extends AbstractController
|
||||
{
|
||||
use ReturnUrlTrait;
|
||||
|
||||
public function __construct(private readonly WorkMonthFilterHandler $filterHandler)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/common/work-month/filter', name: 'app_common_work_month_filter')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$formData = $this->filterHandler->getFilterSettings();
|
||||
$form = $this->createForm(WorkMonthFilterType::class, $formData);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->filterHandler->handleRequest($form);
|
||||
$returnUrl = $this->getReturnUrl($request, 'app_admin_work_month_index');
|
||||
|
||||
return new HxRedirectResponse($returnUrl);
|
||||
}
|
||||
|
||||
return $this->render('common/modal_work_month_filter.html.twig', [
|
||||
'filterForm' => $form->createView(),
|
||||
'filterDto' => $form->getData(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/common/work-month/filter/reset', name: 'app_common_work_month_filter_reset')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function reset(Request $request): Response
|
||||
{
|
||||
$this->filterHandler->resetFilterSettings();
|
||||
$returnUrl = $this->getReturnUrl($request, 'app_admin_work_month_index');
|
||||
|
||||
return $this->redirect($returnUrl);
|
||||
}
|
||||
}
|
||||
@@ -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,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Teamer\WorkMonth;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Repository\TeamerWorkMonthRepository;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* The months a teamer has to sign off, newest first.
|
||||
*
|
||||
* Only months the import has actually produced appear here; the current month is not among
|
||||
* them until the 10th of the month after it.
|
||||
*/
|
||||
class IndexController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TeamerWorkMonthRepository $workMonthRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/teamer/arbeitszeiten', name: 'app_teamer_work_month_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');
|
||||
}
|
||||
|
||||
return $this->render('teamer/work_month/index.html.twig', [
|
||||
'workMonths' => $this->workMonthRepository->findForTeamer($teamer),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Teamer\WorkMonth;
|
||||
|
||||
use App\Entity\TeamerWorkMonth;
|
||||
use App\Entity\User;
|
||||
use App\Service\Pdf\WorkMonthRenderer;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* The Arbeitszeitnachweis to print and sign.
|
||||
*
|
||||
* Built on demand and never stored, exactly as the contract and the invoice are. What keeps
|
||||
* a regenerated sheet identical to the signed one is the lock in WorkHoursSynchronizer: once
|
||||
* a month is submitted, its hours stop moving.
|
||||
*/
|
||||
class PdfController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly WorkMonthRenderer $renderer,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/teamer/arbeitszeiten/pdf/{uuid}', name: 'app_teamer_work_month_pdf')]
|
||||
#[IsGranted('ROLE_TEAMER')]
|
||||
public function index(TeamerWorkMonth $workMonth): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
// the month is addressed by uuid, so this is what keeps one teamer out of another's
|
||||
if ($user->getTeamer() !== $workMonth->getTeamer()) {
|
||||
throw $this->createNotFoundException('Not this teamer\'s work month');
|
||||
}
|
||||
|
||||
$this->logger->info('Download work month pdf', [
|
||||
'user' => $user->getUserIdentifier(),
|
||||
'work_month' => $workMonth->getUuid(),
|
||||
]);
|
||||
|
||||
$response = new Response($this->renderer->render($workMonth));
|
||||
$response->headers->set('Content-Type', 'application/pdf');
|
||||
$response->headers->set('Content-Disposition', $response->headers->makeDisposition(
|
||||
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
|
||||
$this->renderer->filename($workMonth),
|
||||
));
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Teamer\WorkMonth;
|
||||
|
||||
use App\Entity\TeamerWorkMonth;
|
||||
use App\Entity\Upload;
|
||||
use App\Entity\User;
|
||||
use App\Enum\WorkMonthStatus;
|
||||
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 Arbeitszeitnachweis coming back for one month.
|
||||
*
|
||||
* Uploads accumulate rather than replace - Upload is not soft-deletable, and a rejection and
|
||||
* the re-upload answering it both belong on the record. TeamerWorkMonth::getCurrentDocument()
|
||||
* is what decides which one counts.
|
||||
*/
|
||||
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/arbeitszeiten/upload/{uuid}', name: 'app_teamer_work_month_upload')]
|
||||
#[IsGranted('ROLE_TEAMER')]
|
||||
public function index(TeamerWorkMonth $workMonth, Request $request): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
// the month is addressed by uuid, so this is what keeps one teamer out of another's
|
||||
if ($user->getTeamer() !== $workMonth->getTeamer()) {
|
||||
throw $this->createNotFoundException('Not this teamer\'s work month');
|
||||
}
|
||||
|
||||
if (WorkMonthStatus::APPROVED === $workMonth->getStatus()) {
|
||||
throw $this->createNotFoundException('This work month is already approved');
|
||||
}
|
||||
|
||||
// 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($workMonth, $user);
|
||||
|
||||
if (0 === $count) {
|
||||
$this->addFlash('error', 'Es wurde keine Datei hochgeladen');
|
||||
} else {
|
||||
$this->addFlash('success', 'Der Arbeitszeitnachweis wurde hochgeladen');
|
||||
}
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_teamer_work_month_index'));
|
||||
}
|
||||
|
||||
return $this->render('teamer/work_month/modal_upload.html.twig', [
|
||||
'workMonth' => $workMonth,
|
||||
'form' => $form->createView(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function processUpload(TeamerWorkMonth $workMonth, 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_WORK_MONTH);
|
||||
|
||||
$uploads = [];
|
||||
|
||||
/** @var UploadDto $uploadDto */
|
||||
foreach ($uploadDtos as $uploadDto) {
|
||||
$upload = Upload::fromUploadDto($uploadDto, $user, Upload::TYPE_WORK_MONTH);
|
||||
|
||||
// Unlike a season document this one is reviewed, so it enters the checking states
|
||||
// the UploadVoter lets an administrative user act on.
|
||||
$upload->setStatus(Upload::STATUS_NEW);
|
||||
|
||||
$workMonth->addDocument($upload);
|
||||
$this->entityManager->persist($upload);
|
||||
|
||||
$uploads[] = $upload;
|
||||
}
|
||||
|
||||
$this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_WORK_MONTH, $uploadSession);
|
||||
$this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_WORK_MONTH);
|
||||
|
||||
if ([] === $uploads) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (true === $this->featureManager->isActive('sanitize_uploads')) {
|
||||
// Teamers photograph signed sheets, so what arrives is not necessarily a PDF.
|
||||
foreach ($uploads as $upload) {
|
||||
$this->uploadHandler->ensurePdf($upload);
|
||||
}
|
||||
}
|
||||
|
||||
$workMonth
|
||||
->setStatus(WorkMonthStatus::SUBMITTED)
|
||||
->setSubmittedAt(new \DateTimeImmutable())
|
||||
// A new submission answers an earlier rejection; the old verdict must not stand.
|
||||
->setReviewedAt(null)
|
||||
->setReviewedBy(null)
|
||||
->setReviewComment(null)
|
||||
;
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->logger->info('Upload work month document', [
|
||||
'user' => $user->getUserIdentifier(),
|
||||
'work_month' => $workMonth->getUuid(),
|
||||
'period' => sprintf('%04d-%02d', $workMonth->getYear(), $workMonth->getMonth()),
|
||||
'count' => count($uploads),
|
||||
]);
|
||||
|
||||
return count($uploads);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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,237 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* The office's own step on this entry, ticked by an administrative user. Purely
|
||||
* informational: it gates nothing at all, and only $done closes the entry.
|
||||
*
|
||||
* Named for who ticks it rather than for what is ticked, because the step differs per entry:
|
||||
* filing a returned contract is not the same act as registering someone with the
|
||||
* municipality, and a name taken from one of those reads as a second $done on the others.
|
||||
*/
|
||||
#[ORM\Column]
|
||||
private bool $officeDone = false;
|
||||
|
||||
/**
|
||||
* The document this entry hands out, copied from the library when the row was created.
|
||||
*
|
||||
* A snapshot rather than a live lookup: the library holds what the *next* assignments get,
|
||||
* and it is replaced from season to season, so resolving it on read would rewrite what a
|
||||
* finished assignment handed out years ago. An already laid out season that should still get
|
||||
* a newly deposited file is served by the propagate action instead, which is deliberate and
|
||||
* leaves completed work alone.
|
||||
*
|
||||
* SET NULL rather than a guard on the library: deleting the file leaves the row readable and
|
||||
* showing "kein Dokument hinterlegt", which is a state the views already handle.
|
||||
*/
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\JoinColumn(onDelete: 'SET NULL')]
|
||||
private ?Upload $document = null;
|
||||
|
||||
/**
|
||||
* Null means "not overridden" - the deadlines are computed on read by
|
||||
* 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 isOfficeDone(): bool
|
||||
{
|
||||
return $this->officeDone;
|
||||
}
|
||||
|
||||
public function setOfficeDone(bool $officeDone): static
|
||||
{
|
||||
$this->officeDone = $officeDone;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDocument(): ?Upload
|
||||
{
|
||||
return $this->document;
|
||||
}
|
||||
|
||||
public function setDocument(?Upload $document): static
|
||||
{
|
||||
$this->document = $document;
|
||||
|
||||
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,24 @@ class Teamer implements TimestampableEntityInterface, SoftDeletableEntityInterfa
|
||||
#[ORM\Column(length: 8, nullable: true)]
|
||||
private ?string $driverLicenseReviewedBy = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private bool $allSeason = false;
|
||||
|
||||
/**
|
||||
* The teamer's id in the SaaS time-tracking service, learned from the first successful
|
||||
* import and kept because it is stable while the email address the import looks them up
|
||||
* by is not. Written by the importer, never by hand.
|
||||
*/
|
||||
#[ORM\Column(length: 36, nullable: true)]
|
||||
private ?string $saasEmployeeUuid = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, TeamerWorkMonth>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'teamer', targetEntity: TeamerWorkMonth::class, cascade: ['remove'])]
|
||||
#[ORM\OrderBy(['year' => \SortDirection::Descending, 'month' => \SortDirection::Descending])]
|
||||
private Collection $workMonths;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->uuid = Uuid::v4();
|
||||
@@ -188,7 +209,9 @@ 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();
|
||||
$this->workMonths = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function __toString()
|
||||
@@ -791,6 +814,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 +1040,36 @@ 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;
|
||||
}
|
||||
|
||||
public function getSaasEmployeeUuid(): ?string
|
||||
{
|
||||
return $this->saasEmployeeUuid;
|
||||
}
|
||||
|
||||
public function setSaasEmployeeUuid(?string $saasEmployeeUuid): static
|
||||
{
|
||||
$this->saasEmployeeUuid = $saasEmployeeUuid;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, TeamerWorkMonth>
|
||||
*/
|
||||
public function getWorkMonths(): Collection
|
||||
{
|
||||
return $this->workMonths;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Traits\TimestampableEntity;
|
||||
use App\Enum\WorkMonthStatus;
|
||||
use App\Model\WorkMonthDays;
|
||||
use App\Repository\TeamerWorkMonthRepository;
|
||||
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;
|
||||
|
||||
/**
|
||||
* One teamer's working time for one calendar month, imported from the SaaS time-tracking
|
||||
* service, and the state of the Arbeitszeitnachweis the teamer signs for it.
|
||||
*
|
||||
* The days live in a JSON column rather than in one row per day: every read is "one teamer,
|
||||
* one month", which a single row answers directly, and the signed document, its status and
|
||||
* the import's own metadata all need exactly one monthly row to hang off. The figures a list
|
||||
* sorts or filters on are denormalised into columns so nothing has to decode the blob to
|
||||
* answer a question. App\Model\WorkMonthDays is the only place that knows the blob's shape.
|
||||
*
|
||||
* Deliberately not soft-deletable, like SeasonAssignmentTask and Upload: rows are derived
|
||||
* from an import, never deleted by a user, and a soft-deleted row would silently shadow a
|
||||
* re-imported one against the unique constraint below. Not blameable either - the importer
|
||||
* writes these, not a person.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: TeamerWorkMonthRepository::class)]
|
||||
#[ORM\UniqueConstraint(name: 'teamer_work_month_unique', columns: ['teamer_id', 'year', 'month'])]
|
||||
class TeamerWorkMonth implements TimestampableEntityInterface
|
||||
{
|
||||
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: 'workMonths')]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private ?Teamer $teamer = null;
|
||||
|
||||
#[ORM\Column(type: Types::SMALLINT)]
|
||||
private int $year;
|
||||
|
||||
/**
|
||||
* The calendar month, 1-12. The SaaS counts months from zero; that index never leaves
|
||||
* App\Saas.
|
||||
*/
|
||||
#[ORM\Column(type: Types::SMALLINT)]
|
||||
private int $month;
|
||||
|
||||
/**
|
||||
* @var array<string, array{minutes: int, absence?: string}>
|
||||
*/
|
||||
#[ORM\Column(type: Types::JSON)]
|
||||
private array $days = [];
|
||||
|
||||
#[ORM\Column]
|
||||
private int $totalMinutes = 0;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $workedDays = 0;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $absenceDays = 0;
|
||||
|
||||
/**
|
||||
* The month total as the SaaS itself reported it. Kept alongside our own sum so a later
|
||||
* divergence is visible without fetching the month again.
|
||||
*/
|
||||
#[ORM\Column]
|
||||
private int $sourceIstMinutes = 0;
|
||||
|
||||
/**
|
||||
* The month's target. A monthly figure by nature: every booking carries soll 0 with
|
||||
* sollType MONTH, so there is no daily target to break it down into.
|
||||
*/
|
||||
#[ORM\Column]
|
||||
private int $targetMinutes = 0;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $breakMinutes = 0;
|
||||
|
||||
/**
|
||||
* The employee's department in the SaaS, shown in the head of the sheet. Nullable because
|
||||
* a month of nothing but holiday carries no booking to read it from.
|
||||
*/
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $department = null;
|
||||
|
||||
#[ORM\Column(enumType: WorkMonthStatus::class)]
|
||||
private WorkMonthStatus $status = WorkMonthStatus::OPEN;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $submittedAt = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $reviewedAt = null;
|
||||
|
||||
#[ORM\Column(length: 8, nullable: true)]
|
||||
private ?string $reviewedBy = null;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
private ?string $reviewComment = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||
private \DateTimeImmutable $syncedAt;
|
||||
|
||||
/**
|
||||
* Anything the parser could not make sense of, kept so a past import stays explicable.
|
||||
*
|
||||
* @var string[]|null
|
||||
*/
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?array $warnings = null;
|
||||
|
||||
/**
|
||||
* Set when an import found different hours for a month whose figures are frozen because
|
||||
* the teamer already signed it. The stored hours stay as signed; this records that the
|
||||
* SaaS has since disagreed.
|
||||
*/
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $mismatchDetectedAt = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?int $mismatchTotalMinutes = null;
|
||||
|
||||
/**
|
||||
* Every signed copy the teamer has uploaded for this month, oldest first. They
|
||||
* accumulate rather than replace, as season documents do, so a rejection and the
|
||||
* re-upload that answered it both stay on the record.
|
||||
*
|
||||
* @var Collection<int, Upload>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'teamerWorkMonth', targetEntity: Upload::class)]
|
||||
#[ORM\OrderBy(['createdAt' => \SortDirection::Ascending])]
|
||||
private Collection $documents;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->uuid = Uuid::v4();
|
||||
$this->syncedAt = new \DateTimeImmutable();
|
||||
$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 getYear(): int
|
||||
{
|
||||
return $this->year;
|
||||
}
|
||||
|
||||
public function setYear(int $year): static
|
||||
{
|
||||
$this->year = $year;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMonth(): int
|
||||
{
|
||||
return $this->month;
|
||||
}
|
||||
|
||||
public function setMonth(int $month): static
|
||||
{
|
||||
$this->month = $month;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first of the month, for formatting and sorting in templates.
|
||||
*/
|
||||
public function getPeriod(): \DateTimeImmutable
|
||||
{
|
||||
return (new \DateTimeImmutable())->setDate($this->year, $this->month, 1)->setTime(0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* "März 2026" - the way the month is named in mails and on the sheet itself.
|
||||
*/
|
||||
public function getPeriodLabel(): string
|
||||
{
|
||||
return (string) \IntlDateFormatter::formatObject($this->getPeriod(), 'LLLL yyyy', 'de');
|
||||
}
|
||||
|
||||
public function getDays(): WorkMonthDays
|
||||
{
|
||||
return WorkMonthDays::fromArray($this->days);
|
||||
}
|
||||
|
||||
public function setDays(WorkMonthDays $days): static
|
||||
{
|
||||
$this->days = $days->toArray();
|
||||
$this->totalMinutes = $days->getTotalMinutes();
|
||||
$this->breakMinutes = $days->getTotalBreakMinutes();
|
||||
$this->workedDays = $days->countWorkedDays();
|
||||
$this->absenceDays = $days->countAbsenceDays();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTotalMinutes(): int
|
||||
{
|
||||
return $this->totalMinutes;
|
||||
}
|
||||
|
||||
public function getWorkedDays(): int
|
||||
{
|
||||
return $this->workedDays;
|
||||
}
|
||||
|
||||
public function getAbsenceDays(): int
|
||||
{
|
||||
return $this->absenceDays;
|
||||
}
|
||||
|
||||
public function getSourceIstMinutes(): int
|
||||
{
|
||||
return $this->sourceIstMinutes;
|
||||
}
|
||||
|
||||
public function setSourceIstMinutes(int $sourceIstMinutes): static
|
||||
{
|
||||
$this->sourceIstMinutes = $sourceIstMinutes;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTargetMinutes(): int
|
||||
{
|
||||
return $this->targetMinutes;
|
||||
}
|
||||
|
||||
public function setTargetMinutes(int $targetMinutes): static
|
||||
{
|
||||
$this->targetMinutes = $targetMinutes;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBreakMinutes(): int
|
||||
{
|
||||
return $this->breakMinutes;
|
||||
}
|
||||
|
||||
public function getDepartment(): ?string
|
||||
{
|
||||
return $this->department;
|
||||
}
|
||||
|
||||
public function setDepartment(?string $department): static
|
||||
{
|
||||
$this->department = $department;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Worked minus target: positive is time over the month's target, negative under it.
|
||||
*/
|
||||
public function getBalanceMinutes(): int
|
||||
{
|
||||
return $this->totalMinutes - $this->targetMinutes;
|
||||
}
|
||||
|
||||
public function getStatus(): WorkMonthStatus
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function setStatus(WorkMonthStatus $status): static
|
||||
{
|
||||
$this->status = $status;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSubmittedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->submittedAt;
|
||||
}
|
||||
|
||||
public function setSubmittedAt(?\DateTimeImmutable $submittedAt): static
|
||||
{
|
||||
$this->submittedAt = $submittedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getReviewedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->reviewedAt;
|
||||
}
|
||||
|
||||
public function setReviewedAt(?\DateTimeImmutable $reviewedAt): static
|
||||
{
|
||||
$this->reviewedAt = $reviewedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getReviewedBy(): ?string
|
||||
{
|
||||
return $this->reviewedBy;
|
||||
}
|
||||
|
||||
public function setReviewedBy(?string $reviewedBy): static
|
||||
{
|
||||
$this->reviewedBy = $reviewedBy;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getReviewComment(): ?string
|
||||
{
|
||||
return $this->reviewComment;
|
||||
}
|
||||
|
||||
public function setReviewComment(?string $reviewComment): static
|
||||
{
|
||||
$this->reviewComment = $reviewComment;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSyncedAt(): \DateTimeImmutable
|
||||
{
|
||||
return $this->syncedAt;
|
||||
}
|
||||
|
||||
public function setSyncedAt(\DateTimeImmutable $syncedAt): static
|
||||
{
|
||||
$this->syncedAt = $syncedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getWarnings(): array
|
||||
{
|
||||
return $this->warnings ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $warnings
|
||||
*/
|
||||
public function setWarnings(array $warnings): static
|
||||
{
|
||||
$this->warnings = [] !== $warnings ? array_values($warnings) : null;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMismatchDetectedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->mismatchDetectedAt;
|
||||
}
|
||||
|
||||
public function getMismatchTotalMinutes(): ?int
|
||||
{
|
||||
return $this->mismatchTotalMinutes;
|
||||
}
|
||||
|
||||
public function hasMismatch(): bool
|
||||
{
|
||||
return null !== $this->mismatchDetectedAt;
|
||||
}
|
||||
|
||||
public function recordMismatch(?int $totalMinutes): static
|
||||
{
|
||||
$this->mismatchDetectedAt = new \DateTimeImmutable();
|
||||
$this->mismatchTotalMinutes = $totalMinutes;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function clearMismatch(): static
|
||||
{
|
||||
$this->mismatchDetectedAt = null;
|
||||
$this->mismatchTotalMinutes = 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->setTeamerWorkMonth($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* The signed copy that counts: the most recent one the teamer uploaded.
|
||||
*/
|
||||
public function getCurrentDocument(): ?Upload
|
||||
{
|
||||
return $this->documents->last() ?: null;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,24 @@ 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';
|
||||
|
||||
/**
|
||||
* A signed Arbeitszeitnachweis a teamer returns for one work month. These accumulate too,
|
||||
* so a rejection and the re-upload answering it both stay on the month. Unlike the season
|
||||
* documents this one is reviewed, so it carries a status rather than null.
|
||||
*/
|
||||
public const TYPE_WORK_MONTH = 'work_month';
|
||||
|
||||
public const STATUS_NEW = 'new';
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_CHECKED = 'checked';
|
||||
@@ -74,6 +92,12 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface
|
||||
#[ORM\ManyToOne(inversedBy: 'documents')]
|
||||
private ?Disposition $disposition = null;
|
||||
|
||||
#[ORM\ManyToOne(inversedBy: 'documents')]
|
||||
private ?SeasonAssignment $seasonAssignment = null;
|
||||
|
||||
#[ORM\ManyToOne(inversedBy: 'documents')]
|
||||
private ?TeamerWorkMonth $teamerWorkMonth = null;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
private ?string $comment = null;
|
||||
|
||||
@@ -146,6 +170,9 @@ 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',
|
||||
self::TYPE_WORK_MONTH => 'Arbeitszeitnachweis',
|
||||
default => 'Dokument',
|
||||
};
|
||||
}
|
||||
@@ -268,6 +295,30 @@ 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 getTeamerWorkMonth(): ?TeamerWorkMonth
|
||||
{
|
||||
return $this->teamerWorkMonth;
|
||||
}
|
||||
|
||||
public function setTeamerWorkMonth(?TeamerWorkMonth $teamerWorkMonth): static
|
||||
{
|
||||
$this->teamerWorkMonth = $teamerWorkMonth;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getComment(): ?string
|
||||
{
|
||||
return $this->comment;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
enum WorkHoursSyncOutcome: string
|
||||
{
|
||||
/** Hours were fetched and written. */
|
||||
case IMPORTED = 'imported';
|
||||
|
||||
/** The month is signed, so the hours were compared but left alone. */
|
||||
case LOCKED = 'locked';
|
||||
|
||||
/** Nothing to do: no address on file, or nothing to import. */
|
||||
case SKIPPED = 'skipped';
|
||||
|
||||
/** The request or the payload failed. Worth waking someone for. */
|
||||
case FAILED = 'failed';
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* Where one work month stands in the Arbeitszeitnachweis exchange.
|
||||
*
|
||||
* Deliberately not a Symfony workflow: the existing document state machine is keyed to
|
||||
* Disposition's own marking store and cannot carry a month. Four states and two
|
||||
* transitions do not need one.
|
||||
*/
|
||||
enum WorkMonthStatus: string
|
||||
{
|
||||
/** Imported, waiting for the teamer to download, sign and upload it. */
|
||||
case OPEN = 'open';
|
||||
|
||||
/** The teamer uploaded a signed copy; the office has not looked at it yet. */
|
||||
case SUBMITTED = 'submitted';
|
||||
|
||||
/** The office accepted the signed copy. */
|
||||
case APPROVED = 'approved';
|
||||
|
||||
/** The office rejected it. The teamer uploads again, which returns it to SUBMITTED. */
|
||||
case REJECTED = 'rejected';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::OPEN => 'Offen',
|
||||
self::SUBMITTED => 'Eingereicht',
|
||||
self::APPROVED => 'Geprüft',
|
||||
self::REJECTED => 'Abgelehnt',
|
||||
};
|
||||
}
|
||||
|
||||
public function badgeClass(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::OPEN => 'upload-status-badge--new',
|
||||
self::SUBMITTED => 'upload-status-badge--default',
|
||||
self::APPROVED => 'upload-status-badge--checked',
|
||||
self::REJECTED => 'upload-status-badge--error',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the imported hours are frozen.
|
||||
*
|
||||
* Once a teamer has signed a month, the stored hours have to keep matching the paper
|
||||
* they signed, so a later import may compare but not overwrite. A rejected month is
|
||||
* not locked: a rejection usually means the hours themselves were wrong.
|
||||
*/
|
||||
public function locksHours(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::SUBMITTED, self::APPROVED => true,
|
||||
self::OPEN, self::REJECTED => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\EventListener;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\Mailer\Event\MessageEvent;
|
||||
use Symfony\Component\Mime\Address;
|
||||
|
||||
/**
|
||||
* Keeps mail away from real people on deployments that are not production.
|
||||
*
|
||||
* Staging runs the real transports against a copy of the production data, so every recipient
|
||||
* in that database is a real address. With a whitelist configured, an envelope is reduced to
|
||||
* the recipients it allows and a mail left without any recipient is not sent at all.
|
||||
*
|
||||
* An empty whitelist means "do nothing", never "drop everything": production runs with the
|
||||
* environment variable unset and is therefore unaffected by this class. This is a deployment
|
||||
* guard - no business rule decides who may be mailed here.
|
||||
*
|
||||
* Symfony's own framework.mailer.envelope handling cannot do this: it redirects everything to
|
||||
* a catch-all address, and its allowed_recipients only adds the real recipient back on top.
|
||||
*/
|
||||
class EmailRecipientWhitelistSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
private readonly array $allowedRecipients;
|
||||
|
||||
/**
|
||||
* @param string[] $allowedRecipients regular expressions matched against the full address
|
||||
*
|
||||
* @throws \InvalidArgumentException on a pattern that is not a valid regular expression;
|
||||
* the service is lazy, so this surfaces on the first mail
|
||||
*/
|
||||
public function __construct(
|
||||
array $allowedRecipients,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
// A blank entry - a stray space in a .env.local, a deploy template emitting an empty
|
||||
// value - would otherwise make the whitelist non-empty while matching no address at
|
||||
// all, which silently drops every mail. Dropped here, so "empty" stays "do nothing".
|
||||
$patterns = array_values(array_filter(array_map(trim(...), $allowedRecipients), static fn (string $pattern) => '' !== $pattern));
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
// Checked once, here, rather than per address: an invalid pattern makes preg_match()
|
||||
// return false for every address, which looks exactly like "nobody is whitelisted"
|
||||
// and eats the mail. Blowing up on the first send is the louder failure.
|
||||
if (false === @preg_match($this->toRegex($pattern), '')) {
|
||||
throw new \InvalidArgumentException(\sprintf('Invalid recipient whitelist pattern "%s": it is not a valid regular expression.', $pattern));
|
||||
}
|
||||
}
|
||||
|
||||
$this->allowedRecipients = $patterns;
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
// After MessageListener, which applies the configured default headers - a Bcc
|
||||
// added there has to be filtered too - and before EnvelopeListener and
|
||||
// MessageLoggerListener at -255, so a dropped mail is not recorded as sent.
|
||||
MessageEvent::class => ['onMessage', -250],
|
||||
];
|
||||
}
|
||||
|
||||
public function onMessage(MessageEvent $event): void
|
||||
{
|
||||
if (!$this->allowedRecipients) {
|
||||
return;
|
||||
}
|
||||
|
||||
$envelope = $event->getEnvelope();
|
||||
$recipients = $envelope->getRecipients();
|
||||
|
||||
$kept = [];
|
||||
$stripped = [];
|
||||
|
||||
foreach ($recipients as $recipient) {
|
||||
if ($this->isAllowed($recipient)) {
|
||||
$kept[] = $recipient;
|
||||
} else {
|
||||
$stripped[] = $recipient->getAddress();
|
||||
}
|
||||
}
|
||||
|
||||
if (!$stripped) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$kept) {
|
||||
$this->logger->info('Mail dropped: no whitelisted recipient', [
|
||||
'recipients' => $stripped,
|
||||
'subject' => $this->subjectOf($event),
|
||||
]);
|
||||
|
||||
$event->reject();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$envelope->setRecipients($kept);
|
||||
|
||||
$this->logger->info('Mail recipients stripped: not whitelisted', [
|
||||
'stripped' => $stripped,
|
||||
'kept' => array_map(static fn (Address $address) => $address->getAddress(), $kept),
|
||||
'subject' => $this->subjectOf($event),
|
||||
]);
|
||||
}
|
||||
|
||||
private function isAllowed(Address $recipient): bool
|
||||
{
|
||||
foreach ($this->allowedRecipients as $pattern) {
|
||||
if (preg_match($this->toRegex($pattern), $recipient->getAddress())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anchored the way Symfony\Component\Mailer\EventListener\EnvelopeListener anchors its own
|
||||
* patterns: a pattern always has to match the whole address.
|
||||
*/
|
||||
private function toRegex(string $pattern): string
|
||||
{
|
||||
return '{\A'.$pattern.'\z}i';
|
||||
}
|
||||
|
||||
private function subjectOf(MessageEvent $event): ?string
|
||||
{
|
||||
$message = $event->getMessage();
|
||||
|
||||
return method_exists($message, 'getSubject') ? $message->getSubject() : null;
|
||||
}
|
||||
}
|
||||
@@ -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,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Entity\SeasonTaskDocument;
|
||||
use App\Entity\Upload;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* Either an existing library document or a newly uploaded one.
|
||||
*
|
||||
* The upload itself does not travel in the form - it goes through the dropzone into the upload
|
||||
* session, like everywhere else in this app - so the controller decides which of the two the
|
||||
* mapping ends up with: a dropped file wins over the pick, and a submit carrying neither is
|
||||
* refused.
|
||||
*/
|
||||
class SeasonTaskDocumentType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('document', AutocompleteEntityType::class, [
|
||||
'label' => 'Vorhandenes Dokument',
|
||||
'required' => false,
|
||||
'class' => Upload::class,
|
||||
'label_property' => 'originalFilename',
|
||||
'label_function' => fn (Upload $upload) => $upload->getDisplayName() ?? $upload->getOriginalFilename(),
|
||||
'endpoint_route' => 'app_admin_autocomplete_document',
|
||||
])
|
||||
// Unmapped: it names the uploaded file, which is an Upload and not part of the
|
||||
// mapping. Renaming a picked document would rename it for the whole library.
|
||||
->add('displayName', TextType::class, [
|
||||
'label' => 'angezeigter Name der neuen Datei',
|
||||
'required' => false,
|
||||
'mapped' => false,
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
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',
|
||||
],
|
||||
])
|
||||
;
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Enum\WorkMonthStatus;
|
||||
use App\Model\WorkMonthFilterDto;
|
||||
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 WorkMonthFilterType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('teamerName', TextType::class, [
|
||||
'label' => 'Name',
|
||||
'required' => false,
|
||||
])
|
||||
// The day is irrelevant here; FirstDayOfMonthDateTransformer pins it to the 1st.
|
||||
->add('period', AbbreviatedDateType::class, [
|
||||
'label' => 'Monat',
|
||||
'required' => false,
|
||||
'years' => range((int) date('Y') + 1, (int) date('Y') - 5),
|
||||
])
|
||||
->add('statuses', MultiselectType::class, [
|
||||
'label' => 'Status',
|
||||
'required' => false,
|
||||
'empty_label' => 'nicht filtern',
|
||||
'choices' => WorkMonthStatus::cases(),
|
||||
'choice_label' => static fn (WorkMonthStatus $status) => $status->label(),
|
||||
'choice_value' => static fn (?WorkMonthStatus $status) => $status?->value,
|
||||
])
|
||||
->add('includeApproved', CheckboxType::class, [
|
||||
'label' => 'geprüfte Monate anzeigen',
|
||||
'required' => false,
|
||||
])
|
||||
->add('includeMismatchedOnly', CheckboxType::class, [
|
||||
'label' => 'nur Monate mit abweichenden Stunden',
|
||||
'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' => WorkMonthFilterDto::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Enum\WorkMonthStatus;
|
||||
use App\Model\WorkMonthReviewDto;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
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;
|
||||
|
||||
class WorkMonthReviewType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('status', ChoiceType::class, [
|
||||
'label' => 'Entscheidung',
|
||||
'expanded' => true,
|
||||
'placeholder' => false,
|
||||
// Only the two outcomes a review produces: "offen" and "eingereicht" are
|
||||
// states the teamer puts the month into, not the office.
|
||||
'choices' => [
|
||||
WorkMonthStatus::APPROVED->label() => WorkMonthStatus::APPROVED,
|
||||
WorkMonthStatus::REJECTED->label() => WorkMonthStatus::REJECTED,
|
||||
],
|
||||
'choice_value' => static fn (?WorkMonthStatus $status) => $status?->value,
|
||||
])
|
||||
->add('comment', TextareaType::class, [
|
||||
'label' => 'Kommentar/Begründung',
|
||||
'required' => false,
|
||||
'attr' => [
|
||||
'rows' => 3,
|
||||
],
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => WorkMonthReviewDto::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Logging;
|
||||
|
||||
use Monolog\LogRecord;
|
||||
|
||||
/**
|
||||
* Keeps SaaS api keys out of the log while leaving the requests themselves in it.
|
||||
*
|
||||
* The SaaS takes its api key as a path segment rather than in a header, so the url of every
|
||||
* call carries a credential. Symfony's http client logs a line per request, and an HTTP error
|
||||
* repeats the url inside the exception message, which then travels into whatever channel
|
||||
* catches it - so silencing one client would not have been enough.
|
||||
*
|
||||
* Registered without a channel, deliberately: a url can surface anywhere, and a processor
|
||||
* that only guarded the saas channel would miss the exception that matters most.
|
||||
*
|
||||
* Two passes, because the two failure modes are different. The configured key is redacted
|
||||
* wherever it appears, whole or url-encoded, which is exact. The path shapes are redacted by
|
||||
* pattern, which also covers the per-employee keys the roster hands out and any future
|
||||
* endpoint that follows the same convention.
|
||||
*/
|
||||
class RedactSaasCredentialsProcessor
|
||||
{
|
||||
private const PLACEHOLDER = '[redacted]';
|
||||
|
||||
/**
|
||||
* Anything sitting where an api key sits: /api/v2/<something>/<key>[/...].
|
||||
*/
|
||||
private const PATH_PATTERNS = [
|
||||
'#(/api/v[0-9]+/time/account/)[^/\s"\']+#i',
|
||||
'#(/api/v[0-9]+/employees/)[^/\s"\']+#i',
|
||||
'#(/api/v[0-9]+/employee/departments/)[^/\s"\']+#i',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
private readonly array $secrets;
|
||||
|
||||
public function __construct(string $apiKey)
|
||||
{
|
||||
$secrets = [];
|
||||
|
||||
// Short values would match far too much; a real key never is.
|
||||
if (8 <= strlen($apiKey)) {
|
||||
$secrets[$apiKey] = $apiKey;
|
||||
$secrets[rawurlencode($apiKey)] = rawurlencode($apiKey);
|
||||
}
|
||||
|
||||
$this->secrets = array_values($secrets);
|
||||
}
|
||||
|
||||
public function __invoke(LogRecord $record): LogRecord
|
||||
{
|
||||
$message = $this->redact($record->message);
|
||||
$context = $this->redactDeep($record->context);
|
||||
$extra = $this->redactDeep($record->extra);
|
||||
|
||||
if ($message === $record->message && $context === $record->context && $extra === $record->extra) {
|
||||
return $record;
|
||||
}
|
||||
|
||||
// LogRecord only allows some of its fields to be assigned through array access;
|
||||
// message is not one of them, so a redacted record is a new one.
|
||||
return $record->with(message: $message, context: $context, extra: $extra);
|
||||
}
|
||||
|
||||
private function redactDeep(array $values, int $depth = 0): array
|
||||
{
|
||||
// Contexts hold arbitrary payloads; stop rather than walk something pathological.
|
||||
if (8 < $depth) {
|
||||
return $values;
|
||||
}
|
||||
|
||||
foreach ($values as $key => $value) {
|
||||
if (is_string($value)) {
|
||||
$values[$key] = $this->redact($value);
|
||||
} elseif (is_array($value)) {
|
||||
$values[$key] = $this->redactDeep($value, $depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
private function redact(string $value): string
|
||||
{
|
||||
foreach ($this->secrets as $secret) {
|
||||
$value = str_replace($secret, self::PLACEHOLDER, $value);
|
||||
}
|
||||
|
||||
foreach (self::PATH_PATTERNS as $pattern) {
|
||||
$value = (string) preg_replace($pattern, '$1'.self::PLACEHOLDER, $value);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -116,6 +116,42 @@ 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('Arbeitszeiten', [
|
||||
'route' => 'app_admin_work_month_index',
|
||||
'linkAttributes' => [
|
||||
'title' => 'Arbeitszeiten',
|
||||
],
|
||||
'extras' => [
|
||||
'icon' => 'hourglass',
|
||||
'routes' => [
|
||||
['pattern' => '/^app_admin_work_month_/'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
$menu->addChild('Verfügbarkeiten', [
|
||||
'route' => 'app_administrative_availability_index',
|
||||
'linkAttributes' => [
|
||||
@@ -272,6 +308,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,39 @@ 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_/'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// Season teamers only, and on the flag rather than on a current assignment: a month
|
||||
// still has to be reachable after the season it belongs to has ended.
|
||||
if (true === $this->isSeasonTeamer()) {
|
||||
$profileMenu->addChild('Arbeitszeiten', [
|
||||
'route' => 'app_teamer_work_month_index',
|
||||
'linkAttributes' => [
|
||||
'title' => 'Arbeitszeiten',
|
||||
],
|
||||
'extras' => [
|
||||
'routes' => [
|
||||
['pattern' => '/^app_teamer_work_month_/'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$profileMenu->addChild('Stammdaten & Foto', [
|
||||
'route' => 'app_teamer_profile_index',
|
||||
'linkAttributes' => [
|
||||
@@ -120,4 +170,22 @@ class TeamerMenuBuilder extends AbstractMenuBuilder
|
||||
|
||||
return $menu;
|
||||
}
|
||||
|
||||
private function isSeasonTeamer(): bool
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
|
||||
return $user instanceof User && true === $user->getTeamer()?->isAllSeason();
|
||||
}
|
||||
|
||||
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,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
use App\Entity\Teamer;
|
||||
use App\Enum\WorkHoursSyncOutcome;
|
||||
|
||||
/**
|
||||
* What one teamer's import did, so the command and the admin action can report it without
|
||||
* the synchronizer having to know about either.
|
||||
*/
|
||||
class WorkHoursSyncResult
|
||||
{
|
||||
/**
|
||||
* @param string[] $warnings
|
||||
*/
|
||||
private function __construct(
|
||||
private readonly Teamer $teamer,
|
||||
private readonly WorkHoursSyncOutcome $outcome,
|
||||
private readonly ?string $message = null,
|
||||
private readonly ?int $totalMinutes = null,
|
||||
private readonly array $warnings = [],
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $warnings
|
||||
*/
|
||||
public static function imported(Teamer $teamer, int $totalMinutes, array $warnings = []): self
|
||||
{
|
||||
return new self($teamer, WorkHoursSyncOutcome::IMPORTED, null, $totalMinutes, $warnings);
|
||||
}
|
||||
|
||||
public static function locked(Teamer $teamer, string $message, ?int $totalMinutes = null): self
|
||||
{
|
||||
return new self($teamer, WorkHoursSyncOutcome::LOCKED, $message, $totalMinutes);
|
||||
}
|
||||
|
||||
public static function skipped(Teamer $teamer, string $message): self
|
||||
{
|
||||
return new self($teamer, WorkHoursSyncOutcome::SKIPPED, $message);
|
||||
}
|
||||
|
||||
public static function failed(Teamer $teamer, string $message): self
|
||||
{
|
||||
return new self($teamer, WorkHoursSyncOutcome::FAILED, $message);
|
||||
}
|
||||
|
||||
public function getTeamer(): Teamer
|
||||
{
|
||||
return $this->teamer;
|
||||
}
|
||||
|
||||
public function getOutcome(): WorkHoursSyncOutcome
|
||||
{
|
||||
return $this->outcome;
|
||||
}
|
||||
|
||||
public function getMessage(): ?string
|
||||
{
|
||||
return $this->message;
|
||||
}
|
||||
|
||||
public function getTotalMinutes(): ?int
|
||||
{
|
||||
return $this->totalMinutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getWarnings(): array
|
||||
{
|
||||
return $this->warnings;
|
||||
}
|
||||
|
||||
public function isFailure(): bool
|
||||
{
|
||||
return WorkHoursSyncOutcome::FAILED === $this->outcome;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
/**
|
||||
* One calendar day as a work-month view renders it.
|
||||
*
|
||||
* "No minutes" and "zero minutes" are different things here: null means the SaaS reported
|
||||
* nothing at all for that day, 0 means it reported a day that happens to add up to zero -
|
||||
* a rest day, say. The view shows the first as a dash and the second as 0:00.
|
||||
*/
|
||||
class WorkMonthDay
|
||||
{
|
||||
public function __construct(
|
||||
private readonly \DateTimeImmutable $date,
|
||||
private readonly ?int $minutes,
|
||||
private readonly ?string $absence,
|
||||
private readonly ?string $startTime = null,
|
||||
private readonly ?string $endTime = null,
|
||||
private readonly int $breakMinutes = 0,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getDate(): \DateTimeImmutable
|
||||
{
|
||||
return $this->date;
|
||||
}
|
||||
|
||||
public function getMinutes(): ?int
|
||||
{
|
||||
return $this->minutes;
|
||||
}
|
||||
|
||||
public function getAbsence(): ?string
|
||||
{
|
||||
return $this->absence;
|
||||
}
|
||||
|
||||
public function getStartTime(): ?string
|
||||
{
|
||||
return $this->startTime;
|
||||
}
|
||||
|
||||
public function getEndTime(): ?string
|
||||
{
|
||||
return $this->endTime;
|
||||
}
|
||||
|
||||
public function getBreakMinutes(): int
|
||||
{
|
||||
return $this->breakMinutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the day has a span to print. A rest day is booked but never worked in.
|
||||
*/
|
||||
public function hasSpan(): bool
|
||||
{
|
||||
return null !== $this->startTime && null !== $this->endTime;
|
||||
}
|
||||
|
||||
public function isAbsence(): bool
|
||||
{
|
||||
return null !== $this->absence;
|
||||
}
|
||||
|
||||
public function isReported(): bool
|
||||
{
|
||||
return null !== $this->minutes;
|
||||
}
|
||||
|
||||
public function isWeekend(): bool
|
||||
{
|
||||
return in_array((int) $this->date->format('N'), [6, 7], true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
use App\Saas\Model\TimeAccount;
|
||||
use App\Saas\Model\TimeAccountDay;
|
||||
|
||||
/**
|
||||
* The days of one work month, and the only place that knows how they are stored.
|
||||
*
|
||||
* TeamerWorkMonth keeps its days in a JSON column rather than in rows: every read is
|
||||
* "one teamer, one month", which one row answers directly. The price is that the shape is
|
||||
* invisible to the schema, so it is kept small and passes through here alone - a change to
|
||||
* it is a change to this file and nothing else.
|
||||
*
|
||||
* Shape, with everything optional but "minutes" left out when it has nothing to say:
|
||||
* {"2026-03-01": {"minutes": 285, "break": 60, "start": "09:00", "end": "14:45"},
|
||||
* "2026-03-05": {"minutes": 0, "absence": "Ruhetag 24/25"}}
|
||||
* Days the SaaS did not report are simply absent; forCalendarMonth() fills the gaps.
|
||||
*/
|
||||
class WorkMonthDays
|
||||
{
|
||||
/**
|
||||
* @param array<string, array{minutes: int, break?: int, start?: string, end?: string, absence?: string}> $days keyed by Y-m-d
|
||||
*/
|
||||
private function __construct(private readonly array $days)
|
||||
{
|
||||
}
|
||||
|
||||
public static function fromArray(array $days): self
|
||||
{
|
||||
$clean = [];
|
||||
|
||||
foreach ($days as $key => $value) {
|
||||
if (!is_string($key) || !is_array($value) || 1 !== preg_match('/^\d{4}-\d{2}-\d{2}$/', $key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entry = ['minutes' => (int) ($value['minutes'] ?? 0)];
|
||||
|
||||
if (0 < $break = (int) ($value['break'] ?? 0)) {
|
||||
$entry['break'] = $break;
|
||||
}
|
||||
|
||||
$start = self::cleanTime($value['start'] ?? null);
|
||||
$end = self::cleanTime($value['end'] ?? null);
|
||||
|
||||
// A span needs both ends; half of one says nothing.
|
||||
if (null !== $start && null !== $end) {
|
||||
$entry['start'] = $start;
|
||||
$entry['end'] = $end;
|
||||
}
|
||||
|
||||
$absence = $value['absence'] ?? null;
|
||||
|
||||
if (is_string($absence) && '' !== $absence) {
|
||||
$entry['absence'] = $absence;
|
||||
}
|
||||
|
||||
$clean[$key] = $entry;
|
||||
}
|
||||
|
||||
ksort($clean);
|
||||
|
||||
return new self($clean);
|
||||
}
|
||||
|
||||
public static function fromTimeAccount(TimeAccount $account): self
|
||||
{
|
||||
$days = [];
|
||||
|
||||
foreach ($account->getDays() as $key => $day) {
|
||||
/* @var TimeAccountDay $day */
|
||||
$days[$key] = ['minutes' => $day->getMinutes()];
|
||||
|
||||
if (0 < $day->getBreakMinutes()) {
|
||||
$days[$key]['break'] = $day->getBreakMinutes();
|
||||
}
|
||||
|
||||
if (null !== $day->getStartTime() && null !== $day->getEndTime()) {
|
||||
$days[$key]['start'] = $day->getStartTime();
|
||||
$days[$key]['end'] = $day->getEndTime();
|
||||
}
|
||||
|
||||
if (null !== $day->getHolidayType()) {
|
||||
$days[$key]['absence'] = $day->getHolidayType();
|
||||
}
|
||||
}
|
||||
|
||||
return new self($days);
|
||||
}
|
||||
|
||||
private static function cleanTime(mixed $raw): ?string
|
||||
{
|
||||
return is_string($raw) && 1 === preg_match('/^\d{2}:\d{2}$/', $raw) ? $raw : null;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return $this->days;
|
||||
}
|
||||
|
||||
public function getTotalMinutes(): int
|
||||
{
|
||||
return array_sum(array_column($this->days, 'minutes'));
|
||||
}
|
||||
|
||||
public function countWorkedDays(): int
|
||||
{
|
||||
return count(array_filter($this->days, static fn (array $day) => ($day['minutes'] ?? 0) > 0));
|
||||
}
|
||||
|
||||
public function countAbsenceDays(): int
|
||||
{
|
||||
return count(array_filter($this->days, static fn (array $day) => isset($day['absence'])));
|
||||
}
|
||||
|
||||
public function getTotalBreakMinutes(): int
|
||||
{
|
||||
return array_sum(array_map(static fn (array $day) => $day['break'] ?? 0, $this->days));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every day of the month in order, reported or not, for tables and the PDF.
|
||||
*
|
||||
* @return list<WorkMonthDay>
|
||||
*/
|
||||
public function forCalendarMonth(int $year, int $month): array
|
||||
{
|
||||
$first = \DateTimeImmutable::createFromFormat('!Y-n-j', sprintf('%04d-%d-1', $year, $month));
|
||||
|
||||
if (false === $first) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$days = [];
|
||||
|
||||
for ($day = 1, $last = (int) $first->format('t'); $day <= $last; ++$day) {
|
||||
$date = $first->setDate($year, $month, $day);
|
||||
$entry = $this->days[$date->format('Y-m-d')] ?? null;
|
||||
|
||||
$days[] = new WorkMonthDay(
|
||||
$date,
|
||||
null !== $entry ? (int) $entry['minutes'] : null,
|
||||
$entry['absence'] ?? null,
|
||||
$entry['start'] ?? null,
|
||||
$entry['end'] ?? null,
|
||||
(int) ($entry['break'] ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
return $days;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
use App\Enum\WorkMonthStatus;
|
||||
|
||||
class WorkMonthFilterDto extends AbstractFilterDto
|
||||
{
|
||||
protected ?string $teamerName = null;
|
||||
|
||||
/**
|
||||
* Normalised to the first of the month by FirstDayOfMonthDateTransformer.
|
||||
*/
|
||||
protected ?\DateTimeImmutable $period = null;
|
||||
|
||||
/** @var WorkMonthStatus[] */
|
||||
protected array $statuses = [];
|
||||
|
||||
/**
|
||||
* Approved months are done with, so the list is about what still needs attention.
|
||||
*/
|
||||
protected bool $includeApproved = false;
|
||||
|
||||
/**
|
||||
* Months whose hours the SaaS has since disagreed with, which is a rare thing to go
|
||||
* looking for and a noisy thing to see by default.
|
||||
*/
|
||||
protected bool $includeMismatchedOnly = false;
|
||||
|
||||
public function getTeamerName(): ?string
|
||||
{
|
||||
return $this->teamerName;
|
||||
}
|
||||
|
||||
public function setTeamerName(?string $teamerName): static
|
||||
{
|
||||
$this->teamerName = $teamerName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPeriod(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->period;
|
||||
}
|
||||
|
||||
public function setPeriod(?\DateTimeImmutable $period): static
|
||||
{
|
||||
$this->period = $period;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return WorkMonthStatus[]
|
||||
*/
|
||||
public function getStatuses(): array
|
||||
{
|
||||
return $this->statuses;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param WorkMonthStatus[] $statuses
|
||||
*/
|
||||
public function setStatuses(array $statuses): static
|
||||
{
|
||||
$this->statuses = $statuses;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isIncludeApproved(): bool
|
||||
{
|
||||
return $this->includeApproved;
|
||||
}
|
||||
|
||||
public function setIncludeApproved(bool $includeApproved): static
|
||||
{
|
||||
$this->includeApproved = $includeApproved;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isIncludeMismatchedOnly(): bool
|
||||
{
|
||||
return $this->includeMismatchedOnly;
|
||||
}
|
||||
|
||||
public function setIncludeMismatchedOnly(bool $includeMismatchedOnly): static
|
||||
{
|
||||
$this->includeMismatchedOnly = $includeMismatchedOnly;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
use App\Entity\TeamerWorkMonth;
|
||||
use App\Enum\WorkMonthStatus;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
class WorkMonthReviewDto
|
||||
{
|
||||
private TeamerWorkMonth $workMonth;
|
||||
|
||||
#[Assert\NotNull(message: 'Bitte eine Entscheidung treffen')]
|
||||
private ?WorkMonthStatus $status;
|
||||
|
||||
private ?string $comment;
|
||||
|
||||
public function __construct(TeamerWorkMonth $workMonth)
|
||||
{
|
||||
$this->workMonth = $workMonth;
|
||||
$this->status = null;
|
||||
$this->comment = $workMonth->getReviewComment();
|
||||
}
|
||||
|
||||
public function getWorkMonth(): TeamerWorkMonth
|
||||
{
|
||||
return $this->workMonth;
|
||||
}
|
||||
|
||||
public function getStatus(): ?WorkMonthStatus
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function setStatus(?WorkMonthStatus $status): static
|
||||
{
|
||||
$this->status = $status;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getComment(): ?string
|
||||
{
|
||||
return $this->comment;
|
||||
}
|
||||
|
||||
public function setComment(?string $comment): static
|
||||
{
|
||||
$this->comment = $comment;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* A rejection without a reason leaves the teamer guessing what to fix.
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validateComment(ExecutionContextInterface $context): void
|
||||
{
|
||||
if (WorkMonthStatus::REJECTED === $this->status && '' === trim((string) $this->comment)) {
|
||||
$context
|
||||
->buildViolation('Bitte eine Begründung für die Ablehnung angeben')
|
||||
->atPath('comment')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,129 @@
|
||||
<?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()
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows a newly deposited library document may still be copied onto: the entry is not
|
||||
* ticked off yet and its assignment has not ended.
|
||||
*
|
||||
* A finished entry keeps what it handed out - that is the whole point of the row carrying its
|
||||
* own document - and so does an assignment that is over, whether or not every entry on it was
|
||||
* ticked.
|
||||
*
|
||||
* @return SeasonAssignmentTask[]
|
||||
*/
|
||||
public function findOpenForTask(string $taskKey, \DateTimeImmutable $today): array
|
||||
{
|
||||
return $this
|
||||
->createOpenForTaskQueryBuilder($taskKey, $today)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracted like createOpenQueryBuilder() below, so a test can pin the conditions by
|
||||
* compiling the DQL rather than by persisting rows.
|
||||
*/
|
||||
public function createOpenForTaskQueryBuilder(string $taskKey, \DateTimeImmutable $today): QueryBuilder
|
||||
{
|
||||
$qb = $this->createQueryBuilder('task');
|
||||
|
||||
return $qb
|
||||
->innerJoin('task.seasonAssignment', 'season_assignment')
|
||||
->where($qb->expr()->andX(
|
||||
$qb->expr()->eq('task.taskKey', ':taskKey'),
|
||||
$qb->expr()->eq('task.done', ':done'),
|
||||
$qb->expr()->gte('season_assignment.dateTo', ':today'),
|
||||
$qb->expr()->isNull('season_assignment.deletedAt')
|
||||
))
|
||||
->setParameter('taskKey', $taskKey)
|
||||
->setParameter('done', false)
|
||||
->setParameter('today', $today)
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracted so the conditions can be pinned by a test compiling the DQL - the suite has no
|
||||
* fixtures, so what is asserted is what the query says, not what it returns.
|
||||
*/
|
||||
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,52 @@ class TeamerRepository extends ServiceEntityRepository
|
||||
return $qb;
|
||||
}
|
||||
|
||||
public function getAutocompletionData(string $search): array
|
||||
/**
|
||||
* The season teamers whose working time the SaaS import covers for one month.
|
||||
*
|
||||
* The season flag alone is not enough: it stays set all year, and fetching a teamer for a
|
||||
* month they were not placed in would create a sheet with nothing on it and then remind
|
||||
* them to sign it. "Placed in this month" is computed rather than stored - there is no flag
|
||||
* on SeasonAssignment - as an overlap of the assignment's own period with the month,
|
||||
* exactly as SeasonAssignmentRepository does it. An address is required because the API
|
||||
* looks an employee up by email.
|
||||
*
|
||||
* @return Teamer[]
|
||||
*/
|
||||
public function findSeasonTeamersForMonth(\DateTimeImmutable $firstDay, \DateTimeImmutable $lastDay): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('teamer');
|
||||
|
||||
$teamers = $qb
|
||||
return $qb
|
||||
->innerJoin('teamer.seasonAssignments', 'season_assignment')
|
||||
->andWhere($qb->expr()->eq('teamer.allSeason', ':allSeason'))
|
||||
->andWhere($qb->expr()->isNull('teamer.deletedAt'))
|
||||
->andWhere($qb->expr()->isNull('season_assignment.deletedAt'))
|
||||
->andWhere('season_assignment.dateFrom <= :lastDay')
|
||||
->andWhere('season_assignment.dateTo >= :firstDay')
|
||||
->andWhere($qb->expr()->isNotNull('teamer.communication.email'))
|
||||
->andWhere($qb->expr()->neq('teamer.communication.email', ':empty'))
|
||||
->setParameter('allSeason', true)
|
||||
->setParameter('firstDay', $firstDay)
|
||||
->setParameter('lastDay', $lastDay)
|
||||
->setParameter('empty', '')
|
||||
->groupBy('teamer.id')
|
||||
->orderBy('teamer.lastName', \SortDirection::Ascending)
|
||||
->addOrderBy('teamer.firstName', \SortDirection::Ascending)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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');
|
||||
|
||||
$qb
|
||||
->where($qb->expr()->orX(
|
||||
$qb->expr()->like('teamer.lastName', ':search'),
|
||||
$qb->expr()->like('teamer.firstName', ':search'),
|
||||
@@ -180,6 +228,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()
|
||||
;
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Teamer;
|
||||
use App\Entity\TeamerWorkMonth;
|
||||
use App\Enum\WorkMonthStatus;
|
||||
use App\Model\WorkMonthFilterDto;
|
||||
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<TeamerWorkMonth>
|
||||
*
|
||||
* @method TeamerWorkMonth|null find($id, $lockMode = null, $lockVersion = null)
|
||||
* @method TeamerWorkMonth|null findOneBy(array $criteria, array $orderBy = null)
|
||||
* @method TeamerWorkMonth[] findAll()
|
||||
* @method TeamerWorkMonth[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
|
||||
*/
|
||||
class TeamerWorkMonthRepository extends ServiceEntityRepository
|
||||
{
|
||||
use QueryHelperTrait;
|
||||
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, TeamerWorkMonth::class);
|
||||
}
|
||||
|
||||
public function findOneForTeamerAndMonth(Teamer $teamer, int $year, int $month): ?TeamerWorkMonth
|
||||
{
|
||||
return $this->findOneBy(['teamer' => $teamer, 'year' => $year, 'month' => $month]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return TeamerWorkMonth[]
|
||||
*/
|
||||
public function findForTeamer(Teamer $teamer): array
|
||||
{
|
||||
return $this->findBy(
|
||||
['teamer' => $teamer],
|
||||
['year' => \SortDirection::Descending, 'month' => \SortDirection::Descending],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The admin list.
|
||||
*
|
||||
* Deliberately over TeamerWorkMonth rather than over Upload: UploadRepository's list
|
||||
* queries inner-join a disposition, which structurally excludes every document hanging
|
||||
* off anything else, and it is the month that carries the status anyway.
|
||||
*/
|
||||
public function getListQuery(WorkMonthFilterDto $filterDto): Query
|
||||
{
|
||||
$qb = $this->createQueryBuilder('work_month');
|
||||
|
||||
$qb
|
||||
->select('work_month', 'teamer')
|
||||
->innerJoin('work_month.teamer', 'teamer')
|
||||
;
|
||||
|
||||
$this->applyFilterSettings($filterDto, $qb);
|
||||
|
||||
return $qb
|
||||
->addOrderBy('work_month.year', \SortDirection::Descending)
|
||||
->addOrderBy('work_month.month', \SortDirection::Descending)
|
||||
->addOrderBy('teamer.lastName', \SortDirection::Ascending)
|
||||
->getQuery()
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Months still waiting for a signed copy, for the reminder.
|
||||
*
|
||||
* @return TeamerWorkMonth[]
|
||||
*/
|
||||
public function findAwaitingDocument(\DateTimeImmutable $importedBefore): array
|
||||
{
|
||||
return $this
|
||||
->createQueryBuilder('work_month')
|
||||
->select('work_month', 'teamer')
|
||||
->innerJoin('work_month.teamer', 'teamer')
|
||||
->andWhere('work_month.status IN (:awaiting)')
|
||||
->andWhere('work_month.syncedAt <= :importedBefore')
|
||||
->andWhere('teamer.deletedAt IS NULL')
|
||||
->setParameter('awaiting', [WorkMonthStatus::OPEN, WorkMonthStatus::REJECTED])
|
||||
->setParameter('importedBefore', $importedBefore)
|
||||
->addOrderBy('teamer.lastName', \SortDirection::Ascending)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
|
||||
private function applyFilterSettings(WorkMonthFilterDto $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 (null !== $period = $filterDto->getPeriod()) {
|
||||
$qb
|
||||
->andWhere('work_month.year = :year')
|
||||
->andWhere('work_month.month = :month')
|
||||
->setParameter('year', (int) $period->format('Y'))
|
||||
->setParameter('month', (int) $period->format('n'))
|
||||
;
|
||||
}
|
||||
|
||||
if (0 < count($filterDto->getStatuses())) {
|
||||
$qb
|
||||
->andWhere('work_month.status IN (:statuses)')
|
||||
->setParameter('statuses', $filterDto->getStatuses())
|
||||
;
|
||||
} elseif (!$filterDto->isIncludeApproved()) {
|
||||
// Only a default, so an explicit status choice still wins.
|
||||
$qb
|
||||
->andWhere('work_month.status != :approved')
|
||||
->setParameter('approved', WorkMonthStatus::APPROVED)
|
||||
;
|
||||
}
|
||||
|
||||
if ($filterDto->isIncludeMismatchedOnly()) {
|
||||
$qb->andWhere('work_month.mismatchDetectedAt IS NOT NULL');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Loaded 100 of 194 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user