Compare commits

..

16 Commits

Author SHA1 Message Date
Weblate (bot)
2a9de506a1 Translated using Weblate (#5451)
Co-authored-by: GiannosOB <giannos2105@gmail.com>
Co-authored-by: John Titor <utkin2007@gmail.com>
Co-authored-by: Lucas <merlin.lucas99@gmail.com>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Sotski Eugene <jekakmail@gmail.com>
Co-authored-by: Максим Горпиніч <maksimgorpinic2005a@gmail.com>
2025-05-03 11:40:11 +02:00
Kevin Papst
e663cc2b02 Release 2.33.0 (#5438) 2025-04-22 20:43:22 +02:00
Weblate (bot)
a70c803158 Translated using Weblate (#5440)
Co-authored-by: C. H <them4z@gmail.com>
Co-authored-by: GiannosOB <giannos2105@gmail.com>
Co-authored-by: Kuwush <79j0hancarl@gmail.com>
Co-authored-by: Raphael-11 <raef.laffi@medtech.tn>
Co-authored-by: fran secs <fransecs@gmail.com>
2025-04-22 10:36:44 +02:00
Weblate (bot)
9ca69e1114 Translated using Weblate (#5414)
Co-authored-by: Fajar Shiddiq <justgamers0102@gmail.com>
Co-authored-by: Hien <hienly@yandex.com>
Co-authored-by: Marc Sánchez Díaz <msanchez@aporta.biz>
Co-authored-by: Marc-Daniel DALEBA <marcodxv10@gmail.com>
Co-authored-by: Tachibana Saza <tachibanasaza@proton.me>
Co-authored-by: gacarel <gacarel657@bariswc.com>
Co-authored-by: தமிழ்நேரம் <anishprabu.t@gmail.com>
2025-04-06 11:36:12 +02:00
Kevin Papst
2e6b700b43 Release 2.32 (#5411)
* bump packages
* dynamic invoice options
* make sure that invoice previews can be detected
* support for mpdf associated files
* do not include any future times in work contract calculation
* re-add username column in Excel spreadsheet
* deactivate internal rate editing
* show if plugin update exists
* shorten name to Kimai only, without Time-Tracking
* remove check for existing id in work contract
* fix metafield already defined in search
* helper methods to unlock months
* new translation
* send event on unlock month
2025-04-06 09:53:48 +02:00
Kevin Papst
2a75cd6230 random improvements (#5382)
* remove permission check, as own timesheets should always be visible
* new methods to create datetime
* allow access to user roles in javascript
* support class for dropdown actions
* allow to edit internal rate
* support human readable duration in export via user configuration
* allow to order timesheet listing by user, exported and billable field
* bump codecov action
2025-03-13 18:00:50 +01:00
Weblate (bot)
934fdeb107 Translated using Weblate (#5383)
Co-authored-by: Dawid Kiełkowski <dawid55a@gmail.com>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Wellington Terumi Uemura <wellingtonuemura@gmail.com>
Co-authored-by: Xyruz <alphxyruz@gmail.com>
Co-authored-by: Yaron Shahrabani <sh.yaron@gmail.com>
Co-authored-by: hugompd <hugompd@gmail.com>
Co-authored-by: Максим Горпиніч <maksimgorpinic2005a@gmail.com>
2025-03-12 18:26:08 +01:00
Kevin Papst
14cdcd3f63 Release 2.31 (#5372)
* simplify translation
* bump version
* deprecate translations
* pass date-range as argument to export and timesheet filter URL from monthly overview report
* added class for use in responsive screens
* show technical role name
* simplify multi-update title
* more statistic models
* bump composer packages
2025-02-27 17:41:25 +01:00
Weblate (bot)
674bf3a6b5 Translated using Weblate (#5371)
Co-authored-by: Vág Csaba <vagcsaba@gmail.com>
Co-authored-by: Wellington Terumi Uemura <wellingtonuemura@gmail.com>
Co-authored-by: தமிழ்நேரம் <anishprabu.t@gmail.com>
2025-02-27 17:22:17 +01:00
Kevin Papst
cce49cc409 Translation cleanup (#5370)
* use correct translation key
* de-duplicate translation
2025-02-21 20:50:49 +01:00
Weblate (bot)
04cb403ea9 Translated using Weblate (#5369)
Co-authored-by: Jussi Juven <jussi.juven@disec.fi>
Co-authored-by: Kevin Papst <kevin@kevinpapst.de>
Co-authored-by: Kristoffer Grundström <swedishsailfishosuser@tutanota.com>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Максим Горпиніч <maksimgorpinic2005a@gmail.com>
2025-02-21 20:50:23 +01:00
Kevin Papst
afaa845a9a fix export dates are not localized (#5368) 2025-02-20 15:07:28 +01:00
Weblate (bot)
928b5208e9 Translated using Weblate (#5362)
Co-authored-by: Kristoffer Grundström <swedishsailfishosuser@tutanota.com>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Максим Горпиніч <maksimgorpinic2005a@gmail.com>
2025-02-20 15:04:53 +01:00
Kevin Papst
8a8d0503f7 prepare break time field (#5366) 2025-02-20 14:46:20 +01:00
Kevin Papst
b341358d0a Release 2.30 (#5345)
- added missing InvoiceTemplate company, title) field validator
- graceful fallback for missing working-contract mode
- improve email test command (use configured MAIL_FROM)
- additional form types for simple usage in SystemConfiguration and UserPreferences
- allow to extend the working time query via event
2025-02-17 08:32:22 +01:00
Weblate (bot)
d30166e691 Translated using Weblate (#5344)
Co-authored-by: 3limssmile <33elimssmile@gmail.com>
Co-authored-by: Alfredo Sola <alfredo@sola.es>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Serhii Horichenko <serhii@horichenko.com>
Co-authored-by: Wellington Terumi Uemura <wellingtonuemura@gmail.com>
Co-authored-by: Yaron Shahrabani <sh.yaron@gmail.com>
Co-authored-by: vaykly <vaykly@icloud.com>
Co-authored-by: தமிழ்நேரம் <anishprabu.t@gmail.com>
2025-02-17 08:31:54 +01:00
238 changed files with 6656 additions and 3090 deletions

View File

@@ -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

View File

@@ -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
View 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`

View File

@@ -1,4 +1,4 @@
# Upgrading Kimai - Version2.x
# Upgrading Kimai - Version 2.x
_Make sure to create a backup before you start!_

View File

@@ -49,4 +49,11 @@ export default class KimaiUser extends KimaiPlugin {
return this.user.superAdmin;
}
/**
* @returns {array}
*/
getRoles() {
return this.user.roles;
}
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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',

View File

@@ -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

View File

@@ -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

View File

@@ -1,5 +1,5 @@
{
"name": "Kimai Time-Tracker",
"name": "Kimai",
"short_name": "Kimai",
"icons": [
{

View File

@@ -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;

View File

@@ -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,
};

View File

@@ -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));

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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
*/

View File

@@ -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');

View File

@@ -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
)
{
}

View File

@@ -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) {

View File

@@ -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,
]);
}

View File

@@ -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,
]);
}
}

View File

@@ -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();

View File

@@ -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);

View File

@@ -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,
};

View File

@@ -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) {

View File

@@ -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)]

View File

@@ -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();

View File

@@ -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)
{
}

View File

@@ -47,6 +47,9 @@ final class InvoiceDocumentsEvent extends Event
$this->documents = $documents;
}
/**
* @CloudRequired
*/
public function setMaximumAllowedDocuments(int $max): void
{
$this->maximum = $max;

View File

@@ -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
)
{
}

View File

@@ -11,6 +11,9 @@ namespace App\Event;
use Symfony\Contracts\EventDispatcher\Event;
/**
* @deprecated since 2.32 - use TranslatorInterface directly
*/
final class ThemeJavascriptTranslationsEvent extends Event
{
/**

View File

@@ -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;
}
}

View 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;
}
}

View 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;
}
}

View File

@@ -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')) {

View File

@@ -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()]));

View File

@@ -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);

View File

@@ -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);

View 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;
}

View File

@@ -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';
}
}

View File

@@ -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;
}
}

View File

@@ -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';
}
}

View 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');
}
}

View File

@@ -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;
}
}

View File

@@ -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));

View 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;
}
}

View File

@@ -71,7 +71,7 @@ class MultiUpdateTableDTO
public function addUpdate(string $url): MultiUpdateTableDTO
{
$this->actions['action.edit'] = $url;
$this->actions['edit'] = $url;
return $this;
}

View File

@@ -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

View File

@@ -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

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View File

@@ -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' => '',

View File

@@ -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;
}
}

View File

@@ -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);
}

View 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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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
{

View File

@@ -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,
};

View File

@@ -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)
{
}

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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);

View File

@@ -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)) {

View File

@@ -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());
}

View File

@@ -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

View File

@@ -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)
{
}

View File

@@ -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());
}

View File

@@ -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(

View File

@@ -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],
];
}

View File

@@ -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

View File

@@ -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
{

View File

@@ -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

View File

@@ -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

View File

@@ -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 = [];

View File

@@ -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 %}

View File

@@ -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) }}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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')
}) }}

View File

@@ -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) %}
&nbsp;
<a href="{{ path('admin_user_role_delete', {'role': role.id, 'csrfToken': token}) }}" class="confirmation-link" data-question="confirm.delete">{{ icon('trash') }}</a>

View File

@@ -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) }}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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})}) }}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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),
} %}

View File

@@ -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);

View File

@@ -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