From 8069e332febe5dc832bf5d5adb89e309b2700e2e Mon Sep 17 00:00:00 2001 From: Kevin Papst Date: Thu, 12 Jan 2023 12:10:11 +0100 Subject: [PATCH] release 2.0 beta (#3722) * remove twitter link * remove WIP file * adjust release draft message * reset code coverage threshold back to 0.5 * changed wordings * re-activate wizard for fixture accounts * fix repo url and license * license identifier * bump version * moved Kimai 1 import command from core to plugin * do not traverse into invoice template subdirectories (#3735) * fix branch alias * composer update * switch language on wizard select * new twig function to create qr code * fix daily stats in timesheet listing * improved html invoice templates --- .codecov.yml | 2 +- .github/release-drafter.yml | 6 +- .github/workflows/lock.yaml | 4 +- README.md | 17 +- TODO | 36 - UPGRADING.md | 2 +- assets/sass/invoice.scss | 45 +- composer.json | 6 +- composer.lock | 103 +- phpstan.neon | 347 +-- public/build/entrypoints.json | 4 +- ...oice.b17784c1.css => invoice.3c80ee80.css} | 2 +- public/build/manifest.json | 2 +- .../Authentication/ApiTokenUpgradeBadge.php | 8 + src/Command/KimaiImporterCommand.php | 2112 ----------------- .../TimesheetAbstractController.php | 15 +- src/Controller/WizardController.php | 20 +- src/DataFixtures/UserFixtures.php | 3 + src/Entity/User.php | 5 + src/Repository/ApiUserRepository.php | 3 + src/Repository/InvoiceDocumentRepository.php | 23 +- src/Repository/Paginator/LoaderPaginator.php | 9 +- .../Paginator/QueryBuilderPaginator.php | 9 +- src/Repository/UserRepository.php | 3 +- src/Security/KimaiUserProvider.php | 4 + src/Twig/Runtime/QrCodeExtension.php | 39 + src/Twig/RuntimeExtensions.php | 2 + templates/datatable.html.twig | 28 +- templates/invoice/renderer/invoice.html.twig | 2 +- .../invoice/renderer/timesheet.html.twig | 26 +- templates/timesheet/index.html.twig | 280 +-- templates/wizard/profile.html.twig | 8 + tests/Command/CreateUserCommandTest.php | 2 +- tests/Command/KimaiImporterCommandTest.php | 48 - .../SelfRegistrationControllerTest.php | 2 +- .../Plugin/Fixtures/TestPlugin/composer.json | 3 +- tests/Twig/RuntimeExtensionsTest.php | 1 + tests/phpstan.neon | 5 - 38 files changed, 386 insertions(+), 2850 deletions(-) delete mode 100644 TODO rename public/build/{invoice.b17784c1.css => invoice.3c80ee80.css} (86%) delete mode 100755 src/Command/KimaiImporterCommand.php create mode 100644 src/Twig/Runtime/QrCodeExtension.php delete mode 100644 tests/Command/KimaiImporterCommandTest.php diff --git a/.codecov.yml b/.codecov.yml index a80e4ee96..76fd209ba 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -9,7 +9,7 @@ coverage: status: project: default: - threshold: 2.5% + threshold: 0.5% patch: off changes: no diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index be816fbad..3bc130dfb 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -35,11 +35,7 @@ version-resolver: template: | [Upgrade Kimai](https://www.kimai.org/documentation/updates.html) - [Install Kimai](https://www.kimai.org/documentation/installation.html) - [Docker](https://tobybatch.github.io/kimai2/) - **PHP Version compatibility:** - - PHP 7.3 and PHP 7.4 are [end-of-life](https://www.php.net/supported-versions.php) - - PHP 8.0 and PHP 8.1 are supported - - A feature freeze is in place and only bugfix releases will be published for 1.30.x. Next major release will be in the 2.x series (PHP >= 8.1, Symfony 6, Tabler UI, see #2902). + **Compatible with PHP 8.1 and 8.2** $CHANGES diff --git a/.github/workflows/lock.yaml b/.github/workflows/lock.yaml index 0cf9d8920..ff2530da0 100644 --- a/.github/workflows/lock.yaml +++ b/.github/workflows/lock.yaml @@ -16,10 +16,10 @@ jobs: action: runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v3 + - uses: dessant/lock-threads@v4 with: github-token: ${{ github.token }} - issue-inactive-days: '180' + issue-inactive-days: '90' exclude-issue-created-before: '' exclude-issue-created-after: '' exclude-issue-created-between: '' diff --git a/README.md b/README.md index c2fe1ed91..dabe3cd91 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,8 @@

CI Status Code Coverage - Latest stable version - License - Twitter + Latest stable version + License Mastodon

@@ -75,11 +74,11 @@ The best way to start is to [open a new issue](https://github.com/kimai/kimai/is In case you want to contribute, but you wouldn't know how, here are some suggestions: -- Spread the word: More user means more people testing and contributing to Kimai - which in turn means better stability and more and better features. Please vote for Kimai on platforms like Slant, Product Hunt, Softpedia or AlternativeTo, you can tweet about it, share it on LinkedIn, reddit or any of your favorite social media platforms. Every bit helps! -- Answer questions: You know the answer to another user's problem? Share your knowledge! -- Make a feature request: Something can be done better? Something essential missing? Let us know! -- Report bugs -- You don't have to be programmer to help. The documentation and translation could use some love as well. -- Sponsor the project, free software still costs money +- Spread the word: More user means more people testing and contributing to Kimai - which in turn means better stability and more and better features. Please vote for Kimai on platforms like Slant, Product Hunt, Softpedia or AlternativeTo, you can toot or tweet about it, share it on LinkedIn, reddit or any of your favorite social media platforms. Every bit helps! +- Answer questions: You know the answer to another user's problem? Share your knowledge. +- Something can be done better? An essential feature is missing? Create a feature request. +- Report bugs: that shouldn't happen too often. +- You don't have to be programmer, the documentation and translation could use some love as well. +- Sponsor the project: free software costs money to create! There is one simple rule in our "Code of conduct": Don't be an ass! diff --git a/TODO b/TODO deleted file mode 100644 index 5aa624d48..000000000 --- a/TODO +++ /dev/null @@ -1,36 +0,0 @@ -=========================================================================== - -INVOICES - -- HTML Rechnungstemplates funktionieren nicht mehr richtig - -=========================================================================== - -THEME - -- Update to latest release und angepasstes CSS entfernen - -- Theme Dark Mode mit Modus "Browser" sollte Standard sein: - https://github.com/tabler/tabler/issues/892#event-5666309557 - -=========================================================================== - -MIXED - -- Alle URLs ändern - - /admin/activity/ zu /activity/ - - /admin/project/ zu /project/ - - /admin/..../ zu /..../ - - /team/timesheet/ zu /timesheets/ - -- Tags => immer Berechtigung prüfen und "create" option über die VIEW ans Javascript geben - => den FormType nur ändern zu "AutoComplete" wenn es mehr als 500 Tags sind - => Übersetzungen (Suche, Erstellen, Keine Ergebnisse) über data attribute ans JS durchreichen - -=========================================================================== - -MIGRATIONS - -- rename 2.0 migration once it will be released - -=========================================================================== diff --git a/UPGRADING.md b/UPGRADING.md index 1fc2f499e..074b6d178 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -8,7 +8,7 @@ you can upgrade your Kimai installation to the latest stable release. Check below if there are more version specific steps required, which need to be executed after the normal update process. Perform EACH version specific task between your version and the new one, otherwise you risk data inconsistency or a broken installation. -## [2.0](https://github.com/kevinpapst/kimai2/releases/tag/2.0) +## [2.0](https://github.com/kimai/kimai/releases/tag/2.0) **!! This release requires minimum PHP version to 8.1 !!** diff --git a/assets/sass/invoice.scss b/assets/sass/invoice.scss index 6c6244207..bd3c66fcb 100644 --- a/assets/sass/invoice.scss +++ b/assets/sass/invoice.scss @@ -9,9 +9,6 @@ @import "~bootstrap/scss/variables"; @import "~bootstrap/scss/maps"; @import "~bootstrap/scss/mixins"; -//@import "~bootstrap/scss/normalize"; -//@import "~bootstrap/scss/print"; -//@import "~bootstrap/scss/scaffolding"; @import "~bootstrap/scss/reboot"; @import "~bootstrap/scss/type"; @import "~bootstrap/scss/grid"; @@ -19,12 +16,12 @@ @import "~bootstrap/scss/list-group"; @import "~bootstrap/scss/card"; @import "~bootstrap/scss/utilities"; -//@import "~bootstrap/scss/responsive-utilities"; body { font-family: $font-family-sans-serif; &.invoice_print { + --bs-body-font-size: 13px; background-color: #eee; .table.no-border, .table.no-border td, .table.no-border th { @@ -35,6 +32,38 @@ body { font-family: $font-family-sans-serif; } + .mt-2 { + margin-top: 2em; + } + + .mb-3 { + margin-bottom: 3em; + } + + .pt-1 { + padding-top: 1em; + } + + .pb-4 { + padding-bottom: 4em; + } + + .ps-0 { + padding-left: 0; + } + + .bt-1 { + border-top: 1px solid #000000; + } + + .pull-right { + float: right; + } + + .text-end { + text-align: right; + } + .invoice { margin: 105px auto 30px auto; padding: 50px 65px; @@ -48,7 +77,7 @@ body { .page-header { margin: 10px 0 20px 0; - font-size: 22px; + font-size: 20px; > small { color: #666; @@ -79,14 +108,11 @@ body { } .invoice-items { - margin-top: 2em; - margin-bottom: 3em; - .table { thead th { font-weight: bold; border-bottom: 1px solid #ddd; - padding-bottom: 15px + padding-bottom: 10px } tfoot { @@ -105,7 +131,6 @@ body { } } } - } .footer { diff --git a/composer.json b/composer.json index b532bcd79..164c5d3ab 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "kimai/kimai", - "license": "MIT", + "license": "AGPL-3.0-or-later", "type": "project", "description": "Kimai - Time Tracking", "authors": [ @@ -10,7 +10,7 @@ }, { "name": "All contributors", - "homepage": "https://github.com/kevinpapst/kimai2/contributors" + "homepage": "https://github.com/kimai/kimai/contributors" } ], "require": { @@ -191,7 +191,7 @@ }, "extra": { "branch-alias": { - "v2.x-dev": "2.0.x-dev" + "dev-main": "2.0.x-dev" }, "symfony": { "id": "01C3FWRDJJEX9K6Y3A4XDFXPBR", diff --git a/composer.lock b/composer.lock index c578518a9..40fb409ba 100644 --- a/composer.lock +++ b/composer.lock @@ -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": "16922ce13576f2e86b8a3c380fcf814a", + "content-hash": "f3b0627c043009647c1ab9c3ddea3f22", "packages": [ { "name": "bacon/bacon-qr-code", @@ -796,16 +796,16 @@ }, { "name": "doctrine/doctrine-bundle", - "version": "2.8.0", + "version": "2.8.1", "source": { "type": "git", "url": "https://github.com/doctrine/DoctrineBundle.git", - "reference": "0421ebc069519a0f19b9c39e5dc18c359be0feab" + "reference": "fe9b2cc1cd0c9b76553b1d4c1a077590ba231a2d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/0421ebc069519a0f19b9c39e5dc18c359be0feab", - "reference": "0421ebc069519a0f19b9c39e5dc18c359be0feab", + "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/fe9b2cc1cd0c9b76553b1d4c1a077590ba231a2d", + "reference": "fe9b2cc1cd0c9b76553b1d4c1a077590ba231a2d", "shasum": "" }, "require": { @@ -891,7 +891,7 @@ ], "support": { "issues": "https://github.com/doctrine/DoctrineBundle/issues", - "source": "https://github.com/doctrine/DoctrineBundle/tree/2.8.0" + "source": "https://github.com/doctrine/DoctrineBundle/tree/2.8.1" }, "funding": [ { @@ -907,7 +907,7 @@ "type": "tidelift" } ], - "time": "2022-12-28T16:35:32+00:00" + "time": "2023-01-06T00:24:26+00:00" }, { "name": "doctrine/doctrine-migrations-bundle", @@ -1685,16 +1685,16 @@ }, { "name": "egulias/email-validator", - "version": "3.2.4", + "version": "3.2.5", "source": { "type": "git", "url": "https://github.com/egulias/EmailValidator.git", - "reference": "5f35e41eba05fdfbabd95d72f83795c835fb7ed2" + "reference": "b531a2311709443320c786feb4519cfaf94af796" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/5f35e41eba05fdfbabd95d72f83795c835fb7ed2", - "reference": "5f35e41eba05fdfbabd95d72f83795c835fb7ed2", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/b531a2311709443320c786feb4519cfaf94af796", + "reference": "b531a2311709443320c786feb4519cfaf94af796", "shasum": "" }, "require": { @@ -1703,7 +1703,6 @@ "symfony/polyfill-intl-idn": "^1.15" }, "require-dev": { - "php-coveralls/php-coveralls": "^2.2", "phpunit/phpunit": "^8.5.8|^9.3.3", "vimeo/psalm": "^4" }, @@ -1741,7 +1740,7 @@ ], "support": { "issues": "https://github.com/egulias/EmailValidator/issues", - "source": "https://github.com/egulias/EmailValidator/tree/3.2.4" + "source": "https://github.com/egulias/EmailValidator/tree/3.2.5" }, "funding": [ { @@ -1749,7 +1748,7 @@ "type": "github" } ], - "time": "2022-12-30T14:09:25+00:00" + "time": "2023-01-02T17:26:14+00:00" }, { "name": "endroid/qr-code", @@ -9815,16 +9814,16 @@ }, { "name": "tijsverkoyen/css-to-inline-styles", - "version": "2.2.5", + "version": "2.2.6", "source": { "type": "git", "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", - "reference": "4348a3a06651827a27d989ad1d13efec6bb49b19" + "reference": "c42125b83a4fa63b187fdf29f9c93cb7733da30c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/4348a3a06651827a27d989ad1d13efec6bb49b19", - "reference": "4348a3a06651827a27d989ad1d13efec6bb49b19", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/c42125b83a4fa63b187fdf29f9c93cb7733da30c", + "reference": "c42125b83a4fa63b187fdf29f9c93cb7733da30c", "shasum": "" }, "require": { @@ -9862,9 +9861,9 @@ "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", "support": { "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", - "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/2.2.5" + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/2.2.6" }, - "time": "2022-09-12T13:28:28+00:00" + "time": "2023-01-03T09:29:04+00:00" }, { "name": "twig/cssinliner-extra", @@ -10462,20 +10461,20 @@ }, { "name": "zircote/swagger-php", - "version": "4.5.3", + "version": "4.5.4", "source": { "type": "git", "url": "https://github.com/zircote/swagger-php.git", - "reference": "e505bce612a86fe90f8fd50917e0848afc5d2ba8" + "reference": "09356f4d68d29bdf3254811fb2602a5d5d1788ea" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/zircote/swagger-php/zipball/e505bce612a86fe90f8fd50917e0848afc5d2ba8", - "reference": "e505bce612a86fe90f8fd50917e0848afc5d2ba8", + "url": "https://api.github.com/repos/zircote/swagger-php/zipball/09356f4d68d29bdf3254811fb2602a5d5d1788ea", + "reference": "09356f4d68d29bdf3254811fb2602a5d5d1788ea", "shasum": "" }, "require": { - "doctrine/annotations": "^1.7", + "doctrine/annotations": "^1.7 || ^2.0", "ext-json": "*", "php": ">=7.2", "psr/log": "^1.1 || ^2.0 || ^3.0", @@ -10534,9 +10533,9 @@ ], "support": { "issues": "https://github.com/zircote/swagger-php/issues", - "source": "https://github.com/zircote/swagger-php/tree/4.5.3" + "source": "https://github.com/zircote/swagger-php/tree/4.5.4" }, - "time": "2022-12-21T18:26:59+00:00" + "time": "2023-01-04T00:51:43+00:00" } ], "packages-dev": [ @@ -10746,16 +10745,16 @@ }, { "name": "doctrine/data-fixtures", - "version": "1.6.1", + "version": "1.6.2", "source": { "type": "git", "url": "https://github.com/doctrine/data-fixtures.git", - "reference": "1a4232c15143ca3c127812d19b23a7961c41eeed" + "reference": "d52cc6d392717734fac908768a7319f8a417401a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/data-fixtures/zipball/1a4232c15143ca3c127812d19b23a7961c41eeed", - "reference": "1a4232c15143ca3c127812d19b23a7961c41eeed", + "url": "https://api.github.com/repos/doctrine/data-fixtures/zipball/d52cc6d392717734fac908768a7319f8a417401a", + "reference": "d52cc6d392717734fac908768a7319f8a417401a", "shasum": "" }, "require": { @@ -10808,7 +10807,7 @@ ], "support": { "issues": "https://github.com/doctrine/data-fixtures/issues", - "source": "https://github.com/doctrine/data-fixtures/tree/1.6.1" + "source": "https://github.com/doctrine/data-fixtures/tree/1.6.2" }, "funding": [ { @@ -10824,7 +10823,7 @@ "type": "tidelift" } ], - "time": "2022-12-23T12:13:51+00:00" + "time": "2023-01-05T18:42:27+00:00" }, { "name": "doctrine/doctrine-fixtures-bundle", @@ -10979,16 +10978,16 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.13.1", + "version": "v3.13.2", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "78d2251dd86b49c609a0fd37c20dcf0a00aea5a7" + "reference": "3952f08a81bd3b1b15e11c3de0b6bf037faa8496" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/78d2251dd86b49c609a0fd37c20dcf0a00aea5a7", - "reference": "78d2251dd86b49c609a0fd37c20dcf0a00aea5a7", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/3952f08a81bd3b1b15e11c3de0b6bf037faa8496", + "reference": "3952f08a81bd3b1b15e11c3de0b6bf037faa8496", "shasum": "" }, "require": { @@ -11056,7 +11055,7 @@ "description": "A tool to automatically fix PHP code style", "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.13.1" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.13.2" }, "funding": [ { @@ -11064,7 +11063,7 @@ "type": "github" } ], - "time": "2022-12-18T00:47:22+00:00" + "time": "2023-01-02T23:53:50+00:00" }, { "name": "nikic/php-parser", @@ -11235,16 +11234,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.9.4", + "version": "1.9.7", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "d03bccee595e2146b7c9d174486b84f4dc61b0f2" + "reference": "0501435cd342eac7664bd62155b1ef907fc60b6f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/d03bccee595e2146b7c9d174486b84f4dc61b0f2", - "reference": "d03bccee595e2146b7c9d174486b84f4dc61b0f2", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/0501435cd342eac7664bd62155b1ef907fc60b6f", + "reference": "0501435cd342eac7664bd62155b1ef907fc60b6f", "shasum": "" }, "require": { @@ -11274,7 +11273,7 @@ ], "support": { "issues": "https://github.com/phpstan/phpstan/issues", - "source": "https://github.com/phpstan/phpstan/tree/1.9.4" + "source": "https://github.com/phpstan/phpstan/tree/1.9.7" }, "funding": [ { @@ -11290,25 +11289,25 @@ "type": "tidelift" } ], - "time": "2022-12-17T13:33:52+00:00" + "time": "2023-01-04T21:59:57+00:00" }, { "name": "phpstan/phpstan-doctrine", - "version": "1.3.28", + "version": "1.3.29", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-doctrine.git", - "reference": "8302a6a214b8cbbda8249cce6ec627033af26c12" + "reference": "4967ebbc24a2d7e94f5b2f6dad78e0087dd52fc3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/8302a6a214b8cbbda8249cce6ec627033af26c12", - "reference": "8302a6a214b8cbbda8249cce6ec627033af26c12", + "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/4967ebbc24a2d7e94f5b2f6dad78e0087dd52fc3", + "reference": "4967ebbc24a2d7e94f5b2f6dad78e0087dd52fc3", "shasum": "" }, "require": { "php": "^7.2 || ^8.0", - "phpstan/phpstan": "^1.8.11" + "phpstan/phpstan": "^1.9.7" }, "conflict": { "doctrine/collections": "<1.0", @@ -11357,9 +11356,9 @@ "description": "Doctrine extensions for PHPStan", "support": { "issues": "https://github.com/phpstan/phpstan-doctrine/issues", - "source": "https://github.com/phpstan/phpstan-doctrine/tree/1.3.28" + "source": "https://github.com/phpstan/phpstan-doctrine/tree/1.3.29" }, - "time": "2022-12-30T21:24:11+00:00" + "time": "2023-01-04T21:51:32+00:00" }, { "name": "phpstan/phpstan-phpunit", diff --git a/phpstan.neon b/phpstan.neon index 975598836..0d8ff59ec 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -570,316 +570,6 @@ parameters: count: 1 path: src/Command/InvoiceCreateCommand.php - - - message: "#^Call to an undefined method object\\:\\:getEventManager\\(\\)\\.$#" - count: 3 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Cannot call method getCustomers\\(\\) on App\\\\Entity\\\\Team\\|null\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Cannot call method getName\\(\\) on App\\\\Entity\\\\Team\\|null\\.$#" - count: 4 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Cannot call method getProjects\\(\\) on App\\\\Entity\\\\Team\\|null\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Cannot call method getTimezone\\(\\) on App\\\\Entity\\\\User\\|null\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Cannot call method getUsers\\(\\) on App\\\\Entity\\\\Team\\|null\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Cannot call method hasUsers\\(\\) on App\\\\Entity\\\\Team\\|null\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:countFromImport\\(\\) has parameter \\$where with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:countFromImport\\(\\) should return int but returns mixed\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:createActivity\\(\\) has parameter \\$fixedRates with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:createActivity\\(\\) has parameter \\$oldActivity with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:createActivity\\(\\) has parameter \\$rates with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:createActivity\\(\\) should return App\\\\Entity\\\\Activity but returns App\\\\Entity\\\\Activity\\|null\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:createInstanceTeam\\(\\) has parameter \\$activities with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:createInstanceTeam\\(\\) has parameter \\$users with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:deactivateLifecycleCallbacks\\(\\) has no return type specified\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:fetchAllFromImport\\(\\) has parameter \\$where with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:fetchAllFromImport\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:fetchIteratorFromImport\\(\\) return type has no value type specified in iterable type Traversable\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:importActivities\\(\\) has parameter \\$activities with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:importActivities\\(\\) has parameter \\$fixedRates with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:importActivities\\(\\) has parameter \\$rates with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:importCustomers\\(\\) has parameter \\$customers with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:importProjects\\(\\) has parameter \\$fixedRates with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:importProjects\\(\\) has parameter \\$projects with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:importProjects\\(\\) has parameter \\$rates with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:importTimesheetRecords\\(\\) has parameter \\$fixedRates with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:importTimesheetRecords\\(\\) has parameter \\$rates with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:importUsers\\(\\) has parameter \\$rates with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:importUsers\\(\\) has parameter \\$users with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:isKnownActivity\\(\\) has parameter \\$oldActivity with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:isKnownCustomer\\(\\) has parameter \\$oldCustomer with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:isKnownGroup\\(\\) has parameter \\$oldGroup with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:isKnownProject\\(\\) has parameter \\$oldProject with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:isKnownUser\\(\\) has parameter \\$oldUser with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:prepareOptionsFromInput\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:setActivityCache\\(\\) has parameter \\$oldActivity with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:setCustomerCache\\(\\) has parameter \\$oldCustomer with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:setGroupCache\\(\\) has parameter \\$oldGroup with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:setProjectCache\\(\\) has parameter \\$oldProject with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:setUserCache\\(\\) has parameter \\$oldUser with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:validateKimai1Data\\(\\) has parameter \\$activities with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:validateKimai1Data\\(\\) has parameter \\$customer with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:validateKimai1Data\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:validateKimai1Data\\(\\) has parameter \\$projects with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:validateKimai1Data\\(\\) has parameter \\$rates with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:validateKimai1Data\\(\\) has parameter \\$users with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:validateKimai1Data\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Method App\\\\Command\\\\KimaiImporterCommand\\:\\:validateOptions\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Parameter \\#1 \\$customer of method App\\\\Entity\\\\Team\\:\\:addCustomer\\(\\) expects App\\\\Entity\\\\Customer, App\\\\Entity\\\\Customer\\|null given\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Parameter \\#1 \\$name of method App\\\\Entity\\\\TimesheetMeta\\:\\:setName\\(\\) expects string, mixed given\\.$#" - count: 3 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Parameter \\#1 \\$object of method Doctrine\\\\Persistence\\\\ObjectManager\\:\\:persist\\(\\) expects object, App\\\\Entity\\\\Team\\|null given\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Parameter \\#1 \\$string of function strtolower expects string, string\\|null given\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Parameter \\#1 \\$user of method App\\\\Entity\\\\Team\\:\\:addTeamlead\\(\\) expects App\\\\Entity\\\\User, App\\\\Entity\\\\User\\|null given\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Parameter \\#1 \\$user of method App\\\\Entity\\\\Team\\:\\:addUser\\(\\) expects App\\\\Entity\\\\User, App\\\\Entity\\\\User\\|null given\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Parameter \\#1 \\$user of method App\\\\Entity\\\\Timesheet\\:\\:setUser\\(\\) expects App\\\\Entity\\\\User, App\\\\Entity\\\\User\\|null given\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Parameter \\#2 \\$object of method App\\\\Command\\\\KimaiImporterCommand\\:\\:validateImport\\(\\) expects object, App\\\\Entity\\\\Team\\|null given\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Parameter \\#2 \\$plainPassword of method Symfony\\\\Component\\\\PasswordHasher\\\\Hasher\\\\UserPasswordHasherInterface\\:\\:hashPassword\\(\\) expects string, string\\|null given\\.$#" - count: 2 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Parameter \\#2 \\$team of method App\\\\Command\\\\KimaiImporterCommand\\:\\:setGroupCache\\(\\) expects App\\\\Entity\\\\Team, App\\\\Entity\\\\Team\\|null given\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Parameter \\#2 \\$version2 of function version_compare expects string, mixed given\\.$#" - count: 2 - path: src/Command/KimaiImporterCommand.php - - - - message: "#^Property App\\\\Command\\\\KimaiImporterCommand\\:\\:\\$oldActivities type has no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/KimaiImporterCommand.php - - message: "#^Cannot call method getKimaiVersion\\(\\) on App\\\\Plugin\\\\PluginMetadata\\|null\\.$#" count: 1 @@ -6761,7 +6451,7 @@ parameters: path: src/Project/ProjectStatisticService.php - - message: "#^Strict comparison using \\!\\=\\= between null and array\\\\|numeric\\-string, billable\\: bool, exported\\: bool\\}\\> will always evaluate to true\\.$#" + message: "#^Strict comparison using \\!\\=\\= between null and list\\\\|numeric\\-string, billable\\: bool, exported\\: bool\\}\\> will always evaluate to true\\.$#" count: 1 path: src/Project/ProjectStatisticService.php @@ -6970,31 +6660,6 @@ parameters: count: 1 path: src/Repository/CustomerRepository.php - - - message: "#^Method App\\\\Repository\\\\InvoiceDocumentRepository\\:\\:__construct\\(\\) has parameter \\$directories with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Repository/InvoiceDocumentRepository.php - - - - message: "#^Method App\\\\Repository\\\\InvoiceDocumentRepository\\:\\:addDirectory\\(\\) has no return type specified\\.$#" - count: 1 - path: src/Repository/InvoiceDocumentRepository.php - - - - message: "#^Method App\\\\Repository\\\\InvoiceDocumentRepository\\:\\:findByPaths\\(\\) has parameter \\$paths with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Repository/InvoiceDocumentRepository.php - - - - message: "#^Method App\\\\Repository\\\\InvoiceDocumentRepository\\:\\:removeDirectory\\(\\) has no return type specified\\.$#" - count: 1 - path: src/Repository/InvoiceDocumentRepository.php - - - - message: "#^Parameter \\#1 \\$filename of function unlink expects string, string\\|false given\\.$#" - count: 1 - path: src/Repository/InvoiceDocumentRepository.php - - message: "#^Cannot access offset 'counter' on mixed\\.$#" count: 1 @@ -7135,11 +6800,6 @@ parameters: count: 1 path: src/Repository/Paginator/LoaderPaginator.php - - - message: "#^Method App\\\\Repository\\\\Paginator\\\\LoaderPaginator\\:\\:getResults\\(\\) has no return type specified\\.$#" - count: 1 - path: src/Repository/Paginator/LoaderPaginator.php - - message: "#^Parameter \\#1 \\$results of method App\\\\Repository\\\\Loader\\\\LoaderInterface\\:\\:loadResults\\(\\) expects array, mixed given\\.$#" count: 1 @@ -7165,11 +6825,6 @@ parameters: count: 1 path: src/Repository/Paginator/QueryBuilderPaginator.php - - - message: "#^Method App\\\\Repository\\\\Paginator\\\\QueryBuilderPaginator\\:\\:getResults\\(\\) has no return type specified\\.$#" - count: 1 - path: src/Repository/Paginator/QueryBuilderPaginator.php - - message: "#^Cannot call method getSearchFields\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#" count: 1 diff --git a/public/build/entrypoints.json b/public/build/entrypoints.json index 84d285d1b..2b6395941 100644 --- a/public/build/entrypoints.json +++ b/public/build/entrypoints.json @@ -24,7 +24,7 @@ "/build/invoice.c8ae95ad.js" ], "css": [ - "/build/invoice.b17784c1.css" + "/build/invoice.3c80ee80.css" ] }, "invoice-pdf": { @@ -68,7 +68,7 @@ "/build/export-pdf.587575e7.js": "sha384-J50GStmmfVwUTN4dIRQ02eg9hyzGFPSzpTtpPody92j0V6zCqw+s5l8+ZhVTugeW", "/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg", "/build/invoice.c8ae95ad.js": "sha384-2eVY7MBiMQxo1vhfizU+fYfEZbz9bYUdzqxnpTQhxpYiLaxeSV4WnaObq2G7/Pks", - "/build/invoice.b17784c1.css": "sha384-Y1e218/TMmOrl/aQcb77ix5qgFQDPPWpeD6GUtnX16Buj7/Mr6arWMSXFdqlJzAc", + "/build/invoice.3c80ee80.css": "sha384-xjFM2m/EeN7z42ygpt77ll5zAcHTeOjbZFAw2Qcu3IJutgebiAbrXjnH5aJfF5Z6", "/build/invoice-pdf.d86b82ee.js": "sha384-A0HJqP+MvEqQr1uG8wViCeEWxBRKyS6l8D+Ao4pFYHUA12gCC1gRYhk9I+SJPvZq", "/build/invoice-pdf.c88953bb.css": "sha384-ZvSi1e+ZKGzvZJUtAPLjzOSTh13N9zRevq44GKdYdBja/DAplGE55saY2Ur+83yv", "/build/chart.f5becfac.js": "sha384-GSqETm8wULiVXyizvwRompfwu63r/C0Qd/AvrHDE4cqAKiIGCssb3QyBtGu1WN+W", diff --git a/public/build/invoice.b17784c1.css b/public/build/invoice.3c80ee80.css similarity index 86% rename from public/build/invoice.b17784c1.css rename to public/build/invoice.3c80ee80.css index a85b296fa..bc9288a03 100644 --- a/public/build/invoice.b17784c1.css +++ b/public/build/invoice.3c80ee80.css @@ -1 +1 @@ -*,:after,:before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:rgba(0,0,0,0);background-color:var(--bs-body-bg);color:var(--bs-body-color);font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);margin:0;text-align:var(--bs-body-text-align)}hr{border:0;border-top:1px solid;color:inherit;margin:1rem 0;opacity:.25}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{font-weight:500;line-height:1.2;margin-bottom:.5rem;margin-top:0}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-bottom:1rem;margin-top:0}abbr[title]{cursor:help;text-decoration:underline dotted;text-decoration-skip-ink:none}address{font-style:normal;line-height:inherit;margin-bottom:1rem}ol,ul{padding-left:2rem}dl,ol,ul{margin-bottom:1rem;margin-top:0}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}.small,small{font-size:.875em}.mark,mark{background-color:var(--bs-highlight-bg);padding:.1875em}sub,sup{font-size:.75em;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:var(--bs-link-color);text-decoration:underline}a:hover{color:var(--bs-link-hover-color)}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em}pre{display:block;font-size:.875em;margin-bottom:1rem;margin-top:0;overflow:auto}pre code{color:inherit;font-size:inherit;word-break:normal}code{word-wrap:break-word;color:var(--bs-code-color);font-size:.875em}a>code{color:inherit}kbd{background-color:var(--bs-body-color);border-radius:.25rem;color:var(--bs-body-bg);font-size:.875em;padding:.1875rem .375rem}kbd kbd{font-size:1em;padding:0}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{border-collapse:collapse;caption-side:bottom}caption{color:#6c757d;padding-bottom:.5rem;padding-top:.5rem;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border:0 solid;border-color:inherit}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit;margin:0}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator{display:none!important}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{border-style:none;padding:0}textarea{resize:vertical}fieldset{border:0;margin:0;min-width:0;padding:0}legend{float:left;font-size:calc(1.275rem + .3vw);line-height:inherit;margin-bottom:.5rem;padding:0;width:100%}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{-webkit-appearance:button;font:inherit}output{display:inline-block}iframe{border:0}summary{cursor:pointer;display:list-item}progress{vertical-align:baseline}[hidden]{display:none!important}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:calc(1.625rem + 4.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-1{font-size:5rem}}.display-2{font-size:calc(1.575rem + 3.9vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-2{font-size:4.5rem}}.display-3{font-size:calc(1.525rem + 3.3vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-3{font-size:4rem}}.display-4{font-size:calc(1.475rem + 2.7vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-4{font-size:3.5rem}}.display-5{font-size:calc(1.425rem + 2.1vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-5{font-size:3rem}}.display-6{font-size:calc(1.375rem + 1.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-6{font-size:2.5rem}}.list-inline,.list-unstyled{list-style:none;padding-left:0}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:.875em;text-transform:uppercase}.blockquote{font-size:1.25rem;margin-bottom:1rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{color:#6c757d;font-size:.875em;margin-bottom:1rem;margin-top:-1rem}.blockquote-footer:before{content:"\2014\00A0"}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-left:calc(var(--bs-gutter-x)*-.5);margin-right:calc(var(--bs-gutter-x)*-.5);margin-top:calc(var(--bs-gutter-y)*-1)}.row>*{flex-shrink:0;margin-top:var(--bs-gutter-y);max-width:100%;padding-left:calc(var(--bs-gutter-x)*.5);padding-right:calc(var(--bs-gutter-x)*.5);width:100%}.col{flex:1 0 0%}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.33333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.66667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333%}.col-2{flex:0 0 auto;width:16.66667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333%}.col-5{flex:0 0 auto;width:41.66667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333%}.col-8{flex:0 0 auto;width:66.66667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333%}.col-11{flex:0 0 auto;width:91.66667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333%}.offset-2{margin-left:16.66667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333%}.offset-5{margin-left:41.66667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333%}.offset-8{margin-left:66.66667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333%}.offset-11{margin-left:91.66667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0%}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.33333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.66667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333%}.col-sm-2{flex:0 0 auto;width:16.66667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333%}.col-sm-5{flex:0 0 auto;width:41.66667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333%}.col-sm-8{flex:0 0 auto;width:66.66667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333%}.col-sm-11{flex:0 0 auto;width:91.66667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333%}.offset-sm-2{margin-left:16.66667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333%}.offset-sm-5{margin-left:41.66667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333%}.offset-sm-8{margin-left:66.66667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333%}.offset-sm-11{margin-left:91.66667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0%}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.33333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.66667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333%}.col-md-2{flex:0 0 auto;width:16.66667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333%}.col-md-5{flex:0 0 auto;width:41.66667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333%}.col-md-8{flex:0 0 auto;width:66.66667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333%}.col-md-11{flex:0 0 auto;width:91.66667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333%}.offset-md-2{margin-left:16.66667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333%}.offset-md-5{margin-left:41.66667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333%}.offset-md-8{margin-left:66.66667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333%}.offset-md-11{margin-left:91.66667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0%}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.33333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.66667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333%}.col-lg-2{flex:0 0 auto;width:16.66667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333%}.col-lg-5{flex:0 0 auto;width:41.66667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333%}.col-lg-8{flex:0 0 auto;width:66.66667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333%}.col-lg-11{flex:0 0 auto;width:91.66667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333%}.offset-lg-2{margin-left:16.66667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333%}.offset-lg-5{margin-left:41.66667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333%}.offset-lg-8{margin-left:66.66667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333%}.offset-lg-11{margin-left:91.66667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0%}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.33333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.66667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333%}.col-xl-2{flex:0 0 auto;width:16.66667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333%}.col-xl-5{flex:0 0 auto;width:41.66667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333%}.col-xl-8{flex:0 0 auto;width:66.66667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333%}.col-xl-11{flex:0 0 auto;width:91.66667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333%}.offset-xl-2{margin-left:16.66667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333%}.offset-xl-5{margin-left:41.66667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333%}.offset-xl-8{margin-left:66.66667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333%}.offset-xl-11{margin-left:91.66667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0%}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.33333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.66667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333%}.col-xxl-2{flex:0 0 auto;width:16.66667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333%}.col-xxl-5{flex:0 0 auto;width:41.66667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333%}.col-xxl-8{flex:0 0 auto;width:66.66667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333%}.col-xxl-11{flex:0 0 auto;width:91.66667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333%}.offset-xxl-2{margin-left:16.66667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333%}.offset-xxl-5{margin-left:41.66667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333%}.offset-xxl-8{margin-left:66.66667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333%}.offset-xxl-11{margin-left:91.66667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.table{--bs-table-color:var(--bs-body-color);--bs-table-bg:transparent;--bs-table-border-color:var(--bs-border-color);--bs-table-accent-bg:transparent;--bs-table-striped-color:var(--bs-body-color);--bs-table-striped-bg:rgba(0,0,0,.05);--bs-table-active-color:var(--bs-body-color);--bs-table-active-bg:rgba(0,0,0,.1);--bs-table-hover-color:var(--bs-body-color);--bs-table-hover-bg:rgba(0,0,0,.075);border-color:var(--bs-table-border-color);color:var(--bs-table-color);margin-bottom:1rem;vertical-align:top;width:100%}.table>:not(caption)>*>*{background-color:var(--bs-table-bg);border-bottom-width:1px;box-shadow:inset 0 0 0 9999px var(--bs-table-accent-bg);padding:.5rem}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table-group-divider{border-top:2px solid}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem}.table-bordered>:not(caption)>*{border-width:1px 0}.table-bordered>:not(caption)>*>*{border-width:0 1px}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-borderless>:not(:first-child){border-top-width:0}.table-striped-columns>:not(caption)>tr>:nth-child(2n),.table-striped>tbody>tr:nth-of-type(odd)>*{--bs-table-accent-bg:var(--bs-table-striped-bg);color:var(--bs-table-striped-color)}.table-active{--bs-table-accent-bg:var(--bs-table-active-bg);color:var(--bs-table-active-color)}.table-hover>tbody>tr:hover>*{--bs-table-accent-bg:var(--bs-table-hover-bg);color:var(--bs-table-hover-color)}.table-primary{--bs-table-color:#000;--bs-table-bg:#cfe2ff;--bs-table-border-color:#bacbe6;--bs-table-striped-bg:#c5d7f2;--bs-table-striped-color:#000;--bs-table-active-bg:#bacbe6;--bs-table-active-color:#000;--bs-table-hover-bg:#bfd1ec;--bs-table-hover-color:#000}.table-primary,.table-secondary{border-color:var(--bs-table-border-color);color:var(--bs-table-color)}.table-secondary{--bs-table-color:#000;--bs-table-bg:#e2e3e5;--bs-table-border-color:#cbccce;--bs-table-striped-bg:#d7d8da;--bs-table-striped-color:#000;--bs-table-active-bg:#cbccce;--bs-table-active-color:#000;--bs-table-hover-bg:#d1d2d4;--bs-table-hover-color:#000}.table-success{--bs-table-color:#000;--bs-table-bg:#d1e7dd;--bs-table-border-color:#bcd0c7;--bs-table-striped-bg:#c7dbd2;--bs-table-striped-color:#000;--bs-table-active-bg:#bcd0c7;--bs-table-active-color:#000;--bs-table-hover-bg:#c1d6cc;--bs-table-hover-color:#000}.table-info,.table-success{border-color:var(--bs-table-border-color);color:var(--bs-table-color)}.table-info{--bs-table-color:#000;--bs-table-bg:#cff4fc;--bs-table-border-color:#badce3;--bs-table-striped-bg:#c5e8ef;--bs-table-striped-color:#000;--bs-table-active-bg:#badce3;--bs-table-active-color:#000;--bs-table-hover-bg:#bfe2e9;--bs-table-hover-color:#000}.table-warning{--bs-table-color:#000;--bs-table-bg:#fff3cd;--bs-table-border-color:#e6dbb9;--bs-table-striped-bg:#f2e7c3;--bs-table-striped-color:#000;--bs-table-active-bg:#e6dbb9;--bs-table-active-color:#000;--bs-table-hover-bg:#ece1be;--bs-table-hover-color:#000}.table-danger,.table-warning{border-color:var(--bs-table-border-color);color:var(--bs-table-color)}.table-danger{--bs-table-color:#000;--bs-table-bg:#f8d7da;--bs-table-border-color:#dfc2c4;--bs-table-striped-bg:#eccccf;--bs-table-striped-color:#000;--bs-table-active-bg:#dfc2c4;--bs-table-active-color:#000;--bs-table-hover-bg:#e5c7ca;--bs-table-hover-color:#000}.table-light{--bs-table-color:#000;--bs-table-bg:#f8f9fa;--bs-table-border-color:#dfe0e1;--bs-table-striped-bg:#ecedee;--bs-table-striped-color:#000;--bs-table-active-bg:#dfe0e1;--bs-table-active-color:#000;--bs-table-hover-bg:#e5e6e7;--bs-table-hover-color:#000}.table-dark,.table-light{border-color:var(--bs-table-border-color);color:var(--bs-table-color)}.table-dark{--bs-table-color:#fff;--bs-table-bg:#212529;--bs-table-border-color:#373b3e;--bs-table-striped-bg:#2c3034;--bs-table-striped-color:#fff;--bs-table-active-bg:#373b3e;--bs-table-active-color:#fff;--bs-table-hover-bg:#323539;--bs-table-hover-color:#fff}.table-responsive{-webkit-overflow-scrolling:touch;overflow-x:auto}@media (max-width:575.98px){.table-responsive-sm{-webkit-overflow-scrolling:touch;overflow-x:auto}}@media (max-width:767.98px){.table-responsive-md{-webkit-overflow-scrolling:touch;overflow-x:auto}}@media (max-width:991.98px){.table-responsive-lg{-webkit-overflow-scrolling:touch;overflow-x:auto}}@media (max-width:1199.98px){.table-responsive-xl{-webkit-overflow-scrolling:touch;overflow-x:auto}}@media (max-width:1399.98px){.table-responsive-xxl{-webkit-overflow-scrolling:touch;overflow-x:auto}}.list-group{--bs-list-group-color:#212529;--bs-list-group-bg:#fff;--bs-list-group-border-color:rgba(0,0,0,.125);--bs-list-group-border-width:1px;--bs-list-group-border-radius:0.375rem;--bs-list-group-item-padding-x:1rem;--bs-list-group-item-padding-y:0.5rem;--bs-list-group-action-color:#495057;--bs-list-group-action-hover-color:#495057;--bs-list-group-action-hover-bg:#f8f9fa;--bs-list-group-action-active-color:#212529;--bs-list-group-action-active-bg:#e9ecef;--bs-list-group-disabled-color:#6c757d;--bs-list-group-disabled-bg:#fff;--bs-list-group-active-color:#fff;--bs-list-group-active-bg:#0d6efd;--bs-list-group-active-border-color:#0d6efd;border-radius:var(--bs-list-group-border-radius);display:flex;flex-direction:column;margin-bottom:0;padding-left:0}.list-group-numbered{counter-reset:section;list-style-type:none}.list-group-numbered>.list-group-item:before{content:counters(section,".") ". ";counter-increment:section}.list-group-item-action{color:var(--bs-list-group-action-color);text-align:inherit;width:100%}.list-group-item-action:focus,.list-group-item-action:hover{background-color:var(--bs-list-group-action-hover-bg);color:var(--bs-list-group-action-hover-color);text-decoration:none;z-index:1}.list-group-item-action:active{background-color:var(--bs-list-group-action-active-bg);color:var(--bs-list-group-action-active-color)}.list-group-item{background-color:var(--bs-list-group-bg);border:var(--bs-list-group-border-width) solid var(--bs-list-group-border-color);color:var(--bs-list-group-color);display:block;padding:var(--bs-list-group-item-padding-y) var(--bs-list-group-item-padding-x);position:relative;text-decoration:none}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-left-radius:inherit;border-bottom-right-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{background-color:var(--bs-list-group-disabled-bg);color:var(--bs-list-group-disabled-color);pointer-events:none}.list-group-item.active{background-color:var(--bs-list-group-active-bg);border-color:var(--bs-list-group-active-border-color);color:var(--bs-list-group-active-color);z-index:2}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{border-top-width:var(--bs-list-group-border-width);margin-top:calc(var(--bs-list-group-border-width)*-1)}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child:not(:first-child){border-bottom-left-radius:0;border-top-right-radius:var(--bs-list-group-border-radius)}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-left-width:0;border-top-width:var(--bs-list-group-border-width)}.list-group-horizontal>.list-group-item+.list-group-item.active{border-left-width:var(--bs-list-group-border-width);margin-left:calc(var(--bs-list-group-border-width)*-1)}@media (min-width:576px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child:not(:first-child){border-bottom-left-radius:0;border-top-right-radius:var(--bs-list-group-border-radius)}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-left-width:0;border-top-width:var(--bs-list-group-border-width)}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{border-left-width:var(--bs-list-group-border-width);margin-left:calc(var(--bs-list-group-border-width)*-1)}}@media (min-width:768px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child:not(:first-child){border-bottom-left-radius:0;border-top-right-radius:var(--bs-list-group-border-radius)}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-left-width:0;border-top-width:var(--bs-list-group-border-width)}.list-group-horizontal-md>.list-group-item+.list-group-item.active{border-left-width:var(--bs-list-group-border-width);margin-left:calc(var(--bs-list-group-border-width)*-1)}}@media (min-width:992px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child:not(:first-child){border-bottom-left-radius:0;border-top-right-radius:var(--bs-list-group-border-radius)}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-left-width:0;border-top-width:var(--bs-list-group-border-width)}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{border-left-width:var(--bs-list-group-border-width);margin-left:calc(var(--bs-list-group-border-width)*-1)}}@media (min-width:1200px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child:not(:first-child){border-bottom-left-radius:0;border-top-right-radius:var(--bs-list-group-border-radius)}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-left-width:0;border-top-width:var(--bs-list-group-border-width)}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{border-left-width:var(--bs-list-group-border-width);margin-left:calc(var(--bs-list-group-border-width)*-1)}}@media (min-width:1400px){.list-group-horizontal-xxl{flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child:not(:first-child){border-bottom-left-radius:0;border-top-right-radius:var(--bs-list-group-border-radius)}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-left-width:0;border-top-width:var(--bs-list-group-border-width)}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{border-left-width:var(--bs-list-group-border-width);margin-left:calc(var(--bs-list-group-border-width)*-1)}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 var(--bs-list-group-border-width)}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{background-color:#cfe2ff;color:#084298}.list-group-item-primary.list-group-item-action:focus,.list-group-item-primary.list-group-item-action:hover{background-color:#bacbe6;color:#084298}.list-group-item-primary.list-group-item-action.active{background-color:#084298;border-color:#084298;color:#fff}.list-group-item-secondary{background-color:#e2e3e5;color:#41464b}.list-group-item-secondary.list-group-item-action:focus,.list-group-item-secondary.list-group-item-action:hover{background-color:#cbccce;color:#41464b}.list-group-item-secondary.list-group-item-action.active{background-color:#41464b;border-color:#41464b;color:#fff}.list-group-item-success{background-color:#d1e7dd;color:#0f5132}.list-group-item-success.list-group-item-action:focus,.list-group-item-success.list-group-item-action:hover{background-color:#bcd0c7;color:#0f5132}.list-group-item-success.list-group-item-action.active{background-color:#0f5132;border-color:#0f5132;color:#fff}.list-group-item-info{background-color:#cff4fc;color:#055160}.list-group-item-info.list-group-item-action:focus,.list-group-item-info.list-group-item-action:hover{background-color:#badce3;color:#055160}.list-group-item-info.list-group-item-action.active{background-color:#055160;border-color:#055160;color:#fff}.list-group-item-warning{background-color:#fff3cd;color:#664d03}.list-group-item-warning.list-group-item-action:focus,.list-group-item-warning.list-group-item-action:hover{background-color:#e6dbb9;color:#664d03}.list-group-item-warning.list-group-item-action.active{background-color:#664d03;border-color:#664d03;color:#fff}.list-group-item-danger{background-color:#f8d7da;color:#842029}.list-group-item-danger.list-group-item-action:focus,.list-group-item-danger.list-group-item-action:hover{background-color:#dfc2c4;color:#842029}.list-group-item-danger.list-group-item-action.active{background-color:#842029;border-color:#842029;color:#fff}.list-group-item-light{background-color:#fefefe;color:#636464}.list-group-item-light.list-group-item-action:focus,.list-group-item-light.list-group-item-action:hover{background-color:#e5e5e5;color:#636464}.list-group-item-light.list-group-item-action.active{background-color:#636464;border-color:#636464;color:#fff}.list-group-item-dark{background-color:#d3d3d4;color:#141619}.list-group-item-dark.list-group-item-action:focus,.list-group-item-dark.list-group-item-action:hover{background-color:#bebebf;color:#141619}.list-group-item-dark.list-group-item-action.active{background-color:#141619;border-color:#141619;color:#fff}.card{--bs-card-spacer-y:1rem;--bs-card-spacer-x:1rem;--bs-card-title-spacer-y:0.5rem;--bs-card-border-width:1px;--bs-card-border-color:var(--bs-border-color-translucent);--bs-card-border-radius:0.375rem;--bs-card-box-shadow: ;--bs-card-inner-border-radius:calc(0.375rem - 1px);--bs-card-cap-padding-y:0.5rem;--bs-card-cap-padding-x:1rem;--bs-card-cap-bg:rgba(0,0,0,.03);--bs-card-cap-color: ;--bs-card-height: ;--bs-card-color: ;--bs-card-bg:#fff;--bs-card-img-overlay-padding:1rem;--bs-card-group-margin:0.75rem;word-wrap:break-word;background-clip:border-box;background-color:var(--bs-card-bg);border:var(--bs-card-border-width) solid var(--bs-card-border-color);border-radius:var(--bs-card-border-radius);display:flex;flex-direction:column;height:var(--bs-card-height);min-width:0;position:relative}.card>hr{margin-left:0;margin-right:0}.card>.list-group{border-bottom:inherit;border-top:inherit}.card>.list-group:first-child{border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius);border-top-width:0}.card>.list-group:last-child{border-bottom-left-radius:var(--bs-card-inner-border-radius);border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-width:0}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{color:var(--bs-card-color);flex:1 1 auto;padding:var(--bs-card-spacer-y) var(--bs-card-spacer-x)}.card-title{margin-bottom:var(--bs-card-title-spacer-y)}.card-subtitle{margin-top:calc(var(--bs-card-title-spacer-y)*-.5)}.card-subtitle,.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:var(--bs-card-spacer-x)}.card-header{background-color:var(--bs-card-cap-bg);border-bottom:var(--bs-card-border-width) solid var(--bs-card-border-color);color:var(--bs-card-cap-color);margin-bottom:0;padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x)}.card-header:first-child{border-radius:var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius) 0 0}.card-footer{background-color:var(--bs-card-cap-bg);border-top:var(--bs-card-border-width) solid var(--bs-card-border-color);color:var(--bs-card-cap-color);padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x)}.card-footer:last-child{border-radius:0 0 var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius)}.card-header-tabs{border-bottom:0;margin-bottom:calc(var(--bs-card-cap-padding-y)*-1);margin-left:calc(var(--bs-card-cap-padding-x)*-.5);margin-right:calc(var(--bs-card-cap-padding-x)*-.5)}.card-header-tabs .nav-link.active{background-color:var(--bs-card-bg);border-bottom-color:var(--bs-card-bg)}.card-header-pills{margin-left:calc(var(--bs-card-cap-padding-x)*-.5);margin-right:calc(var(--bs-card-cap-padding-x)*-.5)}.card-img-overlay{border-radius:var(--bs-card-inner-border-radius);bottom:0;left:0;padding:var(--bs-card-img-overlay-padding);position:absolute;right:0;top:0}.card-img,.card-img-bottom,.card-img-top{width:100%}.card-img,.card-img-top{border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom{border-bottom-left-radius:var(--bs-card-inner-border-radius);border-bottom-right-radius:var(--bs-card-inner-border-radius)}.card-group>.card{margin-bottom:var(--bs-card-group-margin)}@media (min-width:576px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{border-left:0;margin-left:0}.card-group>.card:not(:last-child){border-bottom-right-radius:0;border-top-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Noto Sans,Liberation Sans,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}body.invoice_print{background-color:#eee}body.invoice_print .table.no-border,body.invoice_print .table.no-border td,body.invoice_print .table.no-border th{border:0}body.invoice_print .h1,body.invoice_print .h2,body.invoice_print .h3,body.invoice_print h1,body.invoice_print h2,body.invoice_print h3{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Noto Sans,Liberation Sans,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}body.invoice_print .invoice{background:#fff;border:none;box-shadow:0 0 20px rgba(80,80,80,.7);margin:105px auto 30px;max-width:210mm;min-height:297mm;padding:50px 65px;position:relative}body.invoice_print .page-header{font-size:22px;margin:10px 0 20px}body.invoice_print .page-header>.small,body.invoice_print .page-header>small{color:#666;display:block;margin-top:5px}body.invoice_print .page-header .h3,body.invoice_print .page-header h3{border-color:#ddd}body.invoice_print div.invoice-address{margin-bottom:30px;margin-top:30px}body.invoice_print table.invoice-meta th{padding-right:40px}body.invoice_print table.invoice-sum th{width:70%}body.invoice_print table.invoice-sum td,body.invoice_print table.invoice-sum th{text-align:right}body.invoice_print .invoice-items{margin-bottom:3em;margin-top:2em}body.invoice_print .invoice-items .table thead th{border-bottom:1px solid #ddd;font-weight:700;padding-bottom:15px}body.invoice_print .invoice-items .table tfoot{border-top:1px solid #ddd}body.invoice_print .invoice-items .table tfoot tr:first-child td,body.invoice_print .invoice-items .table tfoot tr:first-child th{padding-top:15px}body.invoice_print .invoice-items .table tfoot tr td,body.invoice_print .invoice-items .table tfoot tr th{border:none;padding:4px 8px}body.invoice_print .footer{border-top:1px solid #ccc;font-size:.8em;margin-top:50px;padding-top:10px;width:100%}body.invoice_print #freelancer-invoice{box-sizing:border-box}body.invoice_print #freelancer-invoice .h1,body.invoice_print #freelancer-invoice h1{font:700 100% sans-serif;letter-spacing:.5em;text-align:center;text-transform:uppercase}body.invoice_print #freelancer-invoice .h2,body.invoice_print #freelancer-invoice h2{font:700 1.5em sans-serif;margin-bottom:1em}body.invoice_print #freelancer-invoice table{border-collapse:separate;border-spacing:0;width:100%}body.invoice_print #freelancer-invoice header:after{clear:both;content:"";display:table;font-size:1em}body.invoice_print #freelancer-invoice header address{float:left;font-size:.7em;font-style:normal;line-height:1.25;margin:0 1em 1em 0}body.invoice_print #freelancer-invoice article address{float:left;font-size:1em}body.invoice_print #freelancer-invoice article.address{margin:1em 0 1.5em}body.invoice_print #freelancer-invoice article:after{clear:both;content:"";display:table}body.invoice_print #freelancer-invoice article .h1,body.invoice_print #freelancer-invoice article h1{clip:rect(0 0 0 0);position:absolute}body.invoice_print #freelancer-invoice article p{margin-bottom:20px}body.invoice_print #freelancer-invoice table.balance:after,body.invoice_print #freelancer-invoice table.meta:after{clear:both;content:"";display:table}body.invoice_print #freelancer-invoice table.meta{float:right;font-size:85%;width:50%}body.invoice_print #freelancer-invoice table.meta th{font-weight:400;text-align:right}body.invoice_print #freelancer-invoice table.meta td{text-align:right;width:120px}body.invoice_print #freelancer-invoice table.balance{float:right;margin-top:1em}body.invoice_print #freelancer-invoice table.balance th{font-weight:400;text-align:right}body.invoice_print #freelancer-invoice table.balance td{line-height:22px;text-align:right;width:140px}body.invoice_print #freelancer-invoice table.balance .total{font-weight:700}body.invoice_print #freelancer-invoice table.inventory{border-bottom:1px solid #000;clear:both;margin-top:2em;width:100%}body.invoice_print #freelancer-invoice table.inventory td,body.invoice_print #freelancer-invoice table.inventory th{padding:5px}body.invoice_print #freelancer-invoice table.inventory thead th{border-width:0 0 1px;border-bottom:1px solid #000;font-weight:700;padding-bottom:15px}body.invoice_print #freelancer-invoice table.inventory tbody tr:first-child td{padding-top:15px}body.invoice_print #freelancer-invoice table.inventory tbody tr:last-child td{padding-bottom:15px}body.invoice_print #freelancer-invoice .footer{border-color:#000}@media print{body.invoice_print *{-webkit-print-color-adjust:exact}body.invoice_print .wrapper,body.invoice_print body{background-color:#fff;margin:0;padding:0}body.invoice_print #freelancer-invoice .add,body.invoice_print #freelancer-invoice .cut,body.invoice_print #freelancer-invoice span:empty{display:none}body.invoice_print .invoice{box-shadow:unset;font-size:90%;margin:1em 2em;max-width:unset;min-height:unset;padding:0;position:unset;width:unset}} \ No newline at end of file +*,:after,:before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:rgba(0,0,0,0);background-color:var(--bs-body-bg);color:var(--bs-body-color);font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);margin:0;text-align:var(--bs-body-text-align)}hr{border:0;border-top:1px solid;color:inherit;margin:1rem 0;opacity:.25}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{font-weight:500;line-height:1.2;margin-bottom:.5rem;margin-top:0}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-bottom:1rem;margin-top:0}abbr[title]{cursor:help;text-decoration:underline dotted;text-decoration-skip-ink:none}address{font-style:normal;line-height:inherit;margin-bottom:1rem}ol,ul{padding-left:2rem}dl,ol,ul{margin-bottom:1rem;margin-top:0}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}.small,small{font-size:.875em}.mark,mark{background-color:var(--bs-highlight-bg);padding:.1875em}sub,sup{font-size:.75em;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:var(--bs-link-color);text-decoration:underline}a:hover{color:var(--bs-link-hover-color)}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em}pre{display:block;font-size:.875em;margin-bottom:1rem;margin-top:0;overflow:auto}pre code{color:inherit;font-size:inherit;word-break:normal}code{word-wrap:break-word;color:var(--bs-code-color);font-size:.875em}a>code{color:inherit}kbd{background-color:var(--bs-body-color);border-radius:.25rem;color:var(--bs-body-bg);font-size:.875em;padding:.1875rem .375rem}kbd kbd{font-size:1em;padding:0}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{border-collapse:collapse;caption-side:bottom}caption{color:#6c757d;padding-bottom:.5rem;padding-top:.5rem;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border:0 solid;border-color:inherit}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit;margin:0}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator{display:none!important}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{border-style:none;padding:0}textarea{resize:vertical}fieldset{border:0;margin:0;min-width:0;padding:0}legend{float:left;font-size:calc(1.275rem + .3vw);line-height:inherit;margin-bottom:.5rem;padding:0;width:100%}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{-webkit-appearance:button;font:inherit}output{display:inline-block}iframe{border:0}summary{cursor:pointer;display:list-item}progress{vertical-align:baseline}[hidden]{display:none!important}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:calc(1.625rem + 4.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-1{font-size:5rem}}.display-2{font-size:calc(1.575rem + 3.9vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-2{font-size:4.5rem}}.display-3{font-size:calc(1.525rem + 3.3vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-3{font-size:4rem}}.display-4{font-size:calc(1.475rem + 2.7vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-4{font-size:3.5rem}}.display-5{font-size:calc(1.425rem + 2.1vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-5{font-size:3rem}}.display-6{font-size:calc(1.375rem + 1.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-6{font-size:2.5rem}}.list-inline,.list-unstyled{list-style:none;padding-left:0}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:.875em;text-transform:uppercase}.blockquote{font-size:1.25rem;margin-bottom:1rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{color:#6c757d;font-size:.875em;margin-bottom:1rem;margin-top:-1rem}.blockquote-footer:before{content:"\2014\00A0"}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-left:calc(var(--bs-gutter-x)*-.5);margin-right:calc(var(--bs-gutter-x)*-.5);margin-top:calc(var(--bs-gutter-y)*-1)}.row>*{flex-shrink:0;margin-top:var(--bs-gutter-y);max-width:100%;padding-left:calc(var(--bs-gutter-x)*.5);padding-right:calc(var(--bs-gutter-x)*.5);width:100%}.col{flex:1 0 0%}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.33333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.66667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333%}.col-2{flex:0 0 auto;width:16.66667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333%}.col-5{flex:0 0 auto;width:41.66667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333%}.col-8{flex:0 0 auto;width:66.66667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333%}.col-11{flex:0 0 auto;width:91.66667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333%}.offset-2{margin-left:16.66667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333%}.offset-5{margin-left:41.66667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333%}.offset-8{margin-left:66.66667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333%}.offset-11{margin-left:91.66667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0%}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.33333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.66667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333%}.col-sm-2{flex:0 0 auto;width:16.66667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333%}.col-sm-5{flex:0 0 auto;width:41.66667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333%}.col-sm-8{flex:0 0 auto;width:66.66667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333%}.col-sm-11{flex:0 0 auto;width:91.66667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333%}.offset-sm-2{margin-left:16.66667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333%}.offset-sm-5{margin-left:41.66667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333%}.offset-sm-8{margin-left:66.66667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333%}.offset-sm-11{margin-left:91.66667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0%}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.33333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.66667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333%}.col-md-2{flex:0 0 auto;width:16.66667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333%}.col-md-5{flex:0 0 auto;width:41.66667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333%}.col-md-8{flex:0 0 auto;width:66.66667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333%}.col-md-11{flex:0 0 auto;width:91.66667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333%}.offset-md-2{margin-left:16.66667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333%}.offset-md-5{margin-left:41.66667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333%}.offset-md-8{margin-left:66.66667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333%}.offset-md-11{margin-left:91.66667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0%}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.33333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.66667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333%}.col-lg-2{flex:0 0 auto;width:16.66667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333%}.col-lg-5{flex:0 0 auto;width:41.66667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333%}.col-lg-8{flex:0 0 auto;width:66.66667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333%}.col-lg-11{flex:0 0 auto;width:91.66667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333%}.offset-lg-2{margin-left:16.66667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333%}.offset-lg-5{margin-left:41.66667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333%}.offset-lg-8{margin-left:66.66667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333%}.offset-lg-11{margin-left:91.66667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0%}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.33333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.66667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333%}.col-xl-2{flex:0 0 auto;width:16.66667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333%}.col-xl-5{flex:0 0 auto;width:41.66667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333%}.col-xl-8{flex:0 0 auto;width:66.66667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333%}.col-xl-11{flex:0 0 auto;width:91.66667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333%}.offset-xl-2{margin-left:16.66667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333%}.offset-xl-5{margin-left:41.66667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333%}.offset-xl-8{margin-left:66.66667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333%}.offset-xl-11{margin-left:91.66667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0%}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.33333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.66667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333%}.col-xxl-2{flex:0 0 auto;width:16.66667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333%}.col-xxl-5{flex:0 0 auto;width:41.66667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333%}.col-xxl-8{flex:0 0 auto;width:66.66667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333%}.col-xxl-11{flex:0 0 auto;width:91.66667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333%}.offset-xxl-2{margin-left:16.66667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333%}.offset-xxl-5{margin-left:41.66667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333%}.offset-xxl-8{margin-left:66.66667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333%}.offset-xxl-11{margin-left:91.66667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.table{--bs-table-color:var(--bs-body-color);--bs-table-bg:transparent;--bs-table-border-color:var(--bs-border-color);--bs-table-accent-bg:transparent;--bs-table-striped-color:var(--bs-body-color);--bs-table-striped-bg:rgba(0,0,0,.05);--bs-table-active-color:var(--bs-body-color);--bs-table-active-bg:rgba(0,0,0,.1);--bs-table-hover-color:var(--bs-body-color);--bs-table-hover-bg:rgba(0,0,0,.075);border-color:var(--bs-table-border-color);color:var(--bs-table-color);margin-bottom:1rem;vertical-align:top;width:100%}.table>:not(caption)>*>*{background-color:var(--bs-table-bg);border-bottom-width:1px;box-shadow:inset 0 0 0 9999px var(--bs-table-accent-bg);padding:.5rem}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table-group-divider{border-top:2px solid}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem}.table-bordered>:not(caption)>*{border-width:1px 0}.table-bordered>:not(caption)>*>*{border-width:0 1px}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-borderless>:not(:first-child){border-top-width:0}.table-striped-columns>:not(caption)>tr>:nth-child(2n),.table-striped>tbody>tr:nth-of-type(odd)>*{--bs-table-accent-bg:var(--bs-table-striped-bg);color:var(--bs-table-striped-color)}.table-active{--bs-table-accent-bg:var(--bs-table-active-bg);color:var(--bs-table-active-color)}.table-hover>tbody>tr:hover>*{--bs-table-accent-bg:var(--bs-table-hover-bg);color:var(--bs-table-hover-color)}.table-primary{--bs-table-color:#000;--bs-table-bg:#cfe2ff;--bs-table-border-color:#bacbe6;--bs-table-striped-bg:#c5d7f2;--bs-table-striped-color:#000;--bs-table-active-bg:#bacbe6;--bs-table-active-color:#000;--bs-table-hover-bg:#bfd1ec;--bs-table-hover-color:#000}.table-primary,.table-secondary{border-color:var(--bs-table-border-color);color:var(--bs-table-color)}.table-secondary{--bs-table-color:#000;--bs-table-bg:#e2e3e5;--bs-table-border-color:#cbccce;--bs-table-striped-bg:#d7d8da;--bs-table-striped-color:#000;--bs-table-active-bg:#cbccce;--bs-table-active-color:#000;--bs-table-hover-bg:#d1d2d4;--bs-table-hover-color:#000}.table-success{--bs-table-color:#000;--bs-table-bg:#d1e7dd;--bs-table-border-color:#bcd0c7;--bs-table-striped-bg:#c7dbd2;--bs-table-striped-color:#000;--bs-table-active-bg:#bcd0c7;--bs-table-active-color:#000;--bs-table-hover-bg:#c1d6cc;--bs-table-hover-color:#000}.table-info,.table-success{border-color:var(--bs-table-border-color);color:var(--bs-table-color)}.table-info{--bs-table-color:#000;--bs-table-bg:#cff4fc;--bs-table-border-color:#badce3;--bs-table-striped-bg:#c5e8ef;--bs-table-striped-color:#000;--bs-table-active-bg:#badce3;--bs-table-active-color:#000;--bs-table-hover-bg:#bfe2e9;--bs-table-hover-color:#000}.table-warning{--bs-table-color:#000;--bs-table-bg:#fff3cd;--bs-table-border-color:#e6dbb9;--bs-table-striped-bg:#f2e7c3;--bs-table-striped-color:#000;--bs-table-active-bg:#e6dbb9;--bs-table-active-color:#000;--bs-table-hover-bg:#ece1be;--bs-table-hover-color:#000}.table-danger,.table-warning{border-color:var(--bs-table-border-color);color:var(--bs-table-color)}.table-danger{--bs-table-color:#000;--bs-table-bg:#f8d7da;--bs-table-border-color:#dfc2c4;--bs-table-striped-bg:#eccccf;--bs-table-striped-color:#000;--bs-table-active-bg:#dfc2c4;--bs-table-active-color:#000;--bs-table-hover-bg:#e5c7ca;--bs-table-hover-color:#000}.table-light{--bs-table-color:#000;--bs-table-bg:#f8f9fa;--bs-table-border-color:#dfe0e1;--bs-table-striped-bg:#ecedee;--bs-table-striped-color:#000;--bs-table-active-bg:#dfe0e1;--bs-table-active-color:#000;--bs-table-hover-bg:#e5e6e7;--bs-table-hover-color:#000}.table-dark,.table-light{border-color:var(--bs-table-border-color);color:var(--bs-table-color)}.table-dark{--bs-table-color:#fff;--bs-table-bg:#212529;--bs-table-border-color:#373b3e;--bs-table-striped-bg:#2c3034;--bs-table-striped-color:#fff;--bs-table-active-bg:#373b3e;--bs-table-active-color:#fff;--bs-table-hover-bg:#323539;--bs-table-hover-color:#fff}.table-responsive{-webkit-overflow-scrolling:touch;overflow-x:auto}@media (max-width:575.98px){.table-responsive-sm{-webkit-overflow-scrolling:touch;overflow-x:auto}}@media (max-width:767.98px){.table-responsive-md{-webkit-overflow-scrolling:touch;overflow-x:auto}}@media (max-width:991.98px){.table-responsive-lg{-webkit-overflow-scrolling:touch;overflow-x:auto}}@media (max-width:1199.98px){.table-responsive-xl{-webkit-overflow-scrolling:touch;overflow-x:auto}}@media (max-width:1399.98px){.table-responsive-xxl{-webkit-overflow-scrolling:touch;overflow-x:auto}}.list-group{--bs-list-group-color:#212529;--bs-list-group-bg:#fff;--bs-list-group-border-color:rgba(0,0,0,.125);--bs-list-group-border-width:1px;--bs-list-group-border-radius:0.375rem;--bs-list-group-item-padding-x:1rem;--bs-list-group-item-padding-y:0.5rem;--bs-list-group-action-color:#495057;--bs-list-group-action-hover-color:#495057;--bs-list-group-action-hover-bg:#f8f9fa;--bs-list-group-action-active-color:#212529;--bs-list-group-action-active-bg:#e9ecef;--bs-list-group-disabled-color:#6c757d;--bs-list-group-disabled-bg:#fff;--bs-list-group-active-color:#fff;--bs-list-group-active-bg:#0d6efd;--bs-list-group-active-border-color:#0d6efd;border-radius:var(--bs-list-group-border-radius);display:flex;flex-direction:column;margin-bottom:0;padding-left:0}.list-group-numbered{counter-reset:section;list-style-type:none}.list-group-numbered>.list-group-item:before{content:counters(section,".") ". ";counter-increment:section}.list-group-item-action{color:var(--bs-list-group-action-color);text-align:inherit;width:100%}.list-group-item-action:focus,.list-group-item-action:hover{background-color:var(--bs-list-group-action-hover-bg);color:var(--bs-list-group-action-hover-color);text-decoration:none;z-index:1}.list-group-item-action:active{background-color:var(--bs-list-group-action-active-bg);color:var(--bs-list-group-action-active-color)}.list-group-item{background-color:var(--bs-list-group-bg);border:var(--bs-list-group-border-width) solid var(--bs-list-group-border-color);color:var(--bs-list-group-color);display:block;padding:var(--bs-list-group-item-padding-y) var(--bs-list-group-item-padding-x);position:relative;text-decoration:none}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-left-radius:inherit;border-bottom-right-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{background-color:var(--bs-list-group-disabled-bg);color:var(--bs-list-group-disabled-color);pointer-events:none}.list-group-item.active{background-color:var(--bs-list-group-active-bg);border-color:var(--bs-list-group-active-border-color);color:var(--bs-list-group-active-color);z-index:2}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{border-top-width:var(--bs-list-group-border-width);margin-top:calc(var(--bs-list-group-border-width)*-1)}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child:not(:first-child){border-bottom-left-radius:0;border-top-right-radius:var(--bs-list-group-border-radius)}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-left-width:0;border-top-width:var(--bs-list-group-border-width)}.list-group-horizontal>.list-group-item+.list-group-item.active{border-left-width:var(--bs-list-group-border-width);margin-left:calc(var(--bs-list-group-border-width)*-1)}@media (min-width:576px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child:not(:first-child){border-bottom-left-radius:0;border-top-right-radius:var(--bs-list-group-border-radius)}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-left-width:0;border-top-width:var(--bs-list-group-border-width)}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{border-left-width:var(--bs-list-group-border-width);margin-left:calc(var(--bs-list-group-border-width)*-1)}}@media (min-width:768px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child:not(:first-child){border-bottom-left-radius:0;border-top-right-radius:var(--bs-list-group-border-radius)}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-left-width:0;border-top-width:var(--bs-list-group-border-width)}.list-group-horizontal-md>.list-group-item+.list-group-item.active{border-left-width:var(--bs-list-group-border-width);margin-left:calc(var(--bs-list-group-border-width)*-1)}}@media (min-width:992px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child:not(:first-child){border-bottom-left-radius:0;border-top-right-radius:var(--bs-list-group-border-radius)}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-left-width:0;border-top-width:var(--bs-list-group-border-width)}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{border-left-width:var(--bs-list-group-border-width);margin-left:calc(var(--bs-list-group-border-width)*-1)}}@media (min-width:1200px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child:not(:first-child){border-bottom-left-radius:0;border-top-right-radius:var(--bs-list-group-border-radius)}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-left-width:0;border-top-width:var(--bs-list-group-border-width)}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{border-left-width:var(--bs-list-group-border-width);margin-left:calc(var(--bs-list-group-border-width)*-1)}}@media (min-width:1400px){.list-group-horizontal-xxl{flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child:not(:first-child){border-bottom-left-radius:0;border-top-right-radius:var(--bs-list-group-border-radius)}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-left-width:0;border-top-width:var(--bs-list-group-border-width)}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{border-left-width:var(--bs-list-group-border-width);margin-left:calc(var(--bs-list-group-border-width)*-1)}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 var(--bs-list-group-border-width)}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{background-color:#cfe2ff;color:#084298}.list-group-item-primary.list-group-item-action:focus,.list-group-item-primary.list-group-item-action:hover{background-color:#bacbe6;color:#084298}.list-group-item-primary.list-group-item-action.active{background-color:#084298;border-color:#084298;color:#fff}.list-group-item-secondary{background-color:#e2e3e5;color:#41464b}.list-group-item-secondary.list-group-item-action:focus,.list-group-item-secondary.list-group-item-action:hover{background-color:#cbccce;color:#41464b}.list-group-item-secondary.list-group-item-action.active{background-color:#41464b;border-color:#41464b;color:#fff}.list-group-item-success{background-color:#d1e7dd;color:#0f5132}.list-group-item-success.list-group-item-action:focus,.list-group-item-success.list-group-item-action:hover{background-color:#bcd0c7;color:#0f5132}.list-group-item-success.list-group-item-action.active{background-color:#0f5132;border-color:#0f5132;color:#fff}.list-group-item-info{background-color:#cff4fc;color:#055160}.list-group-item-info.list-group-item-action:focus,.list-group-item-info.list-group-item-action:hover{background-color:#badce3;color:#055160}.list-group-item-info.list-group-item-action.active{background-color:#055160;border-color:#055160;color:#fff}.list-group-item-warning{background-color:#fff3cd;color:#664d03}.list-group-item-warning.list-group-item-action:focus,.list-group-item-warning.list-group-item-action:hover{background-color:#e6dbb9;color:#664d03}.list-group-item-warning.list-group-item-action.active{background-color:#664d03;border-color:#664d03;color:#fff}.list-group-item-danger{background-color:#f8d7da;color:#842029}.list-group-item-danger.list-group-item-action:focus,.list-group-item-danger.list-group-item-action:hover{background-color:#dfc2c4;color:#842029}.list-group-item-danger.list-group-item-action.active{background-color:#842029;border-color:#842029;color:#fff}.list-group-item-light{background-color:#fefefe;color:#636464}.list-group-item-light.list-group-item-action:focus,.list-group-item-light.list-group-item-action:hover{background-color:#e5e5e5;color:#636464}.list-group-item-light.list-group-item-action.active{background-color:#636464;border-color:#636464;color:#fff}.list-group-item-dark{background-color:#d3d3d4;color:#141619}.list-group-item-dark.list-group-item-action:focus,.list-group-item-dark.list-group-item-action:hover{background-color:#bebebf;color:#141619}.list-group-item-dark.list-group-item-action.active{background-color:#141619;border-color:#141619;color:#fff}.card{--bs-card-spacer-y:1rem;--bs-card-spacer-x:1rem;--bs-card-title-spacer-y:0.5rem;--bs-card-border-width:1px;--bs-card-border-color:var(--bs-border-color-translucent);--bs-card-border-radius:0.375rem;--bs-card-box-shadow: ;--bs-card-inner-border-radius:calc(0.375rem - 1px);--bs-card-cap-padding-y:0.5rem;--bs-card-cap-padding-x:1rem;--bs-card-cap-bg:rgba(0,0,0,.03);--bs-card-cap-color: ;--bs-card-height: ;--bs-card-color: ;--bs-card-bg:#fff;--bs-card-img-overlay-padding:1rem;--bs-card-group-margin:0.75rem;word-wrap:break-word;background-clip:border-box;background-color:var(--bs-card-bg);border:var(--bs-card-border-width) solid var(--bs-card-border-color);border-radius:var(--bs-card-border-radius);display:flex;flex-direction:column;height:var(--bs-card-height);min-width:0;position:relative}.card>hr{margin-left:0;margin-right:0}.card>.list-group{border-bottom:inherit;border-top:inherit}.card>.list-group:first-child{border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius);border-top-width:0}.card>.list-group:last-child{border-bottom-left-radius:var(--bs-card-inner-border-radius);border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-width:0}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{color:var(--bs-card-color);flex:1 1 auto;padding:var(--bs-card-spacer-y) var(--bs-card-spacer-x)}.card-title{margin-bottom:var(--bs-card-title-spacer-y)}.card-subtitle{margin-top:calc(var(--bs-card-title-spacer-y)*-.5)}.card-subtitle,.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:var(--bs-card-spacer-x)}.card-header{background-color:var(--bs-card-cap-bg);border-bottom:var(--bs-card-border-width) solid var(--bs-card-border-color);color:var(--bs-card-cap-color);margin-bottom:0;padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x)}.card-header:first-child{border-radius:var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius) 0 0}.card-footer{background-color:var(--bs-card-cap-bg);border-top:var(--bs-card-border-width) solid var(--bs-card-border-color);color:var(--bs-card-cap-color);padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x)}.card-footer:last-child{border-radius:0 0 var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius)}.card-header-tabs{border-bottom:0;margin-bottom:calc(var(--bs-card-cap-padding-y)*-1);margin-left:calc(var(--bs-card-cap-padding-x)*-.5);margin-right:calc(var(--bs-card-cap-padding-x)*-.5)}.card-header-tabs .nav-link.active{background-color:var(--bs-card-bg);border-bottom-color:var(--bs-card-bg)}.card-header-pills{margin-left:calc(var(--bs-card-cap-padding-x)*-.5);margin-right:calc(var(--bs-card-cap-padding-x)*-.5)}.card-img-overlay{border-radius:var(--bs-card-inner-border-radius);bottom:0;left:0;padding:var(--bs-card-img-overlay-padding);position:absolute;right:0;top:0}.card-img,.card-img-bottom,.card-img-top{width:100%}.card-img,.card-img-top{border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom{border-bottom-left-radius:var(--bs-card-inner-border-radius);border-bottom-right-radius:var(--bs-card-inner-border-radius)}.card-group>.card{margin-bottom:var(--bs-card-group-margin)}@media (min-width:576px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{border-left:0;margin-left:0}.card-group>.card:not(:last-child){border-bottom-right-radius:0;border-top-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Noto Sans,Liberation Sans,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}body.invoice_print{--bs-body-font-size:13px;background-color:#eee}body.invoice_print .table.no-border,body.invoice_print .table.no-border td,body.invoice_print .table.no-border th{border:0}body.invoice_print .h1,body.invoice_print .h2,body.invoice_print .h3,body.invoice_print h1,body.invoice_print h2,body.invoice_print h3{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Noto Sans,Liberation Sans,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}body.invoice_print .mt-2{margin-top:2em}body.invoice_print .mb-3{margin-bottom:3em}body.invoice_print .pt-1{padding-top:1em}body.invoice_print .pb-4{padding-bottom:4em}body.invoice_print .ps-0{padding-left:0}body.invoice_print .bt-1{border-top:1px solid #000}body.invoice_print .pull-right{float:right}body.invoice_print .text-end{text-align:right}body.invoice_print .invoice{background:#fff;border:none;box-shadow:0 0 20px rgba(80,80,80,.7);margin:105px auto 30px;max-width:210mm;min-height:297mm;padding:50px 65px;position:relative}body.invoice_print .page-header{font-size:20px;margin:10px 0 20px}body.invoice_print .page-header>.small,body.invoice_print .page-header>small{color:#666;display:block;margin-top:5px}body.invoice_print .page-header .h3,body.invoice_print .page-header h3{border-color:#ddd}body.invoice_print div.invoice-address{margin-bottom:30px;margin-top:30px}body.invoice_print table.invoice-meta th{padding-right:40px}body.invoice_print table.invoice-sum th{width:70%}body.invoice_print table.invoice-sum td,body.invoice_print table.invoice-sum th{text-align:right}body.invoice_print .invoice-items .table thead th{border-bottom:1px solid #ddd;font-weight:700;padding-bottom:10px}body.invoice_print .invoice-items .table tfoot{border-top:1px solid #ddd}body.invoice_print .invoice-items .table tfoot tr:first-child td,body.invoice_print .invoice-items .table tfoot tr:first-child th{padding-top:15px}body.invoice_print .invoice-items .table tfoot tr td,body.invoice_print .invoice-items .table tfoot tr th{border:none;padding:4px 8px}body.invoice_print .footer{border-top:1px solid #ccc;font-size:.8em;margin-top:50px;padding-top:10px;width:100%}body.invoice_print #freelancer-invoice{box-sizing:border-box}body.invoice_print #freelancer-invoice .h1,body.invoice_print #freelancer-invoice h1{font:700 100% sans-serif;letter-spacing:.5em;text-align:center;text-transform:uppercase}body.invoice_print #freelancer-invoice .h2,body.invoice_print #freelancer-invoice h2{font:700 1.5em sans-serif;margin-bottom:1em}body.invoice_print #freelancer-invoice table{border-collapse:separate;border-spacing:0;width:100%}body.invoice_print #freelancer-invoice header:after{clear:both;content:"";display:table;font-size:1em}body.invoice_print #freelancer-invoice header address{float:left;font-size:.7em;font-style:normal;line-height:1.25;margin:0 1em 1em 0}body.invoice_print #freelancer-invoice article address{float:left;font-size:1em}body.invoice_print #freelancer-invoice article.address{margin:1em 0 1.5em}body.invoice_print #freelancer-invoice article:after{clear:both;content:"";display:table}body.invoice_print #freelancer-invoice article .h1,body.invoice_print #freelancer-invoice article h1{clip:rect(0 0 0 0);position:absolute}body.invoice_print #freelancer-invoice article p{margin-bottom:20px}body.invoice_print #freelancer-invoice table.balance:after,body.invoice_print #freelancer-invoice table.meta:after{clear:both;content:"";display:table}body.invoice_print #freelancer-invoice table.meta{float:right;font-size:85%;width:50%}body.invoice_print #freelancer-invoice table.meta th{font-weight:400;text-align:right}body.invoice_print #freelancer-invoice table.meta td{text-align:right;width:120px}body.invoice_print #freelancer-invoice table.balance{float:right;margin-top:1em}body.invoice_print #freelancer-invoice table.balance th{font-weight:400;text-align:right}body.invoice_print #freelancer-invoice table.balance td{line-height:22px;text-align:right;width:140px}body.invoice_print #freelancer-invoice table.balance .total{font-weight:700}body.invoice_print #freelancer-invoice table.inventory{border-bottom:1px solid #000;clear:both;margin-top:2em;width:100%}body.invoice_print #freelancer-invoice table.inventory td,body.invoice_print #freelancer-invoice table.inventory th{padding:5px}body.invoice_print #freelancer-invoice table.inventory thead th{border-width:0 0 1px;border-bottom:1px solid #000;font-weight:700;padding-bottom:15px}body.invoice_print #freelancer-invoice table.inventory tbody tr:first-child td{padding-top:15px}body.invoice_print #freelancer-invoice table.inventory tbody tr:last-child td{padding-bottom:15px}body.invoice_print #freelancer-invoice .footer{border-color:#000}@media print{body.invoice_print *{-webkit-print-color-adjust:exact}body.invoice_print .wrapper,body.invoice_print body{background-color:#fff;margin:0;padding:0}body.invoice_print #freelancer-invoice .add,body.invoice_print #freelancer-invoice .cut,body.invoice_print #freelancer-invoice span:empty{display:none}body.invoice_print .invoice{box-shadow:unset;font-size:90%;margin:1em 2em;max-width:unset;min-height:unset;padding:0;position:unset;width:unset}} \ No newline at end of file diff --git a/public/build/manifest.json b/public/build/manifest.json index ef3522f8d..51cec9dbc 100644 --- a/public/build/manifest.json +++ b/public/build/manifest.json @@ -3,7 +3,7 @@ "build/app.js": "/build/app.694c6cb5.js", "build/export-pdf.css": "/build/export-pdf.d8a6c23b.css", "build/export-pdf.js": "/build/export-pdf.587575e7.js", - "build/invoice.css": "/build/invoice.b17784c1.css", + "build/invoice.css": "/build/invoice.3c80ee80.css", "build/invoice.js": "/build/invoice.c8ae95ad.js", "build/invoice-pdf.css": "/build/invoice-pdf.c88953bb.css", "build/invoice-pdf.js": "/build/invoice-pdf.d86b82ee.js", diff --git a/src/API/Authentication/ApiTokenUpgradeBadge.php b/src/API/Authentication/ApiTokenUpgradeBadge.php index b8c060a6b..ec6bcf298 100644 --- a/src/API/Authentication/ApiTokenUpgradeBadge.php +++ b/src/API/Authentication/ApiTokenUpgradeBadge.php @@ -9,12 +9,17 @@ namespace App\API\Authentication; +use App\Entity\User; use Symfony\Component\Security\Core\Exception\LogicException; use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface; final class ApiTokenUpgradeBadge implements BadgeInterface { + /** + * @param string|null $plaintextApiToken + * @param PasswordUpgraderInterface $passwordUpgrader + */ public function __construct(private ?string $plaintextApiToken, private PasswordUpgraderInterface $passwordUpgrader) { } @@ -31,6 +36,9 @@ final class ApiTokenUpgradeBadge implements BadgeInterface return $password; } + /** + * @return PasswordUpgraderInterface + */ public function getPasswordUpgrader(): PasswordUpgraderInterface { return $this->passwordUpgrader; diff --git a/src/Command/KimaiImporterCommand.php b/src/Command/KimaiImporterCommand.php deleted file mode 100755 index d5349381b..000000000 --- a/src/Command/KimaiImporterCommand.php +++ /dev/null @@ -1,2112 +0,0 @@ - new User() - * Global across all instances. - * - * @var User[] - */ - private array $users = []; - /** - * Instance specific mappings of user IDs to cache IDs - * - * @var string[] - */ - private array $userIds = []; - /** - * Old TeamID => new Team() - * Global across all instances. - * - * @var Team[] - */ - private array $teams = []; - /** - * Instance specific mappings of team IDs to cache IDs - * - * @var string[] - */ - private array $teamIds = []; - /** - * Global across all instances. - * - * @var Customer[] - */ - private array $customers = []; - /** - * Old Project ID => new Project() - * Global across all instances. - * - * @var Project[] - */ - private array $projects = []; - /** - * id => [projectId => Activity] - * @var array - */ - private array $activities = []; - private bool $debug = false; - /** - * Global activities (either because they were global OR because --global was used). - * - * @var array - */ - private array $oldActivities = []; - /** - * @var array - */ - private array $options = []; - - public function __construct(private UserPasswordHasherInterface $passwordHasher, private ManagerRegistry $doctrine, private ValidatorInterface $validator) - { - parent::__construct(); - } - - protected function configure(): void - { - $this - ->setDescription('Import data from a Kimai v1 installation') - ->setHelp('This command allows you to import the most important data from a Kimi v1 installation.') - ->addArgument( - 'connection', - InputArgument::REQUIRED, - 'The database connection as URL, e.g.: mysql://user:password@127.0.0.1:3306/kimai?charset=utf8' - ) - ->addArgument('password', InputArgument::REQUIRED, 'The new password for all imported user') - ->addOption('country', null, InputOption::VALUE_OPTIONAL, 'The default country for customer (2-character uppercase)', 'DE') - ->addOption('currency', null, InputOption::VALUE_OPTIONAL, 'The default currency for customer (code like EUR, CHF, GBP or USD)', 'EUR') - ->addOption('prefix', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'The database prefix(es) for your old Kimai v1 instances', ['kimai_']) - ->addOption('timezone', null, InputOption::VALUE_OPTIONAL, 'Default timezone for imported users', date_default_timezone_get()) - ->addOption('language', null, InputOption::VALUE_OPTIONAL, 'Default language for imported users', User::DEFAULT_LANGUAGE) - ->addOption('global', null, InputOption::VALUE_NONE, 'If set, activities without mapping will be created globally instead of project-specific (default behavior)') - ->addOption('fix-utf8', null, InputOption::VALUE_NONE, 'Trying to fix some known encoding problems (wrong encoded character: äÄüÜöÖß)). Use with caution!') - ->addOption('fix-email', null, InputOption::VALUE_REQUIRED, 'Domain that is used to fix empty email addresses (will be set to username@domain)') - ->addOption('fix-timesheet', null, InputOption::VALUE_NONE, 'Fix known timesheet problems (negative durations)') - ->addOption('skip-error-rates', null, InputOption::VALUE_NONE, 'Ignores rate mappings for unknown users (known bug in Kimai 1 when deleting users)') - ->addOption('merge-customer', null, InputOption::VALUE_NONE, 'Merges the customers from multiple instances by their ID (only works with: multiple --prefix)') - ->addOption('merge-project', null, InputOption::VALUE_NONE, 'Merges the projects from multiple instances by their ID (only works with: multiple --prefix)') - ->addOption('merge-user', null, InputOption::VALUE_NONE, 'Merges the users from multiple instances (only works with: multiple --prefix and same username/email combinations)') - ->addOption('merge-team', null, InputOption::VALUE_NONE, 'Merges the team from multiple instances (only works with: multiple --prefix and same team names)') - ->addOption('create-team', null, InputOption::VALUE_NONE, 'Creates a new team for every instance (using the --prefix name as team name)') - ->addOption('alias-as-account-number', null, InputOption::VALUE_NONE, 'Creates a new team for every instance (using the --prefix name as team name)') - ->addOption('meta-comment', null, InputOption::VALUE_REQUIRED, 'Name of the meta field which will be used to store the comment field') - ->addOption('meta-location', null, InputOption::VALUE_REQUIRED, 'Name of the meta field which will be used to store the location field') - ->addOption('meta-tracking-number', null, InputOption::VALUE_REQUIRED, 'Name of the meta field which will be used to store the trackingNumber field') - ->addOption('skip-team-customers', null, InputOption::VALUE_NONE, 'If given, the team (group) permissions for customers will not be synced') - ->addOption('skip-team-projects', null, InputOption::VALUE_NONE, 'If given, the team (group) permissions for projects will not be synced') - ->addOption('skip-team-activities', null, InputOption::VALUE_NONE, 'If given, the team (group) permissions for activities will not be synced') - ; - } - - private function prepareOptionsFromInput(InputInterface $input): array - { - return [ - 'url' => $input->getArgument('connection'), - 'password' => $input->getArgument('password'), - 'unknownAsGlobal' => $input->getOption('global'), - 'country' => $input->getOption('country'), - 'currency' => $input->getOption('currency'), - 'prefix' => $input->getOption('prefix'), - 'language' => $input->getOption('language'), - 'timezone' => $input->getOption('timezone'), - 'skip-error-rates' => $input->getOption('skip-error-rates'), - 'fix-email' => $input->getOption('fix-email'), - 'fix-utf8' => $input->getOption('fix-utf8'), - 'fix-timesheet' => $input->getOption('fix-timesheet'), - 'merge-customer' => $input->getOption('merge-customer'), - 'merge-project' => $input->getOption('merge-project'), - 'merge-user' => $input->getOption('merge-user'), - 'merge-team' => $input->getOption('merge-team'), - 'merge-activity' => false, - 'instance-team' => $input->getOption('create-team'), - 'alias-as-account-number' => $input->getOption('create-team'), - 'meta-comment' => $input->getOption('meta-comment'), - 'meta-location' => $input->getOption('meta-location'), - 'meta-trackingNumber' => $input->getOption('meta-tracking-number'), - 'skip-team-customers' => $input->getOption('skip-team-customers'), - 'skip-team-projects' => $input->getOption('skip-team-projects'), - 'skip-team-activities' => $input->getOption('skip-team-activities'), - ]; - } - - private function validateOptions(array $options, SymfonyStyle $io): bool - { - $password = $options['password']; - if (null === $password || \strlen(trim($password)) < 8) { - $io->error('Password length is not sufficient, at least 8 character are required'); - - return false; - } - - $country = $options['country']; - if (null === $country || 2 !== \strlen(trim($country))) { - $io->error('Country code needs to be exactly 2 character'); - - return false; - } - - $currency = $options['currency']; - if (null === $currency || 3 !== \strlen(trim($currency))) { - $io->error('Currency code needs to be exactly 3 character'); - - return false; - } - - if (!\is_array($options['prefix'])) { - $io->error('Prefix must be an array'); - - return false; - } - - return true; - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = new SymfonyStyle($input, $output); - - $options = $this->prepareOptionsFromInput($input); - if (!$this->validateOptions($options, $io)) { - $io->error('Invalid importer configuration, exiting'); - - return Command::FAILURE; - } - - $this->options = $options; - - // do not convert the times to UTC, Kimai 1 stored them already in UTC - Type::overrideType(Types::DATETIME_MUTABLE, DateTimeType::class); - // don't calculate rates ... this was done in Kimai 1 - $this->deactivateLifecycleCallbacks(); - // create reading database connection to Kimai 1 - $this->connection = $connection = DriverManager::getConnection(['url' => $options['url']]); - $this->connection->getConfiguration()->setSQLLogger(); - /** @var Connection $c */ - $c = $this->doctrine->getConnection(); - $c->getConfiguration()->setSQLLogger(); - - foreach ($options['prefix'] as $prefix) { - $this->dbPrefix = $prefix; - if (!$this->checkDatabaseVersion($connection, $io)) { - return Command::FAILURE; - } - } - - $bytesStart = memory_get_usage(true); - $timeStart = time(); - $allImports = 0; - - foreach ($options['prefix'] as $prefix) { - $this->dbPrefix = $prefix; - - $this->teamIds = []; - $this->userIds = []; - $this->oldActivities = []; - - if (!$options['merge-customer']) { - $this->customers = []; - } - - if (!$options['merge-project']) { - $this->projects = []; - } - - if (!$options['merge-user']) { - $this->users = []; - } - - if (!$options['merge-team']) { - $this->teams = []; - } - - if (!$options['merge-activity']) { - $this->activities = []; - } - - $io->title(sprintf('Handling data from table prefix: %s', $this->dbPrefix)); - - if ($options['fix-email'] !== null) { - $io->text('Fixing email addresses'); - $this->fixEmail($options['fix-email']); - } - - if ($options['fix-utf8']) { - $io->text('Fixing encoding issues now'); - $this->fixEncoding(); - } - - if ($options['fix-timesheet']) { - $io->text('Fixing timesheet issues now'); - $this->fixTimesheet(); - } - - // preload all data to make sure we can fully import everything - try { - $users = $this->fetchAllFromImport('users'); - } catch (Exception $ex) { - $io->error('Failed to load users: ' . $ex->getMessage()); - - return Command::FAILURE; - } - - try { - $customer = $this->fetchAllFromImport('customers'); - } catch (Exception $ex) { - $io->error('Failed to load customers: ' . $ex->getMessage()); - - return Command::FAILURE; - } - - try { - $projects = $this->fetchAllFromImport('projects'); - } catch (Exception $ex) { - $io->error('Failed to load projects: ' . $ex->getMessage()); - - return Command::FAILURE; - } - - try { - $activities = $this->fetchAllFromImport('activities'); - } catch (Exception $ex) { - $io->error('Failed to load activities: ' . $ex->getMessage()); - - return Command::FAILURE; - } - - try { - $fixedRates = $this->fetchAllFromImport('fixedRates'); - } catch (Exception $ex) { - $io->error('Failed to load fixedRates: ' . $ex->getMessage()); - - return Command::FAILURE; - } - - try { - $rates = $this->fetchAllFromImport('rates'); - } catch (Exception $ex) { - $io->error('Failed to load rates: ' . $ex->getMessage()); - - return Command::FAILURE; - } - - $io->success('Fetched Kimai v1 data, validating now'); - $validationMessages = $this->validateKimai1Data($options, $users, $customer, $projects, $activities, $rates); - if (!empty($validationMessages)) { - foreach ($validationMessages as $errorMessage) { - $io->error($errorMessage); - } - - return Command::FAILURE; - } - $io->success('Pre-validated data, importing now'); - - try { - $counter = $this->importUsers($io, $options['password'], $users, $rates, $options['timezone'], $options['language']); - $allImports += $counter; - $io->success('Imported users: ' . $counter); - } catch (Exception $ex) { - $io->error('Failed to import users: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString()); - - return Command::FAILURE; - } - - try { - $counter = $this->importCustomers($io, $customer, $options['country'], $options['currency'], $options['timezone']); - $allImports += $counter; - unset($customer); - $io->success('Imported customers: ' . $counter); - } catch (Exception $ex) { - $io->error('Failed to import customers: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString()); - - return Command::FAILURE; - } - - try { - $counter = $this->importProjects($io, $projects, $fixedRates, $rates); - $allImports += $counter; - unset($projects); - $io->success('Imported projects: ' . $counter); - } catch (Exception $ex) { - $io->error('Failed to import projects: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString()); - - return Command::FAILURE; - } - - try { - $counter = $this->importActivities($io, $activities, $fixedRates, $rates); - $allImports += $counter; - } catch (Exception $ex) { - $io->error('Failed to import activities: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString()); - - return Command::FAILURE; - } - - try { - $counter = $this->importGroups($io); - $allImports += $counter; - $io->success('Imported groups/teams: ' . $counter); - } catch (Exception $ex) { - $io->error('Failed to import groups/teams: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString()); - - return Command::FAILURE; - } - - try { - if ($options['instance-team'] && \count($users) > 0) { - $this->createInstanceTeam($io, $users, $activities, $prefix); - } - } catch (Exception $ex) { - $io->error('Failed to create instance team: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString()); - - return Command::FAILURE; - } - - try { - $counter = $this->importTimesheetRecords($output, $io, $fixedRates, $rates); - $allImports += $counter; - unset($fixedRates); - unset($rates); - $io->success('Imported timesheet records: ' . $counter); - } catch (Exception $ex) { - $io->error('Failed to import timesheet records: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString()); - - return Command::FAILURE; - } - } - - $bytesImported = memory_get_usage(true); - $timeEnd = time(); - - $io->success( - 'Runtime (seconds): ' . ($timeEnd - $timeStart) . PHP_EOL . - 'Imported entries: ' . $allImports . PHP_EOL . - 'Memory at start: ' . $this->bytesHumanReadable($bytesStart) . PHP_EOL . - 'Memory after import: ' . $this->bytesHumanReadable($bytesImported) . PHP_EOL . - 'Total memory usage: ' . $this->bytesHumanReadable($bytesImported - $bytesStart) - ); - - return Command::SUCCESS; - } - - private function validateKimai1Data(array $options, array $users, array $customer, array $projects, array $activities, array $rates): array - { - $validationMessages = []; - - try { - $usedEmails = []; - $userIds = []; - foreach ($users as $oldUser) { - $userIds[] = $oldUser['userID']; - if (empty($oldUser['mail'])) { - $validationMessages[] = sprintf( - 'User "%s" with ID %s has no email', - $oldUser['name'], - $oldUser['userID'] - ); - continue; - } - if (\in_array($oldUser['mail'], $usedEmails)) { - $validationMessages[] = sprintf( - 'Email "%s" for user "%s" with ID %s is already used', - $oldUser['mail'], - $oldUser['name'], - $oldUser['userID'] - ); - } - if ($this->options['alias-as-account-number'] && mb_strlen($oldUser['alias']) > 30) { - $validationMessages[] = sprintf( - 'Alias "%s" for user "%s" with ID %s, which should be used as account number, is longer than 30 character', - $oldUser['alias'], - $oldUser['name'], - $oldUser['userID'] - ); - } - $usedEmails[] = $oldUser['mail']; - } - - $customerIds = []; - foreach ($customer as $oldCustomer) { - $customerIds[] = $oldCustomer['customerID']; - if (($customerNameLength = mb_strlen($oldCustomer['name'])) > 150) { - $validationMessages[] = sprintf( - 'Customer name "%s" (ID %s) is too long. Max. 150 character are allowed, found %s.', - $oldCustomer['name'], - $oldCustomer['customerID'], - $customerNameLength - ); - } - } - - foreach ($projects as $oldProject) { - if (!\in_array($oldProject['customerID'], $customerIds)) { - $validationMessages[] = sprintf( - 'Project "%s" with ID %s has unknown customer with ID %s', - $oldProject['name'], - $oldProject['projectID'], - $oldProject['customerID'] - ); - } - if (($projectNameLength = mb_strlen($oldProject['name'])) > 150) { - $validationMessages[] = sprintf( - 'Project name "%s" (ID %s) is too long. Max. 150 character are allowed, found %s.', - $oldProject['name'], - $oldProject['projectID'], - $projectNameLength - ); - } - } - - foreach ($activities as $oldActivity) { - if (($activityNameLength = mb_strlen($oldActivity['name'])) > 150) { - $validationMessages[] = sprintf( - 'Activity name "%s" (ID %s) is too long. Max. 150 character are allowed, found %s.', - $oldActivity['name'], - $oldActivity['activityID'], - $activityNameLength - ); - } - } - - if (!$options['skip-error-rates']) { - foreach ($rates as $oldRate) { - if ($oldRate['userID'] === null) { - continue; - } - if (!\in_array($oldRate['userID'], $userIds)) { - $validationMessages[] = sprintf( - 'Unknown user with ID "%s" found for rate with project "%s" and activity "%s"', - $oldRate['userID'], - $oldRate['projectID'], - $oldRate['activityID'] - ); - } - } - } - } catch (Exception $ex) { - $validationMessages[] = $ex->getMessage(); - } - - return $validationMessages; - } - - /** - * Checks if the given database connection for import has an underlying database with a compatible structure. - * This is checked against the Kimai version and database revision. - * - * @param Connection $connection - * @param SymfonyStyle $io - * @return bool - * @throws \Doctrine\DBAL\Exception - */ - private function checkDatabaseVersion(Connection $connection, SymfonyStyle $io): bool - { - $optionColumn = $connection->quoteIdentifier('option'); - $qb = $connection->createQueryBuilder(); - - try { - $connection->createQueryBuilder() - ->select('1') - ->from($connection->quoteIdentifier($this->dbPrefix . 'configuration')) - ->executeQuery(); - } catch (Exception $e) { - $io->error( - sprintf('Cannot read from table "%sconfiguration", make sure that your prefix "%s" is correct.', $this->dbPrefix, $this->dbPrefix) - ); - - return false; - } - - $version = $connection->createQueryBuilder() - ->select('value') - ->from($connection->quoteIdentifier($this->dbPrefix . 'configuration')) - ->where($qb->expr()->eq($optionColumn, ':option')) - ->setParameter('option', 'version') - ->executeQuery() - ->fetchOne(); - - $requiredVersion = self::MIN_VERSION; - $requiredRevision = self::MIN_REVISION; - - if (1 === version_compare($requiredVersion, $version)) { - $io->error( - 'Import can only performed from an up-to-date Kimai version:' . PHP_EOL . - 'Needs at least ' . $requiredVersion . ' but found ' . $version - ); - - return false; - } - - $revision = $connection->createQueryBuilder() - ->select('value') - ->from($connection->quoteIdentifier($this->dbPrefix . 'configuration')) - ->where($qb->expr()->eq($optionColumn, ':option')) - ->setParameter('option', 'revision') - ->executeQuery() - ->fetchOne(); - - if (1 === version_compare($requiredRevision, $revision)) { - $io->error( - 'Import can only performed from an up-to-date Kimai version:' . PHP_EOL . - 'Database revision needs to be ' . $requiredRevision . ' but found ' . $revision - ); - - return false; - } - - $requiredTables = [ - 'preferences', - 'users', - 'customers', - 'projects', - 'activities', - 'projects_activities', - 'timeSheet', - 'fixedRates', - 'rates', - 'groups', - 'groups_customers', - 'groups_projects', - 'groups_users', - 'groups_activities', - ]; - - $tables = []; - foreach ($requiredTables as $table) { - $tables[] = $this->dbPrefix . $table; - } - - if (!$connection->createSchemaManager()->tablesExist($tables)) { - $io->error( - 'Import cannot be started, missing tables. Required are: ' . implode(', ', $tables) - ); - - return false; - } - - return true; - } - - /** - * Remove the timesheet lifecycle events subscriber, which would overwrite values for imported timesheet records. - */ - private function deactivateLifecycleCallbacks() - { - $connection = $this->getDoctrine()->getConnection(); - - $allListener = $connection->getEventManager()->getListeners(); - foreach ($allListener as $event => $listeners) { - foreach ($listeners as $hash => $object) { - if ($object instanceof TimesheetSubscriber) { - $connection->getEventManager()->removeEventListener([$event], $object); - /* @phpstan-ignore-next-line */ - } elseif ($object instanceof \KimaiPlugin\AuditTrailBundle\Doctrine\MetadataSubscriber) { - // deactivate audit plugin listener - $connection->getEventManager()->removeEventListener([$event], $object); - } - } - } - } - - /** - * Thanks to "xelozz -at- gmail.com", see http://php.net/manual/en/function.memory-get-usage.php#96280 - * @param int $size - * @return string - */ - private function bytesHumanReadable(int $size): string - { - if ($size === 0) { - return '0'; - } - - $unit = ['b', 'kB', 'MB', 'GB']; - $i = floor(log($size, 1024)); - $a = (int) $i; - - return @round($size / pow(1024, $i), 2) . ' ' . $unit[$a]; - } - - private function fetchAllFromImport(string $table, array $where = []): array - { - $query = $this->connection->createQueryBuilder() - ->select('*') - ->from($this->connection->quoteIdentifier($this->dbPrefix . $table)); - - foreach ($where as $column => $value) { - $query->andWhere($query->expr()->eq($column, $value)); - } - - return $query->executeQuery()->fetchAllAssociative(); - } - - private function countFromImport(string $table, array $where = []): int - { - $query = $this->connection->createQueryBuilder() - ->select('COUNT(*)') - ->from($this->connection->quoteIdentifier($this->dbPrefix . $table)); - - foreach ($where as $column => $value) { - $query->andWhere($query->expr()->eq($column, $value)); - } - - return $query->executeQuery()->fetchOne(); - } - - private function fetchIteratorFromImport(string $table): \Traversable - { - $query = $this->connection->createQueryBuilder() - ->select('*') - ->from($this->connection->quoteIdentifier($this->dbPrefix . $table)); - - return $query->executeQuery()->iterateAssociative(); - } - - private function getDoctrine(): ManagerRegistry - { - return $this->doctrine; - } - - /** - * @param SymfonyStyle $io - * @param object $object - * @return bool - */ - private function validateImport(SymfonyStyle $io, $object): bool - { - $errors = $this->validator->validate($object); - - if ($errors->count() > 0) { - /** @var ConstraintViolation $error */ - foreach ($errors as $error) { - $io->error( - (string) $error - ); - } - - return false; - } - - return true; - } - - private function getCachedUser(int $id): ?User - { - if (isset($this->userIds[$id])) { - $id = $this->userIds[$id]; - } - - if (isset($this->users[$id])) { - return $this->users[$id]; - } - - return null; - } - - private function isKnownUser(SymfonyStyle $io, array $oldUser): bool - { - $cacheId = $oldUser['userID']; - - if (isset($this->userIds[$cacheId])) { - return true; - } - - // workaround when importing multiple instances at once: search if the user exists by unique values - foreach ($this->users as $tmpUserId => $tmpUser) { - $newEmail = strtolower($tmpUser->getEmail()); - $newName = strtolower($tmpUser->getUserIdentifier()); - $oldEmail = strtolower($oldUser['mail']); - $oldName = strtolower($oldUser['name']); - if ($newEmail !== $oldEmail && $newName !== $oldName) { - continue; - } - if ($newEmail === $oldEmail && $newName !== $oldName) { - $io->warning(sprintf( - 'Found problematic user combination. Username matches, but email does not. Cached user: ID %s, %s, %s. New user: ID %s, %s, %s.', - $tmpUser->getId(), - $newEmail, - $newName, - $oldUser['userID'], - $oldEmail, - $oldName - )); - } - if ($newEmail !== $oldEmail && $newName === $oldName) { - $io->warning(sprintf( - 'Found problematic user combination. Emails matches, but username does not. Cached user: ID %s, %s, %s. New user: ID %s, %s, %s.', - $tmpUser->getId(), - $newEmail, - $newName, - $oldUser['userID'], - $oldEmail, - $oldName - )); - } - if ($newEmail === $oldEmail && $newName === $oldName) { - if (isset($this->userIds[$cacheId])) { - throw new Exception('Cannot import duplicate user ' . $newName . ' as the ID is already cached'); - } - - $this->userIds[$cacheId] = $tmpUserId; - - return true; - } - } - - return false; - } - - private function setUserCache(array|int $oldUser, User $user): void - { - $cacheKey = \is_array($oldUser) ? $oldUser['userID'] : $oldUser; - - $this->users[$cacheKey] = $user; - } - - /** - * -- are currently unsupported fields that can't be mapped - * - * ["userID"]=> string(9) "833336177" - * ["name"]=> string(5) "admin" - * ["alias"]=> NULL - * --- ["status"]=> string(1) "0" - * ["trash"]=> string(1) "0" - * ["active"]=> string(1) "1" - * ["mail"]=> string(21) "foo@bar.com" - * ["password"]=> string(32) "" - * ["passwordResetHash"]=> NULL - * ["ban"]=> string(1) "0" - * ["banTime"]=> string(1) "0" - * --- ["secure"]=> string(30) "" - * ["lastProject"]=> string(1) "2" - * ["lastActivity"]=> string(1) "2" - * ["lastRecord"]=> string(1) "2" - * ["timeframeBegin"]=> string(10) "1304200800" - * ["timeframeEnd"]=> string(1) "0" - * ["apikey"]=> NULL - * ["globalRoleID"]=> string(1) "1" - * - * @param SymfonyStyle $io - * @param string $password - * @param array $users - * @param array $rates - * @param string $timezone - * @param string $language - * @return int - * @throws Exception - */ - private function importUsers(SymfonyStyle $io, string $password, array $users, array $rates, string $timezone, string $language): int - { - $counter = 0; - $entityManager = $this->getDoctrine()->getManager(); - - foreach ($users as $oldUser) { - if ($this->isKnownUser($io, $oldUser)) { - continue; - } - - $isActive = $oldUser['active'] && !(bool) $oldUser['trash'] && !(bool) $oldUser['ban']; - $role = (1 === (int) $oldUser['globalRoleID']) ? User::ROLE_SUPER_ADMIN : User::DEFAULT_ROLE; - - $user = new User(); - $user->setUserIdentifier($oldUser['name']); - $user->setEmail($oldUser['mail']); - $user->setPlainPassword($password); - $user->setEnabled($isActive); - $user->setRoles([$role]); - - $newPref = new UserPreference(self::METAFIELD_NAME, $oldUser['userID']); - $user->addPreference($newPref); - - if ($oldUser['alias'] !== null) { - if ($this->options['alias-as-account-number']) { - $user->setAccountNumber(mb_substr($oldUser['alias'], 0, 30)); - } else { - $user->setAlias($oldUser['alias']); - } - } - - $pwd = $this->passwordHasher->hashPassword($user, $user->getPlainPassword()); - $user->setPassword($pwd); - - if (!$this->validateImport($io, $user)) { - throw new Exception('Failed to validate user: ' . $user->getUserIdentifier()); - } - - // find and migrate user preferences - $prefsToImport = ['ui.lang' => 'language', 'timezone' => 'timezone']; - $preferences = $this->fetchAllFromImport('preferences', ['userID' => $oldUser['userID']]); - foreach ($preferences as $pref) { - $key = $pref['option']; - - if (!\array_key_exists($key, $prefsToImport)) { - continue; - } - - if (empty($pref['value'])) { - continue; - } - - $newPref = new UserPreference($prefsToImport[$key], $pref['value']); - $user->addPreference($newPref); - } - - // set default values if they were not set in the user preferences - $defaults = ['language' => $language, 'timezone' => $timezone]; - foreach ($defaults as $key => $default) { - if (null === $user->getPreferenceValue($key)) { - $user->setPreferenceValue($key, $default); - } - } - - // find hourly rate - foreach ($rates as $ratesRow) { - if ($ratesRow['userID'] === $oldUser['userID'] && $ratesRow['activityID'] === null && $ratesRow['projectID'] === null) { - $newPref = new UserPreference(UserPreference::HOURLY_RATE, $ratesRow['rate']); - $user->addPreference($newPref); - } - } - - try { - $entityManager->persist($user); - $entityManager->flush(); - if ($this->debug) { - $io->success('Created user: ' . $user->getUserIdentifier()); - } - ++$counter; - } catch (Exception $ex) { - $io->error('Failed to create user: ' . $user->getUserIdentifier()); - $io->error('Reason: ' . $ex->getMessage()); - } - - $this->setUserCache($oldUser, $user); - } - - return $counter; - } - - private function getCachedCustomer(int $id): ?Customer - { - if (isset($this->customers[$id])) { - return $this->customers[$id]; - } - - return null; - } - - private function isKnownCustomer(array|int $oldCustomer): bool - { - $cacheId = \is_array($oldCustomer) ? $oldCustomer['customerID'] : $oldCustomer; - - return isset($this->customers[$cacheId]); - } - - private function setCustomerCache(array $oldCustomer, Customer $customer): void - { - $this->customers[$oldCustomer['customerID']] = $customer; - } - - /** - * -- are currently unsupported fields that can't be mapped - * - * ["customerID"]=> string(2) "11" - * ["name"]=> string(9) "Customer" - * ["password"]=> NULL - * ["passwordResetHash"]=> NULL - * ["secure"]=> NULL - * ["comment"]=> NULL - * ["visible"]=> string(1) "1" - * ["filter"]=> string(1) "0" - * ["company"]=> string(14) "Customer Ltd." - * --- ["vat"]=> string(2) "19" - * ["contact"]=> string(2) "Someone" - * ["street"]=> string(22) "Street name" - * ["zipcode"]=> string(5) "12345" - * ["city"]=> string(6) "Berlin" - * ["phone"]=> NULL - * ["fax"]=> NULL - * ["mobile"]=> NULL - * ["mail"]=> NULL - * ["homepage"]=> NULL - * ["trash"]=> string(1) "0" - * ["timezone"]=> string(13) "Europe/Berlin" - * - * @param SymfonyStyle $io - * @param array $customers - * @param string $country - * @param string $currency - * @param string $timezone - * @return int - * @throws Exception - */ - private function importCustomers(SymfonyStyle $io, $customers, $country, $currency, string $timezone) - { - $counter = 0; - $entityManager = $this->getDoctrine()->getManager(); - - foreach ($customers as $oldCustomer) { - if ($this->isKnownCustomer($oldCustomer)) { - continue; - } - - $isActive = (bool) $oldCustomer['visible'] && !(bool) $oldCustomer['trash']; - $name = $oldCustomer['name']; - if (empty($name)) { - $name = uniqid(); - $io->warning('Found empty customer name, setting it to: ' . $name); - } - - $newTimezone = $oldCustomer['timezone']; - if (empty($newTimezone)) { - $newTimezone = $timezone; - } - - $customer = new Customer($name); - $customer->setComment($oldCustomer['comment']); - $customer->setCompany($oldCustomer['company']); - $customer->setFax($oldCustomer['fax']); - $customer->setHomepage($oldCustomer['homepage']); - $customer->setMobile($oldCustomer['mobile']); - $customer->setEmail($oldCustomer['mail']); - $customer->setPhone($oldCustomer['phone']); - $customer->setContact($oldCustomer['contact']); - $customer->setAddress($oldCustomer['street'] . PHP_EOL . $oldCustomer['zipcode'] . ' ' . $oldCustomer['city']); - $customer->setTimezone($newTimezone); - $customer->setVisible($isActive); - $customer->setCountry(strtoupper($country)); - $customer->setCurrency(strtoupper($currency)); - - $metaField = new CustomerMeta(); - $metaField->setName(self::METAFIELD_NAME); - $metaField->setValue($oldCustomer['customerID']); - $metaField->setIsVisible(false); - - $customer->setMetaField($metaField); - - if (!$this->validateImport($io, $customer)) { - throw new Exception('Failed to validate customer: ' . $customer->getName()); - } - - try { - $entityManager->persist($customer); - $entityManager->flush(); - if ($this->debug) { - $io->success('Created customer: ' . $customer->getName()); - } - ++$counter; - } catch (Exception $ex) { - $io->error('Reason: ' . $ex->getMessage()); - $io->error('Failed to create customer: ' . $customer->getName()); - } - - $this->setCustomerCache($oldCustomer, $customer); - } - - return $counter; - } - - private function getCachedProject(int $id): ?Project - { - if (isset($this->projects[$id])) { - return $this->projects[$id]; - } - - return null; - } - - private function isKnownProject(array|int $oldProject): bool - { - $cacheId = \is_array($oldProject) ? $oldProject['projectID'] : $oldProject; - - return isset($this->projects[$cacheId]); - } - - private function setProjectCache(array|int $oldProject, Project $project): void - { - $cacheId = \is_array($oldProject) ? $oldProject['projectID'] : $oldProject; - - $this->projects[$cacheId] = $project; - } - - /** - * -- are currently unsupported fields that can't be mapped - * - * ["projectID"]=> string(1) "1" - * ["customerID"]=> string(1) "1" - * ["name"]=> string(11) "Test" - * ["comment"]=> string(0) "" - * ["visible"]=> string(1) "1" - * --- ["filter"]=> string(1) "0" - * ["trash"]=> string(1) "1" - * ["budget"]=> string(4) "0.00" - * --- ["effort"]=> NULL - * --- ["approved"]=> NULL - * --- ["internal"]=> string(1) "0" - * - * @param SymfonyStyle $io - * @param array $projects - * @param array $fixedRates - * @param array $rates - * @return int - * @throws Exception - */ - private function importProjects(SymfonyStyle $io, $projects, array $fixedRates, array $rates): int - { - $counter = 0; - $entityManager = $this->getDoctrine()->getManager(); - - foreach ($projects as $oldProject) { - if ($this->isKnownProject($oldProject)) { - continue; - } - - $isActive = \boolval($oldProject['visible']) && !\boolval($oldProject['trash']); - - $customer = $this->getCachedCustomer($oldProject['customerID']); - if ($customer === null) { - $io->error( - sprintf('Found project with unknown customer. Project ID: "%s", Name: "%s", Customer ID: "%s"', $oldProject['projectID'], $oldProject['name'], $oldProject['customerID']) - ); - continue; - } - - $name = $oldProject['name']; - if (empty($name)) { - $name = uniqid(); - $io->warning('Found empty project name, setting it to: ' . $name); - } - - $project = new Project(); - $project->setCustomer($customer); - $project->setName($name); - $project->setComment($oldProject['comment'] ?: null); - $project->setVisible($isActive); - $project->setBudget($oldProject['budget'] ?: 0); - - $metaField = new ProjectMeta(); - $metaField->setName(self::METAFIELD_NAME); - $metaField->setValue($oldProject['projectID']); - $metaField->setIsVisible(false); - - $project->setMetaField($metaField); - - if (!$this->validateImport($io, $project)) { - throw new Exception('Failed to validate project: ' . $project->getName()); - } - - try { - $entityManager->persist($project); - if ($this->debug) { - $io->success('Created project: ' . $project->getName() . ' for customer: ' . $customer->getName()); - } - ++$counter; - } catch (Exception $ex) { - $io->error('Failed to create project: ' . $project->getName()); - $io->error('Reason: ' . $ex->getMessage()); - } - - foreach ($fixedRates as $fixedRow) { - // activity rates a re-assigned in createActivity() - if ($fixedRow['activityID'] !== null || $fixedRow['projectID'] === null) { - continue; - } - if ($fixedRow['projectID'] === $oldProject['projectID']) { - $projectRate = new ProjectRate(); - $projectRate->setProject($project); - $projectRate->setRate($fixedRow['rate']); - $projectRate->setIsFixed(true); - - try { - $entityManager->persist($projectRate); - if ($this->debug) { - $io->success('Created fixed project rate: ' . $project->getName() . ' for customer: ' . $customer->getName()); - } - } catch (Exception $ex) { - $io->error(sprintf('Failed to create fixed project rate for %s: %s' . $project->getName(), $ex->getMessage())); - } - } - } - - foreach ($rates as $ratesRow) { - if ($ratesRow['activityID'] !== null || $ratesRow['projectID'] === null) { - continue; - } - if ($ratesRow['projectID'] === $oldProject['projectID']) { - $projectRate = new ProjectRate(); - $projectRate->setProject($project); - $projectRate->setRate($ratesRow['rate']); - - if ($ratesRow['userID'] !== null) { - $projectRate->setUser($this->getCachedUser($ratesRow['userID'])); - } - - try { - $entityManager->persist($projectRate); - if ($this->debug) { - $io->success('Created project rate: ' . $project->getName() . ' for customer: ' . $customer->getName()); - } - } catch (Exception $ex) { - $io->error(sprintf('Failed to create project rate for %s: %s' . $project->getName(), $ex->getMessage())); - } - } - } - - $entityManager->flush(); - - $this->setProjectCache($oldProject, $project); - } - - return $counter; - } - - private function clearCache(): void - { - $entityManager = $this->getDoctrine()->getManager(); - $entityManager->clear(); - - if (!($entityManager instanceof EntityManagerInterface)) { - throw new Exception('Received ObjectManager, but need EntityManagerInterface'); - } - - // re-cache all projects - $this->projects = []; - $projects = $entityManager->getRepository(Project::class)->findAll(); - $loader = new ProjectLoader($entityManager); - $loader->loadResults($projects); - /** @var Project $project */ - foreach ($projects as $project) { - $oldId = $project->getMetaField(self::METAFIELD_NAME)?->getValue(); - if (\is_int($oldId)) { - $this->setProjectCache($oldId, $project); - } - } - - // re-cache all activities - $this->activities = []; - $activities = $entityManager->getRepository(Activity::class)->findAll(); - $loader = new ActivityLoader($entityManager); - $loader->loadResults($activities); - /** @var Activity $activity */ - foreach ($activities as $activity) { - $oldActivity = $activity->getMetaField(self::METAFIELD_NAME)?->getValue(); - $projectId = $activity->getProject()?->getId(); - if (\is_int($oldActivity)) { - $this->setActivityCache($oldActivity, $activity, $projectId); - } - } - - // re-cache all users - $this->users = []; - $users = $entityManager->getRepository(User::class)->findAll(); - $loader = new UserLoader($entityManager); - $loader->loadResults($users); - /** @var User $user */ - foreach ($users as $user) { - $oldId = $user->getPreferenceValue(self::METAFIELD_NAME); - if (\is_int($oldId)) { - $this->setUserCache($oldId, $user); - } - } - } - - private function getCachedActivity(int $id, ?int $projectId = null): ?Activity - { - if (isset($this->activities[$id][$projectId])) { - return $this->activities[$id][$projectId]; - } - - return null; - } - - private function isKnownActivity(array|int $oldActivity, ?int $projectId = null): bool - { - $cacheId = \is_array($oldActivity) ? $oldActivity['activityID'] : $oldActivity; - - if (isset($this->activities[$cacheId][$projectId])) { - return true; - } - - return false; - } - - private function setActivityCache(array|int $oldActivity, Activity $activity, ?int $projectId = null): void - { - $cacheId = \is_array($oldActivity) ? $oldActivity['activityID'] : $oldActivity; - - if (!isset($this->activities[$cacheId])) { - $this->activities[$cacheId] = []; - } - $this->activities[$cacheId][$projectId] = $activity; - } - - /** - * -- are currently unsupported fields that can't be mapped - * - * $activities: - * -- ["activityID"]=> string(1) "1" - * ["name"]=> string(6) "Test" - * ["comment"]=> string(0) "" - * ["visible"]=> string(1) "1" - * --- ["filter"]=> string(1) "0" - * ["trash"]=> string(1) "1" - * - * $activityToProject - * ["projectID"]=> string(1) "1" - * ["activityID"]=> string(1) "1" - * ["budget"]=> string(4) "0.00" - * -- ["effort"]=> string(4) "0.00" - * -- ["approved"]=> string(4) "0.00" - * - * @param SymfonyStyle $io - * @param array $activities - * @param array $fixedRates - * @param array $rates - * @return int - * @throws Exception - */ - private function importActivities(SymfonyStyle $io, array $activities, array $fixedRates, array $rates): int - { - $activityToProject = $this->fetchAllFromImport('projects_activities'); - - $counter = 0; - $entityManager = $this->getDoctrine()->getManager(); - - // remember which activity has at least one assigned project - $oldActivityMapping = []; - if ($this->options['unknownAsGlobal']) { - $oldActivityMapping['___GLOBAL___'][] = PHP_INT_MAX; - } else { - foreach ($activityToProject as $mapping) { - $oldActivityMapping[$mapping['activityID']][] = $mapping['projectID']; - } - } - - $global = 0; - $project = 0; - - // create global activities - foreach ($activities as $oldActivity) { - $this->oldActivities[$oldActivity['activityID']] = $oldActivity; - if (isset($oldActivityMapping[$oldActivity['activityID']])) { - continue; - } - - $this->createActivity($io, $entityManager, $oldActivity, $fixedRates, $rates, null); - ++$counter; - ++$global; - } - - if ($global > 0) { - $io->success('Created global activities: ' . $counter); - } - - // create project specific activities - foreach ($activities as $oldActivity) { - if (!isset($oldActivityMapping[$oldActivity['activityID']])) { - continue; - } - foreach ($oldActivityMapping[$oldActivity['activityID']] as $projectId) { - $this->createActivity($io, $entityManager, $oldActivity, $fixedRates, $rates, $projectId); - ++$counter; - ++$project; - } - } - - if ($project > 0) { - $io->success('Created project specific activities: ' . $project); - } - - return $counter; - } - - /** - * @param SymfonyStyle $io - * @param ObjectManager $entityManager - * @param array $oldActivity - * @param array $fixedRates - * @param array $rates - * @param int|null $oldProjectId - * @return Activity - * @throws Exception - */ - private function createActivity( - SymfonyStyle $io, - ObjectManager $entityManager, - array $oldActivity, - array $fixedRates, - array $rates, - ?int $oldProjectId = null - ) { - $oldActivityId = $oldActivity['activityID']; - - if ($this->isKnownActivity($oldActivity, $oldProjectId)) { - return $this->getCachedActivity($oldActivityId, $oldProjectId); - } - - $isActive = ((bool) $oldActivity['visible']) && !(bool) $oldActivity['trash']; - $name = $oldActivity['name']; - if (empty($name)) { - $name = uniqid(); - $io->warning('Found empty activity name, setting it to: ' . $name); - } - - $activity = new Activity(); - $activity->setName($name); - $activity->setComment($oldActivity['comment'] ?? null); - $activity->setVisible($isActive); - $activity->setBudget($oldActivity['budget'] ?? 0); - - if (null !== $oldProjectId) { - $project = $this->getCachedProject($oldProjectId); - if ($project === null) { - throw new Exception( - sprintf( - 'Did not find project [%s], skipping activity creation [%s] %s', - $oldProjectId, - $oldActivityId, - $name - ) - ); - } - $activity->setProject($project); - } - - $metaField = new ActivityMeta(); - $metaField->setName(self::METAFIELD_NAME); - $metaField->setValue($oldActivity['activityID']); - $metaField->setIsVisible(false); - - $activity->setMetaField($metaField); - - if (!$this->validateImport($io, $activity)) { - throw new Exception('Failed to validate activity: ' . $activity->getName()); - } - - try { - $entityManager->persist($activity); - if ($this->debug) { - $io->success('Created activity: ' . $activity->getName()); - } - } catch (Exception $ex) { - $io->error('Failed to create activity: ' . $activity->getName()); - $io->error('Reason: ' . $ex->getMessage()); - } - - $this->setActivityCache($oldActivity, $activity, $oldProjectId); - - foreach ($fixedRates as $fixedRow) { - if ($fixedRow['activityID'] === null) { - continue; - } - if ($fixedRow['projectID'] !== null && $fixedRow['projectID'] !== $oldProjectId) { - continue; - } - - if ($fixedRow['activityID'] === $oldActivityId) { - $activityRate = new ActivityRate(); - $activityRate->setActivity($activity); - $activityRate->setRate($fixedRow['rate']); - $activityRate->setIsFixed(true); - - try { - $entityManager->persist($activityRate); - if ($this->debug) { - $io->success('Created fixed activity rate: ' . $activity->getName()); - } - } catch (Exception $ex) { - $io->error(sprintf('Failed to create fixed activity rate for %s: %s' . $activity->getName(), $ex->getMessage())); - } - } - } - - foreach ($rates as $ratesRow) { - if ($ratesRow['activityID'] === null) { - continue; - } - if ($ratesRow['projectID'] !== null && $ratesRow['projectID'] !== $oldProjectId) { - continue; - } - - if ($ratesRow['activityID'] === $oldActivityId) { - $activityRate = new ActivityRate(); - $activityRate->setActivity($activity); - $activityRate->setRate($ratesRow['rate']); - - if ($ratesRow['userID'] !== null) { - $activityRate->setUser($this->getCachedUser($ratesRow['userID'])); - } - - try { - $entityManager->persist($activityRate); - if ($this->debug) { - $io->success('Created activity rate: ' . $activity->getName()); - } - } catch (Exception $ex) { - $io->error(sprintf('Failed to create activity rate for %s: %s' . $activity->getName(), $ex->getMessage())); - } - } - } - - $entityManager->flush(); - - return $activity; - } - - /** - * -- are currently unsupported fields that can't be mapped - * - * -- ["timeEntryID"]=> string(1) "1" - * ["start"]=> string(10) "1306747800" - * ["end"]=> string(10) "1306752300" - * ["duration"]=> string(4) "4500" - * ["userID"]=> string(9) "228899434" - * ["projectID"]=> string(1) "1" - * ["activityID"]=> string(1) "1" - * ["description"]=> NULL - * ["comment"]=> string(36) "a work description" - * -- ["commentType"]=> string(1) "0" - * ["cleared"]=> string(1) "0" - * ["location"]=> string(0) "" (via meta field) - * ["trackingNumber"]=> NULL (via meta field) - * ["rate"]=> string(5) "50.00" - * ["fixedRate"]=> string(4) "0.00" - * -- ["budget"]=> NULL - * -- ["approved"]=> NULL - * -- ["statusID"]=> string(1) "1" - * -- ["billable"]=> NULL - * - * @param OutputInterface $output - * @param SymfonyStyle $io - * @param array $fixedRates - * @param array $rates - * @return int - * @throws Exception - */ - private function importTimesheetRecords(OutputInterface $output, SymfonyStyle $io, array $fixedRates, array $rates): int - { - // clear the entity manager to free up memory and speed up things - $this->clearCache(); - - $records = $this->fetchIteratorFromImport('timeSheet'); - $total = $this->countFromImport('timeSheet'); - - $errors = [ - 'projectActivityMismatch' => [], - ]; - $counter = 0; - $failed = 0; - $activityCounter = 0; - $userCounter = 0; - $entityManager = $this->getDoctrine()->getManager(); - - $io->writeln('Importing timesheets, please wait'); - $io->writeln(''); - - $progressBar = new ProgressBar($output, $total); - - foreach ($records as $oldRecord) { - if (empty($oldRecord['end'])) { - $io->error('Cannot import running timesheet record, skipping: ' . $oldRecord['timeEntryID']); - $failed++; - continue; - } - - $activity = null; - $activityId = $oldRecord['activityID']; - $projectId = $oldRecord['projectID']; - $project = $this->getCachedProject($projectId); - - if ($project === null) { - $io->error('Could not create timesheet record, missing project with ID: ' . $projectId); - $failed++; - continue; - } - - $customerId = $project->getCustomer()->getId(); - - if (isset($this->activities[$activityId][$projectId])) { - $activity = $this->activities[$activityId][$projectId]; - } elseif (isset($this->activities[$activityId][null])) { - $activity = $this->activities[$activityId][null]; - } - - if (null === $activity && isset($this->oldActivities[$activityId])) { - $oldActivity = $this->oldActivities[$activityId]; - $activity = $this->createActivity($io, $entityManager, $oldActivity, $fixedRates, $rates, $projectId); - ++$activityCounter; - } - - // this should not happen at all - if (null === $activity) { - $io->error('Could not import timesheet record, missing activity with ID: ' . $activityId . '/' . $projectId . '/' . $customerId); - $failed++; - continue; - } - - $duration = (int) ($oldRecord['end'] - $oldRecord['start']); - - // ----------------------- unknown user, damned missing data integrity in Kimai v1 ----------------------- - if ($this->getCachedUser($oldRecord['userID']) === null) { - $tempUserName = uniqid(); - $tempPassword = uniqid() . uniqid(); - - $user = new User(); - $user->setUserIdentifier($tempUserName); - $user->setAlias('Import: ' . $tempUserName); - $user->setEmail($tempUserName . '@example.com'); - $user->setPlainPassword($tempPassword); - $user->setEnabled(false); - $user->setRoles([USER::ROLE_USER]); - - $pwd = $this->passwordHasher->hashPassword($user, $user->getPlainPassword()); - $user->setPassword($pwd); - - if (!$this->validateImport($io, $user)) { - $io->error('Found timesheet record for unknown user and failed to create user, skipping timesheet: ' . $oldRecord['timeEntryID']); - $failed++; - continue; - } - - try { - $entityManager->persist($user); - $entityManager->flush(); - if ($this->debug) { - $io->success('Created deactivated user: ' . $user->getUserIdentifier()); - } - $userCounter++; - } catch (Exception $ex) { - $io->error('Failed to create user: ' . $user->getUserIdentifier()); - $io->error('Reason: ' . $ex->getMessage()); - $failed++; - continue; - } - - $this->setUserCache($oldRecord, $user); - } - // ----------------------- unknown user end ----------------------- - - $timesheet = new Timesheet(); - - $fixedRate = $oldRecord['fixedRate']; - if (!empty($fixedRate) && 0.00 < (float) $fixedRate) { - $timesheet->setFixedRate($fixedRate); - } - - $hourlyRate = $oldRecord['rate']; - if (!empty($hourlyRate) && 0.00 < (float) $hourlyRate) { - $timesheet->setHourlyRate($hourlyRate); - } - - if ($timesheet->getFixedRate() !== null) { - $timesheet->setRate($timesheet->getFixedRate()); - } elseif ($timesheet->getHourlyRate() !== null) { - $hourlyRate = $timesheet->getHourlyRate(); - $rate = Util::calculateRate($hourlyRate, $duration); - $timesheet->setRate($rate); - } - - $user = $this->getCachedUser($oldRecord['userID']); - $dateTimezone = new DateTimeZone('UTC'); - - $begin = new DateTime('@' . $oldRecord['start']); - $begin->setTimezone($dateTimezone); - $end = new DateTime('@' . $oldRecord['end']); - $end->setTimezone($dateTimezone); - - // ---------- workaround for localizeDates ---------- - // if getBegin() is not executed first, then the dates will we re-written in validateImport() below - $timesheet->setBegin($begin)->setEnd($end)->getBegin(); - // -------------------------------------------------- - - // ---------- this was a bug in the past, should not happen anymore ---------- - if ($activity->getProject() !== null && $project->getId() !== $activity->getProject()->getId()) { - $errors['projectActivityMismatch'][] = $oldRecord['timeEntryID']; - continue; - } - // --------------------------------------------------------------------- - - $timesheet->setDescription($oldRecord['description'] ?? ($oldRecord['comment'] ?? null)); - $timesheet->setUser($user); - $timesheet->setBegin($begin); - $timesheet->setEnd($end); - $timesheet->setDuration($duration); - $timesheet->setActivity($activity); - $timesheet->setProject($project); - $timesheet->setExported(\intval($oldRecord['cleared']) !== 0); - $timesheet->setTimezone($user->getTimezone()); - - if ($this->options['meta-comment'] !== null) { - $timesheet->setDescription($oldRecord['description']); - - if ($oldRecord['comment'] !== null && $oldRecord['comment'] !== '') { - $meta = new TimesheetMeta(); - $meta->setName($this->options['meta-comment']); - $meta->setValue($oldRecord['comment']); - $meta->setIsVisible(true); - $timesheet->setMetaField($meta); - } - } - - if ($this->options['meta-location'] !== null && $oldRecord['location'] !== null && $oldRecord['location'] !== '') { - $meta = new TimesheetMeta(); - $meta->setName($this->options['meta-location']); - $meta->setValue($oldRecord['location']); - $meta->setIsVisible(true); - $timesheet->setMetaField($meta); - } - - if ($this->options['meta-trackingNumber'] !== null && $oldRecord['trackingNumber'] !== null && $oldRecord['trackingNumber'] !== '') { - $meta = new TimesheetMeta(); - $meta->setName($this->options['meta-trackingNumber']); - $meta->setValue($oldRecord['trackingNumber']); - $meta->setIsVisible(true); - $timesheet->setMetaField($meta); - } - - if (!$this->validateImport($io, $timesheet)) { - $io->caution('Failed to validate timesheet record: ' . $oldRecord['timeEntryID'] . ' - skipping!'); - $failed++; - continue; - } - - try { - $entityManager->persist($timesheet); - if ($this->debug) { - $io->success('Created timesheet record: ' . $timesheet->getId()); - } - ++$counter; - } catch (Exception $ex) { - $io->error('Failed to create timesheet record: ' . $ex->getMessage()); - $failed++; - } - - $progressBar->advance(); - if (0 === $counter % self::BATCH_SIZE) { - $entityManager->flush(); - $this->clearCache(); - } - } - - $entityManager->flush(); - $this->clearCache(); - - $progressBar->finish(); - $io->writeln(''); - - if ($userCounter > 0) { - $io->success('Created new users during timesheet import: ' . $userCounter); - } - if ($activityCounter > 0) { - $io->success('Created new activities during timesheet import: ' . $activityCounter); - } - if (\count($errors['projectActivityMismatch']) > 0) { - $io->error('Found invalid mapped project - activity combinations in these old timesheet recors: ' . implode(',', $errors['projectActivityMismatch'])); - } - if ($failed > 0) { - $io->error(sprintf('Failed importing %s timesheet records', $failed)); - } - - return $counter; - } - - private function getCachedGroup(int $id): ?Team - { - if (isset($this->teamIds[$id])) { - $id = $this->teamIds[$id]; - } - - if (isset($this->teams[$id])) { - return $this->teams[$id]; - } - - return null; - } - - private function isKnownGroup(array $oldGroup): bool - { - $cacheId = $oldGroup['groupID']; - - if (isset($this->teamIds[$cacheId])) { - return true; - } - - // workaround when importing multiple instances at once: search if the group/team exists by unique values - foreach ($this->teams as $tmpTeamId => $tmpTeam) { - if ($tmpTeam->getName() === $oldGroup['name']) { - if (isset($this->teamIds[$cacheId])) { - throw new Exception('Cannot import duplicate group "' . $tmpTeam->getName() . '" as the ID is already cached'); - } - - $this->teamIds[$cacheId] = $tmpTeamId; - - return true; - } - } - - return false; - } - - private function setGroupCache(array $oldGroup, Team $team): void - { - $this->teams[$oldGroup['groupID']] = $team; - } - - /** Imports Kimai v1 groups as teams and connects teams with users, customers and projects - * - * -- are currently unsupported fields that can't be mapped - * - * $groups - * ["groupID"] => int(10) "1" - * ["name"] => varchar(160) "a group name" - * -- ["trash"] => tinyint(1) 1/0 - * - * $groups_customers - * ["groupID"] => int(10) "1" - * ["customerID"] => int(10) "1" - * - * $groups_projects - * ["groupID"] => int(10) "1" - * ["projectID"] => int(10) "1" - * - * $groups_users - * ["groupID"] => int(10) "1" - * ["customerID"] => int(10) "1" - * -- ["membershipRoleID"] => int(10) "1" - * - * @param SymfonyStyle $io - * @return int - * @throws Exception - */ - private function importGroups(SymfonyStyle $io): int - { - $groups = $this->fetchAllFromImport('groups'); - $groupToUser = $this->fetchAllFromImport('groups_users'); - $groupToCustomer = []; - $groupToProject = []; - $groupToActivity = []; - - if (!$this->options['skip-team-customers']) { - $groupToCustomer = $this->fetchAllFromImport('groups_customers'); - } - if (!$this->options['skip-team-projects']) { - $groupToProject = $this->fetchAllFromImport('groups_projects'); - } - if (!$this->options['skip-team-activities']) { - $groupToActivity = $this->fetchAllFromImport('groups_activities'); - } - - $counter = 0; - $skippedTrashed = 0; - $skippedEmpty = 0; - $failed = 0; - - $newTeams = []; - // create teams just with names of groups - foreach ($groups as $group) { - if ($group['trash'] === 1) { - $io->warning(sprintf('Skipping team "%s" because it is trashed.', $group['name'])); - $skippedTrashed++; - continue; - } - - if (!$this->isKnownGroup($group)) { - $team = new Team($group['name']); - } else { - $team = $this->getCachedGroup($group['groupID']); - } - - $this->setGroupCache($group, $team); - $newTeams[$group['groupID']] = $team; - } - - // connect groups with users - foreach ($groupToUser as $row) { - if (!isset($newTeams[$row['groupID']])) { - continue; - } - $team = $newTeams[$row['groupID']]; - - $user = $this->getCachedUser($row['userID']); - if ($user === null) { - continue; - } - - $team->addUser($user); - - // first user in the team will become team lead - if (!$team->hasTeamleads()) { - $team->addTeamlead($user); - } - - // any other user with admin role in the team will become team lead - // should be the last added admin of the source group - if ($row['membershipRoleID'] === 1) { - $team->addTeamlead($user); - } - } - - // if team has no users it will not be persisted - foreach ($newTeams as $oldId => $team) { - if (!$team->hasUsers()) { - $io->warning(sprintf('Didn\'t import team: %s because it has no users.', $team->getName())); - ++$skippedEmpty; - unset($newTeams[$oldId]); - } - } - - // connect groups with customers - foreach ($groupToCustomer as $row) { - if (!isset($newTeams[$row['groupID']])) { - continue; - } - $team = $newTeams[$row['groupID']]; - - $customer = $this->getCachedCustomer($row['customerID']); - if ($customer === null) { - continue; - } - - $team->addCustomer($customer); - } - - // connect groups with projects - foreach ($groupToProject as $row) { - if (!isset($newTeams[$row['groupID']])) { - continue; - } - $team = $newTeams[$row['groupID']]; - - $project = null; - if ($this->isKnownProject($row['projectID'])) { - $project = $this->getCachedProject($row['projectID']); - } - - if ($project === null) { - continue; - } - - $team->addProject($project); - - if ($project->getCustomer() !== null) { - $team->addCustomer($project->getCustomer()); - } - } - - // connect groups with activities - foreach ($groupToActivity as $row) { - if (!isset($newTeams[$row['groupID']])) { - continue; - } - $team = $newTeams[$row['groupID']]; - - $activity = $this->getCachedActivity($row['activityID']); - if ($activity === null) { - continue; - } - - $team->addActivity($activity); - - $activityProject = $activity->getProject(); - if ($activityProject !== null) { - $team->addProject($activityProject); - $team->addCustomer($activityProject->getCustomer()); - } - } - - $entityManager = $this->getDoctrine()->getManager(); - - // validate and persist each team - foreach ($newTeams as $oldId => $team) { - if (!$this->validateImport($io, $team)) { - throw new Exception('Failed to validate team: ' . $team->getName()); - } - - try { - $entityManager->persist($team); - if ($this->debug) { - $io->success( - sprintf( - 'Created team: %s with %s users, %s projects and %s customers.', - $team->getName(), - \count($team->getUsers()), - \count($team->getProjects()), - \count($team->getCustomers()) - ) - ); - } - ++$counter; - } catch (Exception $ex) { - $io->error('Failed to create team: ' . $team->getName()); - $io->error('Reason: ' . $ex->getMessage()); - ++$failed; - } - } - - $entityManager->flush(); - - if ($skippedTrashed > 0) { - $io->warning('Skipped teams because they are trashed: ' . $skippedTrashed); - } - if ($skippedEmpty > 0) { - $io->warning('Skipped teams because they have no users: ' . $skippedEmpty); - } - if ($failed > 0) { - $io->error('Failed importing teams: ' . $failed); - } - - return $counter; - } - - private function createInstanceTeam(SymfonyStyle $io, array $users, array $activities, string $name): void - { - $team = new Team($name); - $teamlead = $users[array_key_first($users)]; - $teamlead = $this->getCachedUser($teamlead['userID']); - $team->addTeamlead($teamlead); - foreach ($users as $oldUser) { - $team->addUser($this->getCachedUser($oldUser['userID'])); - foreach ($activities as $oldActivity) { - $activity = $this->getCachedActivity($oldActivity['activityID'], null); - if ($activity !== null) { - $team->addActivity($activity); - } - } - } - - $entityManager = $this->getDoctrine()->getManager(); - $entityManager->persist($team); - $io->success('Created instance team: ' . $team->getName()); - $entityManager->flush(); - } - - private function fixEmail(string $domain): void - { - $query = $this->connection->createQueryBuilder() - ->update($this->dbPrefix . 'users') - ->set('mail', sprintf('CONCAT(LOWER(name), "_import@%s")', $domain)) - ->where("mail = '' OR mail IS null") - ; - $query->executeStatement(); - } - - private function fixTimesheet(): void - { - $query = $this->connection->createQueryBuilder() - ->update($this->dbPrefix . 'timeSheet') - ->set('end', 'start') - ->set('duration', '0') - ->set('rate', '0') - ->where('start > end') - ; - $query->executeStatement(); - } - - private function fixEncoding(): void - { - // https://onlineasciitools.com/convert-ascii-to-utf8 - $searchReplace = [ - 'ä' => 'ä', - 'Ä' => 'Ä', - 'ü' => 'ü', - 'Ü' => 'Ü', - 'ö' => 'ö', - 'Ö' => 'Ö', - 'ß' => 'ß', - '⦁' => '-', - ]; - - $tablesColumns = [ - 'timeSheet' => ['comment', 'description', 'location', 'trackingNumber'], - 'users' => ['name', 'alias'], - 'activities' => ['name', 'comment'], - 'projects' => ['name', 'comment'], - 'customers' => ['name', 'comment'], - 'groups' => ['name'], - 'statuses' => ['status'], - 'expenses' => ['designation', 'comment'], - ]; - - foreach ($tablesColumns as $table => $columns) { - foreach ($columns as $column) { - foreach ($searchReplace as $search => $replace) { - $query = $this->connection->createQueryBuilder() - ->update($this->dbPrefix . $table, $this->dbPrefix . $table) - ->set($column, sprintf('REPLACE(%s, "%s", "%s")', $column, $search, $replace)) - ->where($column . ' LIKE "%' . $search . '%"') - ; - $query->executeStatement(); - } - } - } - } -} diff --git a/src/Controller/TimesheetAbstractController.php b/src/Controller/TimesheetAbstractController.php index 0773fbf83..b770ea1ee 100644 --- a/src/Controller/TimesheetAbstractController.php +++ b/src/Controller/TimesheetAbstractController.php @@ -77,18 +77,18 @@ abstract class TimesheetAbstractController extends AbstractController $table->setPaginationRoute($paginationRoute); $table->setReloadEvents('kimai.timesheetUpdate kimai.timesheetDelete'); - $table->addColumn('date', ['class' => 'alwaysVisible', 'orderBy' => 'begin']); + $table->addColumn('date', ['class' => 'alwaysVisible text-nowrap', 'orderBy' => 'begin']); if ($this->canSeeStartEndTime()) { - $table->addColumn('starttime', ['class' => 'd-none d-sm-table-cell text-center', 'orderBy' => 'begin']); - $table->addColumn('endtime', ['class' => 'd-none d-sm-table-cell text-center', 'orderBy' => 'end']); + $table->addColumn('starttime', ['class' => 'd-none d-sm-table-cell text-center text-nowrap', 'orderBy' => 'begin']); + $table->addColumn('endtime', ['class' => 'd-none d-sm-table-cell text-center text-nowrap', 'orderBy' => 'end']); } $table->addColumn('duration', ['class' => 'text-end text-nowrap']); if ($canSeeRate) { - $table->addColumn('hourlyRate', ['class' => 'text-end d-none']); - $table->addColumn('rate', ['class' => 'text-end']); + $table->addColumn('hourlyRate', ['class' => 'text-end d-none text-nowrap']); + $table->addColumn('rate', ['class' => 'text-end text-nowrap']); } $table->addColumn('customer', ['class' => 'd-none d-md-table-cell']); @@ -98,7 +98,7 @@ abstract class TimesheetAbstractController extends AbstractController $table->addColumn('tags', ['class' => 'd-none badges', 'orderBy' => false]); foreach ($metaColumns as $metaColumn) { - $table->addColumn('mf_' . $metaColumn->getName(), ['title' => $metaColumn->getLabel(), 'class' => 'd-none', 'orderBy' => false]); + $table->addColumn('mf_' . $metaColumn->getName(), ['title' => $metaColumn->getLabel(), 'class' => 'd-none', 'orderBy' => false, 'data' => $metaColumn]); } if ($canSeeUsername) { @@ -116,11 +116,8 @@ abstract class TimesheetAbstractController extends AbstractController 'page_setup' => $page, 'dataTable' => $table, 'action_single' => $this->getActionNameSingle(), - 'canSeeUsername' => $canSeeUsername, - 'canSeeRate' => $canSeeRate, 'stats' => $result->getStatistic(), 'showSummary' => $this->includeSummary(), - 'showStartEndTime' => $this->canSeeStartEndTime(), 'metaColumns' => $metaColumns, 'allowMarkdown' => $this->hasMarkdownSupport(), 'editRoute' => $this->getEditRoute() diff --git a/src/Controller/WizardController.php b/src/Controller/WizardController.php index 9fff61d98..0810a6abc 100644 --- a/src/Controller/WizardController.php +++ b/src/Controller/WizardController.php @@ -16,6 +16,7 @@ use App\Form\Type\SkinType; use App\Form\Type\TimezoneType; use App\User\UserService; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; +use Symfony\Component\Form\Extension\Core\Type\HiddenType; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; @@ -42,18 +43,17 @@ final class WizardController extends AbstractController if ($wizard === 'profile') { $data = [ - 'language' => $request->getLocale(), - 'timezone' => $user->getTimezone(), + UserPreference::LOCALE => $request->getLocale(), + UserPreference::TIMEZONE => $user->getTimezone(), + UserPreference::SKIN => $user->getSkin(), + 'reload' => '0', ]; $form = $this->createFormBuilder($data) ->add(UserPreference::LOCALE, LanguageType::class) ->add(UserPreference::TIMEZONE, TimezoneType::class) - ->add(UserPreference::SKIN, SkinType::class, [ - 'attr' => [ - 'onchange' => "document.body.classList.remove('theme-light');document.body.classList.remove('theme-light');" - ], - ]) + ->add(UserPreference::SKIN, SkinType::class) + ->add('reload', HiddenType::class) ->setAction($this->generateUrl('wizard', ['wizard' => 'profile'])) ->setMethod('POST') ->getForm(); @@ -69,7 +69,11 @@ final class WizardController extends AbstractController $user->setWizardAsSeen('profile'); $userService->updateUser($user); - return $this->redirectToRoute('wizard', ['wizard' => 'done', '_locale' => $data['language']]); + if ($data['reload'] === '1') { + return $this->redirectToRoute('wizard', ['wizard' => 'profile', '_locale' => $data['language']]); + } else { + return $this->redirectToRoute('wizard', ['wizard' => 'done', '_locale' => $data['language']]); + } } return $this->render('wizard/profile.html.twig', [ diff --git a/src/DataFixtures/UserFixtures.php b/src/DataFixtures/UserFixtures.php index da2a459b3..52b649d90 100644 --- a/src/DataFixtures/UserFixtures.php +++ b/src/DataFixtures/UserFixtures.php @@ -80,9 +80,12 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface $prefs = $this->getUserPreferences($user, $userData[7]); $user->setPreferences($prefs); + // better to be able to test the wizard in demo installations + /* foreach (User::WIZARDS as $wizard) { $user->setWizardAsSeen($wizard); } + */ $manager->persist($prefs[0]); $manager->persist($prefs[1]); } diff --git a/src/Entity/User.php b/src/Entity/User.php index e76605d58..eb7c3ebd2 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -439,6 +439,11 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas return (bool) $this->getPreferenceValue('export_decimal', false, false); } + public function getSkin(): string + { + return (string) $this->getPreferenceValue(UserPreference::SKIN, 'default', false); + } + public function setTimezone(?string $timezone) { if ($timezone === null) { diff --git a/src/Repository/ApiUserRepository.php b/src/Repository/ApiUserRepository.php index 77235ab6b..cb8dab42f 100644 --- a/src/Repository/ApiUserRepository.php +++ b/src/Repository/ApiUserRepository.php @@ -15,6 +15,9 @@ use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; use Symfony\Component\Security\Core\User\UserInterface; +/** + * @template-implements PasswordUpgraderInterface + */ class ApiUserRepository implements UserLoaderInterface, PasswordUpgraderInterface { public function __construct(private UserRepository $userRepository) diff --git a/src/Repository/InvoiceDocumentRepository.php b/src/Repository/InvoiceDocumentRepository.php index be57ecc81..c33e4efbd 100644 --- a/src/Repository/InvoiceDocumentRepository.php +++ b/src/Repository/InvoiceDocumentRepository.php @@ -11,6 +11,7 @@ namespace App\Repository; use App\Model\InvoiceDocument; use Symfony\Component\Finder\Finder; +use Symfony\Component\Finder\SplFileInfo; final class InvoiceDocumentRepository { @@ -21,6 +22,9 @@ final class InvoiceDocumentRepository */ private array $documentDirs = []; + /** + * @param array $directories + */ public function __construct(array $directories) { foreach ($directories as $directory) { @@ -31,23 +35,19 @@ final class InvoiceDocumentRepository /** * @CloudRequired */ - public function addDirectory(string $directory) + public function addDirectory(string $directory): void { $this->documentDirs[] = $directory; - - return $this; } /** * @CloudRequired */ - public function removeDirectory(string $directory) + public function removeDirectory(string $directory): void { if (($key = array_search($directory, $this->documentDirs)) !== false) { unset($this->documentDirs[$key]); } - - return $this; } /** @@ -59,7 +59,12 @@ final class InvoiceDocumentRepository throw new \InvalidArgumentException('Cannot delete built-in invoice template'); } - @unlink(realpath($invoiceDocument->getFilename())); + $realpath = realpath($invoiceDocument->getFilename()); + if ($realpath === false) { + throw new \InvalidArgumentException('Template does not exist: ' . $invoiceDocument->getFilename()); + } + + @unlink($realpath); } public function getUploadDirectory(): string @@ -135,6 +140,7 @@ final class InvoiceDocumentRepository /** * Returns an array of invoice documents. * + * @param array $paths * @return InvoiceDocument[] */ private function findByPaths(array $paths): array @@ -153,7 +159,8 @@ final class InvoiceDocumentRepository continue; } - $finder = Finder::create()->ignoreDotFiles(true)->files()->in($searchDir)->name('*.*'); + $finder = Finder::create()->ignoreDotFiles(true)->files()->in($searchDir)->depth(0)->name('*.*'); + /** @var SplFileInfo $file */ foreach ($finder->getIterator() as $file) { $doc = new InvoiceDocument($file); // the first found invoice document wins diff --git a/src/Repository/Paginator/LoaderPaginator.php b/src/Repository/Paginator/LoaderPaginator.php index 48a5367c4..22a319fca 100644 --- a/src/Repository/Paginator/LoaderPaginator.php +++ b/src/Repository/Paginator/LoaderPaginator.php @@ -24,6 +24,9 @@ final class LoaderPaginator implements PaginatorInterface return $this->results; } + /** + * @return iterable> + */ public function getSlice(int $offset, int $length): iterable { $query = $this->query @@ -34,13 +37,17 @@ final class LoaderPaginator implements PaginatorInterface return $this->getResults($query); } + /** + * @param Query $query + * @return iterable> + */ private function getResults(Query $query) { $results = $query->execute(); $this->loader->loadResults($results); - return $results; + return $results; // @phpstan-ignore-line } public function getAll(): iterable diff --git a/src/Repository/Paginator/QueryBuilderPaginator.php b/src/Repository/Paginator/QueryBuilderPaginator.php index 4043db9d5..a3d8c7c94 100644 --- a/src/Repository/Paginator/QueryBuilderPaginator.php +++ b/src/Repository/Paginator/QueryBuilderPaginator.php @@ -23,6 +23,9 @@ final class QueryBuilderPaginator implements PaginatorInterface return $this->results; } + /** + * @return iterable> + */ public function getSlice(int $offset, int $length): iterable { $query = $this->query @@ -33,9 +36,13 @@ final class QueryBuilderPaginator implements PaginatorInterface return $this->getResults($query); } + /** + * @param Query $query + * @return iterable> + */ private function getResults(Query $query) { - return $query->execute(); + return $query->execute(); // @phpstan-ignore-line } public function getAll(): iterable diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index fef735d04..d7c48bba1 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -34,6 +34,7 @@ use Symfony\Component\Security\Core\User\UserProviderInterface; /** * @extends \Doctrine\ORM\EntityRepository + * @template-implements PasswordUpgraderInterface */ class UserRepository extends EntityRepository implements UserLoaderInterface, UserProviderInterface, PasswordUpgraderInterface { @@ -58,7 +59,7 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us $entityManager->flush(); } - public function upgradePassword(PasswordAuthenticatedUserInterface|UserInterface $user, string $newHashedPassword): void + public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void { if (!($user instanceof User)) { return; diff --git a/src/Security/KimaiUserProvider.php b/src/Security/KimaiUserProvider.php index fa498fd2f..a1126caf5 100644 --- a/src/Security/KimaiUserProvider.php +++ b/src/Security/KimaiUserProvider.php @@ -10,6 +10,7 @@ namespace App\Security; use App\Configuration\SystemConfiguration; +use App\Entity\User; use App\Ldap\LdapUserProvider; use Symfony\Component\Security\Core\User\ChainUserProvider; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; @@ -17,6 +18,9 @@ use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserProviderInterface; +/** + * @template-implements PasswordUpgraderInterface + */ final class KimaiUserProvider implements UserProviderInterface, PasswordUpgraderInterface { private ?ChainUserProvider $provider = null; diff --git a/src/Twig/Runtime/QrCodeExtension.php b/src/Twig/Runtime/QrCodeExtension.php new file mode 100644 index 000000000..04848f223 --- /dev/null +++ b/src/Twig/Runtime/QrCodeExtension.php @@ -0,0 +1,39 @@ + $writerOptions + * @return string + */ + public function qrCodeDataUriFunction(string $data, array $writerOptions = []): string + { + return Builder::create() + ->writer(new PngWriter()) + ->writerOptions($writerOptions) + ->data($data) + // if this causes errors at some point and needs to be configurable, keep this default! + ->errorCorrectionLevel(new ErrorCorrectionLevelMedium()) + ->build() + ->getDataUri(); + } +} diff --git a/src/Twig/RuntimeExtensions.php b/src/Twig/RuntimeExtensions.php index fe6100252..5e8c3b1bd 100644 --- a/src/Twig/RuntimeExtensions.php +++ b/src/Twig/RuntimeExtensions.php @@ -11,6 +11,7 @@ namespace App\Twig; use App\Twig\Runtime\EncoreExtension; use App\Twig\Runtime\MarkdownExtension; +use App\Twig\Runtime\QrCodeExtension; use App\Twig\Runtime\ThemeExtension; use App\Twig\Runtime\TimesheetExtension; use App\Twig\Runtime\WidgetExtension; @@ -35,6 +36,7 @@ final class RuntimeExtensions extends AbstractExtension new TwigFunction('encore_entry_css_source', [EncoreExtension::class, 'getEncoreEntryCssSource']), new TwigFunction('render_widget', [WidgetExtension::class, 'renderWidget'], ['is_safe' => ['html'], 'needs_environment' => true]), new TwigFunction('icon', [RuntimeExtension::class, 'createIcon'], ['is_safe' => ['html']]), + new TwigFunction('qr_code_data_uri', [QrCodeExtension::class, 'qrCodeDataUriFunction']), ]; } diff --git a/templates/datatable.html.twig b/templates/datatable.html.twig index 32dbffec0..28a0a4a3d 100644 --- a/templates/datatable.html.twig +++ b/templates/datatable.html.twig @@ -27,19 +27,21 @@ {% block datatable_before %}{% endblock %} {% set sortedColumns = dataTable.sortedColumnNames %} - {% for entry in dataTable %} - {% block datatable_row %} - - {% for column, data in sortedColumns %} - {% block datatable_column %} - - {% block datatable_column_value %}{% endblock %} - - {% endblock %} - {% endfor %} - - {% endblock %} - {% endfor %} + {% block datatable_outer %} + {% for entry in dataTable %} + {% block datatable_row %} + + {% for column, data in sortedColumns %} + {% block datatable_column %} + + {% block datatable_column_value %}{% endblock %} + + {% endblock %} + {% endfor %} + + {% endblock %} + {% endfor %} + {% endblock %} {% block datatable_after %}{% endblock %} diff --git a/templates/invoice/renderer/invoice.html.twig b/templates/invoice/renderer/invoice.html.twig index 9b671ae56..3c8dbd56a 100644 --- a/templates/invoice/renderer/invoice.html.twig +++ b/templates/invoice/renderer/invoice.html.twig @@ -62,7 +62,7 @@
-
+
diff --git a/templates/invoice/renderer/timesheet.html.twig b/templates/invoice/renderer/timesheet.html.twig index 4d08a6751..14b2ff0a7 100644 --- a/templates/invoice/renderer/timesheet.html.twig +++ b/templates/invoice/renderer/timesheet.html.twig @@ -15,9 +15,9 @@
-
+
- + - + - + {% if project is not null %} - + - + @@ -66,7 +66,7 @@ -
+
{{ 'invoice.from'|trans }}{{ 'invoice.from'|trans }} {% if model.query.user is not empty %} {{ widgets.username(model.query.user) }} @@ -27,7 +27,7 @@
{{ 'date'|trans }}{{ 'date'|trans }} {% if model.query.begin|date('m') != model.query.end|date('m') or model.query.begin|date('Y') != model.query.end|date('Y') %} {{ model.query.begin|date_short }} - {{ model.query.end|date_short }} @@ -37,7 +37,7 @@
{{ 'customer'|trans }}{{ 'customer'|trans }} {% if model.customer.number is not empty %}[{{ model.customer.number }}]{% endif %} {{ model.customer.name }}{% if model.customer.contact is not empty %} / {{ model.customer.contact }}{% endif %} @@ -45,7 +45,7 @@
{{ 'project'|trans }}{{ 'project'|trans }} {{ project.name }} {% if project.orderNumber is not empty %} @@ -56,7 +56,7 @@ {% endif %} {% if activity is not null %}
{{ 'activity'|trans }}{{ 'activity'|trans }} {{ activity.name }}
@@ -122,16 +122,8 @@ {% endif %}
-
- - - - - - - - -
{{ 'invoice.signature_user'|trans }}
{{ 'invoice.signature_customer'|trans }}
+

{{ 'invoice.signature_user'|trans }}

+

{{ 'invoice.signature_customer'|trans }}

diff --git a/templates/timesheet/index.html.twig b/templates/timesheet/index.html.twig index 08c75f6f9..76dc242d4 100644 --- a/templates/timesheet/index.html.twig +++ b/templates/timesheet/index.html.twig @@ -2,44 +2,68 @@ {% import "macros/widgets.html.twig" as widgets %} {% import "macros/datatables.html.twig" as tables %} -{% set checkOverlappingDesc = false %} -{% set checkOverlappingAsc = false %} -{% set query = dataTable.getQuery() %} -{% if query.orderBy == 'begin' or query.orderBy == 'end' %} - {% set checkOverlappingDesc = (query.order == 'DESC') %} - {% set checkOverlappingAsc = not checkOverlappingDesc %} -{% endif %} +{% block datatable_outer %} + {% set checkOverlappingDesc = false %} + {% set checkOverlappingAsc = false %} + {% set query = dataTable.getQuery() %} + {% if query.orderBy == 'begin' or query.orderBy == 'end' %} + {% set checkOverlappingDesc = (query.order == 'DESC') %} + {% set checkOverlappingAsc = not checkOverlappingDesc %} + {% endif %} -{% set day = null %} -{% set dayDuration = 0 %} -{% set dayRate = {} %} -{% set dayHourlyRate = 0 %} -{% set lastEntry = null %} + {% set day = null %} + {% set dayDuration = 0 %} + {% set dayRate = {} %} + {% set dayHourlyRate = 0 %} + {% set lastEntry = null %} + + {% for entry in dataTable %} + {%- if day is same as(null) -%} + {% set day = entry.begin|date_short %} + {% endif %} + {%- if showSummary and day is not same as(entry.begin|date_short) -%} + {{ _self.summary(day, dayDuration, dayHourlyRate, dayRate, sortedColumns, dataTable) }} + {% set day = entry.begin|date_short %} + {% set dayDuration = 0 %} + {% set dayRate = {} %} + {% set dayHourlyRate = 0 %} + {%- endif -%} + {%- set customerCurrency = entry.project.customer.currency -%} + {%- set entryHourlyRate = entry.hourlyRate|money(customerCurrency) -%} + {% block datatable_row %} + + {% for column, data in sortedColumns %} + {{ block('datatable_column') }} + {% endfor %} + + {% endblock %} + {%- if entry.end -%} + {% if dayRate[customerCurrency] is not defined %} + {% set dayRate = dayRate|merge({(customerCurrency): 0}) %} + {% endif %} + {% set dayRate = dayRate|merge({(customerCurrency): dayRate[customerCurrency] + entry.rate}) %} + {%- endif -%} + {% if dayHourlyRate is not null %} + {% if dayHourlyRate == 0 %} + {% set dayHourlyRate = entryHourlyRate %} + {% elseif dayHourlyRate != entryHourlyRate %} + {% set dayHourlyRate = null %} + {% endif %} + {% endif %} + {%- set dayDuration = dayDuration + entry.duration -%} + {% set lastEntry = entry %} + {% endfor %} + {% if showSummary %} + {{ _self.summary(day, dayDuration, dayHourlyRate, dayRate, sortedColumns, dataTable) }} + {% endif %} +{% endblock %} {% block status %} {% from "macros/status.html.twig" import status_duration %} {{ status_duration(stats.duration|duration) }} {% endblock %} -{% block datatable_after %} - {% if showSummary %} - {{ _self.summary(day, dayDuration, dayHourlyRate, dayRate, columns, canSeeRate, canSeeUsername, showStartEndTime, tableName, metaColumns) }} - {% endif %} -{% endblock %} - -{% block datatable_row %} - {%- set customerCurrency = entry.project.customer.currency -%} - {%- set entryHourlyRate = entry.hourlyRate|money(customerCurrency) -%} - {%- if day is same as(null) -%} - {% set day = entry.begin|date_short %} - {% endif %} - {%- if showSummary and day is not same as(entry.begin|date_short) -%} - {{ _self.summary(day, dayDuration, dayHourlyRate, dayRate, columns, canSeeRate, canSeeUsername, showStartEndTime, tableName, metaColumns) }} - {% set day = entry.begin|date_short %} - {% set dayDuration = 0 %} - {% set dayRate = {} %} - {% set dayHourlyRate = 0 %} - {%- endif -%} +{% block datatable_row_attr %} {% set class = '' %} {% if checkOverlappingDesc or checkOverlappingAsc %} {% if lastEntry is not null and entry.end is not null and entry.user is same as (lastEntry.user) %} @@ -53,144 +77,94 @@ {% if not entry.end %} {% set class = class ~ ' recording' %} {% endif %} - - + {% if is_granted('edit', entry) %} class="modal-ajax-form open-edit{{ class }}" data-href="{{ path(editRoute, {'id': entry.id}) }}"{% endif %} +{% endblock %} + +{% block datatable_column %} + + {% if column == 'id' %} {% if is_granted('edit', entry) or is_granted('delete', entry) %} {{ tables.datatable_multiupdate_row(entry.id) }} {% endif %} - - {{ entry.begin|date_short }} - - {% if showStartEndTime %} - {{ entry.begin|time }} - - {% if entry.end %} - {{ entry.end|time }} - {% else %} - ‐ - {% endif %} - - {% endif %} - + {% elseif column == 'date' %} + {{ entry.begin|date_short }} + {% elseif column == 'starttime' %} + {{ entry.begin|time }} + {% elseif column == 'endtime' %} {% if entry.end %} - {{ entry.duration|duration }} + {{ entry.end|time }} {% else %} - - {{ entry|duration }} - + ‐ {% endif %} - - {% if canSeeRate %} - - {{ entryHourlyRate }} - - - {% if not entry.end or not is_granted('view_rate', entry) %} - ‐ - {% else %} - {{ entry.rate|money(customerCurrency) }} - {% endif %} - + {% elseif column == 'duration' %} + {% if entry.end %} + {{ entry.duration|duration }} + {% else %} + {{ entry|duration }} {% endif %} - - - {{ widgets.label_customer(entry.project.customer) }} - - - {{ widgets.label_project(entry.project) }} - - - {% if entry.activity is not null %} - {{ widgets.label_activity(entry.activity) }} - {% endif %} - - - {% if allowMarkdown %} - {{ entry.description|desc2html }} - {% else %} - {{ entry.description|nl2br }} - {% endif %} - - {{ widgets.tag_list(entry.tags) }} - - {% for field in metaColumns %} - - {{ tables.datatable_meta_column(entry, field) }} - - {% endfor %} - - {% if canSeeUsername %} - - {{ widgets.label_user(entry.user) }} - + {% elseif column == 'hourlyRate' %} + {{ entryHourlyRate }} + {% elseif column == 'rate' %} + {% if not entry.end or not is_granted('view_rate', entry) %} + ‐ + {% else %} + {{ entry.rate|money(customerCurrency) }} {% endif %} - - - {{ widgets.label_boolean(entry.billable) }} - - - {{ widgets.label_boolean(entry.exported) }} - - - {% set event = actions(app.user, action_single, 'index', {'timesheet': entry}) %} - {{ widgets.table_actions(event.actions) }} - - - {%- if entry.end -%} - {% if dayRate[customerCurrency] is not defined %} - {% set dayRate = dayRate|merge({(customerCurrency): 0}) %} + {% elseif column == 'customer' %} + {{ widgets.label_customer(entry.project.customer) }} + {% elseif column == 'project' %} + {{ widgets.label_project(entry.project) }} + {% elseif column == 'activity' %} + {% if entry.activity is not null %} + {{ widgets.label_activity(entry.activity) }} {% endif %} - {% set dayRate = dayRate|merge({(customerCurrency): dayRate[customerCurrency] + entry.rate}) %} - {%- endif -%} - {% if dayHourlyRate is not null %} - {% if dayHourlyRate == 0 %} - {% set dayHourlyRate = entryHourlyRate %} - {% elseif dayHourlyRate != entryHourlyRate %} - {% set dayHourlyRate = null %} + {% elseif column == 'description' %} + {% if allowMarkdown %} + {{ entry.description|desc2html }} + {% else %} + {{ entry.description|nl2br }} {% endif %} + {% elseif column == 'tags' %} + {{ widgets.tag_list(entry.tags) }} + {% elseif column == 'billable' %} + {{ widgets.label_boolean(entry.billable) }} + {% elseif column == 'exported' %} + {{ widgets.label_boolean(entry.exported) }} + {% elseif column == 'username' %} + {{ widgets.label_user(entry.user) }} + {% elseif column == 'actions' %} + {% set event = actions(app.user, action_single, 'index', {'timesheet': entry}) %} + {{ widgets.table_actions(event.actions) }} + {% elseif column starts with 'mf_' %} + {{ widgets.meta_field_value(entry, data) }} {% endif %} - {%- set dayDuration = dayDuration + entry.duration -%} - {% set lastEntry = entry %} + {% endblock %} -{% macro summary(day, duration, dayHourlyRate, dayRates, columns, canSeeRate, canSeeUsername, showStartEndTime, tableName, metaColumns) %} +{% macro summary(day, duration, dayHourlyRate, dayRates, sortedColumns, dataTable) %} {% import "macros/datatables.html.twig" as tables %} - - {{ day }} - {% if showStartEndTime %} - - - {% endif %} - {{ duration|duration }} - {% if canSeeRate %} - - {% if dayHourlyRate is not null and dayHourlyRate != 0 %} - {{ dayHourlyRate }} + {% for column, data in sortedColumns %} + + {% if column == 'date' %} + {{ day }} + {% elseif column == 'duration' %} + {{ duration|duration }} + {% elseif column == 'hourlyRate' %} + {% if dayHourlyRate is not null and dayHourlyRate != 0 %} + {{ dayHourlyRate }} + {% endif %} + {% elseif column == 'rate' %} + {% for currency, rate in dayRates %} + {{ rate|money(currency) }} + {% if not loop.last %} +
{% endif %} - - - {% for currency, rate in dayRates %} - {{ rate|money(currency) }} - {% if not loop.last %} -
- {% endif %} - {% endfor %} - + {% endfor %} + {% else %} + {% endif %} - - - - - - {% for field in metaColumns %} - - {% endfor %} - {% if canSeeUsername %} - - {% endif %} - - - + + {% endfor %} {% endmacro %} diff --git a/templates/wizard/profile.html.twig b/templates/wizard/profile.html.twig index 8b75ecefc..bcd0dc303 100644 --- a/templates/wizard/profile.html.twig +++ b/templates/wizard/profile.html.twig @@ -25,6 +25,14 @@ document.body.classList.add('theme-' + skinChooser.value); }); } + + const localeChooser = document.getElementById('form_language'); + if (localeChooser !== null) { + localeChooser.addEventListener('change', (ev) => { + document.getElementById('form_reload').value = '1'; + ev.target.form.submit(); + }); + } }); {% endblock %} diff --git a/tests/Command/CreateUserCommandTest.php b/tests/Command/CreateUserCommandTest.php index 1136e8ebc..850832574 100644 --- a/tests/Command/CreateUserCommandTest.php +++ b/tests/Command/CreateUserCommandTest.php @@ -99,7 +99,7 @@ class CreateUserCommandTest extends KernelTestCase $commandTester = $this->createUser('MyTestUser2', 'user@example.com', 'ROLE_USER', 'foobar'); $output = $commandTester->getDisplay(); - $this->assertStringContainsString('[ERROR] email: The email is already used.', $output); + $this->assertStringContainsString('[ERROR] email: This e-mail address is already in use.', $output); } public function testUserEmail(): void diff --git a/tests/Command/KimaiImporterCommandTest.php b/tests/Command/KimaiImporterCommandTest.php deleted file mode 100644 index 19dff8468..000000000 --- a/tests/Command/KimaiImporterCommandTest.php +++ /dev/null @@ -1,48 +0,0 @@ -application = new Application($kernel); - - $encoder = $this->createMock(UserPasswordHasherInterface::class); - $registry = $this->createMock(ManagerRegistry::class); - $validator = $this->createMock(ValidatorInterface::class); - - $this->application->add(new KimaiImporterCommand($encoder, $registry, $validator)); - } - - public function testCommandName() - { - $command = $this->application->find('kimai:import-v1'); - self::assertInstanceOf(KimaiImporterCommand::class, $command); - } -} diff --git a/tests/Controller/Security/SelfRegistrationControllerTest.php b/tests/Controller/Security/SelfRegistrationControllerTest.php index 8f70a7c3c..33eb479c2 100644 --- a/tests/Controller/Security/SelfRegistrationControllerTest.php +++ b/tests/Controller/Security/SelfRegistrationControllerTest.php @@ -116,7 +116,7 @@ class SelfRegistrationControllerTest extends ControllerBaseTest $content = $client->getResponse()->getContent(); $this->assertStringContainsString('Kimai – Time Tracking', $content); - $this->assertStringContainsString('An email has been sent to register@example.com. It contains an activation link you must click to activate your account.', $content); + $this->assertStringContainsString('An e-mail has been sent to register@example.com. It contains a link you must click to activate your account.', $content); $this->assertStringContainsString('', $content); } diff --git a/tests/Plugin/Fixtures/TestPlugin/composer.json b/tests/Plugin/Fixtures/TestPlugin/composer.json index 1507bc96b..0b55ad0a0 100644 --- a/tests/Plugin/Fixtures/TestPlugin/composer.json +++ b/tests/Plugin/Fixtures/TestPlugin/composer.json @@ -5,8 +5,7 @@ "type": "kimai-plugin", "version": "1.0", "require": { - "kimai/kimai2-composer": "*", - "kevinpapst/kimai2": "*" + "kimai/kimai": "*" }, "keywords": [ "kimai", diff --git a/tests/Twig/RuntimeExtensionsTest.php b/tests/Twig/RuntimeExtensionsTest.php index 824278905..26960ffd8 100644 --- a/tests/Twig/RuntimeExtensionsTest.php +++ b/tests/Twig/RuntimeExtensionsTest.php @@ -49,6 +49,7 @@ class RuntimeExtensionsTest extends TestCase 'encore_entry_css_source', 'render_widget', 'icon', + 'qr_code_data_uri', ]; $i = 0; diff --git a/tests/phpstan.neon b/tests/phpstan.neon index 4752109bc..4e907935a 100644 --- a/tests/phpstan.neon +++ b/tests/phpstan.neon @@ -2302,11 +2302,6 @@ parameters: count: 1 path: Command/InvoiceCreateCommandTest.php - - - message: "#^Method App\\\\Tests\\\\Command\\\\KimaiImporterCommandTest\\:\\:testCommandName\\(\\) has no return type specified\\.$#" - count: 1 - path: Command/KimaiImporterCommandTest.php - - message: "#^Method App\\\\Tests\\\\Command\\\\PluginCommandTest\\:\\:getCommandTester\\(\\) has no return type specified\\.$#" count: 1