4 Commits

Author SHA1 Message Date
cd5946bda8 Hotfix Sass build 2025-06-01 15:45:15 +02:00
25aa8b8622 Introduce importmap, enhance elimination workflows, and update assets
This commit adds initial importmap configuration to manage assets, updates the elimination preparation workflow with form enhancements and database changes, introduces new styles and JS assets, refines translations, and improves entity handling with an input bag update method.
2025-06-01 15:40:47 +02:00
d3e5cb0569 Refactor elimination feature and improve backoffice usability
This commit introduces a refactored EliminationFactory for better modularity, updates the elimination preparation process, and adds functionality to view eliminations. Backoffice templates and forms have been reorganized, minor translations were corrected, and additional assets like styles and flashes were included for enhanced user experience.
2025-06-01 15:40:47 +02:00
e0350c8c31 WIP 2025-06-01 15:40:47 +02:00
64 changed files with 1992 additions and 958 deletions

View File

@@ -2,3 +2,4 @@
###> symfony/framework-bundle ###
APP_SECRET=e26b9552d9e7f969b160373effaa7690
###< symfony/framework-bundle ###
MAILER_SENDER=info@tijdvoordetest.nl

View File

@@ -33,15 +33,17 @@ jobs:
*.cache-from=type=gha,scope=refs/heads/main
*.cache-to=type=gha,scope=${{github.ref}},mode=max
- name: Start services
run: docker compose up --wait --no-build
run: docker compose up php database --wait --no-build
- name: Lint Twig templates
run: docker compose exec -T php bin/console lint:twig --format=github templates
- name: Coding Style
run: docker compose exec -T php vendor/bin/php-cs-fixer check --diff --show-progress=none
- name: Twig Coding Style
run: docker compose exec -T php vendor/bin/twig-cs-fixer check
- name: Check HTTP reachability
run: curl -v --fail-with-body http://localhost
- name: Check HTTPS reachability
if: false # Remove this line when the homepage will be configured, or change the path to check
run: curl -vk --fail-with-body https://localhost
- name: Check Mercure reachability
if: false
run: curl -vkI --fail-with-body https://localhost/.well-known/mercure?topic=test
- name: Create test database
run: docker compose exec -T php bin/console -e test doctrine:database:create

5
.gitignore vendored
View File

@@ -114,3 +114,8 @@ phpstan.neon
.phpunit.result.cache
/phpunit.xml
###< symfony/phpunit-bridge ###
###> symfony/asset-mapper ###
/public/assets/
/assets/vendor/
###< symfony/asset-mapper ###

View File

@@ -159,6 +159,11 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/phpoffice/phpspreadsheet" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/http-client" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/simple-cache" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/asset-mapper" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-client" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-client-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfonycasts/sass-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/intl-extra" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />

293
.idea/php.xml generated
View File

@@ -1,5 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="LaravelPint">
<laravel_pint_settings>
<laravel_pint_by_interpreter asDefaultInterpreter="true" interpreter_id="96512cb2-7b9e-4e1d-bfa2-bf7f3be424c8">
<option name="timeout" value="30000" />
</laravel_pint_by_interpreter>
</laravel_pint_settings>
</component>
<component name="MessDetector">
<phpmd_settings>
<phpmd_by_interpreter asDefaultInterpreter="true" interpreter_id="96512cb2-7b9e-4e1d-bfa2-bf7f3be424c8" timeout="30000" />
</phpmd_settings>
</component>
<component name="MessDetectorOptionsConfiguration">
<option name="transferred" value="true" />
</component>
@@ -29,159 +41,164 @@
</component>
<component name="PhpIncludePathManager">
<include_path>
<path value="$PROJECT_DIR$/vendor/symfony/service-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/string" />
<path value="$PROJECT_DIR$/vendor/symfony/dotenv" />
<path value="$PROJECT_DIR$/vendor/runtime/frankenphp-symfony" />
<path value="$PROJECT_DIR$/vendor/composer" />
<path value="$PROJECT_DIR$/vendor/symfony/cache-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/finder" />
<path value="$PROJECT_DIR$/vendor/symfony/runtime" />
<path value="$PROJECT_DIR$/vendor/symfony/routing" />
<path value="$PROJECT_DIR$/vendor/psr/event-dispatcher" />
<path value="$PROJECT_DIR$/vendor/symfony/var-exporter" />
<path value="$PROJECT_DIR$/vendor/psr/cache" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-grapheme" />
<path value="$PROJECT_DIR$/vendor/psr/log" />
<path value="$PROJECT_DIR$/vendor/symfony/cache" />
<path value="$PROJECT_DIR$/vendor/psr/container" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-normalizer" />
<path value="$PROJECT_DIR$/vendor/symfony/dependency-injection" />
<path value="$PROJECT_DIR$/vendor/symfony/config" />
<path value="$PROJECT_DIR$/vendor/symfony/filesystem" />
<path value="$PROJECT_DIR$/vendor/symfony/error-handler" />
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/console" />
<path value="$PROJECT_DIR$/vendor/symfony/flex" />
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher" />
<path value="$PROJECT_DIR$/vendor/symfony/var-dumper" />
<path value="$PROJECT_DIR$/vendor/symfony/http-foundation" />
<path value="$PROJECT_DIR$/vendor/symfony/framework-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/http-kernel" />
<path value="$PROJECT_DIR$/vendor/symfony/doctrine-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/stopwatch" />
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-migrations-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/migrations" />
<path value="$PROJECT_DIR$/vendor/doctrine/lexer" />
<path value="$PROJECT_DIR$/vendor/doctrine/dbal" />
<path value="$PROJECT_DIR$/vendor/doctrine/inflector" />
<path value="$PROJECT_DIR$/vendor/doctrine/persistence" />
<path value="$PROJECT_DIR$/vendor/doctrine/instantiator" />
<path value="$PROJECT_DIR$/vendor/doctrine/orm" />
<path value="$PROJECT_DIR$/vendor/doctrine/event-manager" />
<path value="$PROJECT_DIR$/vendor/doctrine/collections" />
<path value="$PROJECT_DIR$/vendor/doctrine/sql-formatter" />
<path value="$PROJECT_DIR$/vendor/doctrine/deprecations" />
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/process" />
<path value="$PROJECT_DIR$/vendor/symfony/maker-bundle" />
<path value="$PROJECT_DIR$/vendor/nikic/php-parser" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan" />
<path value="$PROJECT_DIR$/vendor/sebastian/diff" />
<path value="$PROJECT_DIR$/vendor/rector/rector" />
<path value="$PROJECT_DIR$/vendor/react/stream" />
<path value="$PROJECT_DIR$/vendor/react/promise" />
<path value="$PROJECT_DIR$/vendor/react/event-loop" />
<path value="$PROJECT_DIR$/vendor/react/cache" />
<path value="$PROJECT_DIR$/vendor/react/stream" />
<path value="$PROJECT_DIR$/vendor/clue/ndjson-react" />
<path value="$PROJECT_DIR$/vendor/react/promise" />
<path value="$PROJECT_DIR$/vendor/react/dns" />
<path value="$PROJECT_DIR$/vendor/friendsofphp/php-cs-fixer" />
<path value="$PROJECT_DIR$/vendor/react/socket" />
<path value="$PROJECT_DIR$/vendor/react/child-process" />
<path value="$PROJECT_DIR$/vendor/evenement/evenement" />
<path value="$PROJECT_DIR$/vendor/jean85/pretty-package-versions" />
<path value="$PROJECT_DIR$/vendor/react/dns" />
<path value="$PROJECT_DIR$/vendor/react/socket" />
<path value="$PROJECT_DIR$/vendor/sentry/sentry" />
<path value="$PROJECT_DIR$/vendor/staabm/side-effects-detector" />
<path value="$PROJECT_DIR$/vendor/rector/rector" />
<path value="$PROJECT_DIR$/vendor/sentry/sentry-symfony" />
<path value="$PROJECT_DIR$/vendor/phar-io/manifest" />
<path value="$PROJECT_DIR$/vendor/phar-io/version" />
<path value="$PROJECT_DIR$/vendor/egulias/email-validator" />
<path value="$PROJECT_DIR$/vendor/myclabs/deep-copy" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan-symfony" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan-phpunit" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan-doctrine" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-timer" />
<path value="$PROJECT_DIR$/vendor/phpunit/phpunit" />
<path value="$PROJECT_DIR$/vendor/phpstan/extension-installer" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpdoc-parser" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-invoker" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-file-iterator" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-text-template" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-code-coverage" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-icu" />
<path value="$PROJECT_DIR$/vendor/symfony/form" />
<path value="$PROJECT_DIR$/vendor/runtime/frankenphp-symfony" />
<path value="$PROJECT_DIR$/vendor/symfony/framework-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/validator" />
<path value="$PROJECT_DIR$/vendor/symfony/cache" />
<path value="$PROJECT_DIR$/vendor/symfony/serializer" />
<path value="$PROJECT_DIR$/vendor/symfony/dotenv" />
<path value="$PROJECT_DIR$/vendor/symfony/security-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/intl" />
<path value="$PROJECT_DIR$/vendor/symfony/asset" />
<path value="$PROJECT_DIR$/vendor/symfony/browser-kit" />
<path value="$PROJECT_DIR$/vendor/symfony/config" />
<path value="$PROJECT_DIR$/vendor/symfony/security-http" />
<path value="$PROJECT_DIR$/vendor/symfony/translation" />
<path value="$PROJECT_DIR$/vendor/symfony/translation-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher" />
<path value="$PROJECT_DIR$/vendor/symfony/runtime" />
<path value="$PROJECT_DIR$/vendor/symfony/uid" />
<path value="$PROJECT_DIR$/vendor/symfony/asset-mapper" />
<path value="$PROJECT_DIR$/vendor/symfony/mailer" />
<path value="$PROJECT_DIR$/vendor/symfony/filesystem" />
<path value="$PROJECT_DIR$/vendor/symfony/phpunit-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/var-dumper" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-idn" />
<path value="$PROJECT_DIR$/vendor/symfony/maker-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/options-resolver" />
<path value="$PROJECT_DIR$/vendor/fidry/cpu-core-counter" />
<path value="$PROJECT_DIR$/vendor/symfony/security-csrf" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-normalizer" />
<path value="$PROJECT_DIR$/vendor/symfony/css-selector" />
<path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/web-profiler-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/property-access" />
<path value="$PROJECT_DIR$/vendor/symfony/twig-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/dom-crawler" />
<path value="$PROJECT_DIR$/vendor/symfony/http-client" />
<path value="$PROJECT_DIR$/vendor/symfony/stopwatch" />
<path value="$PROJECT_DIR$/vendor/symfony/finder" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php84" />
<path value="$PROJECT_DIR$/vendor/symfony/yaml" />
<path value="$PROJECT_DIR$/vendor/symfony/mime" />
<path value="$PROJECT_DIR$/vendor/symfony/psr-http-message-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/http-kernel" />
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/routing" />
<path value="$PROJECT_DIR$/vendor/symfony/string" />
<path value="$PROJECT_DIR$/vendor/symfony/security-core" />
<path value="$PROJECT_DIR$/vendor/symfony/property-info" />
<path value="$PROJECT_DIR$/vendor/symfony/type-info" />
<path value="$PROJECT_DIR$/vendor/symfony/service-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/http-client-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/var-exporter" />
<path value="$PROJECT_DIR$/vendor/symfony/flex" />
<path value="$PROJECT_DIR$/vendor/symfony/http-foundation" />
<path value="$PROJECT_DIR$/vendor/symfony/doctrine-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/dependency-injection" />
<path value="$PROJECT_DIR$/vendor/symfony/clock" />
<path value="$PROJECT_DIR$/vendor/symfony/error-handler" />
<path value="$PROJECT_DIR$/vendor/symfony/cache-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/console" />
<path value="$PROJECT_DIR$/vendor/symfony/ux-twig-component" />
<path value="$PROJECT_DIR$/vendor/symfony/password-hasher" />
<path value="$PROJECT_DIR$/vendor/symfony/process" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-grapheme" />
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-migrations-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/lexer" />
<path value="$PROJECT_DIR$/vendor/doctrine/dbal" />
<path value="$PROJECT_DIR$/vendor/doctrine/migrations" />
<path value="$PROJECT_DIR$/vendor/doctrine/data-fixtures" />
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/instantiator" />
<path value="$PROJECT_DIR$/vendor/doctrine/inflector" />
<path value="$PROJECT_DIR$/vendor/doctrine/persistence" />
<path value="$PROJECT_DIR$/vendor/doctrine/collections" />
<path value="$PROJECT_DIR$/vendor/doctrine/sql-formatter" />
<path value="$PROJECT_DIR$/vendor/doctrine/orm" />
<path value="$PROJECT_DIR$/vendor/doctrine/event-manager" />
<path value="$PROJECT_DIR$/vendor/easycorp/easyadmin-bundle" />
<path value="$PROJECT_DIR$/vendor/evenement/evenement" />
<path value="$PROJECT_DIR$/vendor/doctrine/deprecations" />
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-bundle" />
<path value="$PROJECT_DIR$/vendor/markbaker/matrix" />
<path value="$PROJECT_DIR$/vendor/phpoffice/phpspreadsheet" />
<path value="$PROJECT_DIR$/vendor/maennchen/zipstream-php" />
<path value="$PROJECT_DIR$/vendor/markbaker/complex" />
<path value="$PROJECT_DIR$/vendor/sebastian/global-state" />
<path value="$PROJECT_DIR$/vendor/sebastian/complexity" />
<path value="$PROJECT_DIR$/vendor/sebastian/recursion-context" />
<path value="$PROJECT_DIR$/vendor/sebastian/object-enumerator" />
<path value="$PROJECT_DIR$/vendor/ralouphie/getallheaders" />
<path value="$PROJECT_DIR$/vendor/sebastian/comparator" />
<path value="$PROJECT_DIR$/vendor/sebastian/diff" />
<path value="$PROJECT_DIR$/vendor/sebastian/recursion-context" />
<path value="$PROJECT_DIR$/vendor/sebastian/object-reflector" />
<path value="$PROJECT_DIR$/vendor/sebastian/complexity" />
<path value="$PROJECT_DIR$/vendor/sebastian/version" />
<path value="$PROJECT_DIR$/vendor/sebastian/cli-parser" />
<path value="$PROJECT_DIR$/vendor/sebastian/lines-of-code" />
<path value="$PROJECT_DIR$/vendor/sebastian/type" />
<path value="$PROJECT_DIR$/vendor/sebastian/version" />
<path value="$PROJECT_DIR$/vendor/sebastian/comparator" />
<path value="$PROJECT_DIR$/vendor/sebastian/exporter" />
<path value="$PROJECT_DIR$/vendor/sebastian/environment" />
<path value="$PROJECT_DIR$/vendor/myclabs/deep-copy" />
<path value="$PROJECT_DIR$/vendor/phar-io/manifest" />
<path value="$PROJECT_DIR$/vendor/phar-io/version" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-timer" />
<path value="$PROJECT_DIR$/vendor/phpunit/phpunit" />
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-text-template" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-code-coverage" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-invoker" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-file-iterator" />
<path value="$PROJECT_DIR$/vendor/staabm/side-effects-detector" />
<path value="$PROJECT_DIR$/vendor/symfony/twig-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/translation-contracts" />
<path value="$PROJECT_DIR$/vendor/twig/twig" />
<path value="$PROJECT_DIR$/vendor/symfony/uid" />
<path value="$PROJECT_DIR$/vendor/psr/clock" />
<path value="$PROJECT_DIR$/vendor/symfony/security-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/type-info" />
<path value="$PROJECT_DIR$/vendor/symfony/property-access" />
<path value="$PROJECT_DIR$/vendor/symfony/property-info" />
<path value="$PROJECT_DIR$/vendor/symfony/password-hasher" />
<path value="$PROJECT_DIR$/vendor/symfony/clock" />
<path value="$PROJECT_DIR$/vendor/symfony/security-csrf" />
<path value="$PROJECT_DIR$/vendor/symfony/security-core" />
<path value="$PROJECT_DIR$/vendor/symfony/security-http" />
<path value="$PROJECT_DIR$/vendor/symfony/web-profiler-bundle" />
<path value="$PROJECT_DIR$/vendor/thecodingmachine/safe" />
<path value="$PROJECT_DIR$/vendor/phpstan/extension-installer" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan-doctrine" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan-phpunit" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan-symfony" />
<path value="$PROJECT_DIR$/vendor/symfony/asset" />
<path value="$PROJECT_DIR$/vendor/thecodingmachine/phpstan-safe-rule" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-icu" />
<path value="$PROJECT_DIR$/vendor/symfony/form" />
<path value="$PROJECT_DIR$/vendor/twig/html-extra" />
<path value="$PROJECT_DIR$/vendor/twig/extra-bundle" />
<path value="$PROJECT_DIR$/vendor/easycorp/easyadmin-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/mime" />
<path value="$PROJECT_DIR$/vendor/symfony/validator" />
<path value="$PROJECT_DIR$/vendor/symfony/ux-twig-component" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-idn" />
<path value="$PROJECT_DIR$/vendor/symfony/intl" />
<path value="$PROJECT_DIR$/vendor/symfony/translation" />
<path value="$PROJECT_DIR$/vendor/doctrine/data-fixtures" />
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
<path value="$PROJECT_DIR$/vendor/webmozart/assert" />
<path value="$PROJECT_DIR$/vendor/vincentlanglet/twig-cs-fixer" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php84" />
<path value="$PROJECT_DIR$/vendor/symfony/browser-kit" />
<path value="$PROJECT_DIR$/vendor/symfony/dom-crawler" />
<path value="$PROJECT_DIR$/vendor/masterminds/html5" />
<path value="$PROJECT_DIR$/vendor/egulias/email-validator" />
<path value="$PROJECT_DIR$/vendor/symfony/mailer" />
<path value="$PROJECT_DIR$/vendor/symfonycasts/verify-email-bundle" />
<path value="$PROJECT_DIR$/vendor/guzzlehttp/psr7" />
<path value="$PROJECT_DIR$/vendor/ralouphie/getallheaders" />
<path value="$PROJECT_DIR$/vendor/jean85/pretty-package-versions" />
<path value="$PROJECT_DIR$/vendor/psr/http-factory" />
<path value="$PROJECT_DIR$/vendor/psr/http-message" />
<path value="$PROJECT_DIR$/vendor/sentry/sentry-symfony" />
<path value="$PROJECT_DIR$/vendor/sentry/sentry" />
<path value="$PROJECT_DIR$/vendor/symfony/psr-http-message-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/css-selector" />
<path value="$PROJECT_DIR$/vendor/symfony/phpunit-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/serializer" />
<path value="$PROJECT_DIR$/vendor/sebastian/environment" />
<path value="$PROJECT_DIR$/vendor/sebastian/exporter" />
<path value="$PROJECT_DIR$/vendor/symfonycasts/verify-email-bundle" />
<path value="$PROJECT_DIR$/vendor/symfonycasts/sass-bundle" />
<path value="$PROJECT_DIR$/vendor/masterminds/html5" />
<path value="$PROJECT_DIR$/vendor/friendsofphp/php-cs-fixer" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-docblock" />
<path value="$PROJECT_DIR$/vendor/vincentlanglet/twig-cs-fixer" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-common" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/type-resolver" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-docblock" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpdoc-parser" />
<path value="$PROJECT_DIR$/vendor/markbaker/complex" />
<path value="$PROJECT_DIR$/vendor/markbaker/matrix" />
<path value="$PROJECT_DIR$/vendor/phpoffice/phpspreadsheet" />
<path value="$PROJECT_DIR$/vendor/psr/http-client" />
<path value="$PROJECT_DIR$/vendor/thecodingmachine/safe" />
<path value="$PROJECT_DIR$/vendor/thecodingmachine/phpstan-safe-rule" />
<path value="$PROJECT_DIR$/vendor/composer" />
<path value="$PROJECT_DIR$/vendor/psr/http-message" />
<path value="$PROJECT_DIR$/vendor/psr/container" />
<path value="$PROJECT_DIR$/vendor/psr/cache" />
<path value="$PROJECT_DIR$/vendor/psr/event-dispatcher" />
<path value="$PROJECT_DIR$/vendor/psr/simple-cache" />
<path value="$PROJECT_DIR$/vendor/maennchen/zipstream-php" />
<path value="$PROJECT_DIR$/vendor/symfony/yaml" />
<path value="$PROJECT_DIR$/vendor/psr/clock" />
<path value="$PROJECT_DIR$/vendor/psr/http-client" />
<path value="$PROJECT_DIR$/vendor/psr/log" />
<path value="$PROJECT_DIR$/vendor/twig/extra-bundle" />
<path value="$PROJECT_DIR$/vendor/twig/intl-extra" />
<path value="$PROJECT_DIR$/vendor/psr/http-factory" />
<path value="$PROJECT_DIR$/vendor/clue/ndjson-react" />
<path value="$PROJECT_DIR$/vendor/fidry/cpu-core-counter" />
<path value="$PROJECT_DIR$/vendor/nikic/php-parser" />
<path value="$PROJECT_DIR$/vendor/twig/html-extra" />
<path value="$PROJECT_DIR$/vendor/twig/twig" />
</include_path>
</component>
<component name="PhpInterpreters">

View File

@@ -33,7 +33,7 @@ RUN set -eux; \
zip \
uuid \
gd \
excimer \
excimer-1.2.3 \
;
# https://getcomposer.org/doc/03-cli.md#composer-allow-superuser
@@ -99,4 +99,7 @@ RUN set -eux; \
composer dump-autoload --classmap-authoritative --no-dev; \
composer dump-env prod; \
composer run-script --no-dev post-install-cmd; \
chmod +x bin/console; sync;
chmod +x bin/console; \
bin/console sass:build; \
bin/console asset-map:compile --no-debug --quiet --no-ansi; \
sync;

View File

@@ -34,3 +34,8 @@ rector *args:
phpstan *args:
docker compose exec php vendor/bin/phpstan analyse {{ args }}
[confirm]
clean:
docker compose down -v --remove-orphans
rm -rf vendor var assets/vendor public/assets public/bundles .php-cs-fixer.cache .twig-cs-fixer.cache

4
assets/backoffice.js Normal file
View File

@@ -0,0 +1,4 @@
import 'bootstrap/dist/css/bootstrap.min.css'
import * as bootstrap from 'bootstrap'
import './styles/app.scss';

View File

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 164 KiB

4
assets/quiz.js Normal file
View File

@@ -0,0 +1,4 @@
import 'bootstrap/dist/css/bootstrap.min.css'
import * as bootstrap from 'bootstrap'
import './styles/quiz.scss'

0
assets/styles/app.scss Normal file
View File

12
assets/styles/quiz.scss Normal file
View File

@@ -0,0 +1,12 @@
html, body {
height: 100%;
background-image: url("../img/background.png");
background-position: center center;
background-repeat: no-repeat;
background-color: black;
color: white;
display: grid;
align-items: center;
justify-self: center;
}

View File

@@ -31,6 +31,17 @@ services:
- target: 443
published: ${HTTP3_PORT:-443}
protocol: udp
sass:
image: ${IMAGES_PREFIX:-}app-php
volumes:
- ./:/app
entrypoint: ''
depends_on:
- php
command:
- bin/console
- sass:build
- --watch
###> symfony/mercure-bundle ###
###< symfony/mercure-bundle ###

View File

@@ -12,9 +12,6 @@ services:
MERCURE_URL: ${CADDY_MERCURE_URL:-http://php/.well-known/mercure}
MERCURE_PUBLIC_URL: ${CADDY_MERCURE_PUBLIC_URL:-https://${SERVER_NAME:-localhost}/.well-known/mercure}
MERCURE_JWT_SECRET: ${CADDY_MERCURE_JWT_SECRET:-!ChangeThisMercureHubJWTSecretKey!}
# The two next lines can be removed after initial installation
SYMFONY_VERSION: ${SYMFONY_VERSION:-}
STABILITY: ${STABILITY:-stable}
volumes:
- caddy_data:/data
- caddy_config:/config

View File

@@ -11,18 +11,19 @@
"ext-iconv": "*",
"doctrine/dbal": "^4.2.3",
"doctrine/doctrine-bundle": "^2.14.0",
"doctrine/doctrine-migrations-bundle": "^3.4.1",
"doctrine/orm": "^3.3.2",
"easycorp/easyadmin-bundle": "^4.24.6",
"phpdocumentor/reflection-docblock": "^5.6",
"phpoffice/phpspreadsheet": "*",
"doctrine/doctrine-migrations-bundle": "^3.4.2",
"doctrine/orm": "^3.3.3",
"easycorp/easyadmin-bundle": "^4.24.7",
"phpdocumentor/reflection-docblock": "^5.6.2",
"phpoffice/phpspreadsheet": "^4.2.0",
"phpstan/phpdoc-parser": "^2.1",
"runtime/frankenphp-symfony": "^0.2.0",
"sentry/sentry-symfony": "^5.2",
"symfony/asset": "7.2.*",
"symfony/asset-mapper": "7.2.*",
"symfony/console": "7.2.*",
"symfony/dotenv": "7.2.*",
"symfony/flex": "^2.5.0",
"symfony/flex": "^2.7.0",
"symfony/form": "7.2.*",
"symfony/framework-bundle": "7.2.*",
"symfony/mailer": "7.2.*",
@@ -35,28 +36,32 @@
"symfony/twig-bundle": "7.2.*",
"symfony/uid": "7.2.*",
"symfony/yaml": "7.2.*",
"symfonycasts/sass-bundle": "^0.8.2",
"symfonycasts/verify-email-bundle": "^1.17.3",
"thecodingmachine/safe": "^3.1.0"
"thecodingmachine/safe": "^3.3.0",
"twig/extra-bundle": "^3.21",
"twig/intl-extra": "^3.21",
"twig/twig": "^3.21.1"
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^4.1",
"friendsofphp/php-cs-fixer": "^3.75.0",
"phpstan/extension-installer": "^1.4.3",
"phpstan/phpstan": "^2.1.12",
"phpstan/phpstan-doctrine": "^2.0.2",
"phpstan/phpstan": "^2.1.17",
"phpstan/phpstan-doctrine": "^2.0.3",
"phpstan/phpstan-phpunit": "^2.0.6",
"phpstan/phpstan-symfony": "^2.0.4",
"phpunit/phpunit": "^12.1.3",
"rector/rector": "^2.0.12",
"phpstan/phpstan-symfony": "^2.0.6",
"phpunit/phpunit": "^12.1.6",
"rector/rector": "^2.0.16",
"roave/security-advisories": "dev-latest",
"symfony/browser-kit": "7.2.*",
"symfony/css-selector": "7.2.*",
"symfony/maker-bundle": "^1.62.1",
"symfony/phpunit-bridge": "^7.2",
"symfony/maker-bundle": "^1.63.0",
"symfony/phpunit-bridge": "7.2.*",
"symfony/stopwatch": "7.2.*",
"symfony/web-profiler-bundle": "7.2.*",
"thecodingmachine/phpstan-safe-rule": "^1.4",
"vincentlanglet/twig-cs-fixer": "^3.5.1"
"thecodingmachine/phpstan-safe-rule": "^1.4.1",
"vincentlanglet/twig-cs-fixer": "^3.7.1"
},
"config": {
"allow-plugins": {
@@ -94,7 +99,8 @@
"scripts": {
"auto-scripts": {
"cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd"
"assets:install %PUBLIC_DIR%": "symfony-cmd",
"importmap:install": "symfony-cmd"
},
"post-install-cmd": [
"@auto-scripts"

1331
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@ use Symfony\Bundle\TwigBundle\TwigBundle;
use Symfony\Bundle\WebProfilerBundle\WebProfilerBundle;
use Symfony\UX\TwigComponent\TwigComponentBundle;
use SymfonyCasts\Bundle\VerifyEmail\SymfonyCastsVerifyEmailBundle;
use Symfonycasts\SassBundle\SymfonycastsSassBundle;
use Twig\Extra\TwigExtraBundle\TwigExtraBundle;
return [
@@ -30,4 +31,5 @@ return [
DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
SymfonyCastsVerifyEmailBundle::class => ['all' => true],
SentryBundle::class => ['prod' => true],
SymfonycastsSassBundle::class => ['all' => true],
];

View File

@@ -0,0 +1,15 @@
framework:
asset_mapper:
# The paths to make available to the asset mapper.
paths:
- assets/
excluded_patterns:
- '*/assets/styles/_*.scss'
- '*/assets/styles/**/_*.scss'
missing_import_mode: strict
when@prod:
framework:
asset_mapper:
missing_import_mode: warn

35
importmap.php Normal file
View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
/**
* Returns the importmap for this application.
*
* - "path" is a path inside the asset mapper system. Use the
* "debug:asset-map" command to see the full list of paths.
*
* - "entrypoint" (JavaScript only) set to true for any module that will
* be used as an "entrypoint" (and passed to the importmap() Twig function).
*
* The "importmap:require" command can be used to add new entries to this file.
*/
return [
'quiz' => [
'path' => './assets/quiz.js',
'entrypoint' => true,
],
'backoffice' => [
'path' => './assets/backoffice.js',
'entrypoint' => true,
],
'bootstrap' => [
'version' => '5.3.6',
],
'@popperjs/core' => [
'version' => '2.11.8',
],
'bootstrap/dist/css/bootstrap.min.css' => [
'version' => '5.3.6',
'type' => 'css',
],
];

View File

@@ -0,0 +1,41 @@
<?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 Version20250504101440 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(<<<'SQL'
CREATE UNIQUE INDEX UNIQ_C8B28E445E237E064EC001D1 ON candidate (name, season_id)
SQL);
$this->addSql(<<<'SQL'
CREATE UNIQUE INDEX UNIQ_A412FA925E237E064EC001D1 ON quiz (name, season_id)
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
DROP INDEX UNIQ_A412FA925E237E064EC001D1
SQL);
$this->addSql(<<<'SQL'
DROP INDEX UNIQ_C8B28E445E237E064EC001D1
SQL);
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20250521192752 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE elimination ADD quiz_id UUID NOT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE elimination ADD CONSTRAINT FK_5947284F853CD175 FOREIGN KEY (quiz_id) REFERENCES quiz (id) NOT DEFERRABLE INITIALLY IMMEDIATE
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_5947284F853CD175 ON elimination (quiz_id)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE quiz ALTER dropouts SET DEFAULT 1
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE quiz ALTER dropouts SET NOT NULL
SQL);
}
public function down(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE quiz ALTER dropouts DROP DEFAULT
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE quiz ALTER dropouts DROP NOT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE elimination DROP CONSTRAINT FK_5947284F853CD175
SQL);
$this->addSql(<<<'SQL'
DROP INDEX IDX_5947284F853CD175
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE elimination DROP quiz_id
SQL);
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20250521194035 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE elimination ADD created TIMESTAMP(0) WITHOUT TIME ZONE
SQL);
$this->addSql(<<<'SQL'
UPDATE elimination SET created = NOW()
SQL
);
$this->addSql(<<<'SQL'
ALTER TABLE elimination ALTER created SET NOT NULL
SQL
);
}
public function down(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE elimination DROP created
SQL);
}
}

View File

@@ -30,5 +30,4 @@ return RectorConfig::configure()
)
->withAttributesSets(all: true)
->withComposerBased(twig: true, doctrine: true, phpunit: true, symfony: true)
->withAttributesSets()
;

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Repository\SeasonRepository;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
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\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:claim-season',
description: 'Give a user owner rights on a season',
)]
class ClaimSeasonCommand extends Command
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly SeasonRepository $seasonRepository,
private readonly EntityManagerInterface $entityManager)
{
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('email', InputArgument::REQUIRED, 'The email of the user to make admin')
->addArgument('season', InputArgument::REQUIRED, 'The season to claim')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$email = $input->getArgument('email');
$seasonCode = $input->getArgument('season');
try {
$season = $this->seasonRepository->findOneBy(['seasonCode' => $seasonCode]);
if (null === $season) {
throw new \InvalidArgumentException('Season not found');
}
$user = $this->userRepository->findOneBy(['email' => $email]);
if (null === $user) {
throw new \InvalidArgumentException('User not found');
}
$season->addOwner($user);
$this->entityManager->flush();
} catch (\InvalidArgumentException $invalidArgumentException) {
$io->error($invalidArgumentException->getMessage());
return Command::FAILURE;
}
return Command::SUCCESS;
}
}

View File

@@ -26,7 +26,7 @@ class MakeAdminCommand extends Command
protected function configure(): void
{
$this
->addArgument('email', InputArgument::OPTIONAL, 'The email of the user to make admin')
->addArgument('email', InputArgument::REQUIRED, 'The email of the user to make admin')
;
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Controller\Backoffice;
use App\Controller\AbstractController;
use App\Entity\Season;
use App\Entity\User;
use App\Form\CreateSeasonFormType;
use App\Repository\SeasonRepository;
use App\Service\QuizSpreadsheetService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[AsController]
#[IsGranted('ROLE_USER')]
final class BackofficeController extends AbstractController
{
public function __construct(
private readonly SeasonRepository $seasonRepository,
private readonly Security $security,
) {}
#[Route('/backoffice/', name: 'app_backoffice_index')]
public function index(): Response
{
$user = $this->getUser();
\assert($user instanceof User);
$seasons = $this->security->isGranted('ROLE_ADMIN')
? $this->seasonRepository->findAll()
: $this->seasonRepository->getSeasonsForUser($user);
return $this->render('backoffice/index.html.twig', [
'seasons' => $seasons,
]);
}
#[Route('/backoffice/add', name: 'app_backoffice_season_add', priority: 10)]
public function addSeason(Request $request, EntityManagerInterface $em): Response
{
$season = new Season();
$form = $this->createForm(CreateSeasonFormType::class, $season);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$user = $this->getUser();
\assert($user instanceof User);
$season->addOwner($user);
$season->generateSeasonCode();
$em->persist($season);
$em->flush();
return $this->redirectToRoute('app_backoffice_season', ['seasonCode' => $season->getSeasonCode()]);
}
return $this->render('backoffice/season_add.html.twig', ['form' => $form]);
}
#[Route('/backoffice/template', name: 'app_backoffice_template', priority: 10)]
public function getTemplate(QuizSpreadsheetService $excel): Response
{
$response = new StreamedResponse($excel->generateTemplate());
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->headers->set('Content-Disposition', 'attachment; filename="template.xlsx"');
return $response;
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Controller\Backoffice;
use App\Controller\AbstractController;
use App\Entity\Quiz;
use App\Entity\Season;
use App\Repository\CandidateRepository;
use App\Security\Voter\SeasonVoter;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[AsController]
#[IsGranted('ROLE_USER')]
class QuizController extends AbstractController
{
public function __construct(
private readonly CandidateRepository $candidateRepository,
) {}
#[Route('/backoffice/season/{seasonCode}/quiz/{quiz}', name: 'app_backoffice_quiz')]
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
public function index(Season $season, Quiz $quiz): Response
{
return $this->render('backoffice/quiz.html.twig', [
'season' => $season,
'quiz' => $quiz,
'result' => $this->candidateRepository->getScores($quiz),
]);
}
#[Route('/backoffice/season/{seasonCode}/quiz/{quiz}/enable', name: 'app_backoffice_enable')]
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
public function enableQuiz(Season $season, ?Quiz $quiz, EntityManagerInterface $em): Response
{
$season->setActiveQuiz($quiz);
$em->flush();
if ($quiz instanceof Quiz) {
return $this->redirectToRoute('app_backoffice_quiz', ['seasonCode' => $season->getSeasonCode(), 'quiz' => $quiz->getId()]);
}
return $this->redirectToRoute('app_backoffice_season', ['seasonCode' => $season->getSeasonCode()]);
}
}

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Controller\Backoffice;
use App\Controller\AbstractController;
use App\Entity\Candidate;
use App\Entity\Quiz;
use App\Entity\Season;
use App\Enum\FlashType;
use App\Form\AddCandidatesFormType;
use App\Form\UploadQuizFormType;
use App\Security\Voter\SeasonVoter;
use App\Service\QuizSpreadsheetService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\Translation\TranslatorInterface;
#[AsController]
#[IsGranted('ROLE_USER')]
class SeasonController extends AbstractController
{
public function __construct(private readonly TranslatorInterface $translator, private EntityManagerInterface $em,
) {}
#[Route('/backoffice/season/{seasonCode}', name: 'app_backoffice_season')]
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
public function index(Season $season): Response
{
return $this->render('backoffice/season.html.twig', [
'season' => $season,
]);
}
#[Route('/backoffice/season/{seasonCode}/add_candidate', name: 'app_backoffice_add_candidates', priority: 10)]
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
public function addCandidates(Season $season, Request $request): Response
{
$form = $this->createForm(AddCandidatesFormType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$candidates = $form->get('candidates')->getData();
foreach (explode("\r\n", (string) $candidates) as $candidate) {
$season->addCandidate(new Candidate($candidate));
}
$this->em->flush();
return $this->redirectToRoute('app_backoffice_season', ['seasonCode' => $season->getSeasonCode()]);
}
return $this->render('backoffice/season_add_candidates.html.twig', ['form' => $form]);
}
#[Route('/backoffice/season/{seasonCode}/add', name: 'app_backoffice_quiz_add', priority: 10)]
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
public function addQuiz(Request $request, Season $season, QuizSpreadsheetService $quizSpreadsheet): Response
{
$quiz = new Quiz();
$form = $this->createForm(UploadQuizFormType::class, $quiz);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/* @var UploadedFile $sheet */
$sheet = $form->get('sheet')->getData();
$quizSpreadsheet->xlsxToQuiz($quiz, $sheet);
$quiz->setSeason($season);
$this->em->persist($quiz);
$this->em->flush();
$this->addFlash(FlashType::Success, $this->translator->trans('Quiz Added!'));
return $this->redirectToRoute('app_backoffice_season', ['seasonCode' => $season->getSeasonCode()]);
}
return $this->render('/backoffice/quiz_add.html.twig', ['form' => $form, 'season' => $season]);
}
}

View File

@@ -1,163 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Candidate;
use App\Entity\Quiz;
use App\Entity\Season;
use App\Entity\User;
use App\Enum\FlashType;
use App\Form\AddCandidatesFormType;
use App\Form\CreateSeasonFormType;
use App\Form\UploadQuizFormType;
use App\Repository\CandidateRepository;
use App\Repository\SeasonRepository;
use App\Security\Voter\SeasonVoter;
use App\Service\QuizSpreadsheetService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\Translation\TranslatorInterface;
#[AsController]
#[IsGranted('ROLE_USER')]
final class BackofficeController extends AbstractController
{
public function __construct(
private readonly SeasonRepository $seasonRepository,
private readonly CandidateRepository $candidateRepository,
private readonly Security $security,
private readonly TranslatorInterface $translator,
) {}
#[Route('/backoffice/', name: 'app_backoffice_index')]
public function index(): Response
{
$user = $this->getUser();
\assert($user instanceof User);
$seasons = $this->security->isGranted('ROLE_ADMIN')
? $this->seasonRepository->findAll()
: $this->seasonRepository->getSeasonsForUser($user);
return $this->render('backoffice/index.html.twig', [
'seasons' => $seasons,
]);
}
#[Route('/backoffice/add', name: 'app_backoffice_season_add', priority: 10)]
public function seasonAdd(Request $request, EntityManagerInterface $em): Response
{
$season = new Season();
$form = $this->createForm(CreateSeasonFormType::class, $season);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$user = $this->getUser();
\assert($user instanceof User);
$season->addOwner($user);
$season->generateSeasonCode();
$em->persist($season);
$em->flush();
return $this->redirectToRoute('app_backoffice_season', ['seasonCode' => $season->getSeasonCode()]);
}
return $this->render('backoffice/season_add.html.twig', ['form' => $form]);
}
#[Route('/backoffice/{seasonCode}', name: 'app_backoffice_season')]
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
public function season(Season $season): Response
{
return $this->render('backoffice/season.html.twig', [
'season' => $season,
]);
}
#[Route('/backoffice/{seasonCode}/{quiz}', name: 'app_backoffice_quiz')]
public function quiz(Season $season, Quiz $quiz): Response
{
return $this->render('backoffice/quiz.html.twig', [
'season' => $season,
'quiz' => $quiz,
'result' => $this->candidateRepository->getScores($quiz),
]);
}
#[Route('/backoffice/{seasonCode}/{quiz}/enable', name: 'app_backoffice_enable')]
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
public function enableQuiz(Season $season, Quiz $quiz, EntityManagerInterface $em): Response
{
$season->setActiveQuiz($quiz);
$em->flush();
return $this->redirectToRoute('app_backoffice_season', ['seasonCode' => $season->getSeasonCode()]);
}
#[Route('/backoffice/{seasonCode}/add_candidate', name: 'app_backoffice_add_candidates', priority: 10)]
public function addCandidates(Season $season, Request $request, EntityManagerInterface $em): Response
{
$form = $this->createForm(AddCandidatesFormType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$candidates = $form->get('candidates')->getData();
foreach (explode("\r\n", $candidates) as $candidate) {
$season->addCandidate(new Candidate($candidate));
}
$em->flush();
return $this->redirectToRoute('app_backoffice_season', ['seasonCode' => $season->getSeasonCode()]);
}
return $this->render('backoffice/season_add_candidates.html.twig', ['form' => $form]);
}
#[Route('/backoffice/{seasonCode}/add', name: 'app_backoffice_quiz_add', priority: 10)]
public function addQuiz(Request $request, Season $season, QuizSpreadsheetService $quizSpreadsheet, EntityManagerInterface $em): Response
{
$quiz = new Quiz();
$form = $this->createForm(UploadQuizFormType::class, $quiz);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/* @var UploadedFile $sheet */
$sheet = $form->get('sheet')->getData();
$quizSpreadsheet->xlsxToQuiz($quiz, $sheet);
$quiz->setSeason($season);
$em->persist($quiz);
$em->flush();
$this->addFlash(FlashType::Success, $this->translator->trans('Quiz Added!'));
return $this->redirectToRoute('app_backoffice_season', ['seasonCode' => $season->getSeasonCode()]);
}
return $this->render('/backoffice/quiz_add.html.twig', ['form' => $form, 'season' => $season]);
}
#[Route('/backoffice/template', name: 'app_backoffice_template', priority: 10)]
public function getTemplate(QuizSpreadsheetService $excel): Response
{
$response = new StreamedResponse($excel->generateTemplate());
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->headers->set('Content-Disposition', 'attachment; filename="template.xlsx"');
return $response;
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Candidate;
use App\Entity\Season;
use App\Enum\FlashType;
use App\Helpers\Base64;
use App\Repository\CandidateRepository;
use App\Security\Voter\SeasonVoter;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\Translation\TranslatorInterface;
use function Symfony\Component\Translation\t;
#[AsController]
#[IsGranted('ROLE_USER')]
final class EliminationController extends AbstractController
{
public function __construct(private readonly TranslatorInterface $translator) {}
#[Route('/elimination/{seasonCode}', name: 'app_elimination')]
#[IsGranted(SeasonVoter::ELIMINATION, 'season')]
public function index(#[MapEntity] Season $season): Response
{
return $this->render('elimination/index.html.twig', [
'controller_name' => 'EliminationController',
]);
}
#[Route('/elimination/{seasonCode}/{candidateHash}', name: 'app_elimination_cadidate')]
#[IsGranted(SeasonVoter::ELIMINATION, 'season')]
public function candidateScreen(Season $season, string $candidateHash, CandidateRepository $candidateRepository): Response
{
$candidate = $candidateRepository->getCandidateByHash($season, $candidateHash);
if (!$candidate instanceof Candidate) {
$this->addFlash(FlashType::Warning,
t('Cound not find candidate with name %name%', ['%name%' => Base64::base64UrlDecode($candidateHash)])->trans($this->translator)
);
throw new \InvalidArgumentException('Candidate not found');
}
return $this->render('elimination/candidate.html.twig', [
'season' => $season,
'candidate' => $candidate,
]);
}
}

View File

@@ -4,17 +4,19 @@ declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use App\Enum\FlashType;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
use Symfony\Contracts\Translation\TranslatorInterface;
#[AsController]
final class LoginController extends AbstractController
{
#[Route(path: '/login', name: 'app_login_login')]
public function login(AuthenticationUtils $authenticationUtils): Response
public function login(AuthenticationUtils $authenticationUtils, TranslatorInterface $translator): Response
{
// get the login error if there is one
$error = $authenticationUtils->getLastAuthenticationError();
@@ -22,7 +24,11 @@ final class LoginController extends AbstractController
// last username entered by the user
$lastUsername = $authenticationUtils->getLastUsername();
return $this->render('login/login.html.twig', [
if ($error instanceof AuthenticationException) {
$this->addFlash(FlashType::Danger, $translator->trans($error->getMessageKey(), $error->getMessageData(), 'security'));
}
return $this->render('backoffice/login/login.html.twig', [
'last_username' => $lastUsername,
'error' => $error,
]);

View File

@@ -4,17 +4,39 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\Elimination;
use App\Entity\Quiz;
use App\Entity\Season;
use App\Factory\EliminationFactory;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class PrepareEliminationController extends AbstractController
{
#[Route('/backoffice/elimination/prepare', name: 'app_prepare_elimination')]
public function index(): Response
#[Route('/backoffice/elimination/{seasonCode}/{quiz}/prepare', name: 'app_prepare_elimination')]
public function index(Season $season, Quiz $quiz, EliminationFactory $eliminationFactory): Response
{
return $this->render('prepare_elimination/index.html.twig', [
$elimination = $eliminationFactory->createEliminationFromQuiz($quiz);
return $this->redirectToRoute('app_prepare_elimination_view', ['elimination' => $elimination->getId()]);
}
#[Route('/backoffice/elimination/{elimination}', name: 'app_prepare_elimination_view')]
public function viewElimination(Elimination $elimination, Request $request, EntityManagerInterface $em): Response
{
if ('POST' === $request->getMethod()) {
$elimination->updateFromInputBag($request->request);
$em->flush();
$this->addFlash('success', 'Elimination updated');
}
return $this->render('backoffice/prepare_elimination/index.html.twig', [
'controller_name' => 'PrepareEliminationController',
'elimination' => $elimination,
]);
}
}

View File

@@ -48,7 +48,7 @@ final class RegistrationController extends AbstractController
(new TemplatedEmail())
->to((string) $user->getEmail())
->subject($this->translator->trans('Please Confirm your Email'))
->htmlTemplate('registration/confirmation_email.html.twig')
->htmlTemplate('backoffice/registration/confirmation_email.html.twig')
);
$response = $security->login($user, 'form_login', 'main');
@@ -57,7 +57,7 @@ final class RegistrationController extends AbstractController
return $response;
}
return $this->render('registration/register.html.twig', [
return $this->render('backoffice/registration/register.html.twig', [
'registrationForm' => $form,
]);
}

View File

@@ -23,7 +23,7 @@ class Answer
private Uuid $id;
#[ORM\Column(type: Types::SMALLINT, options: ['default' => 0])]
private int $ordering;
private int $ordering = 0;
#[ORM\ManyToOne(inversedBy: 'answers')]
#[ORM\JoinColumn(nullable: false)]

View File

@@ -14,6 +14,7 @@ use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: CandidateRepository::class)]
#[ORM\UniqueConstraint(fields: ['name', 'season'])]
class Candidate
{
#[ORM\Id]

View File

@@ -7,13 +7,19 @@ namespace App\Entity;
use App\Repository\EliminationRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Safe\DateTimeImmutable;
use Symfony\Bridge\Doctrine\IdGenerator\UuidGenerator;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\HttpFoundation\InputBag;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: EliminationRepository::class)]
#[ORM\HasLifecycleCallbacks]
class Elimination
{
public const string SCREEN_GREEN = 'green';
public const string SCREEN_RED = 'red';
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
@@ -24,6 +30,15 @@ class Elimination
#[ORM\Column(type: Types::JSON)]
private array $data = [];
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: false)]
private \DateTimeImmutable $created;
public function __construct(
#[ORM\ManyToOne(inversedBy: 'eliminations')]
#[ORM\JoinColumn(nullable: false)]
private Quiz $quiz,
) {}
public function getId(): Uuid
{
return $this->id;
@@ -36,10 +51,39 @@ class Elimination
}
/** @param array<string, mixed> $data */
public function setData(array $data): static
public function setData(array $data): self
{
$this->data = $data;
return $this;
}
public function getQuiz(): Quiz
{
return $this->quiz;
}
/** @param InputBag<bool|float|int|string> $inputBag */
public function updateFromInputBag(InputBag $inputBag): self
{
foreach ($this->data as $name => $screenColour) {
$newColour = $inputBag->get('colour-'.mb_strtolower($name));
if (\is_string($newColour)) {
$this->data[$name] = $inputBag->get('colour-'.mb_strtolower($name));
}
}
return $this;
}
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$this->created = new DateTimeImmutable();
}
public function getCreated(): \DateTimeInterface
{
return $this->created;
}
}

View File

@@ -13,6 +13,7 @@ use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: QuizRepository::class)]
#[ORM\UniqueConstraint(fields: ['name', 'season'])]
class Quiz
{
#[ORM\Id]
@@ -37,13 +38,19 @@ class Quiz
#[ORM\OneToMany(targetEntity: Correction::class, mappedBy: 'quiz', orphanRemoval: true)]
private Collection $corrections;
#[ORM\Column(nullable: true)]
private ?int $dropouts = null;
#[ORM\Column(nullable: false, options: ['default' => 1])]
private int $dropouts = 1;
/** @var Collection<int, Elimination> */
#[ORM\OneToMany(targetEntity: Elimination::class, mappedBy: 'quiz', cascade: ['persist'], orphanRemoval: true)]
#[ORM\OrderBy(['created' => 'DESC'])]
private Collection $eliminations;
public function __construct()
{
$this->questions = new ArrayCollection();
$this->corrections = new ArrayCollection();
$this->eliminations = new ArrayCollection();
}
public function getId(): ?Uuid
@@ -107,15 +114,28 @@ class Quiz
return $this;
}
public function getDropouts(): ?int
public function getDropouts(): int
{
return $this->dropouts;
}
public function setDropouts(?int $dropouts): static
public function setDropouts(int $dropouts): static
{
$this->dropouts = $dropouts;
return $this;
}
/** @return Collection<int, Elimination> */
public function getEliminations(): Collection
{
return $this->eliminations;
}
public function addElimination(Elimination $elimination): self
{
$this->eliminations->add($elimination);
return $this;
}
}

View File

@@ -35,6 +35,7 @@ class Season
/** @var Collection<int, Candidate> */
#[ORM\OneToMany(targetEntity: Candidate::class, mappedBy: 'season', cascade: ['persist'], orphanRemoval: true)]
#[ORM\OrderBy(['name' => 'ASC'])]
private Collection $candidates;
/** @var Collection<int, User> */

View File

@@ -7,6 +7,7 @@ namespace App\Entity;
use App\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\IdGenerator\UuidGenerator;
use Symfony\Bridge\Doctrine\Types\UuidType;
@@ -31,7 +32,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
private string $email;
/** @var list<string> The user roles */
#[ORM\Column]
#[ORM\Column(type: Types::JSON)]
private array $roles = [];
/** @var string The hashed password */

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Factory;
use App\Entity\Elimination;
use App\Entity\Quiz;
use App\Repository\CandidateRepository;
use Doctrine\ORM\EntityManagerInterface;
final readonly class EliminationFactory
{
public function __construct(
private CandidateRepository $candidateRepository,
private EntityManagerInterface $em,
) {}
public function createEliminationFromQuiz(Quiz $quiz): Elimination
{
$elimination = new Elimination($quiz);
$this->em->persist($elimination);
$scores = $this->candidateRepository->getScores($quiz);
$simpleScores = [];
foreach (array_reverse($scores) as $i => $score) {
$simpleScores[$score['name']] = $i < $quiz->getDropouts() ? Elimination::SCREEN_RED : Elimination::SCREEN_GREEN;
}
$elimination->setData($simpleScores);
$this->em->flush();
return $elimination;
}
}

View File

@@ -19,7 +19,7 @@ class AddCandidatesFormType extends AbstractType
{
$builder
->add('candidates', TextareaType::class, [
'label' => $this->translator->trans('Candidates'),
'label' => $this->translator->trans('Candidates'), 'translation_domain' => false,
])
;
}

View File

@@ -21,6 +21,7 @@ class CreateSeasonFormType extends AbstractType
$builder
->add('name', TextType::class, [
'label' => $this->translator->trans('Season Name'),
'translation_domain' => false,
])
;
}

View File

@@ -18,7 +18,11 @@ class EnterNameType extends AbstractType
{
$builder
->add('name', TextType::class,
['required' => true, 'label' => $this->translator->trans('Enter your name')],
[
'required' => true,
'label' => $this->translator->trans('Enter your name'),
'translation_domain' => false,
],
)
;
}

View File

@@ -8,6 +8,7 @@ use App\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
@@ -27,11 +28,16 @@ class RegistrationFormType extends AbstractType
->add('email', EmailType::class, [
'label' => $this->translator->trans('Email'),
'attr' => ['autocomplete' => 'email'],
'translation_domain' => false,
])
->add('plainPassword', PasswordType::class, [
'label' => $this->translator->trans('Password'),
->add('plainPassword', RepeatedType::class, [
'type' => PasswordType::class,
'invalid_message' => $this->translator->trans('The password fields must match.'),
'options' => ['attr' => ['class' => 'password-field']],
'required' => true,
'first_options' => ['label' => $this->translator->trans('Password')],
'second_options' => ['label' => $this->translator->trans('Repeat Password')],
'mapped' => false,
'attr' => ['autocomplete' => 'new-password'],
'constraints' => [
new NotBlank([
'message' => 'Please enter a password',
@@ -43,6 +49,7 @@ class RegistrationFormType extends AbstractType
'max' => 4096,
]),
],
'translation_domain' => false,
])
;
}

View File

@@ -20,7 +20,7 @@ class SelectSeasonType extends AbstractType
{
$builder
->add('season_code', TextType::class,
['required' => true, 'constraints' => new Regex(pattern: "/^[A-Za-z\d]{5}$/"), 'label' => $this->translator->trans('Season Code')]
['required' => true, 'constraints' => new Regex(pattern: "/^[A-Za-z\d]{5}$/"), 'label' => $this->translator->trans('Season Code'), 'translation_domain' => false]
)
;
}

View File

@@ -23,11 +23,13 @@ class UploadQuizFormType extends AbstractType
$builder
->add('name', TextType::class, [
'label' => $this->translator->trans('Quiz name'),
'translation_domain' => false,
])
->add('sheet', FileType::class, [
'label' => $this->translator->trans('Quiz (xlsx)'),
'mapped' => false,
'required' => true,
'translation_domain' => false,
'constraints' => [
new File([
'maxSize' => '1024k',

View File

@@ -13,11 +13,12 @@ use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\Persistence\ManagerRegistry;
use Safe\Exceptions\UrlException;
use Symfony\Component\Uid\Uuid;
/**
* @extends ServiceEntityRepository<Candidate>
*
* @phpstan-type Result array{0: Candidate, correct: int, time: \DateInterval, corrections?: float, score: float}
* @phpstan-type Result array{id: Uuid, name: string, correct: int, time: \DateInterval, corrections?: float, score: float}
* @phpstan-type ResultList list<Result>
*/
class CandidateRepository extends ServiceEntityRepository
@@ -56,7 +57,7 @@ class CandidateRepository extends ServiceEntityRepository
public function getScores(Quiz $quiz): array
{
$scoreTimeQb = $this->createQueryBuilder('c', 'c.id')
->select('c', 'sum(case when a.isRightAnswer = true then 1 else 0 end) as correct', 'max(ga.created) - min(ga.created) as time')
->select('c.id', 'c.name', 'sum(case when a.isRightAnswer = true then 1 else 0 end) as correct', 'max(ga.created) - min(ga.created) as time')
->join('c.givenAnswers', 'ga')
->join('ga.answer', 'a')
->where('ga.quiz = :quiz')
@@ -64,7 +65,7 @@ class CandidateRepository extends ServiceEntityRepository
->setParameter('quiz', $quiz);
$correctionsQb = $this->createQueryBuilder('c', 'c.id')
->select('c', 'cor.amount as corrections')
->select('c.id', 'cor.amount as corrections')
->innerJoin(Correction::class, 'cor', Join::WITH, 'cor.candidate = c and cor.quiz = :quiz')
->setParameter('quiz', $quiz);
@@ -74,7 +75,7 @@ class CandidateRepository extends ServiceEntityRepository
}
/**
* @param array<string, array{0: Candidate, correct: int, time: \DateInterval, corrections?: float}> $in
* @param array<string, array{id: Uuid, name: string, correct: int, time: \DateInterval, corrections?: float}> $in
*
* @return array<string, Result>
* */

View File

@@ -14,11 +14,13 @@ final class SeasonVoter extends Voter
{
public const string EDIT = 'SEASON_EDIT';
public const string ELIMINATION = 'SEASON_ELIMINATION';
public const string DELETE = 'SEASON_DELETE';
protected function supports(string $attribute, mixed $subject): bool
{
return \in_array($attribute, [self::EDIT, self::DELETE], true)
return \in_array($attribute, [self::EDIT, self::DELETE, self::ELIMINATION], true)
&& $subject instanceof Season;
}
@@ -34,16 +36,9 @@ final class SeasonVoter extends Voter
return true;
}
switch ($attribute) {
case self::EDIT:
case self::DELETE:
if ($subject->isOwner($user)) {
return true;
}
break;
}
return false;
return match ($attribute) {
self::EDIT, self::DELETE, self::ELIMINATION => $subject->isOwner($user),
default => false,
};
}
}

View File

@@ -52,14 +52,14 @@ class QuizSpreadsheetService
}
/** @throws SpreadsheetDataException */
public function xlsxToQuiz(Quiz $quiz, File $file): Quiz
public function xlsxToQuiz(Quiz $quiz, File $file): void
{
$spreadsheet = $this->readSheet($file);
$sheet = $spreadsheet->getSheet($spreadsheet->getFirstSheetIndex());
$answerLines = \array_slice($sheet->toArray(formatData: false), 1);
return $this->fillQuizFromArray($quiz, $answerLines);
$this->fillQuizFromArray($quiz, $answerLines);
}
private function readSheet(File $file): Spreadsheet
@@ -94,6 +94,7 @@ class QuizSpreadsheetService
if (1 === $answerCounter) {
$errors[] = \sprintf('Question %d has no answers', $answerCounter);
}
break;
}

View File

@@ -97,6 +97,21 @@
"config/packages/sentry.yaml"
]
},
"symfony/asset-mapper": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.4",
"ref": "5ad1308aa756d58f999ffbe1540d1189f5d7d14a"
},
"files": [
"assets/quiz.js",
"assets/styles/app.css",
"config/packages/asset_mapper.yaml",
"importmap.php"
]
},
"symfony/console": {
"version": "7.2",
"recipe": {
@@ -287,6 +302,9 @@
"config/routes/web_profiler.yaml"
]
},
"symfonycasts/sass-bundle": {
"version": "v0.8.2"
},
"symfonycasts/verify-email-bundle": {
"version": "v1.17.3"
},

View File

@@ -1,49 +1,3 @@
<!DOCTYPE html>
<html lang="nl" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
crossorigin="anonymous"></script>
<script
src="https://js-de.sentry-cdn.com/30cf438bc708c97e6f45c127bed9af96.min.js"
crossorigin="anonymous"
></script>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="icon"
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>">
<title>
{% block title %}Tijd voor de test{% endblock title %}
</title>
{% block stylesheets %}
{% endblock %}
{% block javascripts %}
{% endblock %}
</head>
<body>
{% block nav %}
{{ include('backoffice/nav.html.twig') }}
{% endblock nav %}
<main>
<div class="container">
{% for label, messages in app.flashes() %}
{% for message in messages %}
<div class="alert alert-{{ label }} alert-dismissible " role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endfor %}
{% block body %}
{% endblock body %}
</div>
</main>
</body>
</html>
{% extends 'base.html.twig' %}
{% block importmap %}{{ importmap('backoffice') }}{% endblock %}
{% block nav %}{{ include('backoffice/nav.html.twig') }}{% endblock %}

View File

@@ -1,6 +1,6 @@
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid">
<a class="navbar-brand" href="#">Tijd voor de test</a>
<a class="navbar-brand" href="{{ path('app_backoffice_index') }}">Tijd voor de test</a>
<button class="navbar-toggler"
type="button"
data-bs-toggle="collapse"

View File

@@ -1,3 +1,46 @@
{% extends 'backoffice/base.html.twig' %}
{% block body %}
<div class="row">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ path('app_backoffice_index') }}">Home</a></li>
<li class="breadcrumb-item"><a
href="{{ path('app_backoffice_season', {seasonCode: elimination.quiz.season.seasonCode}) }}">{{ elimination.quiz.season.name }}</a>
</li>
<li class="breadcrumb-item"><a
href="{{ path('app_backoffice_quiz', {seasonCode: elimination.quiz.season.seasonCode, quiz: elimination.quiz.id}) }}">{{ elimination.quiz.name }}</a>
</li>
<li class="breadcrumb-item active" aria-current="page">Prepare Elimination</li>
</ol>
</nav>
</div>
<div class="row">
<div class="col-12 col-md-6">
<form method="post">
{%~ for candidate, colour in elimination.data %}
<div class="row mb-3">
<label for="colour-{{ candidate|lower }}" class="col-4 col-form-label">{{ candidate }}</label>
<div class="col-4">
<select id="colour-{{ candidate|lower }}" class="form-select"
name="colour-{{ candidate|lower }}">
<option value="green"{% if colour == 'green' %} selected{% endif %}>Green</option>
<option value="red"{% if colour == 'red' %} selected{% endif %}>Red</option>
</select>
</div>
</div>
{% endfor %}
<div class="btn-group py-2">
<button type="submit" class="btn btn-primary" name="start" value="1">{{ 'Save'|trans }}</button>
<button type="submit" class="btn btn-success" name="start"
value="0">{{ 'Save and start elimination'|trans }}</button>
<a href="{{ path('app_backoffice_quiz', {seasonCode: elimination.quiz.season.seasonCode, quiz: elimination.quiz.id}) }}"
class="btn btn-secondary">{{ 'Back'|trans }}</a>
</div>
</form>
</div>
<div class="col-12 col-md-6">
<p>Hier kan dus weer wat uitleg komen</p>
</div>
</div>
{% endblock %}

View File

@@ -2,12 +2,19 @@
{% block body %}
<h2 class="py-2">{{ 'Quiz'|trans }}: {{ quiz.season.name }} - {{ quiz.name }}</h2>
<a class="py-2 btn btn-primary {% if quiz is same as(season.activeQuiz) %}disabled{% endif %}"
href="{{ path('app_backoffice_enable', {seasonCode: season.seasonCode, quiz: quiz.id}) }}">{{ 'Make active'|trans }}</a>
<div class="py-2 btn-group">
<a class="btn btn-primary {% if quiz is same as(season.activeQuiz) %}disabled{% endif %}"
href="{{ path('app_backoffice_enable', {seasonCode: season.seasonCode, quiz: quiz.id}) }}">{{ 'Make active'|trans }}</a>
{% if quiz is same as (season.activeQuiz) %}
<a class="btn btn-secondary"
href="{{ path('app_backoffice_enable', {seasonCode: season.seasonCode, quiz: 'null'}) }}">{{ 'Deactivate Quiz'|trans }}</a>
{% endif %}
</div>
<div id="questions">
<h4 class="py-2">{{ 'Questions'|trans }}</h4>
<div class="accordion">
{% for question in quiz.questions %}
{%~ for question in quiz.questions ~%}
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed"
@@ -16,23 +23,23 @@
data-bs-target="#question-{{ loop.index0 }}"
aria-controls="question-{{ loop.index0 }}">
{% set questionErrors = question.getErrors %}
{% if questionErrors %}
{%~ if questionErrors -%}
<span data-bs-toggle="tooltip"
title="{{ questionErrors }}"
class="badge text-bg-danger rounded-pill me-2">!</span>
{% endif %}
{{ loop.index }}. {{ question.question }}
{{~ loop.index -}}. {{ question.question -}}
</button>
</h2>
<div id="question-{{ loop.index0 }}"
class="accordion-collapse collapse">
<div class="accordion-body">
<ul>
{% for answer in question.answers %}
<li {% if answer.isRightAnswer %}class="text-decoration-underline"{% endif %}>{{ answer.text }}</li>
{% else %}
{{ 'There are no answers for this question'|trans }}
{% endfor %}
{%~ for answer in question.answers %}
<li{% if answer.isRightAnswer %} class="text-decoration-underline"{% endif %}>{{ answer.text -}}</li>
{%~ else %}
{{ 'There are no answers for this question'|trans -}}
{%~ endfor %}
</ul>
</div>
</div>
@@ -43,16 +50,23 @@
</div>
</div>
<div class="scores">
<p>
<h4>{{ 'Score'|trans }}</h4>
</p>
<h4 class="py-2">{{ 'Score'|trans }}</h4>
<div class="btn-toolbar" role="toolbar">
<div class="btn-group btn-group-lg me-2">
<a class="btn btn-primary">{{ 'Start Elimination'|trans }}</a>
</div>
<div class="btn-group btn-group-lg">
<a class="btn btn-secondary">{{ 'Prepare Custom Elimination'|trans }}</a>
<a class="btn btn-secondary">{{ 'Load Prepared Elimination'|trans }}</a>
<a href="{{ path('app_prepare_elimination', {seasonCode: season.seasonCode, quiz: quiz.id}) }}"
class="btn btn-secondary">{{ 'Prepare Custom Elimination'|trans }}</a>
{%~ if not quiz.eliminations.empty %}
<button class="btn btn-secondary dropdown-toggle"
data-bs-toggle="dropdown">{{ 'Load Prepared Elimination'|trans }}</button>
<ul class="dropdown-menu">
{%~ for elimination in quiz.eliminations %}
<li><a class="dropdown-item"
href="{{ path('app_prepare_elimination_view', {elimination: elimination.id}) }}">{{ elimination.created|format_datetime() }}</a>
</li>
{%~ endfor %}
</ul>
{% endif %}
</div>
</div>
<p>{{ 'Number of dropouts:'|trans }} {{ quiz.dropouts }} </p>
@@ -67,9 +81,9 @@
</tr>
</thead>
<tbody>
{% for candidate in result %}
{%~ for candidate in result ~%}
<tr class="table-{% if loop.revindex > quiz.dropouts %}success{% else %}danger{% endif %}">
<td>{{ candidate.0.name }}</td>
<td>{{ candidate.name }}</td>
<td>{{ candidate.correct|default('0') }}</td>
<td>{{ candidate.corrections|default('0') }}</td>
<td>{{ candidate.score|default('x') }}</td>
@@ -85,6 +99,7 @@
</div>
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script>
document.addEventListener('DOMContentLoaded', function () {
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]')

View File

@@ -3,7 +3,7 @@
{% block body %}
<div class="row">
<div class="col-md-6 col-12">
<h2 class="py-2">{{ 'Add a quiz to '|trans }} {{ season.name }}</h2>
<h2 class="py-2">{{ t('Add a quiz to %name%', {'%name%': season.name})|trans }} </h2>
{{ form_start(form) }}
{{ form_row(form.name) }}
{{ form_row(form.sheet) }}

29
templates/base.html.twig Normal file
View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="nl" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script
src="https://js-de.sentry-cdn.com/30cf438bc708c97e6f45c127bed9af96.min.js"
crossorigin="anonymous"
></script>
<title>
{% block title %}Tijd voor de test{% endblock title %}
</title>
{% block stylesheets %}{% endblock %}
{% block javascripts %}{% block importmap %}{% endblock %}{% endblock %}
</head>
<body>
{% block nav %}
{% endblock nav %}
<main>
{% block main %}
<div class="container">
{{ include('flashes.html.twig') }}
{% block body %}
{% endblock body %}
</div>
{% endblock %}
</main>
</body>
</html>

View File

@@ -0,0 +1,13 @@
{% set flashes = app.flashes() %}
{% if flashes is not empty %}
<div class="py-2">
{% for label, messages in flashes %}
{% for message in messages %}
<div class="alert alert-{{ label }} alert-dismissible " role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endfor %}
</div>
{% endif %}

View File

@@ -1,55 +1,2 @@
<!DOCTYPE html>
<html lang="nl" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
crossorigin="anonymous"></script>
<script
src="https://js-de.sentry-cdn.com/30cf438bc708c97e6f45c127bed9af96.min.js"
crossorigin="anonymous"
></script>
<title>
{% block title %}
Tijd voor de test
{% endblock title %}
</title>
<style>
html, body {
height: 100%;
background-image: url("{{ asset('/img/background.png') }}");
background-position: center center;
background-repeat: no-repeat;
background-color: black;
color: white;
display: grid;
align-items: center;
justify-self: center;
}
</style>
</head>
<body>
<main>
<div class="container">
{% for label, messages in app.flashes() %}
{% for message in messages %}
<div class="alert alert-{{ label }} alert-dismissible " role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endfor %}
{% block body %}
{% endblock body %}
</div>
{% block script %}
{% endblock script %}
</main>
</body>
</html>
{% extends 'base.html.twig' %}
{% block importmap %}{{ importmap('quiz') }}{% endblock %}

View File

@@ -0,0 +1,3 @@
{% block body %}
{% endblock %}

View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<title>Hello EliminationController!</title>
{% block body %}
<style>
.example-wrapper { margin: 1em auto; max-width: 800px; width: 95%; font: 18px/1.5 sans-serif; }
.example-wrapper code { background: #F5F5F5; padding: 2px 6px; }
</style>
<div class="example-wrapper">
<h1>Hello {{ controller_name }}! ✅</h1>
This friendly message is coming from:
<ul>
<li>Your controller at <code>/app/src/Controller/EliminationController.php</code></li>
<li>Your template at <code>/app/templates/elimination/index.html.twig</code></li>
</ul>
</div>
{% endblock %}

View File

@@ -1,20 +1,24 @@
'Active Quiz': 'Actieve test'
Add: Toevoegen
'Add Candidate': 'Voeg kandidaat toe'
'Add a quiz to': 'Voeg een test toe aan'
'Add Candidates': 'Voeg kandidaten toe'
'Add a quiz to %name%': 'Voeg een test toe aan %name%'
'All Seasons': 'Alle seizoenen'
'Already have an account? Log in': 'Heb je al een account? Log in'
Back: Terug
Candidate: Kandidaat
'Candidate not found': 'Kandidaat niet gevonden'
Candidates: Kandidaten
'Correct Answers': 'Goede antwoorden'
Corrections: Jokers
'Cound not find candidate with name %name%': 'Kon kandidaat met naam %name% niet vinden'
'Create a season': 'Maak een seizoen aan'
'Create an account': 'Maak een account aan'
'Deactivate Quiz': 'Deactiveer test'
'Download Template': 'Download sjabloon'
Email: E-mail
'Enter your name': 'Voor je naam in'
'Invalid season code': 'Ongeldige seizoenscode'
'Invalid season code': 'Ongeldige seizoencode'
'Load Prepared Elimination': 'Laad voorbereide eliminatie'
Logout: Uitloggen
'Make active': 'Maak actief'
@@ -39,14 +43,18 @@ Quiz: Test
Quizzes: Tests
Register: Registreren
'Remember me': 'Onthoud mij'
'Repeat Password': 'Herhaal wachtwoord'
Save: Opslaan
'Save and start elimination': 'Opslaan en start eliminatie'
Score: Score
Season: Seizoen
'Season Code': Seizoenscode
'Season Name': 'Seizoensnaam'
'Season Code': Seizoencode
'Season Name': Seizoennaam
Seasons: Seizoenen
'Sign in': 'Log in'
'Start Elimination': 'Start eliminatie'
Submit: Verstuur
'The password fields must match.': 'De wachtwoorden moeten overeen komen.'
'There are no answers for this question': 'Er zijn geen antwoorden voor deze vraag'
Time: Tijd
'Your Seasons': 'Jouw seizoenen'