Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a9de506a1 | ||
|
|
e663cc2b02 | ||
|
|
a70c803158 | ||
|
|
9ca69e1114 | ||
|
|
2e6b700b43 | ||
|
|
2a75cd6230 | ||
|
|
934fdeb107 | ||
|
|
14cdcd3f63 | ||
|
|
674bf3a6b5 | ||
|
|
cce49cc409 | ||
|
|
04cb403ea9 | ||
|
|
afaa845a9a | ||
|
|
928b5208e9 | ||
|
|
8a8d0503f7 | ||
|
|
b341358d0a | ||
|
|
d30166e691 |
2
.github/workflows/testing.yaml
vendored
2
.github/workflows/testing.yaml
vendored
@@ -104,7 +104,7 @@ jobs:
|
||||
|
||||
- name: Upload code coverage
|
||||
if: matrix.php == '8.2'
|
||||
uses: codecov/codecov-action@v4
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./coverage.xml
|
||||
|
||||
@@ -43,6 +43,7 @@ $fixer
|
||||
'single_line_after_imports' => true,
|
||||
'switch_case_semicolon_to_colon' => true,
|
||||
'switch_case_space' => true,
|
||||
'php_unit_method_casing' => true,
|
||||
'array_syntax' => [
|
||||
'syntax' => 'short'
|
||||
],
|
||||
|
||||
24
UPGRADING-3.md
Normal file
24
UPGRADING-3.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Upgrading Kimai - Version 3.x
|
||||
|
||||
_Make sure to create a backup before you start!_
|
||||
|
||||
Read the [updates documentation](https://www.kimai.org/documentation/updates.html) to find out how 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.
|
||||
|
||||
## 3.0
|
||||
|
||||
**!! This release requires minimum PHP version 8.4 !!**
|
||||
|
||||
### Developer
|
||||
|
||||
Removed translations:
|
||||
- `action.edit`: use `edit` instead
|
||||
- `my.profile`: use `user_profile` instead
|
||||
- `stats.userAmountToday`: use `` instead
|
||||
- `stats.userAmountWeek`: use `` instead
|
||||
- `stats.userAmountMonth`: use `` instead
|
||||
- `stats.userAmountYear`: use `` instead
|
||||
- `stats.userAmountTotal`: use `` instead
|
||||
- `update_multiple`
|
||||
@@ -1,4 +1,4 @@
|
||||
# Upgrading Kimai - Version2.x
|
||||
# Upgrading Kimai - Version 2.x
|
||||
|
||||
_Make sure to create a backup before you start!_
|
||||
|
||||
|
||||
@@ -49,4 +49,11 @@ export default class KimaiUser extends KimaiPlugin {
|
||||
return this.user.superAdmin;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {array}
|
||||
*/
|
||||
getRoles() {
|
||||
return this.user.roles;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ export default class KimaiPaginatedBoxWidget {
|
||||
constructor(boxId) {
|
||||
this.selector = boxId;
|
||||
const widget = document.querySelector(this.selector);
|
||||
this.href = widget.dataset['href'];
|
||||
|
||||
if (widget.dataset['reload'] !== undefined) {
|
||||
this.events = widget.dataset['reload'].split(' ');
|
||||
@@ -93,7 +92,7 @@ export default class KimaiPaginatedBoxWidget {
|
||||
if (node.tagName !== undefined && node.tagName === 'SCRIPT') {
|
||||
const script = document.createElement('script');
|
||||
script.text = node.innerHTML;
|
||||
node.parentNode.replaceChild(script, node );
|
||||
node.parentNode.replaceChild(script, node);
|
||||
} else {
|
||||
for (const child of node.childNodes) {
|
||||
this._makeScriptExecutable(child);
|
||||
|
||||
@@ -52,6 +52,13 @@ table.dataTable thead > tr > th.hw-min {
|
||||
width: 1%;
|
||||
white-space: normal;
|
||||
}
|
||||
@include media-breakpoint-up(sm) {
|
||||
th.w-sm-min,
|
||||
td.w-sm-min {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
/* If a table column contains ONLY avatar <img> it will collapse, so make it a defined width */
|
||||
.w-avatar {
|
||||
width: 40px;
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"doctrine/doctrine-bundle": "^2.7",
|
||||
"doctrine/doctrine-migrations-bundle": "^3.3",
|
||||
"doctrine/orm": "^2.8",
|
||||
"easybill/zugferd-php": "^2.1",
|
||||
"endroid/qr-code": "^4.8",
|
||||
"erusev/parsedown": "^1.6",
|
||||
"friendsofsymfony/rest-bundle": "^3.0",
|
||||
|
||||
926
composer.lock
generated
926
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1868,6 +1868,13 @@
|
||||
'rtl' => false,
|
||||
'translation' => false,
|
||||
],
|
||||
'ta' =>
|
||||
[
|
||||
'date' => 'd/M/yy',
|
||||
'time' => 'h:mm a',
|
||||
'rtl' => false,
|
||||
'translation' => true,
|
||||
],
|
||||
'tr' =>
|
||||
[
|
||||
'date' => 'd.MM.y',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
parameters:
|
||||
locale: en
|
||||
# can be regenerated with: bin/console kimai:reset:locales
|
||||
kimai_locales: ['ar', 'ca', 'cs', 'da', 'de', 'de_CH', 'el', 'en', 'eo', 'es', 'eu', 'fa', 'fi', 'fo', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'nb_NO', 'nl', 'pa', 'pl', 'pt', 'pt_BR', 'ro', 'ru', 'sk', 'sl', 'sv', 'tr', 'uk', 'vi', 'zh_CN', 'zh_Hant', 'zh_Hant_TW', 'ca_AD', 'ca_ES', 'ca_FR', 'ca_IT', 'cs_CZ', 'da_DK', 'da_GL', 'de_AT', 'de_BE', 'de_CH', 'de_DE', 'de_IT', 'de_LI', 'de_LU', 'el_CY', 'el_GR', 'en_AE', 'en_AG', 'en_AI', 'en_AS', 'en_AT', 'en_AU', 'en_BB', 'en_BE', 'en_BI', 'en_BM', 'en_BS', 'en_BW', 'en_BZ', 'en_CA', 'en_CC', 'en_CH', 'en_CK', 'en_CM', 'en_CX', 'en_CY', 'en_DE', 'en_DG', 'en_DK', 'en_DM', 'en_ER', 'en_FI', 'en_FJ', 'en_FK', 'en_FM', 'en_GB', 'en_GD', 'en_GG', 'en_GH', 'en_GI', 'en_GM', 'en_GU', 'en_GY', 'en_HK', 'en_ID', 'en_IE', 'en_IL', 'en_IM', 'en_IN', 'en_IO', 'en_JE', 'en_JM', 'en_KE', 'en_KI', 'en_KN', 'en_KY', 'en_LC', 'en_LR', 'en_LS', 'en_MG', 'en_MH', 'en_MO', 'en_MP', 'en_MS', 'en_MT', 'en_MU', 'en_MV', 'en_MW', 'en_MY', 'en_NA', 'en_NF', 'en_NG', 'en_NH', 'en_NL', 'en_NR', 'en_NU', 'en_NZ', 'en_PG', 'en_PH', 'en_PK', 'en_PN', 'en_PR', 'en_PW', 'en_RH', 'en_RW', 'en_SB', 'en_SC', 'en_SD', 'en_SE', 'en_SG', 'en_SH', 'en_SI', 'en_SL', 'en_SS', 'en_SX', 'en_SZ', 'en_TC', 'en_TK', 'en_TO', 'en_TT', 'en_TV', 'en_TZ', 'en_UG', 'en_UM', 'en_US', 'en_VC', 'en_VG', 'en_VI', 'en_VU', 'en_WS', 'en_ZA', 'en_ZM', 'en_ZW', 'es_AR', 'es_BO', 'es_BR', 'es_BZ', 'es_CL', 'es_CO', 'es_CR', 'es_CU', 'es_DO', 'es_EA', 'es_EC', 'es_ES', 'es_GQ', 'es_GT', 'es_HN', 'es_IC', 'es_MX', 'es_NI', 'es_PA', 'es_PE', 'es_PH', 'es_PR', 'es_PY', 'es_SV', 'es_US', 'es_UY', 'es_VE', 'eu_ES', 'fa_AF', 'fa_IR', 'fi_FI', 'fo_DK', 'fo_FO', 'fr_BE', 'fr_BF', 'fr_BI', 'fr_BJ', 'fr_BL', 'fr_CA', 'fr_CD', 'fr_CF', 'fr_CG', 'fr_CH', 'fr_CI', 'fr_CM', 'fr_DJ', 'fr_DZ', 'fr_FR', 'fr_GA', 'fr_GF', 'fr_GN', 'fr_GP', 'fr_GQ', 'fr_HT', 'fr_KM', 'fr_LU', 'fr_MA', 'fr_MC', 'fr_MF', 'fr_MG', 'fr_ML', 'fr_MQ', 'fr_MR', 'fr_MU', 'fr_NC', 'fr_NE', 'fr_PF', 'fr_PM', 'fr_RE', 'fr_RW', 'fr_SC', 'fr_SN', 'fr_SY', 'fr_TD', 'fr_TG', 'fr_TN', 'fr_VU', 'fr_WF', 'fr_YT', 'he_IL', 'hr_BA', 'hr_HR', 'hu_HU', 'it_CH', 'it_IT', 'it_SM', 'it_VA', 'ja_JP', 'ko_CN', 'ko_KP', 'ko_KR', 'nl_AW', 'nl_BE', 'nl_BQ', 'nl_CW', 'nl_NL', 'nl_SR', 'nl_SX', 'pl_PL', 'pt_AO', 'pt_BR', 'pt_CH', 'pt_CV', 'pt_GQ', 'pt_GW', 'pt_LU', 'pt_MO', 'pt_MZ', 'pt_PT', 'pt_ST', 'pt_TL', 'ro_MD', 'ro_RO', 'ru_BY', 'ru_KG', 'ru_KZ', 'ru_MD', 'ru_RU', 'ru_UA', 'sk_SK', 'sv_AX', 'sv_FI', 'sv_SE', 'tr_CY', 'tr_TR', 'uk_UA', 'vi_VN']
|
||||
kimai_locales: ['ar', 'cs', 'da', 'de', 'de_CH', 'el', 'en', 'eo', 'es', 'eu', 'fa', 'fi', 'fo', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'nb_NO', 'nl', 'pa', 'pl', 'pt', 'pt_BR', 'ro', 'ru', 'sk', 'sl', 'sv', 'ta', 'tr', 'uk', 'vi', 'zh_CN', 'zh_Hant', 'zh_Hant_TW', 'cs_CZ', 'da_DK', 'da_GL', 'de_AT', 'de_BE', 'de_CH', 'de_DE', 'de_IT', 'de_LI', 'de_LU', 'el_CY', 'el_GR', 'en_AE', 'en_AG', 'en_AI', 'en_AS', 'en_AT', 'en_AU', 'en_BB', 'en_BE', 'en_BI', 'en_BM', 'en_BS', 'en_BW', 'en_BZ', 'en_CA', 'en_CC', 'en_CH', 'en_CK', 'en_CM', 'en_CX', 'en_CY', 'en_DE', 'en_DG', 'en_DK', 'en_DM', 'en_ER', 'en_FI', 'en_FJ', 'en_FK', 'en_FM', 'en_GB', 'en_GD', 'en_GG', 'en_GH', 'en_GI', 'en_GM', 'en_GU', 'en_GY', 'en_HK', 'en_ID', 'en_IE', 'en_IL', 'en_IM', 'en_IN', 'en_IO', 'en_JE', 'en_JM', 'en_KE', 'en_KI', 'en_KN', 'en_KY', 'en_LC', 'en_LR', 'en_LS', 'en_MG', 'en_MH', 'en_MO', 'en_MP', 'en_MS', 'en_MT', 'en_MU', 'en_MV', 'en_MW', 'en_MY', 'en_NA', 'en_NF', 'en_NG', 'en_NH', 'en_NL', 'en_NR', 'en_NU', 'en_NZ', 'en_PG', 'en_PH', 'en_PK', 'en_PN', 'en_PR', 'en_PW', 'en_RH', 'en_RW', 'en_SB', 'en_SC', 'en_SD', 'en_SE', 'en_SG', 'en_SH', 'en_SI', 'en_SL', 'en_SS', 'en_SX', 'en_SZ', 'en_TC', 'en_TK', 'en_TO', 'en_TT', 'en_TV', 'en_TZ', 'en_UG', 'en_UM', 'en_US', 'en_VC', 'en_VG', 'en_VI', 'en_VU', 'en_WS', 'en_ZA', 'en_ZM', 'en_ZW', 'es_AR', 'es_BO', 'es_BR', 'es_BZ', 'es_CL', 'es_CO', 'es_CR', 'es_CU', 'es_DO', 'es_EA', 'es_EC', 'es_ES', 'es_GQ', 'es_GT', 'es_HN', 'es_IC', 'es_MX', 'es_NI', 'es_PA', 'es_PE', 'es_PH', 'es_PR', 'es_PY', 'es_SV', 'es_US', 'es_UY', 'es_VE', 'eu_ES', 'fa_AF', 'fa_IR', 'fi_FI', 'fo_DK', 'fo_FO', 'fr_BE', 'fr_BF', 'fr_BI', 'fr_BJ', 'fr_BL', 'fr_CA', 'fr_CD', 'fr_CF', 'fr_CG', 'fr_CH', 'fr_CI', 'fr_CM', 'fr_DJ', 'fr_DZ', 'fr_FR', 'fr_GA', 'fr_GF', 'fr_GN', 'fr_GP', 'fr_GQ', 'fr_HT', 'fr_KM', 'fr_LU', 'fr_MA', 'fr_MC', 'fr_MF', 'fr_MG', 'fr_ML', 'fr_MQ', 'fr_MR', 'fr_MU', 'fr_NC', 'fr_NE', 'fr_PF', 'fr_PM', 'fr_RE', 'fr_RW', 'fr_SC', 'fr_SN', 'fr_SY', 'fr_TD', 'fr_TG', 'fr_TN', 'fr_VU', 'fr_WF', 'fr_YT', 'he_IL', 'hr_BA', 'hr_HR', 'hu_HU', 'it_CH', 'it_IT', 'it_SM', 'it_VA', 'ja_JP', 'ko_CN', 'ko_KP', 'ko_KR', 'nl_AW', 'nl_BE', 'nl_BQ', 'nl_CW', 'nl_NL', 'nl_SR', 'nl_SX', 'pl_PL', 'pt_AO', 'pt_BR', 'pt_CH', 'pt_CV', 'pt_GQ', 'pt_GW', 'pt_LU', 'pt_MO', 'pt_MZ', 'pt_PT', 'pt_ST', 'pt_TL', 'ro_MD', 'ro_RO', 'ru_BY', 'ru_KG', 'ru_KZ', 'ru_MD', 'ru_RU', 'ru_UA', 'sk_SK', 'sv_AX', 'sv_FI', 'sv_SE', 'tr_CY', 'tr_TR', 'uk_UA', 'vi_VN']
|
||||
|
||||
services:
|
||||
# default configuration for services in *this* file
|
||||
|
||||
80
phpstan.neon
80
phpstan.neon
@@ -2097,11 +2097,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Form/TimesheetAdminEditForm.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method format\\(\\) on mixed\\.$#"
|
||||
count: 4
|
||||
path: src/Form/TimesheetEditForm.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getEnd\\(\\) on App\\\\Entity\\\\Timesheet\\|null\\.$#"
|
||||
count: 2
|
||||
@@ -2122,11 +2117,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Form/TimesheetEditForm.php
|
||||
|
||||
-
|
||||
message: "#^Cannot clone non\\-object variable \\$end of type mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Form/TimesheetEditForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\TimesheetEditForm\\:\\:addActivity\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
@@ -3027,46 +3017,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelCustomerHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getAddress\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getCompany\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getContact\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getDueDays\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getLanguage\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getName\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getPaymentDetails\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getPaymentTerms\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getSubtotal\\(\\) on App\\\\Invoice\\\\CalculatorInterface\\|null\\.$#"
|
||||
count: 1
|
||||
@@ -3082,11 +3032,6 @@ parameters:
|
||||
count: 2
|
||||
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getTitle\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getTotal\\(\\) on App\\\\Invoice\\\\CalculatorInterface\\|null\\.$#"
|
||||
count: 1
|
||||
@@ -3097,11 +3042,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getVatId\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Invoice\\\\Hydrator\\\\InvoiceModelDefaultHydrator\\:\\:hydrate\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
@@ -3432,26 +3372,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Model/Statistic/Month.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Pdf\\\\HtmlToPdfConverter\\:\\:convertToPdf\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Pdf/HtmlToPdfConverter.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Pdf\\\\MPdfConverter\\:\\:convertToPdf\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Pdf/MPdfConverter.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Pdf\\\\MPdfConverter\\:\\:sanitizeOptions\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Pdf/MPdfConverter.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Pdf\\\\MPdfConverter\\:\\:sanitizeOptions\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Pdf/MPdfConverter.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Pdf\\\\PdfContext\\:\\:getOption\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Kimai Time-Tracker",
|
||||
"name": "Kimai",
|
||||
"short_name": "Kimai",
|
||||
"icons": [
|
||||
{
|
||||
|
||||
@@ -9,18 +9,13 @@
|
||||
|
||||
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<User> $passwordUpgrader
|
||||
*/
|
||||
public function __construct(private ?string $plaintextApiToken, private PasswordUpgraderInterface $passwordUpgrader)
|
||||
public function __construct(private ?string $plaintextApiToken, private readonly PasswordUpgraderInterface $passwordUpgrader)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -36,9 +31,6 @@ final class ApiTokenUpgradeBadge implements BadgeInterface
|
||||
return $password;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return PasswordUpgraderInterface<User>
|
||||
*/
|
||||
public function getPasswordUpgrader(): PasswordUpgraderInterface
|
||||
{
|
||||
return $this->passwordUpgrader;
|
||||
|
||||
@@ -120,12 +120,23 @@ class ActivityService
|
||||
// we cannot use max(number) because a varchar column returns unexpected results
|
||||
$start = $this->repository->countActivity();
|
||||
$i = 0;
|
||||
$createDate = new \DateTimeImmutable();
|
||||
|
||||
do {
|
||||
$start++;
|
||||
|
||||
$numberGenerator = new NumberGenerator($format, function (string $originalFormat, string $format, int $increaseBy) use ($start): string|int {
|
||||
$numberGenerator = new NumberGenerator($format, function (string $originalFormat, string $format, int $increaseBy) use ($start, $createDate): string|int {
|
||||
return match ($format) {
|
||||
'Y' => $createDate->format('Y'),
|
||||
'y' => $createDate->format('y'),
|
||||
'M' => $createDate->format('m'),
|
||||
'm' => $createDate->format('n'),
|
||||
'D' => $createDate->format('d'),
|
||||
'd' => $createDate->format('j'),
|
||||
'YY' => (int) $createDate->format('Y') + $increaseBy,
|
||||
'yy' => (int) $createDate->format('y') + $increaseBy,
|
||||
'MM' => (int) $createDate->format('m') + $increaseBy,
|
||||
'DD' => (int) $createDate->format('d') + $increaseBy,
|
||||
'ac' => $start + $increaseBy,
|
||||
default => $originalFormat,
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Constants;
|
||||
use App\Event\EmailEvent;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
@@ -19,7 +20,7 @@ use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Mime\Email;
|
||||
|
||||
#[AsCommand(name: 'kimai:mail:test', description: 'Send a test email')]
|
||||
#[AsCommand(name: 'kimai:mail:test', description: 'Send a test email using MAILER_URL and MAILER_FROM')]
|
||||
final class MailTestCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly EventDispatcherInterface $dispatcher)
|
||||
@@ -30,16 +31,24 @@ final class MailTestCommand extends Command
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addArgument('to', InputArgument::REQUIRED, 'The email address to send the email to');
|
||||
$this->addOption('from', null, InputOption::VALUE_OPTIONAL, 'The sender of the message', 'kimai@example.org');
|
||||
$this->addOption('from', null, InputOption::VALUE_OPTIONAL, 'Deprecated: uses the MAILER_FROM env variable.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$to = $input->getArgument('to');
|
||||
if (!\is_string($to) || $to === '') {
|
||||
throw new \InvalidArgumentException('Need a non-empty "to" address');
|
||||
}
|
||||
|
||||
if ($input->getOption('from') !== null) {
|
||||
throw new \InvalidArgumentException('The "from" option is deprecated and will be ignored');
|
||||
}
|
||||
|
||||
$message = new Email();
|
||||
$message->to((string) $input->getArgument('to')); // @phpstan-ignore-line
|
||||
$message->from((string) $input->getOption('from')); // @phpstan-ignore-line
|
||||
$message->subject('Kimai test email');
|
||||
$message->text('This is an email for testing the text body.');
|
||||
$message->to($to);
|
||||
$message->subject('Test email - ' . Constants::SOFTWARE);
|
||||
$message->text('This is a test email from your time-tracker');
|
||||
|
||||
$this->dispatcher->dispatch(new EmailEvent($message));
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ final class RegenerateLocalesCommand extends Command
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
private array $noRegionCode = ['ar', 'id', 'pa', 'sl', 'ca'];
|
||||
private array $noRegionCode = ['ar', 'id', 'pa', 'sl', 'ca', 'ta'];
|
||||
/**
|
||||
* A list of locales that will be activated, no matter if translation files exist for them.
|
||||
*
|
||||
@@ -47,11 +47,11 @@ final class RegenerateLocalesCommand extends Command
|
||||
*/
|
||||
private array $addLocaleToList = ['zh_Hant_TW'];
|
||||
/**
|
||||
* A list of locales that will NOT be activated, as no translations exist by now.
|
||||
* A list of locales that will NOT be activated, as not enough translations exist by now.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
private array $skipLocale = ['ca'];
|
||||
private array $skipLocale = ['ca', 'et'];
|
||||
|
||||
public function __construct(
|
||||
private readonly string $projectDirectory,
|
||||
@@ -86,6 +86,7 @@ final class RegenerateLocalesCommand extends Command
|
||||
$firstLevelLocales[] = $l;
|
||||
}
|
||||
$firstLevelLocales = array_unique(array_merge($firstLevelLocales, $this->addLocaleToList));
|
||||
|
||||
$io->title('First level locales found');
|
||||
$io->writeln(implode('|', $firstLevelLocales));
|
||||
|
||||
@@ -198,8 +199,23 @@ final class RegenerateLocalesCommand extends Command
|
||||
|
||||
// in the future this list should be reduced to the list of available translations, but for a long time users
|
||||
// could choose from the entire list of all locales, so we likely have to keep that forever ...
|
||||
$io->title('List of "kimai_locales" for services.yaml');
|
||||
$io->writeln("['" . implode("', '", $locales) . "']");
|
||||
$listOfLocales = array_map(fn ($locale) => "'$locale'", $locales);
|
||||
$filename = 'config/services.yaml';
|
||||
$targetFile = $this->projectDirectory . DIRECTORY_SEPARATOR . $filename;
|
||||
$content = file_get_contents($targetFile);
|
||||
if ($content === false) {
|
||||
$io->error('Failed reading configuration file at ' . $filename);
|
||||
} else {
|
||||
$content = preg_replace(
|
||||
'/^(\s*kimai_locales:\s*\[).*?(\])$/m',
|
||||
'${1}' . implode(', ', $listOfLocales) . '${2}',
|
||||
$content
|
||||
);
|
||||
|
||||
file_put_contents($targetFile, $content);
|
||||
|
||||
$io->success('Replaced locale definitions in: ' . $filename);
|
||||
}
|
||||
|
||||
ksort($appLocales);
|
||||
|
||||
|
||||
@@ -454,10 +454,12 @@ final class TranslationCommand extends Command
|
||||
|
||||
foreach ($xml->file->body->{'trans-unit'} as $unit) {
|
||||
$source = $unit->source;
|
||||
if (!isset($unit['resname'])) {
|
||||
if (!isset($unit['resname']) && $source !== null) {
|
||||
$unit['resname'] = $source;
|
||||
}
|
||||
$unit['id'] = $this->generateId($unit['resname']);
|
||||
if ($unit['resname'] !== null) {
|
||||
$unit['id'] = $this->generateId($unit['resname']); // @phpstan-ignore offsetAssign.valueType
|
||||
}
|
||||
}
|
||||
|
||||
$xmlDocument = new \DOMDocument('1.0');
|
||||
@@ -491,7 +493,7 @@ final class TranslationCommand extends Command
|
||||
);
|
||||
}
|
||||
$unit->target[0] = $translations[$key];
|
||||
$unit->target['state'] = 'needs-translation';
|
||||
$unit->target['state'] = 'needs-translation'; // @phpstan-ignore assign.propertyType
|
||||
$foundEmpty = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,11 +17,11 @@ final class Constants
|
||||
/**
|
||||
* The current release version
|
||||
*/
|
||||
public const VERSION = '2.29.0';
|
||||
public const VERSION = '2.33.0';
|
||||
/**
|
||||
* The current release: major * 10000 + minor * 100 + patch
|
||||
*/
|
||||
public const VERSION_ID = 22900;
|
||||
public const VERSION_ID = 23300;
|
||||
/**
|
||||
* The software name
|
||||
*/
|
||||
|
||||
@@ -63,6 +63,8 @@ final class ContractController extends AbstractController
|
||||
|
||||
/** @var \DateTime $yearDate */
|
||||
$yearDate = $values->getDate();
|
||||
// make sure we use the correct datetime for the selected user
|
||||
$yearDate = $this->getDateTimeFactory($profile)->createStartOfYear($yearDate);
|
||||
$year = $workingTimeService->getYear($profile, $yearDate, $now);
|
||||
|
||||
$page = new PageSetup('work_times');
|
||||
|
||||
@@ -38,7 +38,11 @@ final class DashboardController extends AbstractController
|
||||
*/
|
||||
private ?array $widgets = null;
|
||||
|
||||
public function __construct(private EventDispatcherInterface $eventDispatcher, private WidgetService $service, private BookmarkRepository $repository)
|
||||
public function __construct(
|
||||
private readonly EventDispatcherInterface $eventDispatcher,
|
||||
private readonly WidgetService $service,
|
||||
private readonly BookmarkRepository $repository
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -164,6 +164,7 @@ final class InvoiceController extends AbstractController
|
||||
try {
|
||||
$query->setCustomers([$customer]);
|
||||
$model = $this->service->createModel($query);
|
||||
$model->setPreview(true);
|
||||
|
||||
return $this->service->renderInvoice($model, $this->dispatcher, true);
|
||||
} catch (Exception $ex) {
|
||||
|
||||
@@ -34,11 +34,20 @@ final class PluginController extends AbstractController
|
||||
$page = new PageSetup('menu.plugin');
|
||||
$page->setHelp('plugins.html');
|
||||
|
||||
$all = $this->getPluginInformation($client, $cache);
|
||||
$bundles = [];
|
||||
foreach ($all as $item) {
|
||||
if ($item['bundle'] !== null) {
|
||||
$bundles[$item['bundle']] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('plugin/index.html.twig', [
|
||||
'page_setup' => $page,
|
||||
'plugins' => $plugins,
|
||||
'installed' => $installed,
|
||||
'extensions' => $this->getPluginInformation($client, $cache)
|
||||
'extensions' => $all,
|
||||
'bundles' => $bundles,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -59,11 +59,12 @@ final class ProjectDateRangeController extends AbstractController
|
||||
$byCustomer[$customer->getId()]['projects'][] = $entry;
|
||||
}
|
||||
|
||||
return $this->render('reporting/project_daterange.html.twig', [
|
||||
return $this->render('reporting/project/daterange.html.twig', [
|
||||
'report_title' => 'report_project_daterange',
|
||||
'entries' => $byCustomer,
|
||||
'form' => $form->createView(),
|
||||
'queryEnd' => $dateRange->getEnd(),
|
||||
'queryBegin' => $begin,
|
||||
'queryEnd' => $end,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,11 +106,11 @@ abstract class TimesheetAbstractController extends AbstractController
|
||||
}
|
||||
|
||||
if ($canSeeUsername) {
|
||||
$table->addColumn('username', ['class' => 'd-none d-md-table-cell', 'orderBy' => false]);
|
||||
$table->addColumn('username', ['class' => 'd-none d-md-table-cell', 'orderBy' => 'user']);
|
||||
}
|
||||
|
||||
$table->addColumn('billable', ['class' => 'text-center d-none w-min', 'orderBy' => false]);
|
||||
$table->addColumn('exported', ['class' => 'text-center d-none w-min', 'orderBy' => false]);
|
||||
$table->addColumn('billable', ['class' => 'text-center d-none w-min']);
|
||||
$table->addColumn('exported', ['class' => 'text-center d-none w-min']);
|
||||
$table->addColumn('actions', ['class' => 'actions']);
|
||||
|
||||
$page = $this->createPageSetup();
|
||||
|
||||
@@ -45,6 +45,7 @@ final class TimesheetTeamController extends TimesheetAbstractController
|
||||
public function indexAction(int $page, Request $request): Response
|
||||
{
|
||||
$query = $this->createDefaultQuery();
|
||||
$query->addAllowedOrderColumns('user');
|
||||
$query->setPage($page);
|
||||
|
||||
return $this->index($query, $request, 'admin_timesheet', 'admin_timesheet_paginated', TimesheetMetaDisplayEvent::TEAM_TIMESHEET);
|
||||
|
||||
@@ -137,12 +137,23 @@ final class CustomerService
|
||||
// we cannot use max(number) because a varchar column returns unexpected results
|
||||
$start = $this->repository->countCustomer();
|
||||
$i = 0;
|
||||
$createDate = new \DateTimeImmutable();
|
||||
|
||||
do {
|
||||
$start++;
|
||||
|
||||
$numberGenerator = new NumberGenerator($format, function (string $originalFormat, string $format, int $increaseBy) use ($start): string|int {
|
||||
$numberGenerator = new NumberGenerator($format, function (string $originalFormat, string $format, int $increaseBy) use ($start, $createDate): string|int {
|
||||
return match ($format) {
|
||||
'Y' => $createDate->format('Y'),
|
||||
'y' => $createDate->format('y'),
|
||||
'M' => $createDate->format('m'),
|
||||
'm' => $createDate->format('n'),
|
||||
'D' => $createDate->format('d'),
|
||||
'd' => $createDate->format('j'),
|
||||
'YY' => (int) $createDate->format('Y') + $increaseBy,
|
||||
'yy' => (int) $createDate->format('y') + $increaseBy,
|
||||
'MM' => (int) $createDate->format('m') + $increaseBy,
|
||||
'DD' => (int) $createDate->format('d') + $increaseBy,
|
||||
'cc' => $start + $increaseBy,
|
||||
default => $originalFormat,
|
||||
};
|
||||
|
||||
@@ -80,7 +80,6 @@ class Invoice implements EntityWithMetaFields
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'date', type: 'datetime')]
|
||||
private ?\DateTime $createdAt = null;
|
||||
#[ORM\Column(name: 'timezone', type: 'string', length: 64, nullable: false)]
|
||||
private ?string $timezone = null;
|
||||
@@ -176,6 +175,7 @@ class Invoice implements EntityWithMetaFields
|
||||
return $this->total;
|
||||
}
|
||||
|
||||
#[Exporter\Expose(name: 'createdAt', label: 'date', type: 'datetime')]
|
||||
public function getCreatedAt(): ?\DateTime
|
||||
{
|
||||
if (!$this->localized) {
|
||||
|
||||
@@ -30,9 +30,11 @@ class InvoiceTemplate
|
||||
#[Assert\Length(min: 1, max: 60)]
|
||||
private ?string $name = null;
|
||||
#[ORM\Column(name: 'title', type: 'string', length: 255, nullable: false)]
|
||||
#[Assert\Length(max: 255)]
|
||||
#[Assert\NotBlank]
|
||||
private ?string $title = null;
|
||||
#[ORM\Column(name: 'company', type: 'string', length: 255, nullable: false)]
|
||||
#[Assert\Length(max: 255)]
|
||||
#[Assert\NotBlank]
|
||||
private ?string $company = null;
|
||||
#[ORM\Column(name: 'vat_id', type: 'string', length: 50, nullable: true)]
|
||||
|
||||
@@ -87,7 +87,6 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
#[Serializer\Groups(['Project_Entity'])]
|
||||
#[Serializer\Type(name: "DateTime<'Y-m-d'>")]
|
||||
#[Serializer\Accessor(getter: 'getOrderDate')]
|
||||
#[Exporter\Expose(label: 'orderDate', type: 'datetime')]
|
||||
private ?\DateTime $orderDate = null;
|
||||
/**
|
||||
* Project start date (times before this date cannot be recorded)
|
||||
@@ -99,7 +98,6 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
#[Serializer\Groups(['Project'])]
|
||||
#[Serializer\Type(name: "DateTime<'Y-m-d'>")]
|
||||
#[Serializer\Accessor(getter: 'getStart')]
|
||||
#[Exporter\Expose(label: 'project_start', type: 'datetime')]
|
||||
private ?\DateTime $start = null;
|
||||
/**
|
||||
* Project end time (times after this date cannot be recorded)
|
||||
@@ -111,7 +109,6 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
#[Serializer\Groups(['Project'])]
|
||||
#[Serializer\Type(name: "DateTime<'Y-m-d'>")]
|
||||
#[Serializer\Accessor(getter: 'getEnd')]
|
||||
#[Exporter\Expose(label: 'project_end', type: 'datetime')]
|
||||
private ?\DateTime $end = null;
|
||||
#[ORM\Column(name: 'timezone', type: 'string', length: 64, nullable: true)]
|
||||
private ?string $timezone = null;
|
||||
@@ -291,6 +288,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
$this->localized = true;
|
||||
}
|
||||
|
||||
#[Exporter\Expose(name: 'orderDate', label: 'orderDate', type: 'date')]
|
||||
public function getOrderDate(): ?\DateTime
|
||||
{
|
||||
$this->localizeDates();
|
||||
@@ -309,6 +307,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[Exporter\Expose(name: 'start', label: 'project_start', type: 'date')]
|
||||
public function getStart(): ?\DateTime
|
||||
{
|
||||
$this->localizeDates();
|
||||
@@ -327,6 +326,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[Exporter\Expose(name: 'end', label: 'project_end', type: 'date')]
|
||||
public function getEnd(): ?\DateTime
|
||||
{
|
||||
$this->localizeDates();
|
||||
|
||||
@@ -15,7 +15,7 @@ use Symfony\Contracts\EventDispatcher\Event;
|
||||
|
||||
final class InvoiceCreatedEvent extends Event
|
||||
{
|
||||
public function __construct(private Invoice $invoice, private InvoiceModel $model)
|
||||
public function __construct(private readonly Invoice $invoice, private readonly InvoiceModel $model)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,9 @@ final class InvoiceDocumentsEvent extends Event
|
||||
$this->documents = $documents;
|
||||
}
|
||||
|
||||
/**
|
||||
* @CloudRequired
|
||||
*/
|
||||
public function setMaximumAllowedDocuments(int $max): void
|
||||
{
|
||||
$this->maximum = $max;
|
||||
|
||||
@@ -16,7 +16,11 @@ use Symfony\Contracts\EventDispatcher\Event;
|
||||
|
||||
final class InvoicePreRenderEvent extends Event
|
||||
{
|
||||
public function __construct(private InvoiceModel $model, private InvoiceDocument $document, private RendererInterface $renderer)
|
||||
public function __construct(
|
||||
private readonly InvoiceModel $model,
|
||||
private readonly InvoiceDocument $document,
|
||||
private readonly RendererInterface $renderer
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ namespace App\Event;
|
||||
|
||||
use Symfony\Contracts\EventDispatcher\Event;
|
||||
|
||||
/**
|
||||
* @deprecated since 2.32 - use TranslatorInterface directly
|
||||
*/
|
||||
final class ThemeJavascriptTranslationsEvent extends Event
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -15,13 +15,19 @@ use Symfony\Contracts\EventDispatcher\Event;
|
||||
|
||||
final class WorkingTimeApproveMonthEvent extends Event
|
||||
{
|
||||
public function __construct(private User $user, private Month $month, private \DateTimeInterface $approvalDate, private User $approver)
|
||||
public function __construct(
|
||||
private readonly Month $month,
|
||||
private readonly User $approvedBy
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use getMonth()->getUser() instead)
|
||||
*/
|
||||
public function getUser(): User
|
||||
{
|
||||
return $this->user;
|
||||
return $this->getMonth()->getUser();
|
||||
}
|
||||
|
||||
public function getMonth(): Month
|
||||
@@ -29,13 +35,8 @@ final class WorkingTimeApproveMonthEvent extends Event
|
||||
return $this->month;
|
||||
}
|
||||
|
||||
public function getApprovalDate(): \DateTimeInterface
|
||||
public function getApprovedBy(): User
|
||||
{
|
||||
return $this->approvalDate;
|
||||
}
|
||||
|
||||
public function getApprover(): User
|
||||
{
|
||||
return $this->approver;
|
||||
return $this->approvedBy;
|
||||
}
|
||||
}
|
||||
|
||||
46
src/Event/WorkingTimeQueryStatsEvent.php
Normal file
46
src/Event/WorkingTimeQueryStatsEvent.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Event;
|
||||
|
||||
use App\Entity\User;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Symfony\Contracts\EventDispatcher\Event;
|
||||
|
||||
final class WorkingTimeQueryStatsEvent extends Event
|
||||
{
|
||||
public function __construct(
|
||||
private readonly QueryBuilder $queryBuilder,
|
||||
private readonly User $user,
|
||||
private readonly \DateTimeInterface $begin,
|
||||
private readonly \DateTimeInterface $end
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
public function getQueryBuilder(): QueryBuilder
|
||||
{
|
||||
return $this->queryBuilder;
|
||||
}
|
||||
|
||||
public function getUser(): User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function getBegin(): \DateTimeInterface
|
||||
{
|
||||
return $this->begin;
|
||||
}
|
||||
|
||||
public function getEnd(): \DateTimeInterface
|
||||
{
|
||||
return $this->end;
|
||||
}
|
||||
}
|
||||
34
src/Event/WorkingTimeUnlockMonthEvent.php
Normal file
34
src/Event/WorkingTimeUnlockMonthEvent.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Event;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\WorkingTime\Model\Month;
|
||||
use Symfony\Contracts\EventDispatcher\Event;
|
||||
|
||||
final class WorkingTimeUnlockMonthEvent extends Event
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Month $month,
|
||||
private readonly User $unlockedBy
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
public function getMonth(): Month
|
||||
{
|
||||
return $this->month;
|
||||
}
|
||||
|
||||
public function getUnlockedBy(): User
|
||||
{
|
||||
return $this->unlockedBy;
|
||||
}
|
||||
}
|
||||
@@ -50,16 +50,21 @@ final class ProjectSubscriber extends AbstractActionsSubscriber
|
||||
$event->addDivider();
|
||||
}
|
||||
|
||||
$dateRange = '';
|
||||
if (isset($payload['daterange']) && \is_string($payload['daterange'])) {
|
||||
$dateRange = $payload['daterange'];
|
||||
}
|
||||
|
||||
if ($this->isGranted('view_activity')) {
|
||||
$event->addActionToSubmenu('filter', 'activity', ['title' => 'activities', 'url' => $this->path('admin_activity', ['customers[]' => $customer->getId(), 'projects[]' => $project->getId()])]);
|
||||
}
|
||||
|
||||
if ($this->isGranted('view_other_timesheet')) {
|
||||
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'url' => $this->path('admin_timesheet', ['customers[]' => $customer->getId(), 'projects[]' => $project->getId()])]);
|
||||
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'url' => $this->path('admin_timesheet', ['customers[]' => $customer->getId(), 'projects[]' => $project->getId(), 'daterange' => $dateRange])]);
|
||||
}
|
||||
|
||||
if ($this->isGranted('create_export')) {
|
||||
$event->addActionToSubmenu('filter', 'export', ['title' => 'export', 'url' => $this->path('export', ['customers[]' => $customer->getId(), 'projects[]' => $project->getId(), 'exported' => 1, 'daterange' => ''])]);
|
||||
$event->addActionToSubmenu('filter', 'export', ['title' => 'export', 'url' => $this->path('export', ['customers[]' => $customer->getId(), 'projects[]' => $project->getId(), 'exported' => 1, 'daterange' => $dateRange])]);
|
||||
}
|
||||
|
||||
if ($event->hasSubmenu('filter')) {
|
||||
|
||||
@@ -43,10 +43,10 @@ class UserDetailsSubscriber implements EventSubscriberInterface
|
||||
$event->setUser($user);
|
||||
|
||||
if ($this->auth->isGranted('view', $user)) {
|
||||
$event->addLink(new MenuItemModel('user_profile', 'my.profile', 'user_profile', ['username' => $user->getUserIdentifier()]));
|
||||
$event->addLink(new MenuItemModel('user_profile', 'user_profile', 'user_profile', ['username' => $user->getUserIdentifier()]));
|
||||
}
|
||||
if ($this->auth->isGranted('edit', $user)) {
|
||||
$event->addLink(new MenuItemModel('user_profile_edit', 'action.edit', 'user_profile_edit', ['username' => $user->getUserIdentifier()]));
|
||||
$event->addLink(new MenuItemModel('user_profile_edit', 'edit', 'user_profile_edit', ['username' => $user->getUserIdentifier()]));
|
||||
}
|
||||
if ($this->auth->isGranted('password', $user)) {
|
||||
$event->addLink(new MenuItemModel('password', 'profile.password', 'user_profile_password', ['username' => $user->getUserIdentifier()]));
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace App\Export\Base;
|
||||
use App\Entity\ExportableItem;
|
||||
use App\Export\ExportFilename;
|
||||
use App\Export\Package\CellFormatter\DateStringFormatter;
|
||||
use App\Export\Package\CellFormatter\DurationPlainFormatter;
|
||||
use App\Export\Package\SpoutSpreadsheet;
|
||||
use App\Export\RendererInterface;
|
||||
use App\Export\TimesheetExportInterface;
|
||||
@@ -71,6 +72,7 @@ final class CsvRenderer implements RendererInterface, TimesheetExportInterface
|
||||
$spreadsheet->open($filename);
|
||||
|
||||
$this->spreadsheetRenderer->registerFormatter('date', new DateStringFormatter());
|
||||
$this->spreadsheetRenderer->registerFormatter('duration', new DurationPlainFormatter());
|
||||
$this->spreadsheetRenderer->writeSpreadsheet($spreadsheet, $exportItems, $query);
|
||||
|
||||
return new \SplFileInfo($filename);
|
||||
|
||||
@@ -11,6 +11,7 @@ namespace App\Export\Base;
|
||||
|
||||
use App\Entity\ExportableItem;
|
||||
use App\Entity\MetaTableTypeInterface;
|
||||
use App\Entity\User;
|
||||
use App\Event\ActivityMetaDisplayEvent;
|
||||
use App\Event\CustomerMetaDisplayEvent;
|
||||
use App\Event\MetaDisplayEventInterface;
|
||||
@@ -22,6 +23,7 @@ use App\Export\Package\CellFormatter\BooleanFormatter;
|
||||
use App\Export\Package\CellFormatter\CellFormatterInterface;
|
||||
use App\Export\Package\CellFormatter\DateFormatter;
|
||||
use App\Export\Package\CellFormatter\DefaultFormatter;
|
||||
use App\Export\Package\CellFormatter\DurationDecimalFormatter;
|
||||
use App\Export\Package\CellFormatter\DurationFormatter;
|
||||
use App\Export\Package\CellFormatter\RateFormatter;
|
||||
use App\Export\Package\CellFormatter\TextFormatter;
|
||||
@@ -103,7 +105,7 @@ final class SpreadsheetRenderer
|
||||
$formula = null;
|
||||
if (\in_array($column->getName(), $totalColumns)) {
|
||||
$columnName = $columnNames[$totalColumn - 1];
|
||||
$formula = \sprintf('=SUM(%s2:%s%s)', $columnName, $columnName, $currentRow);
|
||||
$formula = \sprintf('=SUBTOTAL(9,%s2:%s%s)', $columnName, $columnName, $currentRow);
|
||||
}
|
||||
$totalRow[] = $formula;
|
||||
$totalColumn++;
|
||||
@@ -130,6 +132,7 @@ final class SpreadsheetRenderer
|
||||
'date' => new DateFormatter(),
|
||||
'time' => new TimeFormatter(),
|
||||
'duration' => new DurationFormatter(),
|
||||
'duration_decimal' => new DurationDecimalFormatter(),
|
||||
default => new DefaultFormatter()
|
||||
};
|
||||
}
|
||||
@@ -141,12 +144,17 @@ final class SpreadsheetRenderer
|
||||
{
|
||||
$showRates = $this->isRenderRate($query);
|
||||
|
||||
$durationFormatter = 'duration';
|
||||
if (($user = $this->voter->getUser()) instanceof User) {
|
||||
$durationFormatter = $user->isExportDecimal() ? 'duration_decimal' : 'duration';
|
||||
}
|
||||
|
||||
$columns = [];
|
||||
|
||||
$columns[] = (new Column('date', $this->getFormatter('date')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getBegin());
|
||||
$columns[] = (new Column('begin', $this->getFormatter('time')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getBegin())->withColumnWidth(ColumnWidth::SMALL);
|
||||
$columns[] = (new Column('end', $this->getFormatter('time')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getEnd())->withColumnWidth(ColumnWidth::SMALL);
|
||||
$columns[] = (new Column('duration', $this->getFormatter('duration')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL);
|
||||
$columns[] = (new Column('duration', $this->getFormatter($durationFormatter)))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL);
|
||||
|
||||
if ($showRates) {
|
||||
$columns[] = (new Column('currency', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getCurrency())->withColumnWidth(ColumnWidth::SMALL);
|
||||
@@ -156,7 +164,8 @@ final class SpreadsheetRenderer
|
||||
$columns[] = (new Column('fixedRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getFixedRate());
|
||||
}
|
||||
|
||||
$columns[] = (new Column('username', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getDisplayName())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
$columns[] = (new Column('alias', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getDisplayName())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
$columns[] = (new Column('username', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getUserIdentifier())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
$columns[] = (new Column('account_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getAccountNumber());
|
||||
$columns[] = (new Column('customer', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
$columns[] = (new Column('project', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
|
||||
15
src/Export/Package/CellFormatter/CellWithFormatInterface.php
Normal file
15
src/Export/Package/CellFormatter/CellWithFormatInterface.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Export\Package\CellFormatter;
|
||||
|
||||
interface CellWithFormatInterface
|
||||
{
|
||||
public function getFormat(): string;
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
namespace App\Export\Package\CellFormatter;
|
||||
|
||||
final class DateFormatter implements CellFormatterInterface
|
||||
final class DateFormatter implements CellFormatterInterface, CellWithFormatInterface
|
||||
{
|
||||
public function formatValue(mixed $value): mixed
|
||||
{
|
||||
@@ -23,4 +23,9 @@ final class DateFormatter implements CellFormatterInterface
|
||||
|
||||
throw new \InvalidArgumentException('Only DateTimeInterface can be formatted');
|
||||
}
|
||||
|
||||
public function getFormat(): string
|
||||
{
|
||||
return 'yyyy-mm-dd';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Export\Package\CellFormatter;
|
||||
|
||||
use App\Utils\Duration;
|
||||
|
||||
final class DurationDecimalFormatter implements CellFormatterInterface
|
||||
{
|
||||
private Duration $duration;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->duration = new Duration();
|
||||
}
|
||||
|
||||
public function formatValue(mixed $value): mixed
|
||||
{
|
||||
if (is_numeric($value)) {
|
||||
return $this->duration->formatDecimal((int) $value);
|
||||
}
|
||||
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
@@ -9,14 +9,34 @@
|
||||
|
||||
namespace App\Export\Package\CellFormatter;
|
||||
|
||||
final class DurationFormatter implements CellFormatterInterface
|
||||
final class DurationFormatter implements CellFormatterInterface, CellWithFormatInterface
|
||||
{
|
||||
public function formatValue(mixed $value): mixed
|
||||
{
|
||||
if (is_numeric($value)) {
|
||||
return (float) number_format($value / 3600, 2, '.', '');
|
||||
if (!is_numeric($value) || (int) $value === 0) {
|
||||
return new \DateInterval('PT0S');
|
||||
}
|
||||
|
||||
return 0.0;
|
||||
$value = (int) $value;
|
||||
|
||||
$seconds = abs($value);
|
||||
$hours = intdiv($seconds, 3600);
|
||||
$seconds %= 3600;
|
||||
$minutes = intdiv($seconds, 60);
|
||||
$seconds %= 60;
|
||||
|
||||
$intervalSpec = \sprintf('PT%dH%dM%dS', $hours, $minutes, $seconds);
|
||||
$interval = new \DateInterval($intervalSpec);
|
||||
|
||||
if ($value < 0) {
|
||||
$interval->invert = 1;
|
||||
}
|
||||
|
||||
return $interval;
|
||||
}
|
||||
|
||||
public function getFormat(): string
|
||||
{
|
||||
return '[hh]:mm:ss';
|
||||
}
|
||||
}
|
||||
|
||||
37
src/Export/Package/CellFormatter/DurationPlainFormatter.php
Normal file
37
src/Export/Package/CellFormatter/DurationPlainFormatter.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Export\Package\CellFormatter;
|
||||
|
||||
final class DurationPlainFormatter implements CellFormatterInterface
|
||||
{
|
||||
public function formatValue(mixed $value): mixed
|
||||
{
|
||||
if (!is_numeric($value) || (int) $value === 0) {
|
||||
return '0:00:00';
|
||||
}
|
||||
|
||||
$value = (int) $value;
|
||||
|
||||
$seconds = abs($value);
|
||||
$hours = intdiv($seconds, 3600);
|
||||
$seconds %= 3600;
|
||||
$minutes = intdiv($seconds, 60);
|
||||
$seconds %= 60;
|
||||
|
||||
$intervalSpec = \sprintf('PT%dH%dM%dS', $hours, $minutes, $seconds);
|
||||
$interval = new \DateInterval($intervalSpec);
|
||||
|
||||
if ($value < 0) {
|
||||
$interval->invert = 1;
|
||||
}
|
||||
|
||||
return $interval->format('%r%h:%I:%S');
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ namespace App\Export\Package;
|
||||
|
||||
use App\Entity\ExportableItem;
|
||||
use App\Export\Package\CellFormatter\CellFormatterInterface;
|
||||
use App\Export\Package\CellFormatter\CellWithFormatInterface;
|
||||
|
||||
class Column
|
||||
{
|
||||
@@ -71,4 +72,13 @@ class Column
|
||||
{
|
||||
return $this->header ?? $this->name;
|
||||
}
|
||||
|
||||
public function getFormat(): ?string
|
||||
{
|
||||
if ($this->formatter instanceof CellWithFormatInterface) {
|
||||
return $this->formatter->getFormat();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,8 @@ use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class SpoutSpreadsheet implements SpreadsheetPackage
|
||||
{
|
||||
private Style $dateStyle;
|
||||
/** @var array<int, Style|null> */
|
||||
private array $styles = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly WriterInterface $writer,
|
||||
@@ -34,7 +35,6 @@ class SpoutSpreadsheet implements SpreadsheetPackage
|
||||
)
|
||||
{
|
||||
$this->writer->setCreator(Constants::SOFTWARE);
|
||||
$this->dateStyle = (new Style())->setFormat('yyyy-mm-dd');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,9 +48,15 @@ class SpoutSpreadsheet implements SpreadsheetPackage
|
||||
}
|
||||
|
||||
$tmp = [];
|
||||
$i = 0;
|
||||
foreach ($columns as $column) {
|
||||
$title = $this->translator->trans($column->getHeader());
|
||||
$tmp[] = Cell::fromValue($title);
|
||||
$style = null;
|
||||
if ($column->getFormat() !== null) {
|
||||
$style = (new Style())->setFormat($column->getFormat());
|
||||
}
|
||||
$this->styles[$i++] = $style;
|
||||
}
|
||||
|
||||
$style = new Style();
|
||||
@@ -100,12 +106,9 @@ class SpoutSpreadsheet implements SpreadsheetPackage
|
||||
}
|
||||
|
||||
$tmp = [];
|
||||
$i = 0;
|
||||
foreach ($columns as $column) {
|
||||
if ($column instanceof \DateTimeInterface) {
|
||||
$tmp[] = Cell::fromValue($column, $this->dateStyle);
|
||||
} else {
|
||||
$tmp[] = Cell::fromValue($column); // @phpstan-ignore argument.type
|
||||
}
|
||||
$tmp[] = Cell::fromValue($column, $this->styles[$i++]); // @phpstan-ignore argument.type
|
||||
}
|
||||
|
||||
$this->writer->addRow(new Row($tmp, $style));
|
||||
|
||||
38
src/Form/DataTransformer/EntityByIdTransformer.php
Normal file
38
src/Form/DataTransformer/EntityByIdTransformer.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Form\DataTransformer;
|
||||
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Symfony\Component\Form\DataTransformerInterface;
|
||||
|
||||
final class EntityByIdTransformer implements DataTransformerInterface // @phpstan-ignore missingType.generics
|
||||
{
|
||||
public function __construct(private readonly EntityRepository $repository) // @phpstan-ignore missingType.generics
|
||||
{
|
||||
}
|
||||
|
||||
public function transform(mixed $value): mixed
|
||||
{
|
||||
if (is_numeric($value)) {
|
||||
return $this->repository->find($value);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
public function reverseTransform(mixed $value): mixed
|
||||
{
|
||||
if (\is_object($value) && method_exists($value, 'getId') && $value->getId() !== null) {
|
||||
return (string) $value->getId();
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,7 @@ class MultiUpdateTableDTO
|
||||
|
||||
public function addUpdate(string $url): MultiUpdateTableDTO
|
||||
{
|
||||
$this->actions['action.edit'] = $url;
|
||||
$this->actions['edit'] = $url;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ use App\Form\Type\DescriptionType;
|
||||
use App\Form\Type\DurationType;
|
||||
use App\Form\Type\FixedRateType;
|
||||
use App\Form\Type\HourlyRateType;
|
||||
use App\Form\Type\InternalRateType;
|
||||
use App\Form\Type\MetaFieldsCollectionType;
|
||||
use App\Form\Type\TagsType;
|
||||
use App\Form\Type\TimePickerType;
|
||||
@@ -206,15 +207,22 @@ class TimesheetEditForm extends AbstractType
|
||||
|
||||
/** @var \DateTime|null $date */
|
||||
$date = $event->getForm()->get('begin_date')->getData();
|
||||
/** @var \DateTime|null $time */
|
||||
$time = $event->getForm()->get('begin_time')->getData();
|
||||
|
||||
if ($date === null || $time === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if the user did not change the time, make sure to keep the seconds
|
||||
$seconds = 0;
|
||||
if ($data->getBegin()?->format('H:i') === $time->format('H:i')) {
|
||||
$seconds = $data->getBegin()->format('s') ?? 0;
|
||||
}
|
||||
|
||||
// mutable datetime are a problem for doctrine
|
||||
$newDate = clone $date;
|
||||
$newDate->setTime($time->format('H'), $time->format('i'));
|
||||
$newDate->setTime((int) $time->format('H'), (int) $time->format('i'), (int) $seconds);
|
||||
|
||||
if ($data->getBegin() === null || $data->getBegin()->getTimestamp() !== $newDate->getTimestamp()) {
|
||||
$data->setBegin($newDate);
|
||||
@@ -250,6 +258,7 @@ class TimesheetEditForm extends AbstractType
|
||||
$timesheet = $event->getData();
|
||||
$oldEnd = $timesheet->getEnd();
|
||||
|
||||
/** @var \DateTime|null $end */
|
||||
$end = $event->getForm()->get('end_time')->getData();
|
||||
if ($end === null || $end === false) {
|
||||
$timesheet->setEnd(null);
|
||||
@@ -266,8 +275,15 @@ class TimesheetEditForm extends AbstractType
|
||||
if ($time === null) {
|
||||
throw new \Exception('Cannot work with timesheets without start time');
|
||||
}
|
||||
|
||||
// if the user did not change the time, make sure to keep the seconds
|
||||
$seconds = 0;
|
||||
if ($oldEnd !== null && $oldEnd->format('H:i') === $end->format('H:i')) {
|
||||
$seconds = $oldEnd->format('s') ?? 0;
|
||||
}
|
||||
|
||||
$newEnd = clone $time;
|
||||
$newEnd->setTime($end->format('H'), $end->format('i'));
|
||||
$newEnd->setTime((int) $end->format('H'), (int) $end->format('i'), (int) $seconds);
|
||||
|
||||
if ($newEnd < $time) {
|
||||
$newEnd->modify('+ 1 day');
|
||||
@@ -310,6 +326,10 @@ class TimesheetEditForm extends AbstractType
|
||||
|
||||
$builder->add('duration', DurationType::class, $durationOptions);
|
||||
|
||||
if ($this->systemConfiguration->isBreakTimeEnabled()) {
|
||||
$builder->add('break', DurationType::class, ['label' => 'break', 'required' => false]);
|
||||
}
|
||||
|
||||
$builder->addEventListener(
|
||||
FormEvents::POST_SET_DATA,
|
||||
function (FormEvent $event) {
|
||||
@@ -338,7 +358,7 @@ class TimesheetEditForm extends AbstractType
|
||||
|
||||
$duration = $timesheet->getDuration() ?? 0;
|
||||
|
||||
// only apply the duration, if the end is not yet set
|
||||
// only apply the duration, if the end is not yet set.
|
||||
// without that check, the end would be overwritten and the real end time would be lost
|
||||
if (($forceApply && $duration > 0) || ($duration > 0 && $timesheet->isRunning())) {
|
||||
$end = clone $timesheet->getBegin();
|
||||
@@ -355,13 +375,13 @@ class TimesheetEditForm extends AbstractType
|
||||
return;
|
||||
}
|
||||
|
||||
$moneyOptions = ['currency' => $currency];
|
||||
|
||||
$builder
|
||||
->add('fixedRate', FixedRateType::class, [
|
||||
'currency' => $currency,
|
||||
])
|
||||
->add('hourlyRate', HourlyRateType::class, [
|
||||
'currency' => $currency,
|
||||
]);
|
||||
->add('fixedRate', FixedRateType::class, $moneyOptions)
|
||||
->add('hourlyRate', HourlyRateType::class, $moneyOptions)
|
||||
//->add('internalRate', InternalRateType::class, $moneyOptions)
|
||||
;
|
||||
}
|
||||
|
||||
protected function addUser(FormBuilderInterface $builder, array $options): void
|
||||
|
||||
@@ -44,8 +44,12 @@ final class TimesheetToolbarForm extends AbstractType
|
||||
$this->addExportStateChoice($builder);
|
||||
$this->addPageSizeChoice($builder);
|
||||
$this->addHiddenPagination($builder);
|
||||
$this->addOrder($builder);
|
||||
$this->addOrderBy($builder, TimesheetQuery::TIMESHEET_ORDER_ALLOWED);
|
||||
|
||||
$query = $options['data'];
|
||||
if ($query instanceof TimesheetQuery) {
|
||||
$this->addOrder($builder);
|
||||
$this->addOrderBy($builder, $query->getAllowedOrderColumns());
|
||||
}
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
|
||||
32
src/Form/Type/ActivityByIdType.php
Normal file
32
src/Form/Type/ActivityByIdType.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Form\Type;
|
||||
|
||||
use App\Form\DataTransformer\EntityByIdTransformer;
|
||||
use App\Repository\ActivityRepository;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
|
||||
final class ActivityByIdType extends AbstractType
|
||||
{
|
||||
public function __construct(private readonly ActivityRepository $repository)
|
||||
{
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder->addModelTransformer(new EntityByIdTransformer($this->repository));
|
||||
}
|
||||
|
||||
public function getParent(): string
|
||||
{
|
||||
return ActivityType::class;
|
||||
}
|
||||
}
|
||||
32
src/Form/Type/CustomerByIdType.php
Normal file
32
src/Form/Type/CustomerByIdType.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Form\Type;
|
||||
|
||||
use App\Form\DataTransformer\EntityByIdTransformer;
|
||||
use App\Repository\CustomerRepository;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
|
||||
final class CustomerByIdType extends AbstractType
|
||||
{
|
||||
public function __construct(private readonly CustomerRepository $repository)
|
||||
{
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder->addModelTransformer(new EntityByIdTransformer($this->repository));
|
||||
}
|
||||
|
||||
public function getParent(): string
|
||||
{
|
||||
return CustomerType::class;
|
||||
}
|
||||
}
|
||||
38
src/Form/Type/InternalRateType.php
Normal file
38
src/Form/Type/InternalRateType.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Form\Type;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* Custom form field type to set the internal rate.
|
||||
*/
|
||||
final class InternalRateType extends AbstractType
|
||||
{
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
// documentation is for NelmioApiDocBundle
|
||||
'documentation' => [
|
||||
'type' => 'number',
|
||||
'description' => 'Internal (hourly) rate',
|
||||
],
|
||||
'required' => false,
|
||||
'label' => 'internalRate',
|
||||
]);
|
||||
}
|
||||
|
||||
public function getParent(): string
|
||||
{
|
||||
return MoneyType::class;
|
||||
}
|
||||
}
|
||||
32
src/Form/Type/ProjectByIdType.php
Normal file
32
src/Form/Type/ProjectByIdType.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Form\Type;
|
||||
|
||||
use App\Form\DataTransformer\EntityByIdTransformer;
|
||||
use App\Repository\ProjectRepository;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
|
||||
final class ProjectByIdType extends AbstractType
|
||||
{
|
||||
public function __construct(private readonly ProjectRepository $repository)
|
||||
{
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder->addModelTransformer(new EntityByIdTransformer($this->repository));
|
||||
}
|
||||
|
||||
public function getParent(): string
|
||||
{
|
||||
return ProjectType::class;
|
||||
}
|
||||
}
|
||||
32
src/Form/Type/TeamByIdType.php
Normal file
32
src/Form/Type/TeamByIdType.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Form\Type;
|
||||
|
||||
use App\Form\DataTransformer\EntityByIdTransformer;
|
||||
use App\Repository\TeamRepository;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
|
||||
final class TeamByIdType extends AbstractType
|
||||
{
|
||||
public function __construct(private readonly TeamRepository $repository)
|
||||
{
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder->addModelTransformer(new EntityByIdTransformer($this->repository));
|
||||
}
|
||||
|
||||
public function getParent(): string
|
||||
{
|
||||
return TeamType::class;
|
||||
}
|
||||
}
|
||||
32
src/Form/Type/UserByIdType.php
Normal file
32
src/Form/Type/UserByIdType.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Form\Type;
|
||||
|
||||
use App\Form\DataTransformer\EntityByIdTransformer;
|
||||
use App\Repository\UserRepository;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
|
||||
final class UserByIdType extends AbstractType
|
||||
{
|
||||
public function __construct(private readonly UserRepository $repository)
|
||||
{
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder->addModelTransformer(new EntityByIdTransformer($this->repository));
|
||||
}
|
||||
|
||||
public function getParent(): string
|
||||
{
|
||||
return UserType::class;
|
||||
}
|
||||
}
|
||||
@@ -18,11 +18,17 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
|
||||
|
||||
public function hydrate(InvoiceModel $model): array
|
||||
{
|
||||
$template = $model->getTemplate();
|
||||
if ($template === null) {
|
||||
throw new \InvalidArgumentException('InvoiceModel needs a template');
|
||||
}
|
||||
|
||||
$currency = $model->getCurrency();
|
||||
$tax = $model->getCalculator()->getTax();
|
||||
$total = $model->getCalculator()->getTotal();
|
||||
$subtotal = $model->getCalculator()->getSubtotal();
|
||||
$formatter = $model->getFormatter();
|
||||
$language = $template->getLanguage();
|
||||
|
||||
$values = [
|
||||
'invoice.due_date' => $formatter->getFormattedDateTime($model->getDueDate()),
|
||||
@@ -31,7 +37,7 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
|
||||
'invoice.date_process' => $model->getInvoiceDate()->format(self::DATE_PROCESS_FORMAT), // since 2.14
|
||||
'invoice.number' => $model->getInvoiceNumber(),
|
||||
'invoice.currency' => $currency,
|
||||
'invoice.language' => $model->getTemplate()->getLanguage(), // since 1.9
|
||||
'invoice.language' => $language, // since 1.9
|
||||
'invoice.currency_symbol' => $formatter->getCurrencySymbol($currency),
|
||||
'invoice.vat' => $model->getCalculator()->getVat(),
|
||||
'invoice.tax_hide' => $model->isHideZeroTax() && $tax === 0.00,
|
||||
@@ -47,15 +53,17 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
|
||||
'invoice.subtotal_nc' => $formatter->getFormattedMoney($subtotal, $currency, false),
|
||||
'invoice.subtotal_plain' => $subtotal,
|
||||
|
||||
'template.name' => $model->getTemplate()->getName() ?? '',
|
||||
'template.company' => $model->getTemplate()->getCompany() ?? '',
|
||||
'template.address' => $model->getTemplate()->getAddress() ?? '',
|
||||
'template.title' => $model->getTemplate()->getTitle() ?? '',
|
||||
'template.payment_terms' => $model->getTemplate()->getPaymentTerms() ?? '',
|
||||
'template.due_days' => $model->getTemplate()->getDueDays(),
|
||||
'template.vat_id' => $model->getTemplate()->getVatId() ?? '',
|
||||
'template.contact' => $model->getTemplate()->getContact() ?? '',
|
||||
'template.payment_details' => $model->getTemplate()->getPaymentDetails() ?? '',
|
||||
'template.name' => $template->getName() ?? '',
|
||||
'template.company' => $template->getCompany() ?? '',
|
||||
'template.address' => $template->getAddress() ?? '',
|
||||
'template.title' => $template->getTitle() ?? '',
|
||||
'template.payment_terms' => $template->getPaymentTerms() ?? '',
|
||||
'template.due_days' => $template->getDueDays(),
|
||||
'template.vat_id' => $template->getVatId() ?? '',
|
||||
'template.contact' => $template->getContact() ?? '',
|
||||
'template.country' => null,
|
||||
'template.country_name' => null,
|
||||
'template.payment_details' => $template->getPaymentDetails() ?? '',
|
||||
|
||||
'query.begin' => '',
|
||||
'query.begin_day' => '',
|
||||
|
||||
@@ -52,6 +52,11 @@ final class InvoiceModel
|
||||
private array $itemHydrator = [];
|
||||
private ?string $invoiceNumber = null;
|
||||
private bool $hideZeroTax = false;
|
||||
private bool $isPreview = false;
|
||||
/**
|
||||
* @var array<string, string|array<string|int, mixed>|null|bool|int|float>
|
||||
*/
|
||||
private array $options = [];
|
||||
|
||||
/**
|
||||
* @internal use InvoiceModelFactory
|
||||
@@ -68,6 +73,22 @@ final class InvoiceModel
|
||||
$this->addItemHydrator(new InvoiceItemDefaultHydrator());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|array<string|int, mixed>|null|bool|int|float $value
|
||||
*/
|
||||
public function setOption(string $key, string|array|null|bool|int|float $value): void
|
||||
{
|
||||
$this->options[$key] = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string|array<string|int, mixed>|null|bool|int|float>
|
||||
*/
|
||||
public function getOptions(): array
|
||||
{
|
||||
return $this->options;
|
||||
}
|
||||
|
||||
public function getQuery(): ?InvoiceQuery
|
||||
{
|
||||
return $this->query;
|
||||
@@ -265,4 +286,14 @@ final class InvoiceModel
|
||||
{
|
||||
$this->hideZeroTax = $hideZeroTax;
|
||||
}
|
||||
|
||||
public function isPreview(): bool
|
||||
{
|
||||
return $this->isPreview;
|
||||
}
|
||||
|
||||
public function setPreview(bool $preview): void
|
||||
{
|
||||
$this->isPreview = $preview;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ final class PdfRenderer extends AbstractTwigRenderer implements DispositionInlin
|
||||
{
|
||||
use PDFRendererTrait;
|
||||
|
||||
public function __construct(Environment $twig, private HtmlToPdfConverter $converter)
|
||||
public function __construct(Environment $twig, private readonly HtmlToPdfConverter $converter)
|
||||
{
|
||||
parent::__construct($twig);
|
||||
}
|
||||
@@ -45,7 +45,7 @@ final class PdfRenderer extends AbstractTwigRenderer implements DispositionInlin
|
||||
$context->setOption('margin_bottom', '8');
|
||||
|
||||
$content = $this->renderTwigTemplate($document, $model, ['pdfContext' => $context]);
|
||||
$content = $this->converter->convertToPdf($content, $context->getOptions());
|
||||
$content = $this->converter->convertToPdf($content, array_merge($model->getOptions(), $context->getOptions()));
|
||||
|
||||
return $this->createPdfResponse($content, $context);
|
||||
}
|
||||
|
||||
54
src/Model/Statistic/DateRange.php
Normal file
54
src/Model/Statistic/DateRange.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Model\Statistic;
|
||||
|
||||
final class DateRange
|
||||
{
|
||||
/**
|
||||
* @var array<string, StatisticDate>
|
||||
*/
|
||||
private array $days = [];
|
||||
|
||||
public function __construct(\DateTimeInterface $begin, \DateTimeInterface $end)
|
||||
{
|
||||
if ($end < $begin) {
|
||||
throw new \InvalidArgumentException('End must be later than begin');
|
||||
}
|
||||
|
||||
$begin = \DateTimeImmutable::createFromInterface($begin);
|
||||
$begin = $begin->setTime(0, 0, 0);
|
||||
|
||||
$end = \DateTimeImmutable::createFromInterface($end);
|
||||
$end = $end->setTime(23, 59, 59);
|
||||
|
||||
do {
|
||||
$this->days[$begin->format('Y-m-d')] = new StatisticDate($begin);
|
||||
$begin = $begin->modify('+1 day');
|
||||
} while ($begin <= $end);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<StatisticDate>
|
||||
*/
|
||||
public function getDays(): array
|
||||
{
|
||||
return array_values($this->days);
|
||||
}
|
||||
|
||||
public function setDate(StatisticDate $date): void
|
||||
{
|
||||
$key = $date->getDate()->format('Y-m-d');
|
||||
if (!\array_key_exists($key, $this->days)) {
|
||||
throw new \InvalidArgumentException('Unknown date given');
|
||||
}
|
||||
|
||||
$this->days[$key] = $date;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ final class StatisticDate extends Timesheet
|
||||
private \DateTimeInterface $date;
|
||||
private int $billableDuration = 0;
|
||||
private float $billableRate = 0.00;
|
||||
private int $amount = 0;
|
||||
|
||||
public function __construct(\DateTimeInterface $date)
|
||||
{
|
||||
@@ -44,4 +45,14 @@ final class StatisticDate extends Timesheet
|
||||
{
|
||||
$this->billableRate = $billableRate;
|
||||
}
|
||||
|
||||
public function getAmount(): int
|
||||
{
|
||||
return $this->amount;
|
||||
}
|
||||
|
||||
public function setAmount(int $amount): void
|
||||
{
|
||||
$this->amount = $amount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,7 @@ interface HtmlToPdfConverter
|
||||
* Returns the binary content of the PDF, which can be saved as file.
|
||||
* Throws an exception if conversion fails.
|
||||
*
|
||||
* @param string $html
|
||||
* @param array $options
|
||||
* @return string
|
||||
* @param array<string, mixed|array<string, mixed>> $options
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function convertToPdf(string $html, array $options = []): string;
|
||||
|
||||
@@ -18,22 +18,29 @@ use Mpdf\Output\Destination;
|
||||
|
||||
final class MPdfConverter implements HtmlToPdfConverter
|
||||
{
|
||||
public function __construct(private FileHelper $fileHelper, private string $cacheDirectory)
|
||||
public function __construct(
|
||||
private readonly FileHelper $fileHelper,
|
||||
private readonly string $cacheDirectory
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed|array<string, mixed>> $options
|
||||
* @return array<string, mixed|array<string, mixed>>
|
||||
*/
|
||||
private function sanitizeOptions(array $options): array
|
||||
{
|
||||
$configs = new ConfigVariables();
|
||||
$fonts = new FontVariables();
|
||||
$allowed = [
|
||||
'mode', 'format', 'default_font_size', 'default_font', 'margin_left', 'margin_right', 'margin_top',
|
||||
'margin_bottom', 'margin_header', 'margin_footer', 'orientation', 'fonts',
|
||||
];
|
||||
|
||||
$filtered = array_filter($options, function ($key) use ($allowed, $configs, $fonts): bool {
|
||||
$filtered = array_filter($options, function ($key): bool {
|
||||
$allowed = [
|
||||
'mode', 'format', 'default_font_size', 'default_font', 'margin_left', 'margin_right', 'margin_top',
|
||||
'margin_bottom', 'margin_header', 'margin_footer', 'orientation', 'fonts', 'associated_files'
|
||||
];
|
||||
if (!\in_array($key, $allowed)) {
|
||||
$configs = new ConfigVariables();
|
||||
if (!\array_key_exists($key, $configs->getDefaults())) {
|
||||
$fonts = new FontVariables();
|
||||
|
||||
return \array_key_exists($key, $fonts->getDefaults());
|
||||
}
|
||||
}
|
||||
@@ -49,10 +56,7 @@ final class MPdfConverter implements HtmlToPdfConverter
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $html
|
||||
* @param array $options
|
||||
* @return string
|
||||
* @throws \Mpdf\MpdfException
|
||||
* @param array<string, mixed|array<string, mixed>> $options
|
||||
*/
|
||||
public function convertToPdf(string $html, array $options = []): string
|
||||
{
|
||||
@@ -93,17 +97,26 @@ final class MPdfConverter implements HtmlToPdfConverter
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<mixed>> $options
|
||||
* @return Mpdf
|
||||
* @param array<string, mixed|array<string, mixed>> $options
|
||||
*/
|
||||
private function initMpdf(array $options): Mpdf
|
||||
{
|
||||
$options['fontDir'] = $this->getFontDirectories();
|
||||
$options['fontdata'] = $this->mergeFontData($options);
|
||||
|
||||
$associatedFiles = [];
|
||||
if (\array_key_exists('associated_files', $options) && \is_array($options['associated_files'])) {
|
||||
$associatedFiles = $options['associated_files'];
|
||||
unset($options['associated_files']);
|
||||
}
|
||||
|
||||
$mpdf = new Mpdf($options);
|
||||
$mpdf->creator = Constants::SOFTWARE;
|
||||
|
||||
if (\count($associatedFiles) > 0) {
|
||||
$mpdf->SetAssociatedFiles($associatedFiles);
|
||||
}
|
||||
|
||||
return $mpdf;
|
||||
}
|
||||
|
||||
@@ -120,8 +133,8 @@ final class MPdfConverter implements HtmlToPdfConverter
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<mixed>> $options
|
||||
* @return array<string, array<mixed>>
|
||||
* @param array<string, mixed|array<string, mixed>> $options
|
||||
* @return array<string, mixed|array<string, mixed>>
|
||||
*/
|
||||
private function mergeFontData(array $options): array
|
||||
{
|
||||
|
||||
@@ -128,12 +128,23 @@ final class ProjectService
|
||||
// we cannot use max(number) because a varchar column returns unexpected results
|
||||
$start = $this->repository->countProject();
|
||||
$i = 0;
|
||||
$createDate = new \DateTimeImmutable();
|
||||
|
||||
do {
|
||||
$start++;
|
||||
|
||||
$numberGenerator = new NumberGenerator($format, function (string $originalFormat, string $format, int $increaseBy) use ($start): string|int {
|
||||
$numberGenerator = new NumberGenerator($format, function (string $originalFormat, string $format, int $increaseBy) use ($start, $createDate): string|int {
|
||||
return match ($format) {
|
||||
'Y' => $createDate->format('Y'),
|
||||
'y' => $createDate->format('y'),
|
||||
'M' => $createDate->format('m'),
|
||||
'm' => $createDate->format('n'),
|
||||
'D' => $createDate->format('d'),
|
||||
'd' => $createDate->format('j'),
|
||||
'YY' => (int) $createDate->format('Y') + $increaseBy,
|
||||
'yy' => (int) $createDate->format('y') + $increaseBy,
|
||||
'MM' => (int) $createDate->format('m') + $increaseBy,
|
||||
'DD' => (int) $createDate->format('d') + $increaseBy,
|
||||
'pc' => $start + $increaseBy,
|
||||
default => $originalFormat,
|
||||
};
|
||||
|
||||
@@ -15,12 +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<User>
|
||||
*/
|
||||
class ApiUserRepository implements UserLoaderInterface, PasswordUpgraderInterface
|
||||
{
|
||||
public function __construct(private UserRepository $userRepository)
|
||||
public function __construct(private readonly UserRepository $userRepository)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,10 @@ class BaseQuery
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private array $orderGroups = [];
|
||||
/**
|
||||
* @var array<string>
|
||||
*/
|
||||
private array $allowedOrderColumns = [];
|
||||
private ?User $currentUser = null;
|
||||
/**
|
||||
* @var array<Team>
|
||||
@@ -428,4 +432,25 @@ class BaseQuery
|
||||
{
|
||||
return $this->isApiCall;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getAllowedOrderColumns(): array
|
||||
{
|
||||
return $this->allowedOrderColumns;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string> $allowedOrderColumns
|
||||
*/
|
||||
public function setAllowedOrderColumns(array $allowedOrderColumns): void
|
||||
{
|
||||
$this->allowedOrderColumns = $allowedOrderColumns;
|
||||
}
|
||||
|
||||
public function addAllowedOrderColumns(string $allowedOrderColumn): void
|
||||
{
|
||||
$this->allowedOrderColumns[] = $allowedOrderColumn;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,10 @@ class TimesheetQuery extends ActivityQuery implements BillableInterface, DateRan
|
||||
public const STATE_EXPORTED = 4;
|
||||
public const STATE_NOT_EXPORTED = 5;
|
||||
|
||||
public const TIMESHEET_ORDER_ALLOWED = ['begin', 'end', 'duration', 'rate', 'hourlyRate', 'customer', 'project', 'activity', 'description'];
|
||||
/**
|
||||
* @deprecated since 2.31.0
|
||||
*/
|
||||
public const TIMESHEET_ORDER_ALLOWED = ['begin', 'end', 'duration', 'rate', 'hourlyRate', 'customer', 'project', 'activity', 'description', 'billable', 'exported'];
|
||||
|
||||
private ?User $timesheetUser = null;
|
||||
/** @var array<Activity> */
|
||||
@@ -61,6 +64,7 @@ class TimesheetQuery extends ActivityQuery implements BillableInterface, DateRan
|
||||
'users' => [],
|
||||
'activities' => [],
|
||||
]);
|
||||
$this->setAllowedOrderColumns(self::TIMESHEET_ORDER_ALLOWED); // @phpstan-ignore-line
|
||||
}
|
||||
|
||||
public function addQueryHint(TimesheetQueryHint $hint): void
|
||||
|
||||
@@ -68,6 +68,7 @@ trait RepositorySearchTrait
|
||||
$alias = 'meta' . $a++;
|
||||
$paramName = 'metaName' . $i++;
|
||||
$paramValue = 'metaValue' . $c++;
|
||||
$subqueryName = 'metaNotExists' . $metaName;
|
||||
|
||||
if ($metaValue === '*') {
|
||||
$qb->leftJoin($rootAlias . '.meta', $alias);
|
||||
@@ -76,7 +77,7 @@ trait RepositorySearchTrait
|
||||
$and->add($qb->expr()->isNotNull($alias . '.value'));
|
||||
} elseif ($metaValue === '~') {
|
||||
$and->add(
|
||||
\sprintf('NOT EXISTS(SELECT metaNotExists FROM %s metaNotExists WHERE metaNotExists.%s = %s.id)', $this->getMetaFieldClass(), $this->getMetaFieldName(), $rootAlias)
|
||||
\sprintf('NOT EXISTS(SELECT %s FROM %s %s WHERE %s.%s = %s.id)', $subqueryName, $this->getMetaFieldClass(), $subqueryName, $subqueryName, $this->getMetaFieldName(), $rootAlias)
|
||||
);
|
||||
} elseif ($metaValue === '' || $metaValue === null) {
|
||||
$qb->leftJoin($rootAlias . '.meta', $alias);
|
||||
@@ -86,7 +87,7 @@ trait RepositorySearchTrait
|
||||
$qb->expr()->eq($alias . '.name', ':' . $paramName),
|
||||
$qb->expr()->isNull($alias . '.value')
|
||||
),
|
||||
\sprintf('NOT EXISTS(SELECT metaNotExists FROM %s metaNotExists WHERE metaNotExists.%s = %s.id)', $this->getMetaFieldClass(), $this->getMetaFieldName(), $rootAlias)
|
||||
\sprintf('NOT EXISTS(SELECT %s FROM %s %s WHERE %s.%s = %s.id)', $subqueryName, $this->getMetaFieldClass(), $subqueryName, $subqueryName, $this->getMetaFieldName(), $rootAlias)
|
||||
)
|
||||
);
|
||||
$qb->setParameter($paramName, $metaName);
|
||||
|
||||
@@ -730,11 +730,6 @@ class TimesheetRepository extends EntityRepository
|
||||
->setParameter('begin', \DateTimeImmutable::createFromInterface($startFrom), Types::DATETIME_IMMUTABLE);
|
||||
}
|
||||
|
||||
$qb->join('t.project', 'p');
|
||||
$qb->join('p.customer', 'c');
|
||||
|
||||
$this->addPermissionCriteria($qb, $user);
|
||||
|
||||
$results = $qb->getQuery()->getScalarResult();
|
||||
|
||||
if (empty($results)) {
|
||||
|
||||
@@ -35,7 +35,6 @@ use Symfony\Component\Security\Core\User\UserProviderInterface;
|
||||
|
||||
/**
|
||||
* @extends EntityRepository<User>
|
||||
* @template-implements PasswordUpgraderInterface<User>
|
||||
* @template-implements UserProviderInterface<User>
|
||||
*/
|
||||
class UserRepository extends EntityRepository implements UserLoaderInterface, UserProviderInterface, PasswordUpgraderInterface
|
||||
@@ -169,6 +168,9 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
|
||||
|
||||
public function refreshUser(UserInterface $user): User
|
||||
{
|
||||
// TODO 3.0 add a last_updated field to user and ONLY load this for comparison.
|
||||
// TODO then only execute the below code if last_updated != session.last_updated
|
||||
|
||||
return $this->loadUserByIdentifier($user->getUserIdentifier());
|
||||
}
|
||||
|
||||
|
||||
@@ -21,18 +21,10 @@ class WorkingTimeRepository extends EntityRepository
|
||||
{
|
||||
private bool $pendingUpdate = false;
|
||||
|
||||
public function deleteWorkingTime(WorkingTime $workingTime): void
|
||||
public function scheduleWorkingTimeDelete(WorkingTime $workingTime): void
|
||||
{
|
||||
$entityManager = $this->getEntityManager();
|
||||
$entityManager->remove($workingTime);
|
||||
$entityManager->flush();
|
||||
}
|
||||
|
||||
public function saveWorkingTime(WorkingTime $workingTime): void
|
||||
{
|
||||
$entityManager = $this->getEntityManager();
|
||||
$entityManager->persist($workingTime);
|
||||
$entityManager->flush();
|
||||
$this->pendingUpdate = true;
|
||||
$this->getEntityManager()->remove($workingTime);
|
||||
}
|
||||
|
||||
public function scheduleWorkingTimeUpdate(WorkingTime $workingTime): void
|
||||
|
||||
@@ -19,7 +19,6 @@ use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Component\Security\Core\User\UserProviderInterface;
|
||||
|
||||
/**
|
||||
* @template-implements PasswordUpgraderInterface<User>
|
||||
* @template-implements UserProviderInterface<User>
|
||||
*/
|
||||
final class KimaiUserProvider implements UserProviderInterface, PasswordUpgraderInterface
|
||||
@@ -29,7 +28,7 @@ final class KimaiUserProvider implements UserProviderInterface, PasswordUpgrader
|
||||
/**
|
||||
* @param iterable<UserProviderInterface<User>> $providers
|
||||
*/
|
||||
public function __construct(private iterable $providers, private SystemConfiguration $configuration)
|
||||
public function __construct(private readonly iterable $providers, private readonly SystemConfiguration $configuration)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -48,8 +48,14 @@ final class DateTimeFactory
|
||||
return $date;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function getStartOfLastMonth(): DateTimeInterface
|
||||
{
|
||||
@trigger_error('The method "DateTimeFactory::getStartOfLastMonth()" will be removed soon', E_USER_DEPRECATED);
|
||||
|
||||
$date = $this->createDateTime('first day of -1 month');
|
||||
$date = $date->setTime(0, 0, 0);
|
||||
|
||||
@@ -66,7 +72,24 @@ final class DateTimeFactory
|
||||
return $this->createDateTime($date);
|
||||
}
|
||||
|
||||
return DateTime::createFromInterface($date);
|
||||
return $this->createDateTime($date->format('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
private function createDate(DateTimeInterface|string|null $date = null): \DateTimeImmutable
|
||||
{
|
||||
if ($date === null) {
|
||||
$date = 'now';
|
||||
}
|
||||
|
||||
if (\is_string($date)) {
|
||||
return $this->create($date);
|
||||
}
|
||||
|
||||
return $this->create($date->format('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
public function getStartOfWeek(DateTimeInterface|string|null $date = null): DateTime
|
||||
@@ -112,8 +135,14 @@ final class DateTimeFactory
|
||||
return $date;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function getEndOfLastMonth(): DateTimeInterface
|
||||
{
|
||||
@trigger_error('The method "DateTimeFactory::getEndOfLastMonth()" will be removed soon', E_USER_DEPRECATED);
|
||||
|
||||
$date = $this->createDateTime('last day of -1 month');
|
||||
$date = $date->setTime(23, 59, 59);
|
||||
|
||||
@@ -140,11 +169,32 @@ final class DateTimeFactory
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $format
|
||||
* @param null|string $datetime
|
||||
* @return bool|DateTime
|
||||
* @deprecated
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function createDateTimeFromFormat(string $format, ?string $datetime = 'now'): bool|DateTime
|
||||
public function createStartOfDay(DateTimeInterface|string|null $date = null): \DateTimeImmutable
|
||||
{
|
||||
@trigger_error('The method "DateTimeFactory::createStartOfDay()" will be removed soon', E_USER_DEPRECATED);
|
||||
|
||||
$date = $this->createDate($date);
|
||||
|
||||
return $date->modify('00:00:00');
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function createEndOfDay(DateTimeInterface|string|null $date = null): \DateTimeImmutable
|
||||
{
|
||||
@trigger_error('The method "DateTimeFactory::createEndOfDay()" will be removed soon', E_USER_DEPRECATED);
|
||||
|
||||
$date = $this->createDate($date);
|
||||
|
||||
return $date->modify('23:59:59');
|
||||
}
|
||||
|
||||
public function createDateTimeFromFormat(string $format, ?string $datetime = 'now'): false|DateTime
|
||||
{
|
||||
return DateTime::createFromFormat($format, $datetime ?? 'now', $this->getTimezone());
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ namespace App\Twig;
|
||||
|
||||
use App\Constants;
|
||||
use App\Entity\EntityWithMetaFields;
|
||||
use App\Form\Type\DateRangeType;
|
||||
use App\Utils\Color;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFilter;
|
||||
@@ -37,6 +38,7 @@ final class Extensions extends AbstractExtension
|
||||
public function getFunctions(): array
|
||||
{
|
||||
return [
|
||||
new TwigFunction('date_range', [$this, 'buildDateRange']),
|
||||
new TwigFunction('report_date', [$this, 'buildReportDate']),
|
||||
new TwigFunction('class_name', [$this, 'getClassName']),
|
||||
new TwigFunction('iso_day_by_name', [$this, 'getIsoDayByName']),
|
||||
@@ -90,6 +92,11 @@ final class Extensions extends AbstractExtension
|
||||
return $dateTime->format(self::REPORT_DATE);
|
||||
}
|
||||
|
||||
public function buildDateRange(\DateTimeInterface $begin, \DateTimeInterface $end): string
|
||||
{
|
||||
return $begin->format(self::REPORT_DATE) . DateRangeType::DATE_SPACER . $end->format(self::REPORT_DATE);
|
||||
}
|
||||
|
||||
public function getIsoDayByName(string $weekDay): int
|
||||
{
|
||||
$key = array_search(
|
||||
|
||||
@@ -191,6 +191,7 @@ final class LocaleFormatExtensions extends AbstractExtension implements LocaleAw
|
||||
$admin = false;
|
||||
$superAdmin = false;
|
||||
$timezone = date_default_timezone_get();
|
||||
$roles = [];
|
||||
|
||||
if ($user !== null) {
|
||||
$browserTitle = (bool) $user->getPreferenceValue('update_browser_title');
|
||||
@@ -200,6 +201,7 @@ final class LocaleFormatExtensions extends AbstractExtension implements LocaleAw
|
||||
$admin = $user->isAdmin();
|
||||
$superAdmin = $user->isSuperAdmin();
|
||||
$timezone = $user->getTimezone();
|
||||
$roles = $user->getRoles();
|
||||
}
|
||||
|
||||
$language ??= $this->locale ?? User::DEFAULT_LANGUAGE;
|
||||
@@ -213,7 +215,7 @@ final class LocaleFormatExtensions extends AbstractExtension implements LocaleAw
|
||||
'twentyFourHours' => $this->localeService->is24Hour($this->locale),
|
||||
'updateBrowserTitle' => $browserTitle,
|
||||
'timezone' => $timezone,
|
||||
'user' => ['id' => $id, 'name' => $name, 'admin' => $admin, 'superAdmin' => $superAdmin],
|
||||
'user' => ['id' => $id, 'name' => $name, 'admin' => $admin, 'superAdmin' => $superAdmin, 'roles' => $roles],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -24,15 +24,17 @@ use Twig\Extension\RuntimeExtensionInterface;
|
||||
|
||||
final class ThemeExtension implements RuntimeExtensionInterface
|
||||
{
|
||||
public function __construct(private EventDispatcherInterface $eventDispatcher, private TranslatorInterface $translator, private SystemConfiguration $configuration, private Security $security)
|
||||
public function __construct(
|
||||
private readonly EventDispatcherInterface $eventDispatcher,
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly SystemConfiguration $configuration,
|
||||
private readonly Security $security
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Environment $environment
|
||||
* @param string $eventName
|
||||
* @param array<string, mixed> $payload
|
||||
* @return ThemeEvent
|
||||
*/
|
||||
public function trigger(Environment $environment, string $eventName, array $payload = []): ThemeEvent
|
||||
{
|
||||
@@ -61,7 +63,7 @@ final class ThemeExtension implements RuntimeExtensionInterface
|
||||
|
||||
public function getJavascriptTranslations(): array
|
||||
{
|
||||
$event = new ThemeJavascriptTranslationsEvent();
|
||||
$event = new ThemeJavascriptTranslationsEvent(); // @phpstan-ignore new.deprecated
|
||||
|
||||
$this->eventDispatcher->dispatch($event);
|
||||
|
||||
@@ -95,9 +97,9 @@ final class ThemeExtension implements RuntimeExtensionInterface
|
||||
return $class;
|
||||
}
|
||||
|
||||
public function generateTitle(?string $prefix = null, string $delimiter = ' – '): string
|
||||
public function generateTitle(): string
|
||||
{
|
||||
return ($prefix ?? '') . Constants::SOFTWARE . $delimiter . $this->translator->trans('time_tracking', [], 'messages');
|
||||
return Constants::SOFTWARE;
|
||||
}
|
||||
|
||||
public function colorize(?string $color, ?string $identifier = null): string
|
||||
|
||||
@@ -19,12 +19,17 @@ final class Duration
|
||||
public const FORMAT_DECIMAL = 'decimal';
|
||||
public const FORMAT_DEFAULT = '%h:%m';
|
||||
|
||||
public function formatDecimal(?int $seconds): float
|
||||
{
|
||||
if ($seconds === null || $seconds === 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return (float) number_format($seconds / 3600, 2, '.', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms seconds into a duration string.
|
||||
*
|
||||
* @param int|null $seconds
|
||||
* @param string $format
|
||||
* @return string|null
|
||||
*/
|
||||
public function format(?int $seconds, string $format = self::FORMAT_DEFAULT): ?string
|
||||
{
|
||||
|
||||
@@ -40,7 +40,7 @@ abstract class AbstractWidget implements WidgetInterface
|
||||
|
||||
public function getWidth(): int
|
||||
{
|
||||
return WidgetInterface::WIDTH_SMALL;
|
||||
return WidgetInterface::WIDTH_HALF;
|
||||
}
|
||||
|
||||
public function getPermissions(): array
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
namespace App\WorkingTime\Mode;
|
||||
|
||||
use App\Entity\User;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
|
||||
|
||||
final class WorkingTimeModeFactory
|
||||
@@ -19,7 +20,8 @@ final class WorkingTimeModeFactory
|
||||
*/
|
||||
public function __construct(
|
||||
#[TaggedIterator(WorkingTimeMode::class)]
|
||||
private readonly iterable $modes
|
||||
private readonly iterable $modes,
|
||||
private readonly LoggerInterface $logger
|
||||
)
|
||||
{
|
||||
}
|
||||
@@ -39,7 +41,15 @@ final class WorkingTimeModeFactory
|
||||
|
||||
public function getModeForUser(User $user): WorkingTimeMode
|
||||
{
|
||||
return $this->getMode($user->getWorkContractMode());
|
||||
try {
|
||||
return $this->getMode($user->getWorkContractMode());
|
||||
} catch (\InvalidArgumentException $ex) {
|
||||
$this->logger->error(
|
||||
\sprintf('Unknown mode "%s" requested for user %s', $user->getWorkContractMode(), $user->getId())
|
||||
);
|
||||
|
||||
return new WorkingTimeModeNone(); // @CloudRequired
|
||||
}
|
||||
}
|
||||
|
||||
public function getMode(string $contractMode): WorkingTimeMode
|
||||
|
||||
@@ -12,6 +12,8 @@ namespace App\WorkingTime;
|
||||
use App\Entity\User;
|
||||
use App\Entity\WorkingTime;
|
||||
use App\Event\WorkingTimeApproveMonthEvent;
|
||||
use App\Event\WorkingTimeQueryStatsEvent;
|
||||
use App\Event\WorkingTimeUnlockMonthEvent;
|
||||
use App\Event\WorkingTimeYearEvent;
|
||||
use App\Event\WorkingTimeYearSummaryEvent;
|
||||
use App\Repository\TimesheetRepository;
|
||||
@@ -154,12 +156,14 @@ final class WorkingTimeService
|
||||
$dayDate = $day->getDay();
|
||||
$result = new WorkingTime($user, $dayDate);
|
||||
|
||||
if (($firstDay === null || $firstDay <= $dayDate) && ($lastDay === null || $lastDay >= $dayDate)) {
|
||||
$result->setExpectedTime($calculator->getWorkHoursForDay($dayDate));
|
||||
}
|
||||
if ($dayDate <= $until) {
|
||||
if (($firstDay === null || $firstDay <= $dayDate) && ($lastDay === null || $lastDay >= $dayDate)) {
|
||||
$result->setExpectedTime($calculator->getWorkHoursForDay($dayDate));
|
||||
}
|
||||
|
||||
if (\array_key_exists($key, $stats)) {
|
||||
$result->setActualTime($stats[$key]);
|
||||
if (\array_key_exists($key, $stats)) {
|
||||
$result->setActualTime($stats[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
$day->setWorkingTime($result);
|
||||
@@ -180,6 +184,7 @@ final class WorkingTimeService
|
||||
return $year->getMonth($monthDate);
|
||||
}
|
||||
|
||||
// deprecated 3.0 remove $user, fetch from $month->getUser() instead
|
||||
public function approveMonth(User $user, Month $month, \DateTimeInterface $approvalDate, User $approvedBy): void
|
||||
{
|
||||
foreach ($month->getDays() as $day) {
|
||||
@@ -188,10 +193,6 @@ final class WorkingTimeService
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($workingTime->getId() !== null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($month->isLocked() || $workingTime->isApproved()) {
|
||||
continue;
|
||||
}
|
||||
@@ -204,15 +205,38 @@ final class WorkingTimeService
|
||||
|
||||
$this->workingTimeRepository->persistScheduledWorkingTimes();
|
||||
|
||||
// $user = $month->getUser();
|
||||
$user->setPreferenceValue(self::LATEST_APPROVAL_PREF, $this->workingTimeRepository->getLatestApprovalDate($user)?->format(self::LATEST_APPROVAL_FORMAT));
|
||||
$this->userRepository->saveUser($user);
|
||||
|
||||
$this->eventDispatcher->dispatch(new WorkingTimeApproveMonthEvent($user, $month, $approvalDate, $approvedBy));
|
||||
$this->eventDispatcher->dispatch(new WorkingTimeApproveMonthEvent($month, $approvedBy));
|
||||
}
|
||||
|
||||
public function unlockMonth(Month $month, User $unlockedBy): void
|
||||
{
|
||||
foreach ($month->getDays() as $day) {
|
||||
$workingTime = $day->getWorkingTime();
|
||||
if ($workingTime === null || $workingTime->getId() === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$workingTime->isApproved()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->workingTimeRepository->scheduleWorkingTimeDelete($workingTime);
|
||||
}
|
||||
|
||||
$this->workingTimeRepository->persistScheduledWorkingTimes();
|
||||
|
||||
$user = $month->getUser();
|
||||
$user->setPreferenceValue(self::LATEST_APPROVAL_PREF, $this->workingTimeRepository->getLatestApprovalDate($user)?->format(self::LATEST_APPROVAL_FORMAT));
|
||||
$this->userRepository->saveUser($user);
|
||||
|
||||
$this->eventDispatcher->dispatch(new WorkingTimeUnlockMonthEvent($month, $unlockedBy));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \DateTimeInterface $year
|
||||
* @param User $user
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function getYearStatistics(\DateTimeInterface $year, User $user): array
|
||||
@@ -235,6 +259,9 @@ final class WorkingTimeService
|
||||
->addGroupBy('day')
|
||||
;
|
||||
|
||||
$event = new WorkingTimeQueryStatsEvent($qb, $user, $begin, $end);
|
||||
$this->eventDispatcher->dispatch($event);
|
||||
|
||||
$results = $qb->getQuery()->getResult();
|
||||
|
||||
$durations = [];
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
{% endblock %}
|
||||
{% block box_tools %}
|
||||
{% if can_edit %}
|
||||
{{ widgets.card_tool_button('edit', {'class': 'modal-ajax-form open-edit', 'title': 'action.edit', 'url': path('admin_activity_edit', {'id': activity.id})}) }}
|
||||
{{ widgets.card_tool_button('edit', {'class': 'modal-ajax-form open-edit', 'title': 'edit', 'url': path('admin_activity_edit', {'id': activity.id})}) }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block box_body_class %}p-0{% endblock %}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
{% set warningMsg %}
|
||||
{{ 'work_times_should.none_configured'|trans }}
|
||||
{% if is_granted('contract', user) %}
|
||||
<a href="{{ path('user_profile_contract', {'username': user.getUserIdentifier()}) }}" class="alert-link">{{ 'action.edit'|trans }}</a>
|
||||
<a href="{{ path('user_profile_contract', {'username': user.getUserIdentifier()}) }}" class="alert-link">{{ 'edit'|trans }}</a>
|
||||
{% endif %}
|
||||
{% endset %}
|
||||
{% from '@theme/components/alert.html.twig' import alert %}
|
||||
@@ -98,10 +98,7 @@
|
||||
{% for month in year.months %}
|
||||
{% set monthShould = month.expectedTime(now) %}
|
||||
{% set monthIs = month.actualTime %}
|
||||
{% set showStats = now > month.month or monthIs > 0 %}
|
||||
{% if showStats %}
|
||||
{% set yearShould = yearShould + monthShould %}
|
||||
{% endif %}
|
||||
{% set yearShould = yearShould + monthShould %}
|
||||
{% set yearIs = yearIs + monthIs %}
|
||||
{% if month.locked %}
|
||||
{% set approved = approved + 1 %}
|
||||
@@ -137,7 +134,6 @@
|
||||
{% for month in year.months %}
|
||||
{% set monthShould = month.expectedTime(now) %}
|
||||
{% set monthIs = month.actualTime %}
|
||||
{% set showStats = now > month.month or monthIs > 0 %}
|
||||
<tr>
|
||||
{% if withWorkHourConfiguration %}
|
||||
<td class="w-min">
|
||||
@@ -152,9 +148,7 @@
|
||||
{{ month.month|month_name }}
|
||||
</td>
|
||||
<td class="text-end total w-min">
|
||||
{% if showStats %}
|
||||
{{ work_times_result(monthShould, monthIs, decimal) }}
|
||||
{% endif %}
|
||||
{{ work_times_result(monthShould, monthIs, decimal) }}
|
||||
</td>
|
||||
<td class="text-end w-min">
|
||||
{{ monthShould|duration(decimal) }}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
{% endblock %}
|
||||
{% block box_tools %}
|
||||
{% if can_edit %}
|
||||
{{ widgets.card_tool_button('edit', {'class': 'modal-ajax-form open-edit', 'title': 'action.edit', 'url': path('admin_customer_edit', {'id': customer.id})}) }}
|
||||
{{ widgets.card_tool_button('edit', {'class': 'modal-ajax-form open-edit', 'title': 'edit', 'url': path('admin_customer_edit', {'id': customer.id})}) }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block box_body_class %}p-0{% endblock %}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
{{ widgets.card_tool_button('create', {'title': 'team.create_default', 'translation_domain': 'teams', 'url': route_create}) }}
|
||||
{% endif %}
|
||||
{% if route_edit is not null %}
|
||||
{{ widgets.card_tool_button('edit', {'class': 'modal-ajax-form open-edit', 'title': 'action.edit', 'url': route_edit}) }}
|
||||
{{ widgets.card_tool_button('edit', {'class': 'modal-ajax-form open-edit', 'title': 'edit', 'url': route_edit}) }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block box_attributes %}id="team_listing_box"{% endblock %}
|
||||
|
||||
@@ -250,7 +250,7 @@
|
||||
{% if (route is not empty and entries is not null) or multi_update_form is not null %}
|
||||
<div class="card-footer d-flex align-items-center">
|
||||
{% if multi_update_form is not null %}
|
||||
{{ form_start(multi_update_form, {'attr': {'class': 'multi_update_form', 'style': 'display:none', 'data-question': 'update_multiple'|trans}}) }}
|
||||
{{ form_start(multi_update_form, {'attr': {'class': 'multi_update_form', 'style': 'display:none', 'data-question': '%action%'}}) }}
|
||||
{% for formChild in multi_update_form.children %}
|
||||
{{ form_widget(formChild) }}
|
||||
{% endfor %}
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
{# what to do here ? #}
|
||||
{% else %}
|
||||
{% if values.children is defined and values.children|length > 0 %}
|
||||
<div class="dropdown">
|
||||
<div class="dropdown{% if values.class is defined %} {{ values.class }}{% endif %}">
|
||||
<button type="button" class="btn {{ btnClasses }} dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
{{ icon(icon, true) }}{% if large %} {{ values.title is defined ? values.title|trans : icon|trans }}{% endif %}
|
||||
</button>
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
<img alt="{{ get_title() }}" style="max-width: 150px;"
|
||||
src="{% if '://' in tabler_bundle.getLogoUrl() %}{{ tabler_bundle.getLogoUrl() }}{% else %}{{ asset(tabler_bundle.getLogoUrl()) }}{% endif %}">
|
||||
{% else %}
|
||||
<p>{{ get_title('', '<br>')|raw }}</p>
|
||||
<p>{{ get_title()|raw }}</p>
|
||||
{% endif %}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
{% block main %}
|
||||
{{ include(kimai_context.modalRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig', {
|
||||
'title': (role.id ? 'action.edit'|trans : 'create'|trans) ~ ': ' ~ 'user_role.title'|trans,
|
||||
'title': (role.id ? 'edit'|trans : 'create'|trans) ~ ': ' ~ 'user_role.title'|trans,
|
||||
'form': form,
|
||||
'back': path('admin_user_permissions')
|
||||
}) }}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<h4 class="card-title">
|
||||
{{ role.getName()|trans }}
|
||||
<span ondblclick="navigator.clipboard.writeText('{{ role.getName() }}');" title="{{ role.getName() }}">{{ role.getName()|trans }}</span>
|
||||
{% if canEditPermissions and (role.name not in system_roles) %}
|
||||
|
||||
<a href="{{ path('admin_user_role_delete', {'role': role.id, 'csrfToken': token}) }}" class="confirmation-link" data-question="confirm.delete">{{ icon('trash') }}</a>
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'description') }}">
|
||||
{{ plugin.metadata.description }}
|
||||
{% if bundles[plugin.id] is defined and bundles[plugin.id]['latest_release'] is defined and bundles[plugin.id]['latest_release'] != plugin.metadata.version %}
|
||||
{{ widgets.alert('warning', 'update_available'|trans({'%version%': bundles[plugin.id]['latest_release']}, 'plugins')) }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'version') }}">
|
||||
{{ widgets.label(plugin.metadata.version, 'primary', plugin.id) }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% macro project(project, view, isTable) %}
|
||||
{% macro project(project, view, isTable, params) %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% set event = actions(app.user, 'project', view, {'project': project, 'token': csrf_token('project.duplicate')}) %}
|
||||
{% set event = actions(app.user, 'project', view, params|default({})|merge({'project': project, 'token': csrf_token('project.duplicate')})) %}
|
||||
{% if view == 'index' or view == 'custom' or isTable is not null %}
|
||||
{{ widgets.table_actions(event.actions) }}
|
||||
{% else %}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
{% block box_attributes %}id="project_details_box"{% endblock %}
|
||||
{% block box_tools %}
|
||||
{% if can_edit %}
|
||||
{{ widgets.card_tool_button('edit', {'class': 'modal-ajax-form open-edit', 'title': 'action.edit', 'url': path('admin_project_edit', {'id': project.id})}) }}
|
||||
{{ widgets.card_tool_button('edit', {'class': 'modal-ajax-form open-edit', 'title': 'edit', 'url': path('admin_project_edit', {'id': project.id})}) }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block box_title %}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
{% embed '@theme/embeds/card.html.twig' with {'project': project, 'activities': activities, 'page': page} %}
|
||||
{% embed '@theme/embeds/card.html.twig' with {'project': project, 'activities': activities, 'page': page, 'id': 'activity_list_box'} %}
|
||||
{% import "activity/actions.html.twig" as actions %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% block box_title %}{{ 'activities'|trans }}{% endblock %}
|
||||
{% block box_attributes %}
|
||||
id="activity_list_box" data-reload="kimai.activityUpdate kimai.activityDelete"
|
||||
{% endblock %}
|
||||
{% block box_attributes %} data-reload="kimai.activityUpdate kimai.activityDelete" {% endblock %}
|
||||
{% block box_tools %}
|
||||
{%- if project.visible and project.customer.visible and is_granted('create_activity') -%}
|
||||
{{ widgets.card_tool_button('create', {'class': 'modal-ajax-form open-edit', 'title': 'create', 'url': path('admin_activity_create_with_project', {'project': project.id})}) }}
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
{{ progress.progressbar_budget(entry, currency) }}
|
||||
{% endif %}
|
||||
{% elseif name == 'actions' %}
|
||||
{{ projectActions.project(project, 'custom') }}
|
||||
{{ projectActions.project(project, 'custom', true, {'daterange': date_range(queryBegin, queryEnd)}) }}
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
@@ -46,6 +46,9 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if form.break is defined %}
|
||||
{{ form_row(form.break, {'row_attr': {'class': 'mb-3 ' ~ form.vars.name ~ '_row_' ~ form.break.vars.name}}) }}
|
||||
{% endif %}
|
||||
{% if form.customer is defined %}
|
||||
{{ form_row(form.customer, {'row_attr': {'class': 'mb-3 ' ~ form.vars.name ~ '_row_' ~ form.customer.vars.name}}) }}
|
||||
{% endif %}
|
||||
@@ -73,7 +76,7 @@
|
||||
{% if form.metaFields is defined and form.metaFields is not empty %}
|
||||
{{ form_row(form.metaFields) }}
|
||||
{% endif %}
|
||||
{% if form.fixedRate is defined or form.hourlyRate is defined or form.billable is defined or form.billableMode is defined or form.exported is defined %}
|
||||
{% if form.fixedRate is defined or form.hourlyRate is defined or form.internalRate is defined or form.billable is defined or form.billableMode is defined or form.exported is defined %}
|
||||
{% embed '@theme/embeds/collapsible.html.twig' with {id: 'timesheet_extended_settings'} %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% block title %}{{ 'extended_settings'|trans }}{% endblock %}
|
||||
@@ -84,6 +87,9 @@
|
||||
{% if form.hourlyRate is defined %}
|
||||
{{ form_row(form.hourlyRate, {'row_attr': {'class': 'mb-3 ' ~ form.vars.name ~ '_row_' ~ form.hourlyRate.vars.name}}) }}
|
||||
{% endif %}
|
||||
{% if form.internalRate is defined %}
|
||||
{{ form_row(form.internalRate, {'row_attr': {'class': 'mb-3 ' ~ form.vars.name ~ '_row_' ~ form.internalRate.vars.name}}) }}
|
||||
{% endif %}
|
||||
{% if form.billable is defined %}
|
||||
{{ form_row(form.billable, {'row_attr': {'class': 'mb-3 ' ~ form.vars.name ~ '_row_' ~ form.billable.vars.name}}) }}
|
||||
{% elseif form.billableMode is defined %}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{% block main %}
|
||||
{% set formEditTemplate = 'default/_form.html.twig' %}
|
||||
{% set formOptions = {
|
||||
'title': 'update_multiple'|trans({'%action%': 'action.edit'|trans, '%count%': dto.entities|length}),
|
||||
'title': 'edit'|trans,
|
||||
'form': form,
|
||||
'back': path(back),
|
||||
} %}
|
||||
|
||||
@@ -25,7 +25,7 @@ class ActionsControllerTest extends APIControllerBaseTestCase
|
||||
$this->assertUrlIsSecured('/api/actions/timesheet/1/index/en');
|
||||
}
|
||||
|
||||
public function test_getTimesheetActions(): void
|
||||
public function testGetTimesheetActions(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
|
||||
@@ -73,7 +73,7 @@ class ActionsControllerTest extends APIControllerBaseTestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function test_getActivityActions(): void
|
||||
public function testGetActivityActions(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
|
||||
|
||||
@@ -125,7 +125,7 @@ class ActionsControllerTest extends APIControllerBaseTestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function test_getProjectActions(): void
|
||||
public function testGetProjectActions(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
|
||||
|
||||
@@ -176,7 +176,7 @@ class ActionsControllerTest extends APIControllerBaseTestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function test_getCustomerActions(): void
|
||||
public function testGetCustomerActions(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
|
||||
|
||||
|
||||
@@ -35,7 +35,8 @@ class ActivityServiceTest extends TestCase
|
||||
private function getSut(
|
||||
?EventDispatcherInterface $dispatcher = null,
|
||||
?ValidatorInterface $validator = null,
|
||||
?ActivityRepository $repository = null
|
||||
?ActivityRepository $repository = null,
|
||||
array $configuration = []
|
||||
): ActivityService {
|
||||
if ($repository === null) {
|
||||
$repository = $this->createMock(ActivityRepository::class);
|
||||
@@ -50,7 +51,7 @@ class ActivityServiceTest extends TestCase
|
||||
$validator->method('validate')->willReturn(new ConstraintViolationList());
|
||||
}
|
||||
|
||||
$configuration = SystemConfigurationFactory::createStub(['activity' => []]);
|
||||
$configuration = SystemConfigurationFactory::createStub(['activity' => $configuration]);
|
||||
|
||||
$service = new ActivityService($repository, $configuration, $dispatcher, $validator);
|
||||
|
||||
@@ -155,4 +156,71 @@ class ActivityServiceTest extends TestCase
|
||||
$project = $sut->createNewActivity();
|
||||
self::assertNull($project->getProject());
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider getTestData
|
||||
*/
|
||||
public function testActivityNumber(string $format, int|string $expected): void
|
||||
{
|
||||
$sut = $this->getSut(null, null, null, ['number_format' => $format]);
|
||||
$activity = $sut->createNewActivity();
|
||||
|
||||
self::assertEquals((string) $expected, $activity->getNumber());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<int, string|\DateTime|int>>
|
||||
*/
|
||||
public static function getTestData(): array
|
||||
{
|
||||
$dateTime = new \DateTime();
|
||||
|
||||
$yearLong = (int) $dateTime->format('Y');
|
||||
$yearShort = (int) $dateTime->format('y');
|
||||
$monthLong = $dateTime->format('m');
|
||||
$monthShort = (int) $dateTime->format('n');
|
||||
$dayLong = $dateTime->format('d');
|
||||
$dayShort = (int) $dateTime->format('j');
|
||||
|
||||
return [
|
||||
// simple tests for single calls
|
||||
['{ac,1}', '2'],
|
||||
['{ac,2}', '02'],
|
||||
['{ac,3}', '002'],
|
||||
['{ac,4}', '0002'],
|
||||
['{Y}', $yearLong],
|
||||
['{y}', $yearShort],
|
||||
['{M}', $monthLong],
|
||||
['{m}', $monthShort],
|
||||
['{D}', $dayLong],
|
||||
['{d}', $dayShort],
|
||||
// number formatting (not testing the lower case versions, as the tests might break depending on the date)
|
||||
['{Y,6}', '00' . $yearLong],
|
||||
['{M,3}', '0' . $monthLong],
|
||||
['{D,3}', '0' . $dayLong],
|
||||
// increment dates
|
||||
['{YY}', $yearLong + 1],
|
||||
['{YY+1}', $yearLong + 1],
|
||||
['{YY+2}', $yearLong + 2],
|
||||
['{YY+3}', $yearLong + 3],
|
||||
['{YY-1}', $yearLong - 1],
|
||||
['{YY-2}', $yearLong - 2],
|
||||
['{YY-3}', $yearLong - 3],
|
||||
['{yy}', $yearShort + 1],
|
||||
['{yy+1}', $yearShort + 1],
|
||||
['{yy+2}', $yearShort + 2],
|
||||
['{yy+3}', $yearShort + 3],
|
||||
['{yy-1}', $yearShort - 1],
|
||||
['{yy-2}', $yearShort - 2],
|
||||
['{yy-3}', $yearShort - 3],
|
||||
['{MM}', $monthShort + 1], // cast to int removes leading zero
|
||||
['{MM+1}', $monthShort + 1], // cast to int removes leading zero
|
||||
['{MM+2}', $monthShort + 2], // cast to int removes leading zero
|
||||
['{MM+3}', $monthShort + 3], // cast to int removes leading zero
|
||||
['{DD}', $dayShort + 1], // cast to int removes leading zero
|
||||
['{DD+1}', $dayShort + 1], // cast to int removes leading zero
|
||||
['{DD+2}', $dayShort + 2], // cast to int removes leading zero
|
||||
['{DD+3}', $dayShort + 3], // cast to int removes leading zero
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user