Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a42c9640f | ||
|
|
8e6764b67a | ||
|
|
ea29799b89 | ||
|
|
a3ef1fd1cd | ||
|
|
1a38c7d7a3 | ||
|
|
636a51e721 | ||
|
|
cc64acf0f8 | ||
|
|
76821c24ed | ||
|
|
190b1dd4f3 | ||
|
|
7937fa281a | ||
|
|
6d78c6ba36 | ||
|
|
5d3e46cce2 | ||
|
|
1c3aac78f6 | ||
|
|
b1fb300a71 | ||
|
|
a4d658b821 | ||
|
|
4920ea5075 | ||
|
|
03dbbdc5ba | ||
|
|
023048e7c0 | ||
|
|
24778d3ffb |
@@ -40,7 +40,7 @@ APP_SECRET=change_this_to_something_unique
|
||||
#================================================================================
|
||||
# Running behind reverse proxies? Try these:
|
||||
# TRUSTED_PROXIES=127.0.0.1,127.0.0.2
|
||||
# TRUSTED_HOSTS=localhost,example.com
|
||||
# TRUSTED_HOSTS=localhost|example.com
|
||||
|
||||
#================================================================================
|
||||
# unlikely, that you need to change this one
|
||||
|
||||
2
.github/workflows/lockfiles.yaml
vendored
2
.github/workflows/lockfiles.yaml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Prevent file change
|
||||
uses: xalvarez/prevent-file-change-action@v1
|
||||
uses: xalvarez/prevent-file-change-action@v3
|
||||
with:
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
pattern: .*\.lock$
|
||||
|
||||
2
.github/workflows/testing.yaml
vendored
2
.github/workflows/testing.yaml
vendored
@@ -62,7 +62,7 @@ jobs:
|
||||
run: APP_ENV=dev bin/console kimai:reload -n
|
||||
|
||||
- name: Check codestyles
|
||||
run: PHP_CS_FIXER_IGNORE_ENV=1 vendor/bin/php-cs-fixer fix --dry-run --verbose --config=.php-cs-fixer.dist.php --using-cache=no --show-progress=none --format=checkstyle | cs2pr
|
||||
run: vendor/bin/php-cs-fixer fix --dry-run --verbose --config=.php-cs-fixer.dist.php --using-cache=no --show-progress=none --format=checkstyle | cs2pr
|
||||
|
||||
- name: Run PHPStan for application
|
||||
run: vendor/bin/phpstan analyse -c phpstan.neon --no-progress --error-format=checkstyle | cs2pr
|
||||
|
||||
@@ -8,6 +8,7 @@ file that was distributed with this source code.
|
||||
COMMENT;
|
||||
|
||||
$fixer = new PhpCsFixer\Config();
|
||||
$fixer->setUnsupportedPhpVersionAllowed(true);
|
||||
$fixer
|
||||
->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect())
|
||||
->setRiskyAllowed(true)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
/*!
|
||||
* [KIMAI] KimaiRecentActivities: responsible to reload the users recent activities
|
||||
* [KIMAI] KimaiRemoteModal: load remote content (without forms) into a modal
|
||||
*/
|
||||
|
||||
import KimaiPlugin from '../KimaiPlugin';
|
||||
|
||||
@@ -73,21 +73,6 @@ export default class KimaiCalendar {
|
||||
/** @type {KimaiAlert} ALERT */
|
||||
const ALERT = this.kimai.getPlugin('alert');
|
||||
|
||||
let initialView = 'dayGridMonth';
|
||||
switch (options['initialView']) {
|
||||
case 'month':
|
||||
initialView = 'dayGridMonth';
|
||||
break;
|
||||
case 'agendaWeek':
|
||||
case 'week':
|
||||
initialView = 'timeGridWeek';
|
||||
break;
|
||||
case 'agendaDay':
|
||||
case 'day':
|
||||
initialView = 'timeGridDay';
|
||||
break;
|
||||
}
|
||||
|
||||
// Instead of using "buttonIcons" the theme needs to be adjusted directly
|
||||
// https://fullcalendar.io/docs/buttonIcons
|
||||
BootstrapTheme.prototype.classes = {
|
||||
@@ -120,7 +105,8 @@ export default class KimaiCalendar {
|
||||
esLocale, euLocale, faLocale, fiLocale, frLocale, heLocale, hrLocale, huLocale, itLocale, jaLocale, koLocale,
|
||||
nbLocale, nlLocale, plLocale, ptLocale, ptBrLocale, roLocale, ruLocale, skLocale, svLocale, trLocale, zhLocale, viLocale ],
|
||||
plugins: [ bootstrap5Plugin, dayGridPlugin, timeGridPlugin, googlePlugin, iCalendarPlugin, interactionPlugin ],
|
||||
initialView: initialView,
|
||||
initialView: this.toInternalViewName(this.options['initialView']),
|
||||
initialDate: this.options['initialDate'],
|
||||
// https://fullcalendar.io/docs/theming
|
||||
themeSystem: 'bootstrap5',
|
||||
// https://fullcalendar.io/docs/headerToolbar
|
||||
@@ -155,8 +141,9 @@ export default class KimaiCalendar {
|
||||
slotMinTime: this.options['timeframeBegin'] + ':00',
|
||||
slotMaxTime: this.options['timeframeEnd'] === '23:59' ? '24:00:00' : (this.options['timeframeEnd'] + ':59'),
|
||||
|
||||
// auto calculation seems to do the better job, therefor deactivated
|
||||
//slotLabelInterval: this.options['slotDuration'],
|
||||
// deactivate for auto calculation, which does a good job.
|
||||
// but 1h seems to be a "normal distance" for calendar apps (like Google and Apple)
|
||||
slotLabelInterval: '1:00',
|
||||
|
||||
// how long should entries look like when they don't have an end
|
||||
defaultTimedEventDuration: this.options['slotDuration'],
|
||||
@@ -172,14 +159,20 @@ export default class KimaiCalendar {
|
||||
// once we can configure working days
|
||||
// hiddenDays: [ 2, 4 ]
|
||||
|
||||
// when we support holidays and other full day events
|
||||
// allDaySlot: false,
|
||||
// dropAccept
|
||||
|
||||
dayMaxEventRows: true,
|
||||
eventMaxStack: this.options['dayLimit'],
|
||||
dayMaxEvents: this.options['dayLimit'],
|
||||
|
||||
// the callbacks "viewDidMount" and "viewWillUnmount" are only called when switching between month and others, not between week and day
|
||||
datesSet: (dateInfo) => {
|
||||
document.dispatchEvent(new CustomEvent('kimai.calendar.changeDate', {detail: {
|
||||
view: this.toExternalViewName(dateInfo.view.type),
|
||||
date: dateInfo.start.toISOString().split('T')[0],
|
||||
}}));
|
||||
},
|
||||
|
||||
views: {
|
||||
dayGrid: {
|
||||
dayMaxEventRows: this.options['dayLimit']
|
||||
@@ -535,6 +528,44 @@ export default class KimaiCalendar {
|
||||
return (event.source.id.indexOf('kimai-') === 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} viewName
|
||||
* @returns {string}
|
||||
*/
|
||||
toExternalViewName(viewName) {
|
||||
switch(viewName) {
|
||||
case 'timeGridDay':
|
||||
return 'day';
|
||||
case 'timeGridWeek':
|
||||
return 'week';
|
||||
case 'dayGridMonth':
|
||||
default:
|
||||
return 'month';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} viewName
|
||||
* @returns {string}
|
||||
*/
|
||||
toInternalViewName(viewName) {
|
||||
switch(viewName) {
|
||||
case 'day':
|
||||
case 'agendaDay':
|
||||
case 'timeGridDay':
|
||||
return 'timeGridDay';
|
||||
case 'week':
|
||||
case 'agendaWeek':
|
||||
case 'timeGridWeek':
|
||||
return 'timeGridWeek';
|
||||
case 'month':
|
||||
case 'agendaMonth':
|
||||
case 'dayGridMonth':
|
||||
default:
|
||||
return 'dayGridMonth';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @return {boolean}
|
||||
@@ -718,16 +749,16 @@ export default class KimaiCalendar {
|
||||
}
|
||||
|
||||
events.forEach(item => {
|
||||
const start = DateTime.fromJSDate(item.start);
|
||||
const start = DateTime.fromJSDate(item.start).toUTC();
|
||||
const dateStr = start.toFormat('yyyy-MM-dd');
|
||||
|
||||
const dateStr = start.toISODate();
|
||||
if (!durations[dateStr]) {
|
||||
durations[dateStr] = 0;
|
||||
}
|
||||
|
||||
// absences or public holidays are all day
|
||||
if (item.end !== null) {
|
||||
const end = DateTime.fromJSDate(item.end);
|
||||
const end = DateTime.fromJSDate(item.end).toUTC();
|
||||
const duration = end.diff(start, 'hours').as('seconds');
|
||||
durations[dateStr] += duration;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@
|
||||
"erusev/parsedown": "^1.6",
|
||||
"friendsofsymfony/rest-bundle": "^3.0",
|
||||
"gedmo/doctrine-extensions": "^3.6",
|
||||
"horstoeko/zugferd": "^1.0",
|
||||
"horstoeko/zugferdublbridge": "^1.0",
|
||||
"jms/serializer-bundle": "^5.0",
|
||||
"kevinpapst/tabler-bundle": "^1.4",
|
||||
"league/csv": "^9.4",
|
||||
|
||||
1816
composer.lock
generated
1816
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,13 @@
|
||||
'rtl' => true,
|
||||
'translation' => true,
|
||||
],
|
||||
'bg' =>
|
||||
[
|
||||
'date' => 'd.MM.y',
|
||||
'time' => 'HH:mm',
|
||||
'rtl' => false,
|
||||
'translation' => true,
|
||||
],
|
||||
'cs' =>
|
||||
[
|
||||
'date' => 'dd.MM.y',
|
||||
|
||||
@@ -39,3 +39,5 @@ fos_rest:
|
||||
- { path: ^/api, prefer_extension: true, fallback_format: json, priorities: [ json ] }
|
||||
zone:
|
||||
- { path: ^/api/* }
|
||||
service:
|
||||
view_handler: App\API\ViewHandler
|
||||
@@ -1,6 +1,7 @@
|
||||
# see https://symfony.com/doc/current/reference/configuration/framework.html
|
||||
parameters:
|
||||
env(TRUSTED_PROXIES): ''
|
||||
env(TRUSTED_HOSTS): ''
|
||||
|
||||
framework:
|
||||
secret: '%env(APP_SECRET)%'
|
||||
@@ -14,6 +15,7 @@ framework:
|
||||
enable_attributes: true
|
||||
|
||||
trusted_proxies: '%env(string:TRUSTED_PROXIES)%'
|
||||
trusted_hosts: '%env(string:TRUSTED_HOSTS)%'
|
||||
|
||||
exceptions:
|
||||
App\Validator\ValidationFailedException:
|
||||
@@ -25,6 +27,7 @@ framework:
|
||||
# Enables session support. Note that the session will ONLY be started if you read or write from it.
|
||||
# Remove or comment this section to explicitly disable session support.
|
||||
session:
|
||||
name: KIMAI_SESSION
|
||||
handler_id: App\Security\SessionHandler
|
||||
cookie_secure: auto
|
||||
cookie_samesite: lax
|
||||
|
||||
@@ -3,20 +3,20 @@ nelmio_api_doc:
|
||||
models:
|
||||
use_jms: true
|
||||
names:
|
||||
- { alias: CustomerEditForm, type: App\Form\API\CustomerApiEditForm, groups: [Default, Entity, Customer] }
|
||||
- { alias: CustomerEditForm, type: App\Form\API\CustomerApiEditForm, groups: [Default, Entity, Customer, Customer_Entity] }
|
||||
- { alias: CustomerEntity, type: App\Entity\Customer, groups: [Default, Entity, Customer, Customer_Entity, Not_Expanded] }
|
||||
- { alias: Customer, type: App\Entity\Customer, groups: [Default, Not_Expanded] }
|
||||
- { alias: CustomerRate, type: App\Entity\CustomerRate, groups: [Default, Entity, Customer_Rate] }
|
||||
- { alias: CustomerRateForm, type: App\Form\API\CustomerRateApiForm, groups: [Default, Entity, Customer_Rate] }
|
||||
- { alias: CustomerCollection, type: App\Entity\Customer, groups: [Default, Collection, Customer] }
|
||||
- { alias: ProjectEditForm, type: App\Form\API\ProjectApiEditForm, groups: [Default, Entity, Project] }
|
||||
- { alias: ProjectEditForm, type: App\Form\API\ProjectApiEditForm, groups: [Default, Entity, Project, Project_Entity] }
|
||||
- { alias: ProjectEntity, type: App\Entity\Project, groups: [Default, Entity, Project, Project_Entity, Not_Expanded] }
|
||||
- { alias: Project, type: App\Entity\Project, groups: [Default, Not_Expanded] }
|
||||
- { alias: ProjectExpanded, type: App\Entity\Project, groups: [Default, Expanded] }
|
||||
- { alias: ProjectRate, type: App\Entity\ProjectRate, groups: [Default, Entity, Project_Rate] }
|
||||
- { alias: ProjectRateForm, type: App\Form\API\ProjectRateApiForm, groups: [Default, Entity, Project_Rate] }
|
||||
- { alias: ProjectCollection, type: App\Entity\Project, groups: [Default, Collection, Project] }
|
||||
- { alias: ActivityEditForm, type: App\Form\API\ActivityApiEditForm, groups: [Default, Entity, Activity] }
|
||||
- { alias: ActivityEditForm, type: App\Form\API\ActivityApiEditForm, groups: [Default, Entity, Activity, Activity_Entity] }
|
||||
- { alias: ActivityEntity, type: App\Entity\Activity, groups: [Default, Entity, Activity, Activity_Entity, Not_Expanded] }
|
||||
- { alias: Activity, type: App\Entity\Activity, groups: [Default, Not_Expanded] }
|
||||
- { alias: ActivityExpanded, type: App\Entity\Activity, groups: [Default, Expanded] }
|
||||
|
||||
@@ -42,6 +42,7 @@ security:
|
||||
- App\Saml\SamlAuthenticator
|
||||
|
||||
remember_me:
|
||||
name: KIMAI_REMEMBER
|
||||
secret: '%kernel.secret%'
|
||||
lifetime: 604800
|
||||
path: /
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
parameters:
|
||||
locale: en
|
||||
# can be regenerated with: bin/console kimai:reset:locales
|
||||
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_CZ', 'en_DE', 'en_DG', 'en_DK', 'en_DM', 'en_ER', 'en_ES', 'en_FI', 'en_FJ', 'en_FK', 'en_FM', 'en_FR', 'en_GB', 'en_GD', 'en_GG', 'en_GH', 'en_GI', 'en_GM', 'en_GS', 'en_GU', 'en_GY', 'en_HK', 'en_HU', 'en_ID', 'en_IE', 'en_IL', 'en_IM', 'en_IN', 'en_IO', 'en_IT', '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_NO', 'en_NR', 'en_NU', 'en_NZ', 'en_PG', 'en_PH', 'en_PK', 'en_PL', 'en_PN', 'en_PR', 'en_PT', 'en_PW', 'en_RH', 'en_RO', 'en_RW', 'en_SB', 'en_SC', 'en_SD', 'en_SE', 'en_SG', 'en_SH', 'en_SI', 'en_SK', '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', 'bg', '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_CZ', 'en_DE', 'en_DG', 'en_DK', 'en_DM', 'en_ER', 'en_ES', 'en_FI', 'en_FJ', 'en_FK', 'en_FM', 'en_FR', 'en_GB', 'en_GD', 'en_GG', 'en_GH', 'en_GI', 'en_GM', 'en_GS', 'en_GU', 'en_GY', 'en_HK', 'en_HU', 'en_ID', 'en_IE', 'en_IL', 'en_IM', 'en_IN', 'en_IO', 'en_IT', '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_NO', 'en_NR', 'en_NU', 'en_NZ', 'en_PG', 'en_PH', 'en_PK', 'en_PL', 'en_PN', 'en_PR', 'en_PT', 'en_PW', 'en_RH', 'en_RO', 'en_RW', 'en_SB', 'en_SC', 'en_SD', 'en_SE', 'en_SG', 'en_SH', 'en_SI', 'en_SK', '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
|
||||
_defaults:
|
||||
autowire: true # Automatically injects dependencies in your services.
|
||||
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
|
||||
public: false # Allows optimizing the container by removing unused services; this also means
|
||||
# fetching services directly from the container via $container->get() won't work.
|
||||
# The best practice is to be explicit about your dependencies anyway.
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
public: false
|
||||
bind:
|
||||
$projectDirectory: '%kernel.project_dir%'
|
||||
$kernelEnvironment: '%kernel.environment%'
|
||||
@@ -26,6 +24,7 @@ services:
|
||||
- '../src/Entity/'
|
||||
- '../src/Event/'
|
||||
- '../src/Export/Package/'
|
||||
- '../src/Export/Base/'
|
||||
- '../src/Form/Model/'
|
||||
- '../src/Model/'
|
||||
- '../src/Repository/Loader/'
|
||||
@@ -113,102 +112,102 @@ services:
|
||||
# ================================================================================
|
||||
|
||||
App\Repository\TimesheetRepository:
|
||||
class: App\Repository\TimesheetRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
class: App\Repository\TimesheetRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
arguments: ['App\Entity\Timesheet']
|
||||
|
||||
App\Repository\UserRepository:
|
||||
class: App\Repository\UserRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
class: App\Repository\UserRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
arguments: ['App\Entity\User']
|
||||
|
||||
App\Repository\TeamRepository:
|
||||
class: App\Repository\TeamRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
class: App\Repository\TeamRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
arguments: ['App\Entity\Team']
|
||||
|
||||
App\Repository\ActivityRepository:
|
||||
class: App\Repository\ActivityRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
class: App\Repository\ActivityRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
arguments: ['App\Entity\Activity']
|
||||
|
||||
App\Repository\ProjectRepository:
|
||||
class: App\Repository\ProjectRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
class: App\Repository\ProjectRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
arguments: ['App\Entity\Project']
|
||||
|
||||
App\Repository\TagRepository:
|
||||
class: App\Repository\TagRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
class: App\Repository\TagRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
arguments: ['App\Entity\Tag']
|
||||
|
||||
App\Repository\CustomerRepository:
|
||||
class: App\Repository\CustomerRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
class: App\Repository\CustomerRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
arguments: ['App\Entity\Customer']
|
||||
|
||||
App\Repository\InvoiceTemplateRepository:
|
||||
class: App\Repository\InvoiceTemplateRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
class: App\Repository\InvoiceTemplateRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
arguments: ['App\Entity\InvoiceTemplate']
|
||||
|
||||
App\Repository\ConfigurationRepository:
|
||||
class: App\Repository\ConfigurationRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
class: App\Repository\ConfigurationRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
arguments: ['App\Entity\Configuration']
|
||||
|
||||
App\Repository\RoleRepository:
|
||||
class: App\Repository\RoleRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
class: App\Repository\RoleRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
arguments: ['App\Entity\Role']
|
||||
|
||||
App\Repository\RolePermissionRepository:
|
||||
class: App\Repository\RolePermissionRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
class: App\Repository\RolePermissionRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
arguments: ['App\Entity\RolePermission']
|
||||
|
||||
|
||||
App\Repository\InvoiceDocumentRepository:
|
||||
class: App\Repository\InvoiceDocumentRepository
|
||||
arguments: ['%kimai.invoice.documents%']
|
||||
|
||||
App\Repository\CustomerRateRepository:
|
||||
class: App\Repository\CustomerRateRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
class: App\Repository\CustomerRateRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
arguments: ['App\Entity\CustomerRate']
|
||||
|
||||
App\Repository\ActivityRateRepository:
|
||||
class: App\Repository\ActivityRateRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
class: App\Repository\ActivityRateRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
arguments: ['App\Entity\ActivityRate']
|
||||
|
||||
App\Repository\ProjectRateRepository:
|
||||
class: App\Repository\ProjectRateRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
class: App\Repository\ProjectRateRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
arguments: ['App\Entity\ProjectRate']
|
||||
|
||||
App\Repository\InvoiceRepository:
|
||||
class: App\Repository\InvoiceRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
class: App\Repository\InvoiceRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
arguments: ['App\Entity\Invoice']
|
||||
|
||||
App\Repository\BookmarkRepository:
|
||||
class: App\Repository\BookmarkRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
class: App\Repository\BookmarkRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
arguments: ['App\Entity\Bookmark']
|
||||
|
||||
App\Repository\WorkingTimeRepository:
|
||||
class: App\Repository\WorkingTimeRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
class: App\Repository\WorkingTimeRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
arguments: ['App\Entity\WorkingTime']
|
||||
|
||||
App\Repository\AccessTokenRepository:
|
||||
class: App\Repository\AccessTokenRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
class: App\Repository\AccessTokenRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
arguments: ['App\Entity\AccessToken']
|
||||
|
||||
App\Repository\ExportTemplateRepository:
|
||||
class: App\Repository\ExportTemplateRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
class: App\Repository\ExportTemplateRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
arguments: ['App\Entity\ExportTemplate']
|
||||
|
||||
monolog.formatter.kimai:
|
||||
@@ -222,3 +221,6 @@ services:
|
||||
arguments:
|
||||
- "[%%datetime%%] %%message%% %%context%%\n"
|
||||
- "Y-m-d H:i:s"
|
||||
|
||||
App\API\ViewHandler:
|
||||
arguments: ['@fos_rest.view_handler.default']
|
||||
|
||||
1
kimai.sh
1
kimai.sh
@@ -22,6 +22,7 @@ function update_kimai() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf var/sessions/ 2>&1
|
||||
rm -rf var/cache/* 2>&1
|
||||
git fetch --tags
|
||||
git checkout "$VERSION"
|
||||
|
||||
46
migrations/Version20251031142000.php
Normal file
46
migrations/Version20251031142000.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Doctrine\AbstractMigration;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
|
||||
/**
|
||||
* @version 2.41
|
||||
*/
|
||||
final class Version20251031142000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add dedicated address fields for customer and customer-field for invoice template';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE kimai2_customers ADD address_line1 VARCHAR(150) DEFAULT NULL, ADD address_line2 VARCHAR(150) DEFAULT NULL, ADD address_line3 VARCHAR(150) DEFAULT NULL, ADD postcode VARCHAR(20) DEFAULT NULL, ADD city VARCHAR(50) DEFAULT NULL, ADD buyer_reference VARCHAR(50) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE kimai2_invoice_templates ADD customer_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE kimai2_invoice_templates MODIFY company VARCHAR(255) NULL');
|
||||
$this->addSql('ALTER TABLE kimai2_invoice_templates ADD CONSTRAINT FK_1626CFE99395C3F3 FOREIGN KEY (customer_id) REFERENCES kimai2_customers (id) ON DELETE SET NULL');
|
||||
$this->addSql('CREATE INDEX IDX_1626CFE99395C3F3 ON kimai2_invoice_templates (customer_id)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE kimai2_invoice_templates DROP FOREIGN KEY FK_1626CFE99395C3F3');
|
||||
$this->addSql('DROP INDEX IDX_1626CFE99395C3F3 ON kimai2_invoice_templates');
|
||||
$this->addSql('ALTER TABLE kimai2_invoice_templates DROP customer_id');
|
||||
$this->addSql('ALTER TABLE kimai2_customers DROP address_line1, DROP address_line2, DROP address_line3, DROP postcode, DROP city, DROP buyer_reference');
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
43
migrations/Version20251031143000.php
Normal file
43
migrations/Version20251031143000.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?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 DoctrineMigrations;
|
||||
|
||||
use App\Doctrine\AbstractMigration;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
|
||||
/**
|
||||
* @version 2.41
|
||||
*/
|
||||
final class Version20251031143000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create the invoice template meta table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE kimai2_invoice_templates_meta (id INT AUTO_INCREMENT NOT NULL, template_id INT NOT NULL, name VARCHAR(50) NOT NULL, value TEXT DEFAULT NULL, visible TINYINT(1) DEFAULT 0 NOT NULL, INDEX IDX_A165B0555DA0FB8 (template_id), UNIQUE INDEX UNIQ_A165B0555DA0FB85E237E06 (template_id, name), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||
$this->addSql('ALTER TABLE kimai2_invoice_templates_meta ADD CONSTRAINT FK_A165B0555DA0FB8 FOREIGN KEY (template_id) REFERENCES kimai2_invoice_templates (id) ON DELETE CASCADE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$table = $schema->getTable('kimai2_invoice_templates_meta');
|
||||
$table->removeForeignKey('FK_A165B0555DA0FB8');
|
||||
|
||||
$schema->dropTable('kimai2_invoice_templates_meta');
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
346
phpstan.neon
346
phpstan.neon
@@ -41,6 +41,7 @@ parameters:
|
||||
symfony:
|
||||
containerXmlPath: %rootDir%/../../../var/cache/dev/App_KernelDevDebugContainer.xml
|
||||
ignoreErrors:
|
||||
- identifier: offsetAccess.notFound
|
||||
- '#^Method .*\(\) has parameter \$builder with generic interface Symfony\\Component\\Form\\FormBuilderInterface but does not specify its types\: TData$#'
|
||||
|
||||
-
|
||||
@@ -423,11 +424,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Command/ReloadCommand.php
|
||||
|
||||
-
|
||||
message: "#^Access to an undefined property SimpleXMLElement\\|false\\:\\:\\$file\\.$#"
|
||||
count: 1
|
||||
path: src/Command/TranslationCommand.php
|
||||
|
||||
-
|
||||
message: "#^Argument of an invalid type list\\<string\\>\\|false supplied for foreach, only iterables are supported\\.$#"
|
||||
count: 4
|
||||
@@ -658,26 +654,11 @@ parameters:
|
||||
count: 1
|
||||
path: src/Controller/ActivityController.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#2 \\$replace of method App\\\\Repository\\\\ActivityRepository\\:\\:deleteActivity\\(\\) expects App\\\\Entity\\\\Activity\\|null, mixed given\\.$#"
|
||||
count: 1
|
||||
path: src/Controller/ActivityController.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset 'user' on mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Controller/CalendarController.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$name of class App\\\\Entity\\\\Team constructor expects string, string\\|null given\\.$#"
|
||||
count: 1
|
||||
path: src/Controller/CustomerController.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#2 \\$replace of method App\\\\Repository\\\\CustomerRepository\\:\\:deleteCustomer\\(\\) expects App\\\\Entity\\\\Customer\\|null, mixed given\\.$#"
|
||||
count: 1
|
||||
path: src/Controller/CustomerController.php
|
||||
|
||||
-
|
||||
message: "#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\\.$#"
|
||||
count: 2
|
||||
@@ -763,11 +744,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Controller/InvoiceController.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Controller/InvoiceController.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getName\\(\\) on Symfony\\\\Component\\\\Form\\\\FormInterface\\|null\\.$#"
|
||||
count: 1
|
||||
@@ -808,16 +784,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Controller/ProjectController.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#2 \\$replace of method App\\\\Repository\\\\ProjectRepository\\:\\:deleteProject\\(\\) expects App\\\\Entity\\\\Project\\|null, mixed given\\.$#"
|
||||
count: 1
|
||||
path: src/Controller/ProjectController.php
|
||||
|
||||
-
|
||||
message: "#^Call to function array_key_exists\\(\\) with 'entry' and array\\{day\\: DateTime, entry\\: App\\\\Entity\\\\Timesheet\\} will always evaluate to true\\.$#"
|
||||
count: 1
|
||||
path: src/Controller/QuickEntryController.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method format\\(\\) on DateTime\\|null\\.$#"
|
||||
count: 1
|
||||
@@ -993,31 +959,16 @@ parameters:
|
||||
count: 1
|
||||
path: src/Customer/CustomerStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\DataFixtures\\\\InvoiceFixtures\\:\\:getInvoiceConfigs\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/DataFixtures/InvoiceFixtures.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\DataFixtures\\\\TeamFixtures\\:\\:getGroups\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/DataFixtures/TeamFixtures.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method setTimestamp\\(\\) on DateTime\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/DataFixtures/TimesheetFixtures.php
|
||||
|
||||
-
|
||||
message: "#^Cannot clone DateTime\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/DataFixtures/TimesheetFixtures.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$begin of method App\\\\Entity\\\\Timesheet\\:\\:setBegin\\(\\) expects DateTime, DateTime\\|null given\\.$#"
|
||||
count: 1
|
||||
path: src/DataFixtures/TimesheetFixtures.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$user of method App\\\\DataFixtures\\\\TimesheetFixtures\\:\\:createTimesheetEntry\\(\\) expects App\\\\Entity\\\\User, App\\\\Entity\\\\User\\|null given\\.$#"
|
||||
count: 2
|
||||
@@ -1523,121 +1474,11 @@ parameters:
|
||||
count: 1
|
||||
path: src/Export/Annotation/Order.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getBudget\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Export/Base/HtmlRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getCurrency\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Export/Base/HtmlRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getCustomer\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Export/Base/HtmlRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
|
||||
count: 5
|
||||
path: src/Export/Base/HtmlRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Export/Base/HtmlRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getName\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Export/Base/HtmlRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getTimeBudget\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Export/Base/HtmlRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method jsonSerialize\\(\\) on App\\\\Model\\\\Statistic\\\\BudgetStatistic\\|null\\.$#"
|
||||
count: 2
|
||||
path: src/Export/Base/HtmlRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Export\\\\Base\\\\HtmlRenderer\\:\\:calculateActivityBudget\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Export/Base/HtmlRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Export\\\\Base\\\\HtmlRenderer\\:\\:calculateProjectBudget\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Export/Base/HtmlRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Export\\\\Base\\\\HtmlRenderer\\:\\:calculateSummary\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Export/Base/HtmlRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Export\\\\Base\\\\HtmlRenderer\\:\\:getOptions\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Export/Base/HtmlRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getBudget\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Export/Base/PDFRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getCurrency\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Export/Base/PDFRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getCustomer\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Export/Base/PDFRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
|
||||
count: 5
|
||||
path: src/Export/Base/PDFRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Export/Base/PDFRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getName\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Export/Base/PDFRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getTimeBudget\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Export/Base/PDFRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method jsonSerialize\\(\\) on App\\\\Model\\\\Statistic\\\\BudgetStatistic\\|null\\.$#"
|
||||
count: 2
|
||||
path: src/Export/Base/PDFRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Export\\\\Base\\\\PDFRenderer\\:\\:calculateActivityBudget\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Export/Base/PDFRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Export\\\\Base\\\\PDFRenderer\\:\\:calculateProjectBudget\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Export/Base/PDFRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Export\\\\Base\\\\PDFRenderer\\:\\:calculateSummary\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Export/Base/PDFRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Export\\\\Base\\\\PDFRenderer\\:\\:getOptions\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
@@ -1648,11 +1489,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Export/Base/PDFRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$filename of static method App\\\\Utils\\\\FileHelper\\:\\:convertToAsciiFilename\\(\\) expects string, array\\|string given\\.$#"
|
||||
count: 1
|
||||
path: src/Export/Base/PDFRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Property App\\\\Export\\\\Base\\\\PDFRenderer\\:\\:\\$pdfOptions type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
@@ -2048,11 +1884,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Form/TimesheetEditForm.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method modify\\(\\) on DateTime\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Form/TimesheetEditForm.php
|
||||
|
||||
-
|
||||
message: "#^Cannot clone DateTime\\|null\\.$#"
|
||||
count: 1
|
||||
@@ -2823,11 +2654,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Form/Type/MetaFieldsCollectionType.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method modify\\(\\) on DateTime\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Type/QuickEntryTimesheetType.php
|
||||
|
||||
-
|
||||
message: "#^Cannot cast mixed to int\\.$#"
|
||||
count: 2
|
||||
@@ -2888,11 +2714,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Form/UserTwoFactorType.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getVat\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Calculator/AbstractCalculator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getTimestamp\\(\\) on DateTime\\|null\\.$#"
|
||||
count: 2
|
||||
@@ -2943,31 +2764,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelCustomerHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getSubtotal\\(\\) on App\\\\Invoice\\\\CalculatorInterface\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getTax\\(\\) on App\\\\Invoice\\\\CalculatorInterface\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getTimeWorked\\(\\) on App\\\\Invoice\\\\CalculatorInterface\\|null\\.$#"
|
||||
count: 2
|
||||
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getTotal\\(\\) on App\\\\Invoice\\\\CalculatorInterface\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getVat\\(\\) on App\\\\Invoice\\\\CalculatorInterface\\|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
|
||||
@@ -2998,16 +2794,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelUserHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getCompany\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/InvoiceFilename.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getName\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/InvoiceFilename.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$filename of method App\\\\Invoice\\\\InvoiceFilename\\:\\:convert\\(\\) expects string, string\\|null given\\.$#"
|
||||
count: 1
|
||||
@@ -3053,11 +2839,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Invoice/Renderer/AbstractSpreadsheetRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getTitle\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Renderer/AbstractSpreadsheetRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$haystack of function stripos expects string, mixed given\\.$#"
|
||||
count: 5
|
||||
@@ -3078,11 +2859,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Invoice/Renderer/AbstractTwigRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getLanguage\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Renderer/AbstractTwigRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Invoice\\\\Renderer\\\\AbstractTwigRenderer\\:\\:renderTwigTemplate\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
@@ -3103,21 +2879,11 @@ parameters:
|
||||
count: 2
|
||||
path: src/Invoice/Renderer/DocxRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$filename of static method App\\\\Utils\\\\FileHelper\\:\\:convertToAsciiFilename\\(\\) expects string, array\\|string given\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Renderer/PdfRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getCustomer\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/ServiceInvoice.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getRenderer\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
|
||||
count: 6
|
||||
path: src/Invoice/ServiceInvoice.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method hasInvoiceTemplate\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
|
||||
count: 1
|
||||
@@ -3323,21 +3089,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Plugin/PluginMetadata.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset 'duration' on mixed\\.$#"
|
||||
count: 3
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset 'id' on mixed\\.$#"
|
||||
count: 4
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset 'lastRecord' on mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getEnd\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#"
|
||||
count: 2
|
||||
@@ -3368,31 +3119,11 @@ parameters:
|
||||
count: 2
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$datetime of method App\\\\Timesheet\\\\DateTimeFactory\\:\\:createDateTime\\(\\) expects string, mixed given\\.$#"
|
||||
count: 1
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$duration of method App\\\\Model\\\\TimesheetCountedStatistic\\:\\:setDuration\\(\\) expects int, float\\|int given\\.$#"
|
||||
count: 1
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$durationDay of method App\\\\Reporting\\\\ProjectView\\\\ProjectViewModel\\:\\:setDurationDay\\(\\) expects int, mixed given\\.$#"
|
||||
count: 1
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$durationMonth of method App\\\\Reporting\\\\ProjectView\\\\ProjectViewModel\\:\\:setDurationMonth\\(\\) expects int, mixed given\\.$#"
|
||||
count: 1
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$durationWeek of method App\\\\Reporting\\\\ProjectView\\\\ProjectViewModel\\:\\:setDurationWeek\\(\\) expects int, mixed given\\.$#"
|
||||
count: 1
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$project of class App\\\\Reporting\\\\ProjectDetails\\\\ProjectDetailsModel constructor expects App\\\\Entity\\\\Project, App\\\\Entity\\\\Project\\|null given\\.$#"
|
||||
count: 1
|
||||
@@ -3543,11 +3274,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Repository/TimesheetRepository.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method add\\(\\) on DateTime\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Repository/TimesheetRepository.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getCustomer\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#"
|
||||
count: 1
|
||||
@@ -3708,91 +3434,36 @@ parameters:
|
||||
count: 2
|
||||
path: src/Timesheet/Rounding/CeilRounding.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method setTimestamp\\(\\) on DateTime\\|null\\.$#"
|
||||
count: 2
|
||||
path: src/Timesheet/Rounding/CeilRounding.php
|
||||
|
||||
-
|
||||
message: "#^Cannot clone DateTime\\|null\\.$#"
|
||||
count: 2
|
||||
path: src/Timesheet/Rounding/CeilRounding.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$begin of method App\\\\Entity\\\\Timesheet\\:\\:setBegin\\(\\) expects DateTime, DateTime\\|null given\\.$#"
|
||||
count: 1
|
||||
path: src/Timesheet/Rounding/CeilRounding.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getTimestamp\\(\\) on DateTime\\|null\\.$#"
|
||||
count: 2
|
||||
path: src/Timesheet/Rounding/ClosestRounding.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method setTimestamp\\(\\) on DateTime\\|null\\.$#"
|
||||
count: 4
|
||||
path: src/Timesheet/Rounding/ClosestRounding.php
|
||||
|
||||
-
|
||||
message: "#^Cannot clone DateTime\\|null\\.$#"
|
||||
count: 2
|
||||
path: src/Timesheet/Rounding/ClosestRounding.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$begin of method App\\\\Entity\\\\Timesheet\\:\\:setBegin\\(\\) expects DateTime, DateTime\\|null given\\.$#"
|
||||
count: 1
|
||||
path: src/Timesheet/Rounding/ClosestRounding.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getTimestamp\\(\\) on DateTime\\|null\\.$#"
|
||||
count: 2
|
||||
path: src/Timesheet/Rounding/DefaultRounding.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method setTimestamp\\(\\) on DateTime\\|null\\.$#"
|
||||
count: 2
|
||||
path: src/Timesheet/Rounding/DefaultRounding.php
|
||||
|
||||
-
|
||||
message: "#^Cannot clone DateTime\\|null\\.$#"
|
||||
count: 2
|
||||
path: src/Timesheet/Rounding/DefaultRounding.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$begin of method App\\\\Entity\\\\Timesheet\\:\\:setBegin\\(\\) expects DateTime, DateTime\\|null given\\.$#"
|
||||
count: 1
|
||||
path: src/Timesheet/Rounding/DefaultRounding.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getTimestamp\\(\\) on DateTime\\|null\\.$#"
|
||||
count: 2
|
||||
path: src/Timesheet/Rounding/FloorRounding.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method setTimestamp\\(\\) on DateTime\\|null\\.$#"
|
||||
count: 2
|
||||
path: src/Timesheet/Rounding/FloorRounding.php
|
||||
|
||||
-
|
||||
message: "#^Cannot clone DateTime\\|null\\.$#"
|
||||
count: 2
|
||||
path: src/Timesheet/Rounding/FloorRounding.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$begin of method App\\\\Entity\\\\Timesheet\\:\\:setBegin\\(\\) expects DateTime, DateTime\\|null given\\.$#"
|
||||
count: 1
|
||||
path: src/Timesheet/Rounding/FloorRounding.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getTimezone\\(\\) on App\\\\Entity\\\\User\\|null\\.$#"
|
||||
count: 2
|
||||
path: src/Timesheet/TimesheetService.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getTimezone\\(\\) on DateTime\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Timesheet/TimesheetService.php
|
||||
|
||||
-
|
||||
message: "#^Cannot clone DateTime\\|null\\.$#"
|
||||
count: 1
|
||||
@@ -3818,11 +3489,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Timesheet/TimesheetService.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$begin of method App\\\\Entity\\\\Timesheet\\:\\:setBegin\\(\\) expects DateTime, DateTime\\|null given\\.$#"
|
||||
count: 1
|
||||
path: src/Timesheet/TimesheetService.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Timesheet\\\\TimesheetStatisticService\\:\\:getDailyStatisticsGrouped\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
@@ -3963,11 +3629,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Twig/Runtime/WidgetExtension.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\User\\\\LoginManager\\:\\:logInUser\\(\\) has no return type specified\\.$#"
|
||||
count: 1
|
||||
path: src/User/LoginManager.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\User\\\\UserService\\:\\:updateUser\\(\\) has parameter \\$groups with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
@@ -4392,3 +4053,8 @@ parameters:
|
||||
message: "#^Method App\\\\Form\\\\MultiUpdate\\\\MultiUpdateTableDTO\\:\\:setEntities\\(\\) has parameter \\$entities with generic interface Doctrine\\\\Common\\\\Collections\\\\Collection but does not specify its types\\: TKey, T$#"
|
||||
count: 1
|
||||
path: src/Form/MultiUpdate/MultiUpdateTableDTO.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Entity\\\\InvoiceTemplate\\:\\:getMetaFields\\(\\) return type with generic interface Doctrine\\\\Common\\\\Collections\\\\Collection does not specify its types\\: TKey, T$#"
|
||||
count: 1
|
||||
path: src/Entity/InvoiceTemplate.php
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -54,7 +54,7 @@
|
||||
"calendar": {
|
||||
"js": [
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/calendar.f4379767.js"
|
||||
"/build/calendar.d82e420f.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/calendar.d757753e.css"
|
||||
@@ -92,7 +92,7 @@
|
||||
"/build/invoice-pdf.26d98626.js": "sha384-gwNzQiU1y6qU/M9DPGiNW0MVZkLctEHk37sCES2X9ov+zugEaDABdkMjKBYOC9lz",
|
||||
"/build/invoice-pdf.2b749265.css": "sha384-DXXgkz2WWnrWnfBnXX5fmfPQSPb98upMnWxYKwTGYS04EhrPIWfDCutB2unIrWh7",
|
||||
"/build/chart.62631acc.js": "sha384-L4evSO0OZiQt+jTqfMR70M2Vid7Hl5YdPocC6syhSoBRavlf7Az9/Ldh4YJUuwAU",
|
||||
"/build/calendar.f4379767.js": "sha384-fNnf3iqMaALA89ncvi3nHAlJ2vlI1gjuL1fqnktU+RKVKTrVFCGuDrlIurNHrggx",
|
||||
"/build/calendar.d82e420f.js": "sha384-jJg6+vV6XQn7YlY5wBwvBAujWGgYhSMCS+eDFFxoir+Y69UKB4Lqc69zP+HFzwts",
|
||||
"/build/calendar.d757753e.css": "sha384-cTmQMgHYjd2gfObFWmEUph7qQLCyXaIkneSf+bQ2mqVmZwqOB+pJOm/UYTyTjALJ",
|
||||
"/build/dashboard.632f98fb.js": "sha384-PlHarP53f8b+47VZvbQw3LURA2vODFf7UMwpnktvukrhswaHtx93cx2M1BtO56JH",
|
||||
"/build/dashboard.b7129fa1.css": "sha384-2nn5hLA+3YedgHYBpge62S8Losj8aoPwK9Zk9EvN1xEYatvOUQ7H3rIR2UUJAGOS",
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"build/invoice-pdf.js": "/build/invoice-pdf.26d98626.js",
|
||||
"build/chart.js": "/build/chart.62631acc.js",
|
||||
"build/calendar.css": "/build/calendar.d757753e.css",
|
||||
"build/calendar.js": "/build/calendar.f4379767.js",
|
||||
"build/calendar.js": "/build/calendar.d82e420f.js",
|
||||
"build/dashboard.css": "/build/dashboard.b7129fa1.css",
|
||||
"build/dashboard.js": "/build/dashboard.632f98fb.js",
|
||||
"build/highlight.css": "/build/highlight.98bf3927.css",
|
||||
|
||||
@@ -12,8 +12,6 @@ namespace App\API;
|
||||
use App\Activity\ActivityService;
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\ActivityRate;
|
||||
use App\Entity\User;
|
||||
use App\Event\ActivityMetaDefinitionEvent;
|
||||
use App\Form\API\ActivityApiEditForm;
|
||||
use App\Form\API\ActivityRateApiForm;
|
||||
use App\Repository\ActivityRateRepository;
|
||||
@@ -26,7 +24,6 @@ use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -39,14 +36,12 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
final class ActivityController extends BaseApiController
|
||||
{
|
||||
public const GROUPS_ENTITY = ['Default', 'Entity', 'Activity', 'Activity_Entity'];
|
||||
public const GROUPS_FORM = ['Default', 'Entity', 'Activity'];
|
||||
public const GROUPS_COLLECTION = ['Default', 'Collection', 'Activity'];
|
||||
public const GROUPS_RATE = ['Default', 'Entity', 'Activity_Rate'];
|
||||
|
||||
public function __construct(
|
||||
private readonly ViewHandlerInterface $viewHandler,
|
||||
private readonly ActivityRepository $repository,
|
||||
private readonly EventDispatcherInterface $dispatcher,
|
||||
private readonly ActivityRateRepository $activityRateRepository,
|
||||
private readonly ActivityService $activityService
|
||||
) {
|
||||
@@ -63,25 +58,12 @@ final class ActivityController extends BaseApiController
|
||||
#[Rest\QueryParam(name: 'globals', requirements: '0|1|true|false', strict: true, nullable: true, description: 'Use if you want to fetch only global activities. Allowed values: 0|1 (default: 0 for false)')]
|
||||
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|name|project', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, name, project (default: name)')]
|
||||
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: ASC)')]
|
||||
#[Rest\QueryParam(name: 'term', description: 'Free search term')]
|
||||
#[Rest\QueryParam(name: 'term', description: 'Free search term', nullable: true)]
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher, ProjectRepository $projectRepository): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
$query = new ActivityQuery();
|
||||
$query->loadTeams();
|
||||
$query->setCurrentUser($user);
|
||||
|
||||
$order = $paramFetcher->get('order');
|
||||
if (\is_string($order) && $order !== '') {
|
||||
$query->setOrder($order);
|
||||
}
|
||||
|
||||
$orderBy = $paramFetcher->get('orderBy');
|
||||
if (\is_string($orderBy) && $orderBy !== '') {
|
||||
$query->setOrderBy($orderBy);
|
||||
}
|
||||
$this->prepareQuery($query, $paramFetcher);
|
||||
|
||||
$globals = $paramFetcher->get('globals');
|
||||
if (\is_string($globals) && ($globals === 'true' || $globals === '1')) {
|
||||
@@ -113,7 +95,6 @@ final class ActivityController extends BaseApiController
|
||||
$query->setSearchTerm(new SearchTerm($term));
|
||||
}
|
||||
|
||||
$query->setIsApiCall(true);
|
||||
$data = $this->repository->getActivitiesForQuery($query);
|
||||
$view = new View($data, 200);
|
||||
$view->getContext()->setGroups(self::GROUPS_COLLECTION);
|
||||
@@ -148,10 +129,7 @@ final class ActivityController extends BaseApiController
|
||||
throw $this->createAccessDeniedException('User cannot create activities');
|
||||
}
|
||||
|
||||
$activity = new Activity();
|
||||
|
||||
$event = new ActivityMetaDefinitionEvent($activity);
|
||||
$this->dispatcher->dispatch($event);
|
||||
$activity = $this->activityService->createNewActivity();
|
||||
|
||||
$form = $this->createForm(ActivityApiEditForm::class, $activity, [
|
||||
'include_budget' => $this->isGranted('budget', $activity),
|
||||
@@ -161,7 +139,7 @@ final class ActivityController extends BaseApiController
|
||||
$form->submit($request->request->all());
|
||||
|
||||
if ($form->isValid()) {
|
||||
$this->repository->saveActivity($activity);
|
||||
$this->activityService->saveActivity($activity);
|
||||
|
||||
$view = new View($activity, 200);
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||
@@ -170,7 +148,7 @@ final class ActivityController extends BaseApiController
|
||||
}
|
||||
|
||||
$view = new View($form);
|
||||
$view->getContext()->setGroups(self::GROUPS_FORM);
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
@@ -185,8 +163,7 @@ final class ActivityController extends BaseApiController
|
||||
#[Route(methods: ['PATCH'], path: '/{id}', name: 'patch_activity', requirements: ['id' => '\d+'])]
|
||||
public function patchAction(Request $request, Activity $activity): Response
|
||||
{
|
||||
$event = new ActivityMetaDefinitionEvent($activity);
|
||||
$this->dispatcher->dispatch($event);
|
||||
$this->activityService->loadMetaFields($activity);
|
||||
|
||||
$form = $this->createForm(ActivityApiEditForm::class, $activity, [
|
||||
'include_budget' => $this->isGranted('budget', $activity),
|
||||
@@ -198,12 +175,12 @@ final class ActivityController extends BaseApiController
|
||||
|
||||
if (false === $form->isValid()) {
|
||||
$view = new View($form, Response::HTTP_OK);
|
||||
$view->getContext()->setGroups(self::GROUPS_FORM);
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
$this->repository->saveActivity($activity);
|
||||
$this->activityService->saveActivity($activity);
|
||||
|
||||
$view = new View($activity, Response::HTTP_OK);
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||
@@ -241,8 +218,7 @@ final class ActivityController extends BaseApiController
|
||||
#[Rest\RequestParam(name: 'value', strict: true, nullable: false, description: 'The meta-field value')]
|
||||
public function metaAction(Activity $activity, ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
$event = new ActivityMetaDefinitionEvent($activity);
|
||||
$this->dispatcher->dispatch($event);
|
||||
$this->activityService->loadMetaFields($activity);
|
||||
|
||||
$name = $paramFetcher->get('name');
|
||||
$value = $paramFetcher->get('value');
|
||||
@@ -253,7 +229,7 @@ final class ActivityController extends BaseApiController
|
||||
|
||||
$meta->setValue($value);
|
||||
|
||||
$this->repository->saveActivity($activity);
|
||||
$this->activityService->saveActivity($activity);
|
||||
|
||||
$view = new View($activity, 200);
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||
|
||||
@@ -12,16 +12,16 @@ namespace App\API;
|
||||
use App\Entity\User;
|
||||
use App\Repository\Query\BaseQuery;
|
||||
use App\Timesheet\DateTimeFactory;
|
||||
use App\Utils\Pagination;
|
||||
use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
use FOS\RestBundle\View\View;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\Form\FormTypeInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
abstract class BaseApiController extends AbstractController
|
||||
{
|
||||
public const MAX_PAGE_SIZE = 500;
|
||||
public const DATE_ONLY_FORMAT = 'yyyy-MM-dd';
|
||||
public const DATE_FORMAT = DateTimeType::HTML5_FORMAT;
|
||||
public const DATE_FORMAT_PHP = 'Y-m-d\TH:i:s';
|
||||
@@ -60,13 +60,19 @@ abstract class BaseApiController extends AbstractController
|
||||
return DateTimeFactory::createByUser($user);
|
||||
}
|
||||
|
||||
protected function prepareQuery(BaseQuery $query, ParamFetcherInterface $paramFetcher): void
|
||||
/**
|
||||
* @template T of BaseQuery
|
||||
* @param T $query
|
||||
* @param ParamFetcherInterface $paramFetcher
|
||||
* @return T
|
||||
*/
|
||||
protected function prepareQuery(BaseQuery $query, ParamFetcherInterface $paramFetcher): BaseQuery
|
||||
{
|
||||
$query->setIsApiCall(true);
|
||||
$query->setCurrentUser($this->getUser());
|
||||
|
||||
// there is no function has() in ParamFetcherInterface, so we need to use all() and check for the key
|
||||
$all = $paramFetcher->all(true);
|
||||
$all = $paramFetcher->all();
|
||||
|
||||
if (\array_key_exists('page', $all)) {
|
||||
$page = $all['page'];
|
||||
@@ -78,33 +84,28 @@ abstract class BaseApiController extends AbstractController
|
||||
if (\array_key_exists('size', $all)) {
|
||||
$size = $all['size'];
|
||||
if (is_numeric($size)) {
|
||||
$query->setPageSize((int) $size);
|
||||
$size = (int) $size;
|
||||
if ($size < 1 || $size > self::MAX_PAGE_SIZE) {
|
||||
throw new BadRequestHttpException('Size must be between 1 and ' . self::MAX_PAGE_SIZE);
|
||||
}
|
||||
$query->setPageSize($size);
|
||||
}
|
||||
}
|
||||
|
||||
if (\array_key_exists('pageSize', $all)) {
|
||||
$size = $all['pageSize'];
|
||||
if (is_numeric($size)) {
|
||||
$query->setPageSize((int) $size);
|
||||
if (\array_key_exists('order', $all)) {
|
||||
$order = $all['order'];
|
||||
if (\is_string($order) && $order !== '') {
|
||||
$query->setOrder($order);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function createPaginatedView(Pagination $pagination): View
|
||||
{
|
||||
$results = (array) $pagination->getCurrentPageResults();
|
||||
if (\array_key_exists('orderBy', $all)) {
|
||||
$orderBy = $all['orderBy'];
|
||||
if (\is_string($orderBy) && $orderBy !== '') {
|
||||
$query->setOrderBy($orderBy);
|
||||
}
|
||||
}
|
||||
|
||||
$view = new View($results, 200);
|
||||
$this->addPagination($view, $pagination);
|
||||
|
||||
return $view;
|
||||
}
|
||||
|
||||
protected function addPagination(View $view, Pagination $pagination): void
|
||||
{
|
||||
$view->setHeader('X-Page', (string) $pagination->getCurrentPage());
|
||||
$view->setHeader('X-Total-Count', (string) $pagination->getNbResults());
|
||||
$view->setHeader('X-Total-Pages', (string) $pagination->getNbPages());
|
||||
$view->setHeader('X-Per-Page', (string) $pagination->getMaxPerPage());
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,16 +23,12 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
#[OA\Tag(name: 'Default')]
|
||||
final class ConfigurationController extends BaseApiController
|
||||
{
|
||||
public function __construct(private readonly ViewHandlerInterface $viewHandler)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch timesheet configuration
|
||||
*/
|
||||
#[OA\Response(response: 200, description: 'Returns the instance specific timesheet configuration', content: new OA\JsonContent(ref: new Model(type: TimesheetConfig::class)))]
|
||||
#[Route(path: '/config/timesheet', methods: ['GET'])]
|
||||
public function timesheetConfigAction(SystemConfiguration $configuration): Response
|
||||
public function timesheetConfigAction(SystemConfiguration $configuration, ViewHandlerInterface $viewHandler): Response
|
||||
{
|
||||
$model = new TimesheetConfig();
|
||||
$model->setTrackingMode($configuration->getTimesheetTrackingMode());
|
||||
@@ -44,7 +40,7 @@ final class ConfigurationController extends BaseApiController
|
||||
$view = new View($model, 200);
|
||||
$view->getContext()->setGroups(['Default', 'Config']);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
return $viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,11 +48,11 @@ final class ConfigurationController extends BaseApiController
|
||||
*/
|
||||
#[OA\Response(response: 200, description: 'Returns the configured color codes and names', content: new OA\JsonContent(type: 'object', example: ['Red' => '#ff0000'], additionalProperties: new OA\AdditionalProperties(type: 'string')))]
|
||||
#[Route(path: '/config/colors', methods: ['GET'])]
|
||||
public function colorConfigAction(SystemConfiguration $configuration): Response
|
||||
public function colorConfigAction(SystemConfiguration $configuration, ViewHandlerInterface $viewHandler): Response
|
||||
{
|
||||
$view = new View($configuration->getThemeColors(), 200);
|
||||
$view->getContext()->setGroups(['Default']);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
return $viewHandler->handle($view);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ use App\Customer\CustomerService;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\CustomerRate;
|
||||
use App\Entity\User;
|
||||
use App\Event\CustomerMetaDefinitionEvent;
|
||||
use App\Form\API\CustomerApiEditForm;
|
||||
use App\Form\API\CustomerRateApiForm;
|
||||
use App\Repository\CustomerRateRepository;
|
||||
@@ -25,7 +24,6 @@ use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -38,14 +36,12 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
final class CustomerController extends BaseApiController
|
||||
{
|
||||
public const GROUPS_ENTITY = ['Default', 'Entity', 'Customer', 'Customer_Entity'];
|
||||
public const GROUPS_FORM = ['Default', 'Entity', 'Customer'];
|
||||
public const GROUPS_COLLECTION = ['Default', 'Collection', 'Customer'];
|
||||
public const GROUPS_RATE = ['Default', 'Entity', 'Customer_Rate'];
|
||||
|
||||
public function __construct(
|
||||
private readonly ViewHandlerInterface $viewHandler,
|
||||
private readonly CustomerRepository $repository,
|
||||
private readonly EventDispatcherInterface $dispatcher,
|
||||
private readonly CustomerRateRepository $customerRateRepository,
|
||||
private readonly CustomerService $customerService,
|
||||
) {
|
||||
@@ -59,7 +55,7 @@ final class CustomerController extends BaseApiController
|
||||
#[Rest\QueryParam(name: 'visible', requirements: '1|2|3', default: 1, strict: true, nullable: true, description: 'Visibility status to filter customers: 1=visible, 2=hidden, 3=both')]
|
||||
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: ASC)')]
|
||||
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|name', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, name (default: name)')]
|
||||
#[Rest\QueryParam(name: 'term', description: 'Free search term')]
|
||||
#[Rest\QueryParam(name: 'term', description: 'Free search term', nullable: true)]
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
@@ -125,9 +121,6 @@ final class CustomerController extends BaseApiController
|
||||
|
||||
$customer = $customerService->createNewCustomer('');
|
||||
|
||||
$event = new CustomerMetaDefinitionEvent($customer);
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
$form = $this->createForm(CustomerApiEditForm::class, $customer, [
|
||||
'include_budget' => $this->isGranted('budget', $customer),
|
||||
'include_time' => $this->isGranted('time', $customer),
|
||||
@@ -136,7 +129,7 @@ final class CustomerController extends BaseApiController
|
||||
$form->submit($request->request->all());
|
||||
|
||||
if ($form->isValid()) {
|
||||
$this->repository->saveCustomer($customer);
|
||||
$this->customerService->saveCustomer($customer);
|
||||
|
||||
$view = new View($customer, 200);
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||
@@ -145,7 +138,7 @@ final class CustomerController extends BaseApiController
|
||||
}
|
||||
|
||||
$view = new View($form);
|
||||
$view->getContext()->setGroups(self::GROUPS_FORM);
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
@@ -160,8 +153,7 @@ final class CustomerController extends BaseApiController
|
||||
#[Route(methods: ['PATCH'], path: '/{id}', name: 'patch_customer', requirements: ['id' => '\d+'])]
|
||||
public function patchAction(Request $request, Customer $customer): Response
|
||||
{
|
||||
$event = new CustomerMetaDefinitionEvent($customer);
|
||||
$this->dispatcher->dispatch($event);
|
||||
$this->customerService->loadMetaFields($customer);
|
||||
|
||||
$form = $this->createForm(CustomerApiEditForm::class, $customer, [
|
||||
'include_budget' => $this->isGranted('budget', $customer),
|
||||
@@ -173,12 +165,12 @@ final class CustomerController extends BaseApiController
|
||||
|
||||
if (false === $form->isValid()) {
|
||||
$view = new View($form, Response::HTTP_OK);
|
||||
$view->getContext()->setGroups(self::GROUPS_FORM);
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
$this->repository->saveCustomer($customer);
|
||||
$this->customerService->saveCustomer($customer);
|
||||
|
||||
$view = new View($customer, Response::HTTP_OK);
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||
@@ -216,8 +208,7 @@ final class CustomerController extends BaseApiController
|
||||
#[Rest\RequestParam(name: 'value', strict: true, nullable: false, description: 'The meta-field value')]
|
||||
public function metaAction(Customer $customer, ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
$event = new CustomerMetaDefinitionEvent($customer);
|
||||
$this->dispatcher->dispatch($event);
|
||||
$this->customerService->loadMetaFields($customer);
|
||||
|
||||
$name = $paramFetcher->get('name');
|
||||
$value = $paramFetcher->get('value');
|
||||
@@ -228,7 +219,7 @@ final class CustomerController extends BaseApiController
|
||||
|
||||
$meta->setValue($value);
|
||||
|
||||
$this->repository->saveCustomer($customer);
|
||||
$this->customerService->saveCustomer($customer);
|
||||
|
||||
$view = new View($customer, 200);
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||
|
||||
@@ -23,12 +23,6 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
#[OA\Tag(name: 'Export')]
|
||||
final class ExportController extends BaseApiController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ViewHandlerInterface $viewHandler,
|
||||
private readonly ExportTemplateRepository $repository,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete export template
|
||||
*/
|
||||
@@ -36,12 +30,12 @@ final class ExportController extends BaseApiController
|
||||
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete export template')], x: ['internal' => true])]
|
||||
#[OA\Parameter(name: 'id', description: 'Export template ID to delete', in: 'path', required: true)]
|
||||
#[Route(path: '/{id}', name: 'delete_export_template', requirements: ['id' => '\d+'], methods: ['DELETE'])]
|
||||
public function deleteTemplate(ExportTemplate $exportTemplate): Response
|
||||
public function deleteTemplate(ExportTemplate $exportTemplate, ExportTemplateRepository $repository, ViewHandlerInterface $viewHandler): Response
|
||||
{
|
||||
$this->repository->removeExportTemplate($exportTemplate);
|
||||
$repository->removeExportTemplate($exportTemplate);
|
||||
|
||||
$view = new View(null, Response::HTTP_NO_CONTENT);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
return $viewHandler->handle($view);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ final class InvoiceController extends BaseApiController
|
||||
}
|
||||
|
||||
$data = $this->repository->getPagerfantaForQuery($query);
|
||||
$view = $this->createPaginatedView($data);
|
||||
$view = new View($data, 200);
|
||||
$view->getContext()->setGroups(self::GROUPS_COLLECTION);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
|
||||
@@ -13,7 +13,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
final class NotFoundException extends NotFoundHttpException
|
||||
{
|
||||
public function __construct(string $message = 'Not found', \Exception $previous = null, int $code = 404, array $headers = [])
|
||||
public function __construct(string $message = 'Not found', ?\Throwable $previous = null, int $code = 404, array $headers = [])
|
||||
{
|
||||
parent::__construct($message, $previous, $code, $headers);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ namespace App\API;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\ProjectRate;
|
||||
use App\Entity\User;
|
||||
use App\Event\ProjectMetaDefinitionEvent;
|
||||
use App\Form\API\ProjectApiEditForm;
|
||||
use App\Form\API\ProjectRateApiForm;
|
||||
use App\Project\ProjectService;
|
||||
@@ -26,7 +25,6 @@ use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -40,14 +38,12 @@ use Symfony\Component\Validator\Constraints;
|
||||
final class ProjectController extends BaseApiController
|
||||
{
|
||||
public const GROUPS_ENTITY = ['Default', 'Entity', 'Project', 'Project_Entity'];
|
||||
public const GROUPS_FORM = ['Default', 'Entity', 'Project'];
|
||||
public const GROUPS_COLLECTION = ['Default', 'Collection', 'Project'];
|
||||
public const GROUPS_RATE = ['Default', 'Entity', 'Project_Rate'];
|
||||
|
||||
public function __construct(
|
||||
private readonly ViewHandlerInterface $viewHandler,
|
||||
private readonly ProjectRepository $repository,
|
||||
private readonly EventDispatcherInterface $dispatcher,
|
||||
private readonly ProjectRateRepository $projectRateRepository,
|
||||
private readonly ProjectService $projectService
|
||||
) {
|
||||
@@ -67,7 +63,7 @@ final class ProjectController extends BaseApiController
|
||||
#[Rest\QueryParam(name: 'globalActivities', requirements: '0|1', strict: true, nullable: true, description: "If given, filters projects by their 'global activity' support. Allowed values: 1 (supports global activities) and 0 (without global activities) (default: all)")]
|
||||
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: ASC)')]
|
||||
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|name|customer', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, name, customer (default: name)')]
|
||||
#[Rest\QueryParam(name: 'term', description: 'Free search term')]
|
||||
#[Rest\QueryParam(name: 'term', description: 'Free search term', nullable: true)]
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher, CustomerRepository $customerRepository): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
@@ -197,7 +193,7 @@ final class ProjectController extends BaseApiController
|
||||
}
|
||||
|
||||
$view = new View($form);
|
||||
$view->getContext()->setGroups(self::GROUPS_FORM);
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
@@ -212,8 +208,7 @@ final class ProjectController extends BaseApiController
|
||||
#[Route(methods: ['PATCH'], path: '/{id}', name: 'patch_project', requirements: ['id' => '\d+'])]
|
||||
public function patchAction(Request $request, Project $project): Response
|
||||
{
|
||||
$event = new ProjectMetaDefinitionEvent($project);
|
||||
$this->dispatcher->dispatch($event);
|
||||
$this->projectService->loadMetaFields($project);
|
||||
|
||||
$form = $this->createForm(ProjectApiEditForm::class, $project, [
|
||||
'timezone' => $this->getDateTimeFactory()->getTimezone()->getName(),
|
||||
@@ -227,7 +222,7 @@ final class ProjectController extends BaseApiController
|
||||
|
||||
if (false === $form->isValid()) {
|
||||
$view = new View($form, Response::HTTP_OK);
|
||||
$view->getContext()->setGroups(self::GROUPS_FORM);
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
@@ -270,8 +265,7 @@ final class ProjectController extends BaseApiController
|
||||
#[Rest\RequestParam(name: 'value', strict: true, nullable: false, description: 'The meta-field value')]
|
||||
public function metaAction(Project $project, ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
$event = new ProjectMetaDefinitionEvent($project);
|
||||
$this->dispatcher->dispatch($event);
|
||||
$this->projectService->loadMetaFields($project);
|
||||
|
||||
$name = $paramFetcher->get('name');
|
||||
$value = $paramFetcher->get('value');
|
||||
|
||||
@@ -82,7 +82,7 @@ final class TimesheetController extends BaseApiController
|
||||
#[Rest\QueryParam(name: 'activity', requirements: '\d+', strict: true, nullable: true, description: 'Activity ID to filter timesheets')]
|
||||
#[Rest\QueryParam(name: 'activities', map: true, requirements: '\d+', strict: true, nullable: true, default: [], description: 'List of activity IDs to filter, e.g.: activities[]=1&activities[]=2')]
|
||||
#[Rest\QueryParam(name: 'page', requirements: '\d+', strict: true, nullable: true, description: 'The page to display, renders a 404 if not found (default: 1)')]
|
||||
#[Rest\QueryParam(name: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries for each page (default: 50)')]
|
||||
#[Rest\QueryParam(name: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries for each page (default: 50, max: 500)')]
|
||||
#[Rest\QueryParam(name: 'tags', map: true, strict: true, nullable: true, default: [], description: 'List of tag names, e.g. tags[]=bar&tags[]=foo')]
|
||||
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|begin|end|rate', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, begin, end, rate (default: begin)')]
|
||||
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: DESC)')]
|
||||
@@ -92,12 +92,12 @@ final class TimesheetController extends BaseApiController
|
||||
#[Rest\QueryParam(name: 'active', requirements: '0|1', strict: true, nullable: true, description: 'Filter for running/active records. Allowed values: 0=stopped, 1=active (default: all)')]
|
||||
#[Rest\QueryParam(name: 'billable', requirements: '0|1', strict: true, nullable: true, description: 'Filter for non-/billable records. Allowed values: 0=non-billable, 1=billable (default: all)')]
|
||||
#[Rest\QueryParam(name: 'full', requirements: '0|1|true|false', strict: true, nullable: true, description: 'Allows to fetch full objects including subresources. Allowed values: 0|1|false|true (default: false)')]
|
||||
#[Rest\QueryParam(name: 'term', description: 'Free search term')]
|
||||
#[Rest\QueryParam(name: 'term', description: 'Free search term', nullable: true)]
|
||||
#[Rest\QueryParam(name: 'modified_after', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records changed after this date will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher, CustomerRepository $customerRepository, ProjectRepository $projectRepository, ActivityRepository $activityRepository, UserRepository $userRepository): Response
|
||||
{
|
||||
$query = new TimesheetQuery(false);
|
||||
$query->setCurrentUser($this->getUser());
|
||||
$this->prepareQuery($query, $paramFetcher);
|
||||
$seeAll = false;
|
||||
|
||||
if ($this->isGranted('view_other_timesheet')) {
|
||||
@@ -169,16 +169,6 @@ final class TimesheetController extends BaseApiController
|
||||
$query->addActivity($activity);
|
||||
}
|
||||
|
||||
$page = $paramFetcher->get('page');
|
||||
if (\is_string($page) && $page !== '') {
|
||||
$query->setPage((int) $page);
|
||||
}
|
||||
|
||||
$size = $paramFetcher->get('size');
|
||||
if (\is_string($size) && $size !== '') {
|
||||
$query->setPageSize((int) $size);
|
||||
}
|
||||
|
||||
/** @var array<string> $tags */
|
||||
$tags = $paramFetcher->get('tags');
|
||||
if (\is_array($tags) && \count($tags) > 0) {
|
||||
@@ -191,16 +181,6 @@ final class TimesheetController extends BaseApiController
|
||||
}
|
||||
}
|
||||
|
||||
$order = $paramFetcher->get('order');
|
||||
if (\is_string($order) && $order !== '') {
|
||||
$query->setOrder($order);
|
||||
}
|
||||
|
||||
$orderBy = $paramFetcher->get('orderBy');
|
||||
if (\is_string($orderBy) && $orderBy !== '') {
|
||||
$query->setOrderBy($orderBy);
|
||||
}
|
||||
|
||||
$factory = $this->getDateTimeFactory();
|
||||
|
||||
$begin = $paramFetcher->get('begin');
|
||||
@@ -252,12 +232,9 @@ final class TimesheetController extends BaseApiController
|
||||
$query->setModifiedAfter($factory->createDateTime($modifiedAfter));
|
||||
}
|
||||
|
||||
$query->setIsApiCall(true);
|
||||
$data = $this->repository->getPagerfantaForQuery($query);
|
||||
$results = (array) $data->getCurrentPageResults();
|
||||
|
||||
$view = new View($results, 200);
|
||||
$this->addPagination($view, $data);
|
||||
$view = new View($data, 200);
|
||||
|
||||
$full = $paramFetcher->get('full');
|
||||
if ($full === '1' || $full === 'true') {
|
||||
|
||||
@@ -11,6 +11,7 @@ namespace App\API;
|
||||
|
||||
use App\Entity\AccessToken;
|
||||
use App\Entity\User;
|
||||
use App\Entity\UserPreference;
|
||||
use App\Event\PrepareUserEvent;
|
||||
use App\Form\API\UserApiCreateForm;
|
||||
use App\Form\API\UserApiEditForm;
|
||||
@@ -23,10 +24,12 @@ use FOS\RestBundle\Controller\Annotations as Rest;
|
||||
use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Attribute\Model;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
@@ -55,7 +58,7 @@ final class UserController extends BaseApiController
|
||||
#[Rest\QueryParam(name: 'visible', requirements: '1|2|3', default: 1, strict: true, nullable: true, description: 'Visibility status to filter users: 1=visible, 2=hidden, 3=all')]
|
||||
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|username|alias|email', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, username, alias, email (default: username)')]
|
||||
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: ASC)')]
|
||||
#[Rest\QueryParam(name: 'term', description: 'Free search term')]
|
||||
#[Rest\QueryParam(name: 'term', description: 'Free search term', nullable: true)]
|
||||
#[Rest\QueryParam(name: 'full', requirements: '0|1|true|false', strict: true, nullable: true, description: 'Allows to fetch full objects including subresources. Allowed values: 0|1|false|true (default: false)')]
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
@@ -224,4 +227,44 @@ final class UserController extends BaseApiController
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user preferences
|
||||
*/
|
||||
#[IsGranted('edit', 'profile')]
|
||||
#[OA\Response(response: 200, description: 'Sets the value of a consifgured preference. You cannot create unknown preferences: if the given name is not configured, an exception will be raised.', content: new OA\JsonContent(ref: '#/components/schemas/UserEntity'))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'User ID to set the custom-field value for', required: true)]
|
||||
#[OA\RequestBody(required: true, content: new OA\JsonContent(type: 'array', items: new OA\Items(new Model(type: UserPreference::class))))]
|
||||
#[Route(methods: ['PATCH'], path: '/{id}/preferences', requirements: ['id' => '\d+'])]
|
||||
public function updateUserPreference(User $profile, Request $request, EventDispatcherInterface $dispatcher): Response
|
||||
{
|
||||
$event = new PrepareUserEvent($profile, false);
|
||||
$dispatcher->dispatch($event);
|
||||
|
||||
foreach ($request->request->all() as $preference) {
|
||||
// why is this not handled by FosRestBundle ?
|
||||
if (!\is_array($preference)) {
|
||||
throw new BadRequestHttpException('Invalid request, array expected');
|
||||
}
|
||||
if (!\array_key_exists('name', $preference) || !\array_key_exists('value', $preference)) {
|
||||
throw new BadRequestHttpException('Missing required parameter "name" or "value"');
|
||||
}
|
||||
|
||||
$name = $preference['name'];
|
||||
$value = $preference['value'];
|
||||
|
||||
if (null === ($meta = $profile->getPreference($name))) {
|
||||
throw $this->createNotFoundException(\sprintf('Unknown custom-field "%s" requested', $name));
|
||||
}
|
||||
|
||||
$meta->setValue($value);
|
||||
}
|
||||
|
||||
$this->repository->saveUser($profile);
|
||||
|
||||
$view = new View($profile, 200);
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
}
|
||||
|
||||
78
src/API/ViewHandler.php
Normal file
78
src/API/ViewHandler.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?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\API;
|
||||
|
||||
use App\Utils\Pagination;
|
||||
use FOS\RestBundle\View\ConfigurableViewHandlerInterface;
|
||||
use FOS\RestBundle\View\View;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ViewHandler implements ConfigurableViewHandlerInterface
|
||||
{
|
||||
public function __construct(private readonly ConfigurableViewHandlerInterface $baseViewHandler)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[]|string $groups
|
||||
*/
|
||||
public function setExclusionStrategyGroups($groups): void
|
||||
{
|
||||
$this->baseViewHandler->setExclusionStrategyGroups($groups);
|
||||
}
|
||||
|
||||
public function setExclusionStrategyVersion(string $version): void
|
||||
{
|
||||
$this->baseViewHandler->setExclusionStrategyVersion($version);
|
||||
}
|
||||
|
||||
public function setSerializeNullStrategy(bool $isEnabled): void
|
||||
{
|
||||
$this->baseViewHandler->setSerializeNullStrategy($isEnabled);
|
||||
}
|
||||
|
||||
public function supports(string $format): bool
|
||||
{
|
||||
return $this->baseViewHandler->supports($format);
|
||||
}
|
||||
|
||||
public function registerHandler(string $format, callable $callable): void
|
||||
{
|
||||
$this->baseViewHandler->registerHandler($format, $callable);
|
||||
}
|
||||
|
||||
public function handle(View $view, ?Request $request = null): Response
|
||||
{
|
||||
$data = $view->getData();
|
||||
|
||||
if ($data instanceof Pagination) {
|
||||
$results = (array) $data->getCurrentPageResults();
|
||||
$view->setData($results);
|
||||
|
||||
$view->setHeader('X-Page', (string) $data->getCurrentPage());
|
||||
$view->setHeader('X-Total-Count', (string) $data->getNbResults());
|
||||
$view->setHeader('X-Total-Pages', (string) $data->getNbPages());
|
||||
$view->setHeader('X-Per-Page', (string) $data->getMaxPerPage());
|
||||
}
|
||||
|
||||
return $this->baseViewHandler->handle($view, $request);
|
||||
}
|
||||
|
||||
public function createRedirectResponse(View $view, string $location, string $format): Response
|
||||
{
|
||||
return $this->baseViewHandler->createRedirectResponse($view, $location, $format);
|
||||
}
|
||||
|
||||
public function createResponse(View $view, Request $request, string $format): Response
|
||||
{
|
||||
return $this->baseViewHandler->createResponse($view, $request, $format);
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,11 @@ class ActivityService
|
||||
{
|
||||
}
|
||||
|
||||
public function loadMetaFields(Activity $activity): void
|
||||
{
|
||||
$this->dispatcher->dispatch(new ActivityMetaDefinitionEvent($activity));
|
||||
}
|
||||
|
||||
public function createNewActivity(?Project $project = null): Activity
|
||||
{
|
||||
$activity = new Activity();
|
||||
@@ -49,7 +54,7 @@ class ActivityService
|
||||
$activity->setProject($project);
|
||||
}
|
||||
|
||||
$this->dispatcher->dispatch(new ActivityMetaDefinitionEvent($activity));
|
||||
$this->loadMetaFields($activity);
|
||||
$this->dispatcher->dispatch(new ActivityCreateEvent($activity));
|
||||
|
||||
return $activity;
|
||||
@@ -82,10 +87,10 @@ class ActivityService
|
||||
return $activity;
|
||||
}
|
||||
|
||||
public function deleteActivity(Activity $activity): void
|
||||
public function deleteActivity(Activity $activity, ?Activity $replace = null): void
|
||||
{
|
||||
$this->dispatcher->dispatch(new ActivityDeleteEvent($activity));
|
||||
$this->repository->deleteActivity($activity);
|
||||
$this->dispatcher->dispatch(new ActivityDeleteEvent($activity, $replace));
|
||||
$this->repository->deleteActivity($activity, $replace);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -162,7 +162,7 @@ class ActivityStatisticService
|
||||
/**
|
||||
* @param Activity[] $activities
|
||||
*/
|
||||
private function createStatisticQueryBuilder(array $activities, \DateTimeInterface $begin = null, ?\DateTimeInterface $end = null): QueryBuilder
|
||||
private function createStatisticQueryBuilder(array $activities, ?\DateTimeInterface $begin = null, ?\DateTimeInterface $end = null): QueryBuilder
|
||||
{
|
||||
$qb = $this->timesheetRepository->createQueryBuilder('t');
|
||||
$qb
|
||||
|
||||
21
src/Audit/Loggable.php
Normal file
21
src/Audit/Loggable.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?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\Audit;
|
||||
|
||||
#[\Attribute(\Attribute::TARGET_CLASS)]
|
||||
final class Loggable
|
||||
{
|
||||
/**
|
||||
* @param class-string|null $customFieldClass
|
||||
*/
|
||||
public function __construct(public ?string $customFieldClass = null)
|
||||
{
|
||||
}
|
||||
}
|
||||
18
src/Audit/Versioned.php
Normal file
18
src/Audit/Versioned.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?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\Audit;
|
||||
|
||||
#[\Attribute(\Attribute::TARGET_PROPERTY)]
|
||||
final class Versioned
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
}
|
||||
55
src/Calendar/CalendarQuery.php
Normal file
55
src/Calendar/CalendarQuery.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?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\Calendar;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Form\Type\CalendarViewType;
|
||||
use DateTimeInterface;
|
||||
|
||||
class CalendarQuery
|
||||
{
|
||||
private ?DateTimeInterface $date = null;
|
||||
private string $view = CalendarViewType::DEFAULT_VIEW;
|
||||
private ?User $user = null;
|
||||
|
||||
public function getDate(): ?DateTimeInterface
|
||||
{
|
||||
return $this->date;
|
||||
}
|
||||
|
||||
public function setDate(?DateTimeInterface $date): void
|
||||
{
|
||||
$this->date = $date;
|
||||
}
|
||||
|
||||
public function getView(): string
|
||||
{
|
||||
return $this->view;
|
||||
}
|
||||
|
||||
public function setView(string $view): void
|
||||
{
|
||||
$this->view = match($view){
|
||||
'agendaDay', 'day' => 'day',
|
||||
'agendaWeek', 'week' => 'week',
|
||||
default => 'month',
|
||||
};
|
||||
}
|
||||
|
||||
public function getUser(): ?User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function setUser(?User $user): void
|
||||
{
|
||||
$this->user = $user;
|
||||
}
|
||||
}
|
||||
@@ -277,7 +277,7 @@ final class InvoiceCreateCommand extends Command
|
||||
|
||||
$tpl = $this->getTemplateForCustomer($input, $customer);
|
||||
if (null === $tpl) {
|
||||
$io->warning(\sprintf('Could not find invoice template for project "%s", skipping!', $project->getName()));
|
||||
$io->warning('Could not find invoice template for project, skipping.');
|
||||
continue;
|
||||
}
|
||||
$query->setTemplate($tpl);
|
||||
@@ -295,7 +295,7 @@ final class InvoiceCreateCommand extends Command
|
||||
$invoices[] = $this->serviceInvoice->createInvoice($model, $this->eventDispatcher);
|
||||
}
|
||||
} catch (\Exception $ex) {
|
||||
$io->error(\sprintf('Failed to create invoice for project "%s" with: %s', $project->getName(), $ex->getMessage()));
|
||||
$io->error(\sprintf('Failed to create invoice for project with: %s', $ex->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,7 +352,7 @@ final class InvoiceCreateCommand extends Command
|
||||
|
||||
$tpl = $this->getTemplateForCustomer($input, $customer);
|
||||
if (null === $tpl) {
|
||||
$io->warning(\sprintf('Could not find invoice template for customer "%s", skipping!', $customer->getName()));
|
||||
$io->warning('Could not find invoice template for customer, skipping.');
|
||||
continue;
|
||||
}
|
||||
$query->setTemplate($tpl);
|
||||
@@ -370,7 +370,7 @@ final class InvoiceCreateCommand extends Command
|
||||
$invoices[] = $this->serviceInvoice->createInvoice($model, $this->eventDispatcher);
|
||||
}
|
||||
} catch (\Exception $ex) {
|
||||
$io->error(\sprintf('Failed to create invoice for customer "%s" with: %s', $customer->getName(), $ex->getMessage()));
|
||||
$io->error(\sprintf('Failed to create invoice for customer with: %s', $ex->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,7 +416,7 @@ final class InvoiceCreateCommand extends Command
|
||||
$file = $this->serviceInvoice->getInvoiceFile($invoice);
|
||||
if (null === $file) {
|
||||
$io->warning(
|
||||
\sprintf('Created invoice with ID %s, but file was not found %s', $invoice->getId(), $invoice->getInvoiceFilename())
|
||||
\sprintf('Created invoice with ID %s, but file was not found %s', $invoice->getId() ?? 'unknown', $invoice->getInvoiceFilename() ?? 'unknown')
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ final class RegenerateLocalesCommand extends Command
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
private array $noRegionCode = ['ar', 'id', 'pa', 'sl', 'ca', 'ta'];
|
||||
private array $noRegionCode = ['ar', 'id', 'pa', 'sl', 'ca', 'ta', 'bg'];
|
||||
/**
|
||||
* A list of locales that will be activated no matter if translation files exist for them.
|
||||
*
|
||||
@@ -153,6 +153,9 @@ final class RegenerateLocalesCommand extends Command
|
||||
$settings['time'] = str_replace('Bh', 'H', $settings['time']);
|
||||
}
|
||||
|
||||
// BULGARIAN: postfix of d.MM.y \'г\'. causes problems in several areas
|
||||
$settings['date'] = str_replace("\u{202f}'г'.", '', $settings['date']);
|
||||
|
||||
// KOREAN: time format failed parsing - see https://github.com/kimai/kimai/issues/4402
|
||||
// Special case where time-patterns start with A / a => this will lead to an error
|
||||
// \DateTimeImmutable::getLastErrors() => Meridian can only come after an hour has been found
|
||||
|
||||
@@ -100,7 +100,7 @@ final class ResetTestCommand extends AbstractResetCommand
|
||||
$user2->setAvatar('https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y');
|
||||
$user2->setEnabled(true);
|
||||
$user2->setRoles(['ROLE_USER']);
|
||||
$user2->setUserIdentifier('john_user');
|
||||
$user2->setUserIdentifier(UserFixtures::USERNAME_USER);
|
||||
$user2->setEmail('john_user@example.com');
|
||||
$token2 = new AccessToken($user2, UserFixtures::DEFAULT_API_TOKEN . '_user');
|
||||
$token2->setName('Test fixture');
|
||||
@@ -118,41 +118,41 @@ final class ResetTestCommand extends AbstractResetCommand
|
||||
$token3 = new AccessToken($user3, UserFixtures::DEFAULT_API_TOKEN . '_inactive');
|
||||
$token3->setName('Test fixture');
|
||||
|
||||
$user4 = new User();
|
||||
$user4->setPreferenceValue(UserPreference::HOURLY_RATE, 35);
|
||||
$user4->setAlias('Tony Maier');
|
||||
$user4->setRegisteredAt(new \DateTime('2018-02-06 23:28:57'));
|
||||
$user4->setTitle('Head of Development');
|
||||
$user4->setAvatar('https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg');
|
||||
$user4->setEnabled(true);
|
||||
$user4->setRoles(['ROLE_TEAMLEAD']);
|
||||
$user4->setUserIdentifier('tony_teamlead');
|
||||
$user4->setEmail('tony_teamlead@example.com');
|
||||
$token4 = new AccessToken($user4, UserFixtures::DEFAULT_API_TOKEN . '_teamlead');
|
||||
$userTeamlead = new User();
|
||||
$userTeamlead->setPreferenceValue(UserPreference::HOURLY_RATE, 35);
|
||||
$userTeamlead->setAlias('Tony Maier');
|
||||
$userTeamlead->setRegisteredAt(new \DateTime('2018-02-06 23:28:57'));
|
||||
$userTeamlead->setTitle('Head of Development');
|
||||
$userTeamlead->setAvatar('https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg');
|
||||
$userTeamlead->setEnabled(true);
|
||||
$userTeamlead->setRoles(['ROLE_TEAMLEAD']);
|
||||
$userTeamlead->setUserIdentifier(UserFixtures::USERNAME_TEAMLEAD);
|
||||
$userTeamlead->setEmail('tony_teamlead@example.com');
|
||||
$token4 = new AccessToken($userTeamlead, UserFixtures::DEFAULT_API_TOKEN . '_teamlead');
|
||||
$token4->setName('Test fixture');
|
||||
|
||||
$user5 = new User();
|
||||
$user5->setPreferenceValue(UserPreference::HOURLY_RATE, 81);
|
||||
$user5->setAlias('Anna Smith');
|
||||
$user5->setRegisteredAt(new \DateTime('2018-02-06 23:28:57'));
|
||||
$user5->setTitle('Administrator');
|
||||
$user5->setEnabled(true);
|
||||
$user5->setRoles(['ROLE_ADMIN']);
|
||||
$user5->setUserIdentifier('anna_admin');
|
||||
$user5->setEmail('anna_admin@example.com');
|
||||
$token5 = new AccessToken($user5, UserFixtures::DEFAULT_API_TOKEN . '_admin');
|
||||
$userAdmin = new User();
|
||||
$userAdmin->setPreferenceValue(UserPreference::HOURLY_RATE, 81);
|
||||
$userAdmin->setAlias('Anna Smith');
|
||||
$userAdmin->setRegisteredAt(new \DateTime('2018-02-06 23:28:57'));
|
||||
$userAdmin->setTitle('Administrator');
|
||||
$userAdmin->setEnabled(true);
|
||||
$userAdmin->setRoles(['ROLE_ADMIN']);
|
||||
$userAdmin->setUserIdentifier(UserFixtures::USERNAME_ADMIN);
|
||||
$userAdmin->setEmail('anna_admin@example.com');
|
||||
$token5 = new AccessToken($userAdmin, UserFixtures::DEFAULT_API_TOKEN . '_admin');
|
||||
$token5->setName('Test fixture');
|
||||
|
||||
$user6 = new User();
|
||||
$user6->setPreferenceValue(UserPreference::HOURLY_RATE, 46);
|
||||
$user6->setRegisteredAt(new \DateTime('2018-02-06 23:28:57'));
|
||||
$user6->setTitle('Super Administrator');
|
||||
$user6->setAvatar('/bundles/avanzuadmintheme/img/avatar.png');
|
||||
$user6->setEnabled(true);
|
||||
$user6->setRoles(['ROLE_SUPER_ADMIN']);
|
||||
$user6->setUserIdentifier('susan_super');
|
||||
$user6->setEmail('susan_super@example.com');
|
||||
$token6 = new AccessToken($user6, UserFixtures::DEFAULT_API_TOKEN . '_super');
|
||||
$userSuperAdmin = new User();
|
||||
$userSuperAdmin->setPreferenceValue(UserPreference::HOURLY_RATE, 46);
|
||||
$userSuperAdmin->setRegisteredAt(new \DateTime('2018-02-06 23:28:57'));
|
||||
$userSuperAdmin->setTitle('Super Administrator');
|
||||
$userSuperAdmin->setAvatar('/bundles/avanzuadmintheme/img/avatar.png');
|
||||
$userSuperAdmin->setEnabled(true);
|
||||
$userSuperAdmin->setRoles(['ROLE_SUPER_ADMIN']);
|
||||
$userSuperAdmin->setUserIdentifier(UserFixtures::USERNAME_SUPER_ADMIN);
|
||||
$userSuperAdmin->setEmail('susan_super@example.com');
|
||||
$token6 = new AccessToken($userSuperAdmin, UserFixtures::DEFAULT_API_TOKEN . '_super');
|
||||
$token6->setName('Test fixture');
|
||||
|
||||
$user7 = new User();
|
||||
@@ -180,9 +180,9 @@ final class ResetTestCommand extends AbstractResetCommand
|
||||
[$user1, $token1],
|
||||
[$user2, $token2],
|
||||
[$user3, $token3],
|
||||
[$user4, $token4],
|
||||
[$user5, $token5],
|
||||
[$user6, $token6],
|
||||
[$userTeamlead, $token4],
|
||||
[$userAdmin, $token5],
|
||||
[$userSuperAdmin, $token6],
|
||||
[$user7, $token7],
|
||||
[$user8, $token8],
|
||||
];
|
||||
|
||||
@@ -457,7 +457,7 @@ final class TranslationCommand extends Command
|
||||
$unit['resname'] = $source;
|
||||
}
|
||||
if ($unit['resname'] !== null) {
|
||||
$unit['id'] = $this->generateId($unit['resname']); // @phpstan-ignore offsetAssign.valueType
|
||||
$unit['id'] = $this->generateId($unit['resname']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,7 +492,7 @@ final class TranslationCommand extends Command
|
||||
);
|
||||
}
|
||||
$unit->target[0] = $translations[$key];
|
||||
$unit->target['state'] = 'needs-translation'; // @phpstan-ignore assign.propertyType
|
||||
$unit->target['state'] = 'needs-translation';
|
||||
$foundEmpty = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,11 +17,11 @@ final class Constants
|
||||
/**
|
||||
* The current release version
|
||||
*/
|
||||
public const VERSION = '2.38.0';
|
||||
public const VERSION = '2.43.0';
|
||||
/**
|
||||
* The current release: major * 10000 + minor * 100 + patch
|
||||
*/
|
||||
public const VERSION_ID = 23800;
|
||||
public const VERSION_ID = 24300;
|
||||
/**
|
||||
* The software name
|
||||
*/
|
||||
|
||||
@@ -14,11 +14,9 @@ use App\Activity\ActivityStatisticService;
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\ActivityRate;
|
||||
use App\Entity\MetaTableTypeInterface;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Team;
|
||||
use App\Event\ActivityDetailControllerEvent;
|
||||
use App\Event\ActivityMetaDefinitionEvent;
|
||||
use App\Event\ActivityMetaDisplayEvent;
|
||||
use App\Export\Spreadsheet\EntityWithMetaFieldsExporter;
|
||||
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
|
||||
@@ -51,19 +49,14 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
#[Route(path: '/admin/activity')]
|
||||
final class ActivityController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ActivityRepository $repository,
|
||||
private readonly SystemConfiguration $configuration,
|
||||
private readonly EventDispatcherInterface $dispatcher,
|
||||
private readonly ActivityService $activityService
|
||||
)
|
||||
public function __construct(private readonly ActivityRepository $repository)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route(path: '/', defaults: ['page' => 1], name: 'admin_activity', methods: ['GET'])]
|
||||
#[Route(path: '/page/{page}', requirements: ['page' => '[1-9]\d*'], name: 'admin_activity_paginated', methods: ['GET'])]
|
||||
#[IsGranted(new Expression("is_granted('listing', 'activity')"))]
|
||||
public function indexAction(int $page, Request $request): Response
|
||||
public function indexAction(int $page, Request $request, EventDispatcherInterface $dispatcher, SystemConfiguration $configuration): Response
|
||||
{
|
||||
$query = new ActivityQuery();
|
||||
$query->loadTeams();
|
||||
@@ -76,7 +69,10 @@ final class ActivityController extends AbstractController
|
||||
}
|
||||
|
||||
$entries = $this->repository->getPagerfantaForQuery($query);
|
||||
$metaColumns = $this->findMetaColumns($query);
|
||||
|
||||
$event = new ActivityMetaDisplayEvent($query, ActivityMetaDisplayEvent::ACTIVITY);
|
||||
$dispatcher->dispatch($event);
|
||||
$metaColumns = $event->getFields();
|
||||
|
||||
$table = new DataTable('activity_admin', $query);
|
||||
$table->setPagination($entries);
|
||||
@@ -114,29 +110,16 @@ final class ActivityController extends AbstractController
|
||||
'page_setup' => $page,
|
||||
'dataTable' => $table,
|
||||
'metaColumns' => $metaColumns,
|
||||
'defaultCurrency' => $this->configuration->getCustomerDefaultCurrency(),
|
||||
'defaultCurrency' => $configuration->getCustomerDefaultCurrency(),
|
||||
'now' => $this->getDateTimeFactory()->createDateTime(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ActivityQuery $query
|
||||
* @return MetaTableTypeInterface[]
|
||||
*/
|
||||
private function findMetaColumns(ActivityQuery $query): array
|
||||
{
|
||||
$event = new ActivityMetaDisplayEvent($query, ActivityMetaDisplayEvent::ACTIVITY);
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
return $event->getFields();
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/details', name: 'activity_details', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('view', 'activity')]
|
||||
public function detailsAction(Activity $activity, TeamRepository $teamRepository, ActivityRateRepository $rateRepository, ActivityStatisticService $statisticService): Response
|
||||
public function detailsAction(Activity $activity, TeamRepository $teamRepository, ActivityRateRepository $rateRepository, ActivityStatisticService $statisticService, ActivityService $activityService, EventDispatcherInterface $dispatcher): Response
|
||||
{
|
||||
$event = new ActivityMetaDefinitionEvent($activity);
|
||||
$this->dispatcher->dispatch($event);
|
||||
$activityService->loadMetaFields($activity);
|
||||
|
||||
$stats = null;
|
||||
$rates = [];
|
||||
@@ -179,7 +162,7 @@ final class ActivityController extends AbstractController
|
||||
|
||||
// additional boxes by plugins
|
||||
$event = new ActivityDetailControllerEvent($activity);
|
||||
$this->dispatcher->dispatch($event);
|
||||
$dispatcher->dispatch($event);
|
||||
$boxes = $event->getController();
|
||||
|
||||
$page = $this->createPageSetup();
|
||||
@@ -247,31 +230,28 @@ final class ActivityController extends AbstractController
|
||||
|
||||
#[Route(path: '/create/{project}', name: 'admin_activity_create_with_project', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('create_activity')]
|
||||
public function createWithProjectAction(Request $request, Project $project): Response
|
||||
public function createWithProjectAction(Project $project, Request $request, ActivityService $activityService, SystemConfiguration $configuration): Response
|
||||
{
|
||||
return $this->createActivity($request, $project);
|
||||
return $this->createActivity($request, $activityService, $configuration, $project);
|
||||
}
|
||||
|
||||
#[Route(path: '/create', name: 'admin_activity_create', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('create_activity')]
|
||||
public function createAction(Request $request): Response
|
||||
public function createAction(Request $request, ActivityService $activityService, SystemConfiguration $configuration): Response
|
||||
{
|
||||
return $this->createActivity($request, null);
|
||||
return $this->createActivity($request, $activityService, $configuration, null);
|
||||
}
|
||||
|
||||
private function createActivity(Request $request, ?Project $project = null): Response
|
||||
private function createActivity(Request $request, ActivityService $activityService, SystemConfiguration $configuration, ?Project $project = null): Response
|
||||
{
|
||||
$activity = $this->activityService->createNewActivity($project);
|
||||
$activity = $activityService->createNewActivity($project);
|
||||
|
||||
$event = new ActivityMetaDefinitionEvent($activity);
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
$editForm = $this->createEditForm($activity);
|
||||
$editForm = $this->createEditForm($activity, $configuration);
|
||||
$editForm->handleRequest($request);
|
||||
|
||||
if ($editForm->isSubmitted() && $editForm->isValid()) {
|
||||
try {
|
||||
$this->activityService->saveActivity($activity);
|
||||
$activityService->saveActivity($activity);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRouteAfterCreate('activity_details', ['id' => $activity->getId()]);
|
||||
@@ -289,7 +269,7 @@ final class ActivityController extends AbstractController
|
||||
|
||||
#[Route(path: '/{id}/permissions', name: 'admin_activity_permissions', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('permissions', 'activity')]
|
||||
public function teamPermissionsAction(Activity $activity, Request $request): Response
|
||||
public function teamPermissionsAction(Activity $activity, Request $request, ActivityService $activityService): Response
|
||||
{
|
||||
$form = $this->createForm(ActivityTeamPermissionForm::class, $activity, [
|
||||
'action' => $this->generateUrl('admin_activity_permissions', ['id' => $activity->getId()]),
|
||||
@@ -300,7 +280,7 @@ final class ActivityController extends AbstractController
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
try {
|
||||
$this->activityService->saveActivity($activity);
|
||||
$activityService->saveActivity($activity);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
if ($this->isGranted('view', $activity)) {
|
||||
@@ -345,17 +325,16 @@ final class ActivityController extends AbstractController
|
||||
|
||||
#[Route(path: '/{id}/edit', name: 'admin_activity_edit', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('edit', 'activity')]
|
||||
public function editAction(Activity $activity, Request $request): Response
|
||||
public function editAction(Activity $activity, Request $request, ActivityService $activityService, SystemConfiguration $configuration): Response
|
||||
{
|
||||
$event = new ActivityMetaDefinitionEvent($activity);
|
||||
$this->dispatcher->dispatch($event);
|
||||
$activityService->loadMetaFields($activity);
|
||||
|
||||
$editForm = $this->createEditForm($activity);
|
||||
$editForm = $this->createEditForm($activity, $configuration);
|
||||
$editForm->handleRequest($request);
|
||||
|
||||
if ($editForm->isSubmitted() && $editForm->isValid()) {
|
||||
try {
|
||||
$this->activityService->saveActivity($activity);
|
||||
$activityService->saveActivity($activity);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
if ($this->isGranted('view', $activity)) {
|
||||
@@ -377,7 +356,7 @@ final class ActivityController extends AbstractController
|
||||
|
||||
#[Route(path: '/{id}/delete', name: 'admin_activity_delete', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('delete', 'activity')]
|
||||
public function deleteAction(Activity $activity, Request $request, ActivityStatisticService $statisticService): Response
|
||||
public function deleteAction(Activity $activity, Request $request, ActivityStatisticService $statisticService, ActivityService $activityService): Response
|
||||
{
|
||||
$stats = $statisticService->getActivityStatistics($activity);
|
||||
|
||||
@@ -404,7 +383,9 @@ final class ActivityController extends AbstractController
|
||||
|
||||
if ($deleteForm->isSubmitted() && $deleteForm->isValid()) {
|
||||
try {
|
||||
$this->repository->deleteActivity($activity, $deleteForm->get('activity')->getData());
|
||||
/** @var Activity|null $replace */
|
||||
$replace = $deleteForm->get('activity')->getData();
|
||||
$activityService->deleteActivity($activity, $replace);
|
||||
$this->flashSuccess('action.delete.success');
|
||||
} catch (Exception $ex) {
|
||||
$this->flashDeleteException($ex);
|
||||
@@ -463,9 +444,9 @@ final class ActivityController extends AbstractController
|
||||
/**
|
||||
* @return FormInterface<mixed>
|
||||
*/
|
||||
private function createEditForm(Activity $activity): FormInterface
|
||||
private function createEditForm(Activity $activity, SystemConfiguration $configuration): FormInterface
|
||||
{
|
||||
$currency = $this->configuration->getCustomerDefaultCurrency();
|
||||
$currency = $configuration->getCustomerDefaultCurrency();
|
||||
$url = $this->generateUrl('admin_activity_create');
|
||||
if ($activity->getProject()?->getId() !== null) {
|
||||
$url = $this->generateUrl('admin_activity_create_with_project', ['project' => $activity->getProject()->getId()]);
|
||||
|
||||
@@ -9,15 +9,18 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Calendar\CalendarQuery;
|
||||
use App\Calendar\CalendarService;
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Entity\User;
|
||||
use App\Form\CalendarForm;
|
||||
use App\Form\Toolbar\CalendarToolbarForm;
|
||||
use App\Form\Type\CalendarViewType;
|
||||
use App\Timesheet\TrackingModeService;
|
||||
use App\Utils\PageSetup;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
@@ -35,34 +38,41 @@ final class CalendarController extends AbstractController
|
||||
#[Route(path: '/{profile}', name: 'calendar_user', methods: ['GET'])]
|
||||
public function userCalendar(Request $request): Response
|
||||
{
|
||||
$form = null;
|
||||
$profile = $this->getUser();
|
||||
$currentUser = $this->getUser();
|
||||
$profile = $currentUser;
|
||||
$canChangeUser = $this->isGranted('view_other_timesheet');
|
||||
|
||||
if ($this->isGranted('view_other_timesheet')) {
|
||||
$form = $this->createFormForGetRequest(CalendarForm::class, ['user' => $profile], [
|
||||
'action' => $this->generateUrl('calendar'),
|
||||
]);
|
||||
$query = new CalendarQuery();
|
||||
$query->setUser($profile);
|
||||
$query->setDate($this->getDateTimeFactory($profile)->create());
|
||||
|
||||
$form->handleRequest($request);
|
||||
$defaultView = CalendarViewType::DEFAULT_VIEW;
|
||||
$userView = $profile->getPreference('calendar_initial_view')?->getValue();
|
||||
if ($userView !== null) {
|
||||
$defaultView = (string) $userView;
|
||||
}
|
||||
$query->setView($defaultView);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$values = $form->getData();
|
||||
if ($values['user'] instanceof User) {
|
||||
$profile = $values['user'];
|
||||
}
|
||||
}
|
||||
$form = $this->createFormForGetRequest(CalendarToolbarForm::class, $query, [
|
||||
'action' => $this->generateUrl('calendar'),
|
||||
'change_user' => $canChangeUser,
|
||||
]);
|
||||
|
||||
$form = $form->createView();
|
||||
$form->submit($request->query->all(), false);
|
||||
|
||||
// hide if the current user is the only available one
|
||||
if (\count($form->offsetGet('user')->vars['choices']) < 2) {
|
||||
$form = null;
|
||||
$profile = $this->getUser();
|
||||
}
|
||||
if ($query->getUser() === null) {
|
||||
$query->setUser($currentUser);
|
||||
}
|
||||
|
||||
/** @var User $profile */
|
||||
$profile = $query->getUser();
|
||||
|
||||
if ($currentUser !== $profile && !$canChangeUser) {
|
||||
throw new AccessDeniedException('User is not allowed to see other users calendar');
|
||||
}
|
||||
|
||||
$mode = $this->service->getActiveMode();
|
||||
$factory = $this->getDateTimeFactory();
|
||||
$factory = $this->getDateTimeFactory($profile);
|
||||
|
||||
// if now is default time, we do not pass it on, so it can be re-calculated for each new entry
|
||||
$defaultStart = null;
|
||||
@@ -89,7 +99,9 @@ final class CalendarController extends AbstractController
|
||||
|
||||
return $this->render('calendar/user.html.twig', [
|
||||
'page_setup' => $page,
|
||||
'form' => $form,
|
||||
'initial_view' => $query->getView(),
|
||||
'initial_date' => $query->getDate(),
|
||||
'form' => $form->createView(),
|
||||
'user' => $profile,
|
||||
'config' => $config,
|
||||
'dragAndDrop' => $dragAndDrop,
|
||||
|
||||
@@ -83,8 +83,25 @@ final class ContractController extends AbstractController
|
||||
$boxConfiguration->setDecimal(false);
|
||||
$boxConfiguration->setCollapsed($summary->count() > 0);
|
||||
|
||||
$hasConfiguration = $profile->hasWorkHourConfiguration();
|
||||
$days = [];
|
||||
if ($hasConfiguration) {
|
||||
$calculator = $workingTimeService->getContractMode($profile)->getCalculator($profile);
|
||||
$start = $dateTimeFactory->getStartOfWeek();
|
||||
$end = $dateTimeFactory->getEndOfWeek();
|
||||
while ($start < $end) {
|
||||
$tmp = clone $start;
|
||||
$days[] = [
|
||||
'date' => $tmp,
|
||||
'duration' => $calculator->isWorkDay($tmp) ? $calculator->getWorkHoursForDay($tmp) : null
|
||||
];
|
||||
$start = $start->add(new \DateInterval('P1D'));
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('contract/status.html.twig', [
|
||||
'withWorkHourConfiguration' => $profile->hasWorkHourConfiguration(),
|
||||
'days' => $days,
|
||||
'withWorkHourConfiguration' => $hasConfiguration,
|
||||
'box_configuration' => $boxConfiguration,
|
||||
'page_setup' => $page,
|
||||
'decimal' => $boxConfiguration->isDecimal(),
|
||||
|
||||
@@ -14,10 +14,8 @@ use App\Customer\CustomerStatisticService;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\CustomerComment;
|
||||
use App\Entity\CustomerRate;
|
||||
use App\Entity\MetaTableTypeInterface;
|
||||
use App\Entity\Team;
|
||||
use App\Event\CustomerDetailControllerEvent;
|
||||
use App\Event\CustomerMetaDefinitionEvent;
|
||||
use App\Event\CustomerMetaDisplayEvent;
|
||||
use App\Export\Spreadsheet\EntityWithMetaFieldsExporter;
|
||||
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
|
||||
@@ -35,6 +33,7 @@ use App\Repository\Query\CustomerQuery;
|
||||
use App\Repository\Query\ProjectQuery;
|
||||
use App\Repository\Query\TeamQuery;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Repository\Query\VisibilityInterface;
|
||||
use App\Repository\TeamRepository;
|
||||
use App\Utils\DataTable;
|
||||
use App\Utils\PageSetup;
|
||||
@@ -54,17 +53,14 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
#[Route(path: '/admin/customer')]
|
||||
final class CustomerController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CustomerRepository $repository,
|
||||
private readonly EventDispatcherInterface $dispatcher
|
||||
)
|
||||
public function __construct(private readonly CustomerRepository $repository)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route(path: '/', defaults: ['page' => 1], name: 'admin_customer', methods: ['GET'])]
|
||||
#[Route(path: '/page/{page}', requirements: ['page' => '[1-9]\d*'], name: 'admin_customer_paginated', methods: ['GET'])]
|
||||
#[IsGranted(new Expression("is_granted('listing', 'customer')"))]
|
||||
public function indexAction(int $page, Request $request): Response
|
||||
public function indexAction(int $page, Request $request, EventDispatcherInterface $dispatcher): Response
|
||||
{
|
||||
$query = new CustomerQuery();
|
||||
$query->loadTeams();
|
||||
@@ -77,7 +73,9 @@ final class CustomerController extends AbstractController
|
||||
}
|
||||
|
||||
$entries = $this->repository->getPagerfantaForQuery($query);
|
||||
$metaColumns = $this->findMetaColumns($query);
|
||||
$event = new CustomerMetaDisplayEvent($query, CustomerMetaDisplayEvent::CUSTOMER);
|
||||
$dispatcher->dispatch($event);
|
||||
$metaColumns = $event->getFields();
|
||||
|
||||
$table = new DataTable('customer_admin', $query);
|
||||
$table->setPagination($entries);
|
||||
@@ -129,24 +127,13 @@ final class CustomerController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return MetaTableTypeInterface[]
|
||||
*/
|
||||
private function findMetaColumns(CustomerQuery $query): array
|
||||
{
|
||||
$event = new CustomerMetaDisplayEvent($query, CustomerMetaDisplayEvent::CUSTOMER);
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
return $event->getFields();
|
||||
}
|
||||
|
||||
#[Route(path: '/create', name: 'admin_customer_create', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('create_customer')]
|
||||
public function createAction(Request $request, CustomerService $customerService): Response
|
||||
{
|
||||
$customer = $customerService->createNewCustomer('');
|
||||
|
||||
return $this->renderCustomerForm($customer, $request, true);
|
||||
return $this->renderCustomerForm($customer, $request, $customerService);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/permissions', name: 'admin_customer_permissions', methods: ['GET', 'POST'])]
|
||||
@@ -281,7 +268,7 @@ final class CustomerController extends AbstractController
|
||||
$query->setPage($page);
|
||||
$query->setPageSize(5);
|
||||
$query->addCustomer($customer);
|
||||
$query->setShowBoth();
|
||||
$query->setVisibility(VisibilityInterface::SHOW_BOTH);
|
||||
$query->addOrderGroup('visible', ProjectQuery::ORDER_DESC);
|
||||
$query->addOrderGroup('name', ProjectQuery::ORDER_ASC);
|
||||
|
||||
@@ -297,10 +284,9 @@ final class CustomerController extends AbstractController
|
||||
|
||||
#[Route(path: '/{id}/details', name: 'customer_details', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('view', 'customer')]
|
||||
public function detailsAction(Customer $customer, TeamRepository $teamRepository, CustomerRateRepository $rateRepository, CustomerStatisticService $statisticService): Response
|
||||
public function detailsAction(Customer $customer, TeamRepository $teamRepository, CustomerRateRepository $rateRepository, CustomerStatisticService $statisticService, CustomerService $customerService, EventDispatcherInterface $dispatcher): Response
|
||||
{
|
||||
$event = new CustomerMetaDefinitionEvent($customer);
|
||||
$this->dispatcher->dispatch($event);
|
||||
$customerService->loadMetaFields($customer);
|
||||
|
||||
$stats = null;
|
||||
$timezone = null;
|
||||
@@ -349,7 +335,7 @@ final class CustomerController extends AbstractController
|
||||
|
||||
// additional boxes by plugins
|
||||
$event = new CustomerDetailControllerEvent($customer);
|
||||
$this->dispatcher->dispatch($event);
|
||||
$dispatcher->dispatch($event);
|
||||
$boxes = $event->getController();
|
||||
|
||||
$page = $this->createPageSetup();
|
||||
@@ -421,14 +407,16 @@ final class CustomerController extends AbstractController
|
||||
|
||||
#[Route(path: '/{id}/edit', name: 'admin_customer_edit', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('edit', 'customer')]
|
||||
public function editAction(Customer $customer, Request $request): Response
|
||||
public function editAction(Customer $customer, Request $request, CustomerService $customerService): Response
|
||||
{
|
||||
return $this->renderCustomerForm($customer, $request);
|
||||
$customerService->loadMetaFields($customer);
|
||||
|
||||
return $this->renderCustomerForm($customer, $request, $customerService);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/delete', name: 'admin_customer_delete', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('delete', 'customer')]
|
||||
public function deleteAction(Customer $customer, Request $request, CustomerStatisticService $statisticService): Response
|
||||
public function deleteAction(Customer $customer, Request $request, CustomerStatisticService $statisticService, CustomerService $customerService): Response
|
||||
{
|
||||
$stats = $statisticService->getCustomerStatistics($customer);
|
||||
|
||||
@@ -452,7 +440,9 @@ final class CustomerController extends AbstractController
|
||||
|
||||
if ($deleteForm->isSubmitted() && $deleteForm->isValid()) {
|
||||
try {
|
||||
$this->repository->deleteCustomer($customer, $deleteForm->get('customer')->getData());
|
||||
/** @var Customer|null $replace */
|
||||
$replace = $deleteForm->get('customer')->getData();
|
||||
$customerService->deleteCustomer($customer, $replace);
|
||||
$this->flashSuccess('action.delete.success');
|
||||
} catch (\Exception $ex) {
|
||||
$this->flashDeleteException($ex);
|
||||
@@ -496,15 +486,28 @@ final class CustomerController extends AbstractController
|
||||
return $writer->getFileResponse($spreadsheet);
|
||||
}
|
||||
|
||||
private function renderCustomerForm(Customer $customer, Request $request, bool $create = false): Response
|
||||
private function renderCustomerForm(Customer $customer, Request $request, CustomerService $customerService): Response
|
||||
{
|
||||
$editForm = $this->createEditForm($customer);
|
||||
$create = ($customer->getId() === null);
|
||||
|
||||
if ($create) {
|
||||
$url = $this->generateUrl('admin_customer_create');
|
||||
} else {
|
||||
$url = $this->generateUrl('admin_customer_edit', ['id' => $customer->getId()]);
|
||||
}
|
||||
|
||||
$editForm = $this->createForm(CustomerEditForm::class, $customer, [
|
||||
'action' => $url,
|
||||
'method' => 'POST',
|
||||
'include_budget' => $this->isGranted('budget', $customer),
|
||||
'include_time' => $this->isGranted('time', $customer),
|
||||
]);
|
||||
|
||||
$editForm->handleRequest($request);
|
||||
|
||||
if ($editForm->isSubmitted() && $editForm->isValid()) {
|
||||
try {
|
||||
$this->repository->saveCustomer($customer);
|
||||
$customerService->saveCustomer($customer);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
if ($create) {
|
||||
@@ -556,28 +559,6 @@ final class CustomerController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return FormInterface<Customer>
|
||||
*/
|
||||
private function createEditForm(Customer $customer): FormInterface
|
||||
{
|
||||
$event = new CustomerMetaDefinitionEvent($customer);
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
if ($customer->getId() === null) {
|
||||
$url = $this->generateUrl('admin_customer_create');
|
||||
} else {
|
||||
$url = $this->generateUrl('admin_customer_edit', ['id' => $customer->getId()]);
|
||||
}
|
||||
|
||||
return $this->createForm(CustomerEditForm::class, $customer, [
|
||||
'action' => $url,
|
||||
'method' => 'POST',
|
||||
'include_budget' => $this->isGranted('budget', $customer),
|
||||
'include_time' => $this->isGranted('time', $customer),
|
||||
]);
|
||||
}
|
||||
|
||||
private function createPageSetup(): PageSetup
|
||||
{
|
||||
$page = new PageSetup('customers');
|
||||
|
||||
@@ -85,13 +85,21 @@ final class ExportController extends AbstractController
|
||||
|
||||
$buttons = [];
|
||||
foreach ($this->export->getRenderer() as $renderer) {
|
||||
$class = \get_class($renderer);
|
||||
$pos = strrpos($class, '\\');
|
||||
if ($pos !== false) {
|
||||
$class = substr($class, $pos + 1);
|
||||
if (method_exists($renderer, 'getType')) {
|
||||
$class = $renderer->getType();
|
||||
} else {
|
||||
// TODO remove me with 3.0
|
||||
$class = \get_class($renderer);
|
||||
$pos = strrpos($class, '\\');
|
||||
if ($pos !== false) {
|
||||
$class = substr($class, $pos + 1);
|
||||
}
|
||||
$class = strtolower(str_replace('Renderer', '', $class));
|
||||
}
|
||||
$class = strtolower(str_replace('Renderer', '', $class));
|
||||
$buttons[$class][$renderer->getId()] = $renderer->getTitle();
|
||||
$buttons[$class][$renderer->getId()] = [
|
||||
'title' => $renderer->getTitle(),
|
||||
'internal' => method_exists($renderer, 'isInternal') ? $renderer->isInternal() : false,
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->isGranted('view_other_timesheet')) {
|
||||
@@ -231,7 +239,8 @@ final class ExportController extends AbstractController
|
||||
}
|
||||
|
||||
return $this->render('export/template.html.twig', [
|
||||
'form' => $form->createView()
|
||||
'form' => $form->createView(),
|
||||
'template' => $exportTemplate,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use App\Entity\MetaTableTypeInterface;
|
||||
use App\Event\InvoiceDocumentsEvent;
|
||||
use App\Event\InvoiceMetaDefinitionEvent;
|
||||
use App\Event\InvoiceMetaDisplayEvent;
|
||||
use App\Event\InvoiceTemplateMetaDefinitionEvent;
|
||||
use App\Export\Spreadsheet\EntityWithMetaFieldsExporter;
|
||||
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
|
||||
use App\Export\Spreadsheet\Writer\XlsxWriter;
|
||||
@@ -320,7 +321,7 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
if (null === $file) {
|
||||
throw $this->createNotFoundException(
|
||||
\sprintf('Invoice file "%s" could not be found for invoice ID "%s"', $invoice->getInvoiceFilename(), $invoice->getId())
|
||||
\sprintf('Invoice file could not be found for invoice ID "%s"', $invoice->getId())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -425,7 +426,7 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
$table->addColumn('name', ['class' => 'alwaysVisible', 'orderBy' => false]);
|
||||
$table->addColumn('title', ['class' => 'd-none text-nowrap', 'orderBy' => false]);
|
||||
$table->addColumn('company', ['class' => 'd-none', 'orderBy' => false]);
|
||||
$table->addColumn('company', ['class' => 'd-none', 'orderBy' => false, 'title' => 'sending_company']);
|
||||
$table->addColumn('vat_id', ['class' => 'd-none text-nowrap', 'orderBy' => false]);
|
||||
$table->addColumn('tax_rate', ['class' => 'd-none text-nowrap', 'orderBy' => false]);
|
||||
$table->addColumn('due_days', ['class' => 'd-none text-nowrap', 'orderBy' => false]);
|
||||
@@ -728,6 +729,9 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
private function renderTemplateForm(InvoiceTemplate $template, Request $request): Response
|
||||
{
|
||||
$event = new InvoiceTemplateMetaDefinitionEvent($template);
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
$editForm = $this->createTemplateEditForm($template);
|
||||
|
||||
$editForm->handleRequest($request);
|
||||
|
||||
@@ -11,13 +11,11 @@ namespace App\Controller;
|
||||
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\MetaTableTypeInterface;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\ProjectComment;
|
||||
use App\Entity\ProjectRate;
|
||||
use App\Entity\Team;
|
||||
use App\Event\ProjectDetailControllerEvent;
|
||||
use App\Event\ProjectMetaDefinitionEvent;
|
||||
use App\Event\ProjectMetaDisplayEvent;
|
||||
use App\Export\Spreadsheet\EntityWithMetaFieldsExporter;
|
||||
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
|
||||
@@ -38,6 +36,7 @@ use App\Repository\Query\ActivityQuery;
|
||||
use App\Repository\Query\ProjectQuery;
|
||||
use App\Repository\Query\TeamQuery;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Repository\Query\VisibilityInterface;
|
||||
use App\Repository\TeamRepository;
|
||||
use App\Utils\Context;
|
||||
use App\Utils\DataTable;
|
||||
@@ -58,19 +57,14 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
#[Route(path: '/admin/project')]
|
||||
final class ProjectController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProjectRepository $repository,
|
||||
private readonly SystemConfiguration $configuration,
|
||||
private readonly EventDispatcherInterface $dispatcher,
|
||||
private readonly ProjectService $projectService
|
||||
)
|
||||
public function __construct(private readonly ProjectRepository $repository)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route(path: '/', defaults: ['page' => 1], name: 'admin_project', methods: ['GET'])]
|
||||
#[Route(path: '/page/{page}', requirements: ['page' => '[1-9]\d*'], name: 'admin_project_paginated', methods: ['GET'])]
|
||||
#[IsGranted(new Expression("is_granted('listing', 'project')"))]
|
||||
public function indexAction(int $page, Request $request): Response
|
||||
public function indexAction(int $page, Request $request, EventDispatcherInterface $dispatcher): Response
|
||||
{
|
||||
$query = new ProjectQuery();
|
||||
$query->loadTeams();
|
||||
@@ -83,7 +77,9 @@ final class ProjectController extends AbstractController
|
||||
}
|
||||
|
||||
$entries = $this->repository->getPagerfantaForQuery($query);
|
||||
$metaColumns = $this->findMetaColumns($query);
|
||||
$event = new ProjectMetaDisplayEvent($query, ProjectMetaDisplayEvent::PROJECT);
|
||||
$dispatcher->dispatch($event);
|
||||
$metaColumns = $event->getFields();
|
||||
|
||||
$table = new DataTable('project_admin', $query);
|
||||
$table->setPagination($entries);
|
||||
@@ -129,21 +125,9 @@ final class ProjectController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ProjectQuery $query
|
||||
* @return MetaTableTypeInterface[]
|
||||
*/
|
||||
private function findMetaColumns(ProjectQuery $query): array
|
||||
{
|
||||
$event = new ProjectMetaDisplayEvent($query, ProjectMetaDisplayEvent::PROJECT);
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
return $event->getFields();
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/permissions', name: 'admin_project_permissions', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('permissions', 'project')]
|
||||
public function teamPermissions(Project $project, Request $request): Response
|
||||
public function teamPermissions(Project $project, Request $request, ProjectService $projectService): Response
|
||||
{
|
||||
$form = $this->createForm(ProjectTeamPermissionForm::class, $project, [
|
||||
'action' => $this->generateUrl('admin_project_permissions', ['id' => $project->getId()]),
|
||||
@@ -154,7 +138,7 @@ final class ProjectController extends AbstractController
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
try {
|
||||
$this->projectService->saveProject($project);
|
||||
$projectService->saveProject($project);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
if ($this->isGranted('view', $project)) {
|
||||
@@ -176,28 +160,28 @@ final class ProjectController extends AbstractController
|
||||
|
||||
#[Route(path: '/create/{customer}', name: 'admin_project_create_with_customer', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('create_project')]
|
||||
public function createWithCustomerAction(Request $request, Customer $customer): Response
|
||||
public function createWithCustomerAction(Request $request, Customer $customer, ProjectService $projectService, SystemConfiguration $configuration): Response
|
||||
{
|
||||
return $this->createProject($request, $customer);
|
||||
return $this->createProject($request, $projectService, $configuration, $customer);
|
||||
}
|
||||
|
||||
#[Route(path: '/create', name: 'admin_project_create', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('create_project')]
|
||||
public function createAction(Request $request): Response
|
||||
public function createAction(Request $request, ProjectService $projectService, SystemConfiguration $configuration): Response
|
||||
{
|
||||
return $this->createProject($request, null);
|
||||
return $this->createProject($request, $projectService, $configuration, null);
|
||||
}
|
||||
|
||||
private function createProject(Request $request, ?Customer $customer = null): Response
|
||||
private function createProject(Request $request, ProjectService $projectService, SystemConfiguration $configuration, ?Customer $customer = null): Response
|
||||
{
|
||||
$project = $this->projectService->createNewProject($customer);
|
||||
$project = $projectService->createNewProject($customer);
|
||||
|
||||
$editForm = $this->createEditForm($project);
|
||||
$editForm = $this->createEditForm($project, $configuration->getCustomerDefaultCurrency());
|
||||
$editForm->handleRequest($request);
|
||||
|
||||
if ($editForm->isSubmitted() && $editForm->isValid()) {
|
||||
try {
|
||||
$this->projectService->saveProject($project, new Context($this->getUser()));
|
||||
$projectService->saveProject($project, new Context($this->getUser()));
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRouteAfterCreate('project_details', ['id' => $project->getId()]);
|
||||
@@ -313,7 +297,7 @@ final class ProjectController extends AbstractController
|
||||
$query->setPageSize(5);
|
||||
$query->addProject($project);
|
||||
$query->setExcludeGlobals(true);
|
||||
$query->setShowBoth();
|
||||
$query->setVisibility(VisibilityInterface::SHOW_BOTH);
|
||||
$query->addOrderGroup('visible', ActivityQuery::ORDER_DESC);
|
||||
$query->addOrderGroup('name', ActivityQuery::ORDER_ASC);
|
||||
|
||||
@@ -329,10 +313,9 @@ final class ProjectController extends AbstractController
|
||||
|
||||
#[Route(path: '/{id}/details', name: 'project_details', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('view', 'project')]
|
||||
public function detailsAction(Project $project, TeamRepository $teamRepository, ProjectRateRepository $rateRepository, ProjectStatisticService $statisticService, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
public function detailsAction(Project $project, TeamRepository $teamRepository, ProjectRateRepository $rateRepository, ProjectStatisticService $statisticService, ProjectService $projectService, CsrfTokenManagerInterface $csrfTokenManager, EventDispatcherInterface $dispatcher): Response
|
||||
{
|
||||
$event = new ProjectMetaDefinitionEvent($project);
|
||||
$this->dispatcher->dispatch($event);
|
||||
$projectService->loadMetaFields($project);
|
||||
|
||||
$stats = null;
|
||||
$defaultTeam = null;
|
||||
@@ -376,7 +359,7 @@ final class ProjectController extends AbstractController
|
||||
|
||||
// additional boxes by plugins
|
||||
$event = new ProjectDetailControllerEvent($project);
|
||||
$this->dispatcher->dispatch($event);
|
||||
$dispatcher->dispatch($event);
|
||||
$boxes = $event->getController();
|
||||
|
||||
$page = $this->createPageSetup();
|
||||
@@ -447,14 +430,16 @@ final class ProjectController extends AbstractController
|
||||
|
||||
#[Route(path: '/{id}/edit', name: 'admin_project_edit', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('edit', 'project')]
|
||||
public function editAction(Project $project, Request $request): Response
|
||||
public function editAction(Project $project, Request $request, ProjectService $projectService, SystemConfiguration $configuration): Response
|
||||
{
|
||||
$editForm = $this->createEditForm($project);
|
||||
$projectService->loadMetaFields($project);
|
||||
|
||||
$editForm = $this->createEditForm($project, $configuration->getCustomerDefaultCurrency());
|
||||
$editForm->handleRequest($request);
|
||||
|
||||
if ($editForm->isSubmitted() && $editForm->isValid()) {
|
||||
try {
|
||||
$this->projectService->saveProject($project);
|
||||
$projectService->saveProject($project);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
if ($this->isGranted('view', $project)) {
|
||||
@@ -501,7 +486,7 @@ final class ProjectController extends AbstractController
|
||||
|
||||
#[Route(path: '/{id}/delete', name: 'admin_project_delete', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('delete', 'project')]
|
||||
public function deleteAction(Project $project, Request $request, ProjectStatisticService $statisticService): Response
|
||||
public function deleteAction(Project $project, Request $request, ProjectStatisticService $statisticService, ProjectService $projectService): Response
|
||||
{
|
||||
$stats = $statisticService->getProjectStatistics($project);
|
||||
|
||||
@@ -526,7 +511,9 @@ final class ProjectController extends AbstractController
|
||||
|
||||
if ($deleteForm->isSubmitted() && $deleteForm->isValid()) {
|
||||
try {
|
||||
$this->repository->deleteProject($project, $deleteForm->get('project')->getData());
|
||||
/** @var Project|null $replace */
|
||||
$replace = $deleteForm->get('project')->getData();
|
||||
$projectService->deleteProject($project, $replace);
|
||||
$this->flashSuccess('action.delete.success');
|
||||
} catch (\Exception $ex) {
|
||||
$this->flashDeleteException($ex);
|
||||
@@ -591,12 +578,8 @@ final class ProjectController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
private function createEditForm(Project $project): FormInterface
|
||||
private function createEditForm(Project $project, string $currency): FormInterface
|
||||
{
|
||||
$event = new ProjectMetaDefinitionEvent($project);
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
$currency = $this->configuration->getCustomerDefaultCurrency();
|
||||
$url = $this->generateUrl('admin_project_create');
|
||||
|
||||
if ($project->getId() !== null) {
|
||||
|
||||
@@ -77,6 +77,7 @@ final class QuickEntryController extends AbstractController
|
||||
$endWeek = $factory->getEndOfWeek($begin);
|
||||
|
||||
$tmpDay = clone $startWeek;
|
||||
/** @var array<string, array{day: \DateTime}> $week */
|
||||
$week = [];
|
||||
while ($tmpDay < $endWeek) {
|
||||
$nextDay = clone $tmpDay;
|
||||
@@ -125,9 +126,9 @@ final class QuickEntryController extends AbstractController
|
||||
if ($amount > 0) {
|
||||
$takeOverWeeks = $this->configuration->find('quick_entry.recent_activity_weeks');
|
||||
$startFrom = null;
|
||||
if ($takeOverWeeks !== null && \intval($takeOverWeeks) > 0) {
|
||||
if (is_numeric($takeOverWeeks) && \intval($takeOverWeeks) > 0) {
|
||||
$startFrom = clone $startWeek;
|
||||
$startFrom->modify(\sprintf('-%s weeks', $takeOverWeeks));
|
||||
$startFrom->modify(\sprintf('-%s weeks', (string) $takeOverWeeks));
|
||||
}
|
||||
|
||||
$favorites = $this->favoriteRecordService->favoriteEntries($user, $amount);
|
||||
|
||||
@@ -37,6 +37,7 @@ use App\Validator\Constraints\ColorChoices;
|
||||
use App\Validator\Constraints\DateTimeFormat;
|
||||
use App\Validator\Constraints\TimeFormat;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CountryType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CurrencyType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||
@@ -587,7 +588,8 @@ final class SystemConfigurationController extends AbstractController
|
||||
->setConstraints([new NotBlank(), new TimeFormat()]),
|
||||
(new Configuration('calendar.slot_duration'))
|
||||
->setTranslationDomain('system-configuration')
|
||||
->setType(TextType::class)
|
||||
->setType(ChoiceType::class)
|
||||
->setOptions(['choices' => ['00:15' => '00:15:00', '00:30' => '00:30:00', '01:00' => '01:00:00']])
|
||||
->setConstraints([new Regex(['pattern' => '/[0-2]{1}[0-9]{1}:[0-9]{2}:[0-9]{2}/']), new NotNull()]),
|
||||
(new Configuration('calendar.dragdrop_amount'))
|
||||
->setTranslationDomain('system-configuration')
|
||||
|
||||
@@ -22,6 +22,7 @@ use Symfony\Component\Form\Extension\Core\Type\FormType;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
@@ -94,6 +95,10 @@ final class TeamController extends AbstractController
|
||||
{
|
||||
$newTeam = clone $team;
|
||||
|
||||
if ($team->getName() === null) {
|
||||
throw new BadRequestHttpException('Team with empty name cannot be duplicated');
|
||||
}
|
||||
|
||||
$i = 1;
|
||||
do {
|
||||
$newName = \sprintf('%s (%s)', $team->getName(), $i++);
|
||||
|
||||
@@ -365,9 +365,7 @@ abstract class TimesheetAbstractController extends AbstractController
|
||||
}
|
||||
|
||||
if ($dto->isRecalculateRates()) {
|
||||
$timesheet->setFixedRate(null);
|
||||
$timesheet->setHourlyRate(null);
|
||||
$timesheet->setInternalRate(null);
|
||||
$timesheet->resetRates();
|
||||
$execute = true;
|
||||
} elseif (null !== $dto->getFixedRate()) {
|
||||
$timesheet->setFixedRate($dto->getFixedRate());
|
||||
|
||||
@@ -45,6 +45,11 @@ final class CustomerService
|
||||
return $timezone;
|
||||
}
|
||||
|
||||
public function loadMetaFields(Customer $customer): void
|
||||
{
|
||||
$this->dispatcher->dispatch(new CustomerMetaDefinitionEvent($customer));
|
||||
}
|
||||
|
||||
public function createNewCustomer(string $name): Customer
|
||||
{
|
||||
$customer = new Customer($name);
|
||||
@@ -53,7 +58,7 @@ final class CustomerService
|
||||
$customer->setCurrency($this->configuration->getCustomerDefaultCurrency());
|
||||
$customer->setNumber($this->calculateNextCustomerNumber());
|
||||
|
||||
$this->dispatcher->dispatch(new CustomerMetaDefinitionEvent($customer));
|
||||
$this->loadMetaFields($customer);
|
||||
$this->dispatcher->dispatch(new CustomerCreateEvent($customer));
|
||||
|
||||
return $customer;
|
||||
@@ -86,10 +91,10 @@ final class CustomerService
|
||||
return $customer;
|
||||
}
|
||||
|
||||
public function deleteCustomer(Customer $customer): void
|
||||
public function deleteCustomer(Customer $customer, ?Customer $replace = null): void
|
||||
{
|
||||
$this->dispatcher->dispatch(new CustomerDeleteEvent($customer));
|
||||
$this->repository->deleteCustomer($customer);
|
||||
$this->dispatcher->dispatch(new CustomerDeleteEvent($customer, $replace));
|
||||
$this->repository->deleteCustomer($customer, $replace);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,7 +19,7 @@ use App\Timesheet\DateTimeFactory;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Query;
|
||||
use Doctrine\ORM\Query\Expr\Join;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
@@ -112,7 +112,7 @@ class CustomerStatisticService
|
||||
$qb = $this->timesheetRepository->createQueryBuilder('t');
|
||||
$qb
|
||||
->select('IDENTITY(p.customer) AS id')
|
||||
->join(Project::class, 'p', Query\Expr\Join::WITH, 't.project = p.id')
|
||||
->join(Project::class, 'p', Join::WITH, 't.project = p.id')
|
||||
->addSelect('COALESCE(SUM(t.duration), 0) as duration')
|
||||
->addSelect('COALESCE(SUM(t.rate), 0) as rate')
|
||||
->addSelect('COALESCE(SUM(t.internalRate), 0) as internalRate')
|
||||
|
||||
@@ -92,6 +92,10 @@ final class CustomerFixtures extends Fixture
|
||||
$entry->setTimezone($faker->timezone());
|
||||
$entry->setVisible($visible);
|
||||
$entry->setVatId($faker->creditCardNumber());
|
||||
$entry->setPostCode($faker->postcode());
|
||||
$entry->setCity($faker->city());
|
||||
$entry->setAddressLine1($faker->streetAddress());
|
||||
$entry->setAddressLine2($faker->streetAddress());
|
||||
|
||||
if (rand(0, 3) % 3) {
|
||||
$entry->setBudget(rand(self::MIN_BUDGET, self::MAX_BUDGET));
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\DataFixtures;
|
||||
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\InvoiceTemplate;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
@@ -29,6 +30,8 @@ final class InvoiceFixtures extends Fixture
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
$faker = Factory::create('en_US');
|
||||
/** @var non-empty-array<Customer> $customers */
|
||||
$customers = $manager->getRepository(Customer::class)->findBy(['visible' => true]);
|
||||
|
||||
foreach ($this->getInvoiceConfigs($faker) as $invoiceConfig) {
|
||||
// name, title, renderer, calculator, numberGenerator, company, vat, dueDays, address, paymentTerms
|
||||
@@ -38,15 +41,16 @@ final class InvoiceFixtures extends Fixture
|
||||
$template->setRenderer($invoiceConfig[2]);
|
||||
$template->setCalculator($invoiceConfig[3]);
|
||||
$template->setNumberGenerator($invoiceConfig[4]);
|
||||
$template->setCompany($invoiceConfig[5]);
|
||||
$template->setCompany($invoiceConfig[5]); // @phpstan-ignore method.deprecated
|
||||
$template->setCustomer($customers[array_rand($customers)]);
|
||||
$template->setVat($invoiceConfig[6]);
|
||||
$template->setDueDays($invoiceConfig[7]);
|
||||
$template->setPaymentTerms($invoiceConfig[8]);
|
||||
$template->setLanguage('en');
|
||||
$template->setAddress($this->generateAddress($faker));
|
||||
$template->setAddress($this->generateAddress($faker)); // @phpstan-ignore method.deprecated
|
||||
$template->setContact($this->generateContact($faker));
|
||||
$template->setPaymentDetails($this->generatePaymentDetails($faker));
|
||||
$template->setVatId($faker->creditCardNumber());
|
||||
$template->setVatId($faker->creditCardNumber()); // @phpstan-ignore method.deprecated
|
||||
|
||||
$manager->persist($template);
|
||||
$manager->flush();
|
||||
@@ -54,8 +58,7 @@ final class InvoiceFixtures extends Fixture
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Generator $faker
|
||||
* @return array
|
||||
* @return array<array{0: string, 1: string, 2: string, 3: string, 4: string, 5: string, 6: int, 7: int, 8: string}>
|
||||
*/
|
||||
private function getInvoiceConfigs(Generator $faker): array
|
||||
{
|
||||
|
||||
@@ -37,8 +37,7 @@ final class TeamFixtures extends Fixture
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ObjectManager $manager
|
||||
* @return array<int|string, User>
|
||||
* @return non-empty-array<int|string, User>
|
||||
*/
|
||||
private function getAllUsers(ObjectManager $manager): array
|
||||
{
|
||||
@@ -46,15 +45,18 @@ final class TeamFixtures extends Fixture
|
||||
/** @var User[] $entries */
|
||||
$entries = $manager->getRepository(User::class)->findAll();
|
||||
foreach ($entries as $temp) {
|
||||
$all[$temp->getId()] = $temp;
|
||||
$all[(string) $temp->getId()] = $temp;
|
||||
}
|
||||
|
||||
if (\count($all) === 0) {
|
||||
throw new \Exception('Need users to setup teams');
|
||||
}
|
||||
|
||||
return $all;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ObjectManager $manager
|
||||
* @return array<int|string, Project>
|
||||
* @return non-empty-array<int|string, Project>
|
||||
*/
|
||||
private function getAllProjects(ObjectManager $manager): array
|
||||
{
|
||||
@@ -63,7 +65,11 @@ final class TeamFixtures extends Fixture
|
||||
/** @var Project[] $entries */
|
||||
$entries = $manager->getRepository(Project::class)->findAll();
|
||||
foreach ($entries as $temp) {
|
||||
$all[$temp->getId()] = $temp;
|
||||
$all[(string) $temp->getId()] = $temp;
|
||||
}
|
||||
|
||||
if (\count($all) === 0) {
|
||||
throw new \Exception('Need projects to setup teams');
|
||||
}
|
||||
|
||||
return $all;
|
||||
|
||||
@@ -62,10 +62,12 @@ final class TimesheetFixtures extends Fixture implements FixtureGroupInterface
|
||||
$all = 0;
|
||||
|
||||
foreach ($allUser as $user) {
|
||||
// reload, because the manager might have been cleared
|
||||
$user = $manager->find(User::class, $user->getId());
|
||||
// random amount of timesheet entries for every user
|
||||
$timesheetForUser = rand(self::MIN_TIMESHEETS_PER_USER, self::MAX_TIMESHEETS_PER_USER);
|
||||
|
||||
// load on each round, because the manager might have been cleared
|
||||
$activities = $this->getAllActivities($manager);
|
||||
$projects = $this->getAllProjects($manager);
|
||||
|
||||
@@ -130,10 +132,9 @@ final class TimesheetFixtures extends Fixture implements FixtureGroupInterface
|
||||
|
||||
/**
|
||||
* @template T of object
|
||||
* @param ObjectManager $manager
|
||||
* @param class-string<T> $class
|
||||
* @param int $amount
|
||||
* @return array<int, T>
|
||||
* @return non-empty-array<int, T>
|
||||
*/
|
||||
private function findRandom(ObjectManager $manager, string $class, int $amount): array
|
||||
{
|
||||
@@ -157,12 +158,15 @@ final class TimesheetFixtures extends Fixture implements FixtureGroupInterface
|
||||
/** @var array<int, T> $result */
|
||||
$result = $qb->where($qb->expr()->in('entity.id', $ids))->setMaxResults($amount)->getQuery()->getResult();
|
||||
|
||||
if (\count($result) === 0) {
|
||||
throw new \Exception('Could not find any entity: ' . $class);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ObjectManager $manager
|
||||
* @return array<int|string, Tag>
|
||||
* @return non-empty-array<int|string, Tag>
|
||||
*/
|
||||
private function getAllTags(ObjectManager $manager): array
|
||||
{
|
||||
@@ -170,8 +174,7 @@ final class TimesheetFixtures extends Fixture implements FixtureGroupInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ObjectManager $manager
|
||||
* @return array<int|string, User>
|
||||
* @return non-empty-array<int|string, User>
|
||||
*/
|
||||
private function getAllUsers(ObjectManager $manager): array
|
||||
{
|
||||
@@ -179,15 +182,18 @@ final class TimesheetFixtures extends Fixture implements FixtureGroupInterface
|
||||
/** @var User[] $entries */
|
||||
$entries = $manager->getRepository(User::class)->findAll();
|
||||
foreach ($entries as $temp) {
|
||||
$all[$temp->getId()] = $temp;
|
||||
$all[(string) $temp->getId()] = $temp;
|
||||
}
|
||||
|
||||
if (\count($all) === 0) {
|
||||
throw new \Exception('Need users to setup timesheets');
|
||||
}
|
||||
|
||||
return $all;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ObjectManager $manager
|
||||
* @return array<int|string, Project>
|
||||
* @return non-empty-array<int|string, Project>
|
||||
*/
|
||||
private function getAllProjects(ObjectManager $manager): array
|
||||
{
|
||||
@@ -195,8 +201,7 @@ final class TimesheetFixtures extends Fixture implements FixtureGroupInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ObjectManager $manager
|
||||
* @return array<int|string, Activity>
|
||||
* @return non-empty-array<int|string, Activity>
|
||||
*/
|
||||
private function getAllActivities(ObjectManager $manager): array
|
||||
{
|
||||
|
||||
@@ -74,11 +74,9 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* @param User $user
|
||||
* @param string|null $timezone
|
||||
* @return array<UserPreference>
|
||||
*/
|
||||
private function getUserPreferences(User $user, string $timezone = null): array
|
||||
private function getUserPreferences(User $user, ?string $timezone = null): array
|
||||
{
|
||||
$preferences = [];
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ORM\Entity(repositoryClass: CustomerRepository::class)]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
#[Exporter\Order(['id', 'name', 'company', 'number', 'vatId', 'address', 'contact', 'email', 'phone', 'mobile', 'fax', 'homepage', 'country', 'currency', 'timezone', 'budget', 'timeBudget', 'budgetType', 'color', 'visible', 'comment', 'billable'])]
|
||||
#[Exporter\Order(['id', 'name', 'company', 'number', 'vatId', 'address', 'contact', 'email', 'phone', 'mobile', 'fax', 'homepage', 'addressLine1', 'addressLine2', 'addressLine3', 'postCode', 'city', 'country', 'currency', 'timezone', 'budget', 'timeBudget', 'budgetType', 'color', 'visible', 'comment', 'billable'])]
|
||||
#[Constraints\Customer]
|
||||
class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
{
|
||||
@@ -77,7 +77,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
#[ORM\Column(name: 'company', type: Types::STRING, length: 100, nullable: true)]
|
||||
#[Assert\Length(max: 100)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Customer_Entity'])]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'company')]
|
||||
private ?string $company = null;
|
||||
#[ORM\Column(name: 'vat_id', type: Types::STRING, length: 50, nullable: true)]
|
||||
@@ -86,13 +86,19 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
#[Serializer\Groups(['Customer_Entity'])]
|
||||
#[Exporter\Expose(label: 'vat_id')]
|
||||
private ?string $vatId = null;
|
||||
#[ORM\Column(name: 'contact', type: 'string', length: 100, nullable: true)]
|
||||
/**
|
||||
* Contact person (name)
|
||||
*/
|
||||
#[ORM\Column(name: 'contact', type: Types::STRING, length: 100, nullable: true)]
|
||||
#[Assert\Length(max: 100)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Customer_Entity'])]
|
||||
#[Exporter\Expose(label: 'contact')]
|
||||
private ?string $contact = null;
|
||||
#[ORM\Column(name: 'address', type: 'text', nullable: true)]
|
||||
/**
|
||||
* Unstructured address, better use the fields: addressLine1-3, postcode, city, country
|
||||
*/
|
||||
#[ORM\Column(name: 'address', type: Types::TEXT, nullable: true)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Customer_Entity'])]
|
||||
#[Exporter\Expose(label: 'address')]
|
||||
@@ -102,7 +108,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
#[Assert\Country]
|
||||
#[Assert\Length(max: 2)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Customer_Entity'])]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'country')]
|
||||
private ?string $country = null;
|
||||
#[ORM\Column(name: 'currency', type: Types::STRING, length: 3, nullable: false)]
|
||||
@@ -110,29 +116,29 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
#[Assert\Currency]
|
||||
#[Assert\Length(max: 3)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Customer'])]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'currency')]
|
||||
private ?string $currency = self::DEFAULT_CURRENCY;
|
||||
#[ORM\Column(name: 'phone', type: Types::STRING, length: 30, nullable: true)]
|
||||
#[Assert\Length(max: 30)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Customer_Entity'])]
|
||||
#[Serializer\Groups(['Customer'])]
|
||||
#[Exporter\Expose(label: 'phone')]
|
||||
private ?string $phone = null;
|
||||
#[ORM\Column(name: 'fax', type: Types::STRING, length: 30, nullable: true)]
|
||||
#[Assert\Length(max: 30)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Customer_Entity'])]
|
||||
#[Serializer\Groups(['Customer'])]
|
||||
#[Exporter\Expose(label: 'fax')]
|
||||
private ?string $fax = null;
|
||||
#[ORM\Column(name: 'mobile', type: Types::STRING, length: 30, nullable: true)]
|
||||
#[Assert\Length(max: 30)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Customer_Entity'])]
|
||||
#[Serializer\Groups(['Customer'])]
|
||||
#[Exporter\Expose(label: 'mobile')]
|
||||
private ?string $mobile = null;
|
||||
/**
|
||||
* Customers contact email
|
||||
* Contact email
|
||||
*/
|
||||
#[ORM\Column(name: 'email', type: Types::STRING, length: 75, nullable: true)]
|
||||
#[Assert\Length(max: 75)]
|
||||
@@ -143,7 +149,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
#[ORM\Column(name: 'homepage', type: Types::STRING, length: 100, nullable: true)]
|
||||
#[Assert\Length(max: 100)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Customer_Entity'])]
|
||||
#[Serializer\Groups(['Customer'])]
|
||||
#[Exporter\Expose(label: 'homepage')]
|
||||
private ?string $homepage = null;
|
||||
/**
|
||||
@@ -154,7 +160,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
#[Assert\Timezone]
|
||||
#[Assert\Length(max: 64)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Customer_Entity'])]
|
||||
#[Serializer\Groups(['Customer'])]
|
||||
#[Exporter\Expose(label: 'timezone')]
|
||||
private ?string $timezone = null;
|
||||
/**
|
||||
@@ -190,6 +196,43 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
private ?InvoiceTemplate $invoiceTemplate = null;
|
||||
#[ORM\Column(name: 'invoice_text', type: Types::TEXT, nullable: true)]
|
||||
private ?string $invoiceText = null;
|
||||
#[ORM\Column(name: 'address_line1', type: Types::STRING, length: 150, nullable: true)]
|
||||
#[Assert\Length(max: 150)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Customer_Entity'])]
|
||||
#[Exporter\Expose(label: 'address_line1')]
|
||||
private ?string $addressLine1 = null;
|
||||
#[ORM\Column(name: 'address_line2', type: Types::STRING, length: 150, nullable: true)]
|
||||
#[Assert\Length(max: 150)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Customer_Entity'])]
|
||||
#[Exporter\Expose(label: 'address_line2')]
|
||||
private ?string $addressLine2 = null;
|
||||
#[ORM\Column(name: 'address_line3', type: Types::STRING, length: 150, nullable: true)]
|
||||
#[Assert\Length(max: 150)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Customer_Entity'])]
|
||||
#[Exporter\Expose(label: 'address_line3')]
|
||||
private ?string $addressLine3 = null;
|
||||
#[ORM\Column(name: 'postcode', type: Types::STRING, length: 20, nullable: true)]
|
||||
#[Assert\Length(max: 20)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Customer_Entity'])]
|
||||
#[Exporter\Expose(label: 'postcode')]
|
||||
private ?string $postCode = null;
|
||||
// this should be more than enough to cover 99.99% - https://en.wikipedia.org/wiki/List_of_long_place_names
|
||||
#[ORM\Column(name: 'city', type: Types::STRING, length: 50, nullable: true)]
|
||||
#[Assert\Length(max: 50)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Customer_Entity'])]
|
||||
#[Exporter\Expose(label: 'city')]
|
||||
private ?string $city = null;
|
||||
#[ORM\Column(name: 'buyer_reference', type: Types::STRING, length: 50, nullable: true)]
|
||||
#[Assert\Length(max: 50)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Customer_Entity'])]
|
||||
#[Exporter\Expose(label: 'buyer_reference')]
|
||||
private ?string $buyerReference = null;
|
||||
|
||||
public function __construct(string $name)
|
||||
{
|
||||
@@ -299,6 +342,35 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
return $this->address;
|
||||
}
|
||||
|
||||
public function getFormattedAddress(): ?string
|
||||
{
|
||||
$address = $this->getAddressLine1();
|
||||
if ($this->getAddressLine2() !== null) {
|
||||
$address .= PHP_EOL;
|
||||
$address .= $this->getAddressLine2();
|
||||
}
|
||||
if ($this->getAddressLine3() !== null) {
|
||||
$address .= PHP_EOL;
|
||||
$address .= $this->getAddressLine3();
|
||||
}
|
||||
if ($this->getPostCode() !== null || $this->getCity() !== null) {
|
||||
$address .= PHP_EOL;
|
||||
if ($this->getPostCode() !== null) {
|
||||
$address .= $this->getPostCode();
|
||||
if ($this->getCity() !== null) {
|
||||
$address .= ' ';
|
||||
}
|
||||
}
|
||||
$address .= $this->getCity() ?? '';
|
||||
}
|
||||
|
||||
if ($address !== null && $address !== '') {
|
||||
return $address;
|
||||
}
|
||||
|
||||
return $this->getAddress();
|
||||
}
|
||||
|
||||
public function setCountry(?string $country): void
|
||||
{
|
||||
$this->country = $country;
|
||||
@@ -479,6 +551,66 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
return $this->teams;
|
||||
}
|
||||
|
||||
public function getAddressLine1(): ?string
|
||||
{
|
||||
return $this->addressLine1;
|
||||
}
|
||||
|
||||
public function setAddressLine1(?string $addressLine1): void
|
||||
{
|
||||
$this->addressLine1 = $addressLine1;
|
||||
}
|
||||
|
||||
public function getAddressLine2(): ?string
|
||||
{
|
||||
return $this->addressLine2;
|
||||
}
|
||||
|
||||
public function setAddressLine2(?string $addressLine2): void
|
||||
{
|
||||
$this->addressLine2 = $addressLine2;
|
||||
}
|
||||
|
||||
public function getAddressLine3(): ?string
|
||||
{
|
||||
return $this->addressLine3;
|
||||
}
|
||||
|
||||
public function setAddressLine3(?string $addressLine3): void
|
||||
{
|
||||
$this->addressLine3 = $addressLine3;
|
||||
}
|
||||
|
||||
public function getPostCode(): ?string
|
||||
{
|
||||
return $this->postCode;
|
||||
}
|
||||
|
||||
public function setPostCode(?string $postCode): void
|
||||
{
|
||||
$this->postCode = $postCode;
|
||||
}
|
||||
|
||||
public function getCity(): ?string
|
||||
{
|
||||
return $this->city;
|
||||
}
|
||||
|
||||
public function setCity(?string $city): void
|
||||
{
|
||||
$this->city = $city;
|
||||
}
|
||||
|
||||
public function getBuyerReference(): ?string
|
||||
{
|
||||
return $this->buyerReference;
|
||||
}
|
||||
|
||||
public function setBuyerReference(?string $buyerReference): void
|
||||
{
|
||||
$this->buyerReference = $buyerReference;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->getName();
|
||||
|
||||
@@ -60,6 +60,11 @@ class ExportTemplate
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function isNew(): bool
|
||||
{
|
||||
return $this->id === null;
|
||||
}
|
||||
|
||||
public function setTitle(?string $title): void
|
||||
{
|
||||
$this->title = $title;
|
||||
@@ -106,7 +111,7 @@ class ExportTemplate
|
||||
$this->columns = $columns ?? [];
|
||||
}
|
||||
|
||||
public function getOption(string $key, int|string|bool $default): int|string|bool
|
||||
public function getOption(string $key, int|string|bool|null $default): int|string|bool|null
|
||||
{
|
||||
if (\array_key_exists($key, $this->options)) {
|
||||
return $this->options[$key] ?? $default;
|
||||
@@ -115,8 +120,16 @@ class ExportTemplate
|
||||
return $default;
|
||||
}
|
||||
|
||||
public function setOption(string $key, int|string|null|bool $value): void
|
||||
public function setOption(string $key, int|string|bool|null $value): void
|
||||
{
|
||||
if ($value === null) {
|
||||
if (\array_key_exists($key, $this->options)) {
|
||||
unset($this->options[$key]);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->options[$key] = $value;
|
||||
}
|
||||
|
||||
@@ -153,6 +166,96 @@ class ExportTemplate
|
||||
return (string) $this->getOption('separator', ',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Only used for PDF export
|
||||
*/
|
||||
public function setName(?string $name): void
|
||||
{
|
||||
$this->setOption('name', $name);
|
||||
}
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
$name = $this->getOption('name', null);
|
||||
|
||||
return \is_string($name) ? $name : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only used for PDF export
|
||||
*/
|
||||
public function setPageSize(?string $pageSize): void
|
||||
{
|
||||
$this->setOption('pageSize', $pageSize);
|
||||
}
|
||||
|
||||
public function getPageSize(): ?string
|
||||
{
|
||||
$pageSize = $this->getOption('pageSize', null);
|
||||
|
||||
return \is_string($pageSize) ? $pageSize : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only used for PDF export
|
||||
*/
|
||||
public function setOrientation(?string $orientation): void
|
||||
{
|
||||
if ($orientation !== null) {
|
||||
$orientation = strtolower($orientation);
|
||||
if (!\in_array($orientation, ['landscape', 'portrait'], true)) {
|
||||
throw new \InvalidArgumentException('Invalid orientation. Allowed values are "landscape" and "portrait".');
|
||||
}
|
||||
}
|
||||
$this->setOption('orientation', $orientation);
|
||||
}
|
||||
|
||||
public function getOrientation(): ?string
|
||||
{
|
||||
$orientation = $this->getOption('orientation', null);
|
||||
|
||||
return \is_string($orientation) ? $orientation : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only used for PDF export
|
||||
* @param array<string> $columns
|
||||
*/
|
||||
public function setSummaryColumns(array $columns): void
|
||||
{
|
||||
$columns = \count($columns) > 0 ? implode(',', $columns) : null;
|
||||
|
||||
$this->setOption('summary_columns', $columns);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string>
|
||||
*/
|
||||
public function getSummaryColumns(): array
|
||||
{
|
||||
$columns = $this->getOption('summary_columns', null);
|
||||
if (!\is_string($columns)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return explode(',', $columns);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only used for PDF export
|
||||
*/
|
||||
public function setFont(?string $font): void
|
||||
{
|
||||
$this->setOption('font', $font);
|
||||
}
|
||||
|
||||
public function getFont(): ?string
|
||||
{
|
||||
$font = $this->getOption('font', null);
|
||||
|
||||
return \is_string($font) ? $font : null;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->title ?? 'New';
|
||||
|
||||
@@ -221,18 +221,12 @@ class Invoice implements EntityWithMetaFields
|
||||
public function setModel(InvoiceModel $model): Invoice
|
||||
{
|
||||
$template = $model->getTemplate();
|
||||
if ($template === null) {
|
||||
throw new \InvalidArgumentException('Missing invoice template');
|
||||
}
|
||||
|
||||
if ($template->getDueDays() === null || $template->getVat() === null) {
|
||||
throw new \InvalidArgumentException('Missing due-days or vat setting');
|
||||
}
|
||||
|
||||
$customer = $model->getCustomer();
|
||||
if ($customer === null) {
|
||||
throw new \InvalidArgumentException('Missing invoice customer');
|
||||
}
|
||||
|
||||
$user = $model->getUser();
|
||||
if ($user === null) {
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\InvoiceTemplateRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
@@ -20,7 +22,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ORM\Entity(repositoryClass: InvoiceTemplateRepository::class)]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[UniqueEntity('name')]
|
||||
class InvoiceTemplate
|
||||
class InvoiceTemplate implements EntityWithMetaFields
|
||||
{
|
||||
#[ORM\Column(name: 'id', type: Types::INTEGER)]
|
||||
#[ORM\Id]
|
||||
@@ -34,9 +36,8 @@ class InvoiceTemplate
|
||||
#[Assert\Length(max: 255)]
|
||||
#[Assert\NotBlank]
|
||||
private ?string $title = null;
|
||||
#[ORM\Column(name: 'company', type: 'string', length: 255, nullable: false)]
|
||||
#[ORM\Column(name: 'company', type: Types::STRING, length: 255, nullable: true)]
|
||||
#[Assert\Length(max: 255)]
|
||||
#[Assert\NotBlank]
|
||||
private ?string $company = null;
|
||||
#[ORM\Column(name: 'vat_id', type: Types::STRING, length: 50, nullable: true)]
|
||||
#[Assert\Length(max: 50)]
|
||||
@@ -75,17 +76,32 @@ class InvoiceTemplate
|
||||
#[ORM\Column(name: 'language', type: Types::STRING, length: 6, nullable: false)]
|
||||
#[Assert\NotBlank]
|
||||
private ?string $language = 'en';
|
||||
/**
|
||||
* Customer for this invoice template
|
||||
*/
|
||||
#[ORM\ManyToOne(targetEntity: Customer::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
#[Assert\NotNull]
|
||||
private ?Customer $customer = null;
|
||||
/**
|
||||
* @var Collection<int, InvoiceTemplateMeta>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'template', targetEntity: InvoiceTemplateMeta::class, cascade: ['persist'])]
|
||||
private Collection $meta;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->meta = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function setName(string $name): InvoiceTemplate
|
||||
public function setName(string $name): void
|
||||
{
|
||||
$this->name = $name;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getName(): ?string
|
||||
@@ -93,6 +109,16 @@ class InvoiceTemplate
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function getCustomer(): ?Customer
|
||||
{
|
||||
return $this->customer;
|
||||
}
|
||||
|
||||
public function setCustomer(?Customer $customer): void
|
||||
{
|
||||
$this->customer = $customer;
|
||||
}
|
||||
|
||||
// ---- trait methods below ---
|
||||
|
||||
public function getTitle(): ?string
|
||||
@@ -100,23 +126,22 @@ class InvoiceTemplate
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
public function setTitle(?string $title): InvoiceTemplate
|
||||
public function setTitle(?string $title): void
|
||||
{
|
||||
$this->title = $title;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAddress(): ?string
|
||||
{
|
||||
return $this->address;
|
||||
return $this->customer?->getFormattedAddress() ?? $this->address;
|
||||
}
|
||||
|
||||
public function setAddress(?string $address): InvoiceTemplate
|
||||
/**
|
||||
* @deprecated since 2.41
|
||||
*/
|
||||
public function setAddress(?string $address): void
|
||||
{
|
||||
$this->address = $address;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getNumberGenerator(): string
|
||||
@@ -124,11 +149,9 @@ class InvoiceTemplate
|
||||
return $this->numberGenerator;
|
||||
}
|
||||
|
||||
public function setNumberGenerator(string $numberGenerator): InvoiceTemplate
|
||||
public function setNumberGenerator(string $numberGenerator): void
|
||||
{
|
||||
$this->numberGenerator = $numberGenerator;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDueDays(): ?int
|
||||
@@ -136,11 +159,9 @@ class InvoiceTemplate
|
||||
return $this->dueDays;
|
||||
}
|
||||
|
||||
public function setDueDays(?int $dueDays): InvoiceTemplate
|
||||
public function setDueDays(?int $dueDays): void
|
||||
{
|
||||
$this->dueDays = $dueDays;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getVat(): ?float
|
||||
@@ -148,23 +169,22 @@ class InvoiceTemplate
|
||||
return $this->vat;
|
||||
}
|
||||
|
||||
public function setVat(?float $vat): InvoiceTemplate
|
||||
public function setVat(?float $vat): void
|
||||
{
|
||||
$this->vat = $vat;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCompany(): ?string
|
||||
{
|
||||
return $this->company;
|
||||
return $this->customer?->getCompany() ?? $this->customer?->getName() ?? $this->company;
|
||||
}
|
||||
|
||||
public function setCompany(?string $company): InvoiceTemplate
|
||||
/**
|
||||
* @deprecated since 2.41
|
||||
*/
|
||||
public function setCompany(?string $company): void
|
||||
{
|
||||
$this->company = $company;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRenderer(): string
|
||||
@@ -172,11 +192,9 @@ class InvoiceTemplate
|
||||
return $this->renderer;
|
||||
}
|
||||
|
||||
public function setRenderer(string $renderer): InvoiceTemplate
|
||||
public function setRenderer(string $renderer): void
|
||||
{
|
||||
$this->renderer = $renderer;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCalculator(): string
|
||||
@@ -184,11 +202,9 @@ class InvoiceTemplate
|
||||
return $this->calculator;
|
||||
}
|
||||
|
||||
public function setCalculator(string $calculator): InvoiceTemplate
|
||||
public function setCalculator(string $calculator): void
|
||||
{
|
||||
$this->calculator = $calculator;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPaymentTerms(): ?string
|
||||
@@ -196,23 +212,22 @@ class InvoiceTemplate
|
||||
return $this->paymentTerms;
|
||||
}
|
||||
|
||||
public function setPaymentTerms(?string $paymentTerms): InvoiceTemplate
|
||||
public function setPaymentTerms(?string $paymentTerms): void
|
||||
{
|
||||
$this->paymentTerms = $paymentTerms;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getVatId(): ?string
|
||||
{
|
||||
return $this->vatId;
|
||||
return $this->customer?->getVatId() ?? $this->vatId;
|
||||
}
|
||||
|
||||
public function setVatId(?string $vatId): InvoiceTemplate
|
||||
/**
|
||||
* @deprecated since 2.41
|
||||
*/
|
||||
public function setVatId(?string $vatId): void
|
||||
{
|
||||
$this->vatId = $vatId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getContact(): ?string
|
||||
@@ -220,11 +235,9 @@ class InvoiceTemplate
|
||||
return $this->contact;
|
||||
}
|
||||
|
||||
public function setContact(?string $contact): InvoiceTemplate
|
||||
public function setContact(?string $contact): void
|
||||
{
|
||||
$this->contact = $contact;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPaymentDetails(): ?string
|
||||
@@ -232,11 +245,9 @@ class InvoiceTemplate
|
||||
return $this->paymentDetails;
|
||||
}
|
||||
|
||||
public function setPaymentDetails(?string $paymentDetails): InvoiceTemplate
|
||||
public function setPaymentDetails(?string $paymentDetails): void
|
||||
{
|
||||
$this->paymentDetails = $paymentDetails;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -252,9 +263,78 @@ class InvoiceTemplate
|
||||
return $this->language;
|
||||
}
|
||||
|
||||
public function setLanguage(?string $language): InvoiceTemplate
|
||||
public function setLanguage(?string $language): void
|
||||
{
|
||||
$this->language = $language;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Tax[]
|
||||
*/
|
||||
public function getTaxRates(): array
|
||||
{
|
||||
// TODO make me configurable via UI
|
||||
$tax = new Tax(
|
||||
TaxType::STANDARD,
|
||||
'VAT',
|
||||
$this->vat ?? 0.00
|
||||
);
|
||||
|
||||
return [$tax];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection|MetaTableTypeInterface[]
|
||||
*/
|
||||
public function getMetaFields(): Collection
|
||||
{
|
||||
return $this->meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return MetaTableTypeInterface[]
|
||||
*/
|
||||
public function getVisibleMetaFields(): array
|
||||
{
|
||||
$all = [];
|
||||
foreach ($this->meta as $meta) {
|
||||
if ($meta->isVisible()) {
|
||||
$all[] = $meta;
|
||||
}
|
||||
}
|
||||
|
||||
return $all;
|
||||
}
|
||||
|
||||
public function getMetaField(string $name): ?MetaTableTypeInterface
|
||||
{
|
||||
foreach ($this->meta as $field) {
|
||||
if ($field->getName() !== null && strtolower($field->getName()) === strtolower($name)) {
|
||||
return $field;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function setMetaField(MetaTableTypeInterface $meta): EntityWithMetaFields
|
||||
{
|
||||
if ($meta->getName() === null) {
|
||||
throw new \InvalidArgumentException('Meta-field needs to have a name');
|
||||
}
|
||||
|
||||
if (!$meta instanceof InvoiceTemplateMeta) {
|
||||
throw new \InvalidArgumentException('Meta-field needs to be an instanceof InvoiceTemplateMeta');
|
||||
}
|
||||
|
||||
if (null === ($current = $this->getMetaField($meta->getName()))) {
|
||||
$meta->setEntity($this);
|
||||
$this->meta->add($meta);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
$current->merge($meta);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
49
src/Entity/InvoiceTemplateMeta.php
Normal file
49
src/Entity/InvoiceTemplateMeta.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?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\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Table(name: 'kimai2_invoice_templates_meta')]
|
||||
#[ORM\UniqueConstraint(columns: ['template_id', 'name'])]
|
||||
#[ORM\Entity]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
class InvoiceTemplateMeta implements MetaTableTypeInterface
|
||||
{
|
||||
use MetaTableTypeTrait;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: InvoiceTemplate::class, inversedBy: 'meta')]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
private ?InvoiceTemplate $template = null;
|
||||
|
||||
public function setEntity(EntityWithMetaFields $entity): MetaTableTypeInterface
|
||||
{
|
||||
if (!($entity instanceof InvoiceTemplate)) {
|
||||
throw new \InvalidArgumentException(
|
||||
\sprintf('Expected instanceof InvoiceTemplate, received "%s"', \get_class($entity))
|
||||
);
|
||||
}
|
||||
$this->template = $entity;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return InvoiceTemplate|null
|
||||
*/
|
||||
public function getEntity(): ?EntityWithMetaFields
|
||||
{
|
||||
return $this->template;
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
*/
|
||||
#[ORM\Column(name: 'start', type: Types::DATETIME_MUTABLE, nullable: true)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Project'])]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Serializer\Type(name: "DateTime<'Y-m-d'>")]
|
||||
#[Serializer\Accessor(getter: 'getStart')]
|
||||
private ?\DateTime $start = null;
|
||||
@@ -107,7 +107,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
*/
|
||||
#[ORM\Column(name: 'end', type: Types::DATETIME_MUTABLE, nullable: true)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Project'])]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Serializer\Type(name: "DateTime<'Y-m-d'>")]
|
||||
#[Serializer\Accessor(getter: 'getEnd')]
|
||||
private ?\DateTime $end = null;
|
||||
|
||||
36
src/Entity/Tax.php
Normal file
36
src/Entity/Tax.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?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\Entity;
|
||||
|
||||
final class Tax
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TaxType $type,
|
||||
private readonly string $name = 'VAT',
|
||||
private readonly float $rate = 0.0,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
public function getType(): TaxType
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function getRate(): float
|
||||
{
|
||||
return $this->rate;
|
||||
}
|
||||
}
|
||||
17
src/Entity/TaxType.php
Normal file
17
src/Entity/TaxType.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?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\Entity;
|
||||
|
||||
enum TaxType: string
|
||||
{
|
||||
case STANDARD = 'standard';
|
||||
case REVERSE = 'reverse';
|
||||
case EXEMPT = 'exempt';
|
||||
}
|
||||
@@ -106,7 +106,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
#[ORM\Column(name: 'avatar', type: Types::STRING, length: 255, nullable: true)]
|
||||
#[Assert\Length(max: 255, groups: ['Profile'])]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['User_Entity'])]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
private ?string $avatar = null;
|
||||
/**
|
||||
* API token (password) for this user
|
||||
@@ -468,6 +468,9 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
return $this->getPreferenceValue(UserPreference::FIRST_WEEKDAY, User::DEFAULT_FIRST_WEEKDAY, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @ deprecated since 2.40 - will be removed with 3.0
|
||||
*/
|
||||
public function isExportDecimal(): bool
|
||||
{
|
||||
return (bool) $this->getPreferenceValue('export_decimal', false, false);
|
||||
@@ -962,7 +965,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setLastLogin(\DateTime $time = null): User
|
||||
public function setLastLogin(?\DateTime $time = null): User
|
||||
{
|
||||
$this->lastLogin = $time;
|
||||
|
||||
|
||||
@@ -30,9 +30,9 @@ class WorkingTime
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
private ?User $user = null;
|
||||
#[ORM\Column(name: 'date', type: Types::DATE_MUTABLE, nullable: false)]
|
||||
#[ORM\Column(name: 'date', type: Types::DATE_IMMUTABLE, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
private \DateTimeInterface $date;
|
||||
private \DateTimeImmutable $date;
|
||||
#[ORM\Column(name: 'expected', type: Types::INTEGER, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
private int $expectedTime = 0;
|
||||
@@ -46,7 +46,7 @@ class WorkingTime
|
||||
#[Assert\NotNull]
|
||||
private ?\DateTimeImmutable $approvedAt = null;
|
||||
|
||||
public function __construct(User $user, \DateTimeInterface $date)
|
||||
public function __construct(User $user, \DateTimeImmutable $date)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->date = $date;
|
||||
@@ -62,7 +62,7 @@ class WorkingTime
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function getDate(): \DateTimeInterface
|
||||
public function getDate(): \DateTimeImmutable
|
||||
{
|
||||
return $this->date;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ use Symfony\Contracts\EventDispatcher\Event;
|
||||
*/
|
||||
abstract class AbstractActivityEvent extends Event
|
||||
{
|
||||
public function __construct(private Activity $activity)
|
||||
public function __construct(private readonly Activity $activity)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ use Symfony\Contracts\EventDispatcher\Event;
|
||||
*/
|
||||
abstract class AbstractCustomerEvent extends Event
|
||||
{
|
||||
public function __construct(private Customer $customer)
|
||||
public function __construct(private readonly Customer $customer)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ use Symfony\Contracts\EventDispatcher\Event;
|
||||
*/
|
||||
abstract class AbstractProjectEvent extends Event
|
||||
{
|
||||
public function __construct(private Project $project)
|
||||
public function __construct(private readonly Project $project)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -9,9 +9,19 @@
|
||||
|
||||
namespace App\Event;
|
||||
|
||||
use App\Entity\Activity;
|
||||
use App\Webhook\Attribute\AsWebhook;
|
||||
|
||||
#[AsWebhook(name: 'activity.deleted', description: 'Triggered right before an activity will be deleted', payload: 'object.getActivity()')]
|
||||
final class ActivityDeleteEvent extends AbstractActivityEvent
|
||||
{
|
||||
public function __construct(Activity $activity, private readonly ?Activity $replacementActivity = null)
|
||||
{
|
||||
parent::__construct($activity);
|
||||
}
|
||||
|
||||
public function getReplacementActivity(): ?Activity
|
||||
{
|
||||
return $this->replacementActivity;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ use Symfony\Contracts\EventDispatcher\Event;
|
||||
*/
|
||||
final class ActivityMetaDefinitionEvent extends Event
|
||||
{
|
||||
public function __construct(private Activity $entity)
|
||||
public function __construct(private readonly Activity $entity)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -9,9 +9,19 @@
|
||||
|
||||
namespace App\Event;
|
||||
|
||||
use App\Entity\Customer;
|
||||
use App\Webhook\Attribute\AsWebhook;
|
||||
|
||||
#[AsWebhook(name: 'customer.deleted', description: 'Triggered right before a customer will be deleted', payload: 'object.getCustomer()')]
|
||||
final class CustomerDeleteEvent extends AbstractCustomerEvent
|
||||
{
|
||||
public function __construct(Customer $customer, private readonly ?Customer $replacementCustomer = null)
|
||||
{
|
||||
parent::__construct($customer);
|
||||
}
|
||||
|
||||
public function getReplacementCustomer(): ?Customer
|
||||
{
|
||||
return $this->replacementCustomer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ use Symfony\Contracts\EventDispatcher\Event;
|
||||
*/
|
||||
final class CustomerMetaDefinitionEvent extends Event
|
||||
{
|
||||
public function __construct(private Customer $entity)
|
||||
public function __construct(private readonly Customer $entity)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
28
src/Event/InvoiceTemplateMetaDefinitionEvent.php
Normal file
28
src/Event/InvoiceTemplateMetaDefinitionEvent.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?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\InvoiceTemplate;
|
||||
use Symfony\Contracts\EventDispatcher\Event;
|
||||
|
||||
/**
|
||||
* This event can be used, to dynamically add meta fields to invoice templates
|
||||
*/
|
||||
final class InvoiceTemplateMetaDefinitionEvent extends Event
|
||||
{
|
||||
public function __construct(private readonly InvoiceTemplate $entity)
|
||||
{
|
||||
}
|
||||
|
||||
public function getEntity(): InvoiceTemplate
|
||||
{
|
||||
return $this->entity;
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,19 @@
|
||||
|
||||
namespace App\Event;
|
||||
|
||||
use App\Entity\Project;
|
||||
use App\Webhook\Attribute\AsWebhook;
|
||||
|
||||
#[AsWebhook(name: 'project.deleted', description: 'Triggered right before a project will be deleted', payload: 'object.getProject()')]
|
||||
final class ProjectDeleteEvent extends AbstractProjectEvent
|
||||
{
|
||||
public function __construct(Project $project, private readonly ?Project $replacementProject = null)
|
||||
{
|
||||
parent::__construct($project);
|
||||
}
|
||||
|
||||
public function getReplacementProject(): ?Project
|
||||
{
|
||||
return $this->replacementProject;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ use Symfony\Contracts\EventDispatcher\Event;
|
||||
*/
|
||||
final class ProjectMetaDefinitionEvent extends Event
|
||||
{
|
||||
public function __construct(private Project $entity)
|
||||
public function __construct(private readonly Project $entity)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
91
src/Export/Base/AbstractSpreadsheetRenderer.php
Normal file
91
src/Export/Base/AbstractSpreadsheetRenderer.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?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\Base;
|
||||
|
||||
use App\Entity\ExportableItem;
|
||||
use App\Export\ColumnConverter;
|
||||
use App\Export\Package\Column;
|
||||
use App\Export\Package\SpreadsheetPackage;
|
||||
use App\Export\TemplateInterface;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
|
||||
abstract class AbstractSpreadsheetRenderer
|
||||
{
|
||||
private bool $internal = false;
|
||||
|
||||
public function isInternal(): bool
|
||||
{
|
||||
return $this->internal;
|
||||
}
|
||||
|
||||
public function setInternal(bool $internal): void
|
||||
{
|
||||
$this->internal = $internal;
|
||||
}
|
||||
|
||||
protected function getFileResponse(string $file, string $filename, string $contentType): BinaryFileResponse
|
||||
{
|
||||
$response = new BinaryFileResponse($file);
|
||||
$disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename);
|
||||
|
||||
$response->headers->set('Content-Type', $contentType);
|
||||
$response->headers->set('Content-Disposition', $disposition);
|
||||
$response->deleteFileAfterSend(true);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ExportableItem[] $exportItems
|
||||
*/
|
||||
protected function writeSpreadsheet(ColumnConverter $converter, TemplateInterface $template, SpreadsheetPackage $spreadsheetPackage, array $exportItems, TimesheetQuery $query): void
|
||||
{
|
||||
/** @var array<Column> $columns */
|
||||
$columns = $converter->getColumns($template, $query);
|
||||
$spreadsheetPackage->setColumns($columns);
|
||||
|
||||
$currentRow = 1;
|
||||
foreach ($exportItems as $exportItem) {
|
||||
$cells = [];
|
||||
foreach ($columns as $column) {
|
||||
$cells[] = $column->getValue($exportItem);
|
||||
}
|
||||
$spreadsheetPackage->addRow($cells);
|
||||
$currentRow++;
|
||||
}
|
||||
|
||||
if ($currentRow > 1) {
|
||||
$totalColumns = ['duration', 'rate', 'internalRate'];
|
||||
// that should be enough for the near future: the number of array entries must cover the max number of columns
|
||||
$columnNames = [
|
||||
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
|
||||
'AA', 'AB', 'AC', 'AD', 'AE', 'AF', 'AG', 'AH', 'AI', 'AJ', 'AK', 'AL', 'AM', 'AN', 'AO', 'AP', 'AQ', 'AR', 'AS', 'AT', 'AU', 'AV', 'AW', 'AX', 'AY', 'AZ',
|
||||
'BA', 'BB', 'BC', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI', 'BJ', 'BK', 'BL', 'BM', 'BN', 'BO', 'BP', 'BQ', 'BR', 'BS', 'BT', 'BU', 'BV', 'BW', 'BX', 'BY', 'BZ',
|
||||
];
|
||||
$totalRow = [];
|
||||
$totalColumn = 1;
|
||||
foreach ($columns as $column) {
|
||||
$formula = null;
|
||||
if (\in_array($column->getName(), $totalColumns)) {
|
||||
$columnName = $columnNames[$totalColumn - 1];
|
||||
$formula = \sprintf('=SUBTOTAL(9,%s2:%s%s)', $columnName, $columnName, $currentRow);
|
||||
}
|
||||
$totalRow[] = $formula;
|
||||
$totalColumn++;
|
||||
}
|
||||
|
||||
$spreadsheetPackage->addRow($totalRow, ['totals' => true]);
|
||||
}
|
||||
|
||||
$spreadsheetPackage->save();
|
||||
}
|
||||
}
|
||||
@@ -10,56 +10,44 @@
|
||||
namespace App\Export\Base;
|
||||
|
||||
use App\Entity\ExportableItem;
|
||||
use App\Export\ColumnConverter;
|
||||
use App\Export\ExportFilename;
|
||||
use App\Export\ExportRendererInterface;
|
||||
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;
|
||||
use App\Export\TemplateInterface;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use OpenSpout\Writer\CSV\Options;
|
||||
use OpenSpout\Writer\CSV\Writer;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Exclude;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
final class CsvRenderer implements RendererInterface, TimesheetExportInterface
|
||||
#[Exclude]
|
||||
final class CsvRenderer extends AbstractSpreadsheetRenderer implements ExportRendererInterface
|
||||
{
|
||||
use ExportTrait;
|
||||
|
||||
private string $id = 'csv';
|
||||
private string $title = 'default';
|
||||
private ?string $locale = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly SpreadsheetRenderer $spreadsheetRenderer,
|
||||
private readonly TranslatorInterface $translator
|
||||
private readonly ColumnConverter $columnConverter,
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly TemplateInterface $template,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
public function setId(string $id): void
|
||||
public function getType(): string
|
||||
{
|
||||
$this->id = $id;
|
||||
return 'csv';
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function setTitle(string $title): void
|
||||
{
|
||||
$this->title = $title;
|
||||
}
|
||||
|
||||
public function setLocale(?string $locale): void
|
||||
{
|
||||
$this->locale = $locale;
|
||||
return $this->template->getId();
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return $this->title;
|
||||
return $this->template->getTitle();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,18 +75,19 @@ final class CsvRenderer implements RendererInterface, TimesheetExportInterface
|
||||
$options = new Options();
|
||||
$options->SHOULD_ADD_BOM = false;
|
||||
|
||||
$opts = $this->spreadsheetRenderer->getTemplate($query)->getOptions();
|
||||
$opts = $this->template->getOptions();
|
||||
if (\array_key_exists('separator', $opts) && $opts['separator'] === ';') {
|
||||
$options->FIELD_DELIMITER = ';';
|
||||
}
|
||||
|
||||
$spreadsheet = new SpoutSpreadsheet(new Writer($options), $this->translator, $this->locale ?? $this->spreadsheetRenderer->getTemplate($query)->getLocale());
|
||||
$spreadsheet = new SpoutSpreadsheet(new Writer($options), $this->translator, $this->template->getLocale());
|
||||
$spreadsheet->open($filename);
|
||||
|
||||
$this->spreadsheetRenderer->registerFormatter('date', new DateStringFormatter());
|
||||
$this->spreadsheetRenderer->registerFormatter('duration', new DurationPlainFormatter(false));
|
||||
$this->spreadsheetRenderer->registerFormatter('duration_seconds', new DurationPlainFormatter(true));
|
||||
$this->spreadsheetRenderer->writeSpreadsheet($spreadsheet, $exportItems, $query);
|
||||
$this->columnConverter->registerFormatter('date', new DateStringFormatter());
|
||||
$this->columnConverter->registerFormatter('duration', new DurationPlainFormatter(false));
|
||||
$this->columnConverter->registerFormatter('duration_seconds', new DurationPlainFormatter(true));
|
||||
|
||||
$this->writeSpreadsheet($this->columnConverter, $this->template, $spreadsheet, $exportItems, $query);
|
||||
|
||||
return new \SplFileInfo($filename);
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
<?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\Base;
|
||||
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
trait ExportTrait
|
||||
{
|
||||
protected function getFileResponse(string $file, string $filename, string $contentType): BinaryFileResponse
|
||||
{
|
||||
$response = new BinaryFileResponse($file);
|
||||
$disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename);
|
||||
|
||||
$response->headers->set('Content-Type', $contentType);
|
||||
$response->headers->set('Content-Disposition', $disposition);
|
||||
$response->deleteFileAfterSend(true);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -26,25 +26,41 @@ use App\Repository\Query\ProjectQuery;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Twig\SecurityPolicy\ExportPolicy;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Exclude;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Twig\Environment;
|
||||
use Twig\Extension\SandboxExtension;
|
||||
|
||||
/**
|
||||
* TODO 3.0 remove default values from constructor parameters and make class final
|
||||
* @final
|
||||
*/
|
||||
#[Exclude]
|
||||
class HtmlRenderer implements ExportRendererInterface
|
||||
{
|
||||
use RendererTrait;
|
||||
|
||||
private string $id = 'html';
|
||||
private string $template = 'default.html.twig';
|
||||
|
||||
public function __construct(
|
||||
protected readonly Environment $twig,
|
||||
protected readonly EventDispatcherInterface $dispatcher,
|
||||
private readonly ProjectStatisticService $projectStatisticService,
|
||||
private readonly ActivityStatisticService $activityStatisticService
|
||||
private readonly ActivityStatisticService $activityStatisticService,
|
||||
private string $id = 'html', // deprecated default parameter - TODO 3.0
|
||||
private readonly string $title = 'print', // deprecated default parameter - TODO 3.0
|
||||
private string $template = 'export/print.html.twig', // deprecated default parameter - TODO 3.0
|
||||
) {
|
||||
}
|
||||
|
||||
public function isInternal(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return 'html';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return MetaTableTypeInterface[]
|
||||
*/
|
||||
@@ -109,14 +125,20 @@ class HtmlRenderer implements ExportRendererInterface
|
||||
|
||||
protected function getTemplate(): string
|
||||
{
|
||||
return '@export/' . $this->template;
|
||||
return $this->template;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since 2.40.0
|
||||
*/
|
||||
public function setTemplate(string $filename): void
|
||||
{
|
||||
$this->template = $filename;
|
||||
$this->template = '@export/' . $filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since 2.40.0
|
||||
*/
|
||||
public function setId(string $id): void
|
||||
{
|
||||
$this->id = $id;
|
||||
@@ -129,6 +151,6 @@ class HtmlRenderer implements ExportRendererInterface
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return 'print';
|
||||
return $this->title;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,35 +12,50 @@ namespace App\Export\Base;
|
||||
use App\Entity\ExportableItem;
|
||||
use App\Export\ExportFilename;
|
||||
use App\Export\ExportRendererInterface;
|
||||
use App\Export\TimesheetExportInterface;
|
||||
use App\Pdf\HtmlToPdfConverter;
|
||||
use App\Pdf\PdfContext;
|
||||
use App\Pdf\PdfRendererTrait;
|
||||
use App\Project\ProjectStatisticService;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Twig\SecurityPolicy\ExportPolicy;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Exclude;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Twig\Environment;
|
||||
use Twig\Extension\SandboxExtension;
|
||||
|
||||
class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface, TimesheetExportInterface
|
||||
/**
|
||||
* TODO 3.0 remove default values from constructor parameters and make class final
|
||||
* @final
|
||||
*/
|
||||
#[Exclude]
|
||||
class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface
|
||||
{
|
||||
use RendererTrait;
|
||||
use PDFRendererTrait;
|
||||
|
||||
private string $id = 'pdf';
|
||||
private string $title = 'pdf';
|
||||
private string $template = 'default.pdf.twig';
|
||||
private array $pdfOptions = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly Environment $twig,
|
||||
private readonly HtmlToPdfConverter $converter,
|
||||
private readonly ProjectStatisticService $projectStatisticService
|
||||
private readonly ProjectStatisticService $projectStatisticService,
|
||||
private string $id = 'pdf', // deprecated default parameter - TODO 3.0
|
||||
private string $title = 'pdf', // deprecated default parameter - TODO 3.0
|
||||
private string $template = 'export/pdf-layout.html.twig', // deprecated default parameter - TODO 3.0
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
public function isInternal(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return 'pdf';
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return $this->title;
|
||||
@@ -48,7 +63,7 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface
|
||||
|
||||
protected function getTemplate(): string
|
||||
{
|
||||
return '@export/' . $this->template;
|
||||
return $this->template;
|
||||
}
|
||||
|
||||
protected function getOptions(TimesheetQuery $query): array
|
||||
@@ -68,11 +83,9 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface
|
||||
return $this->pdfOptions;
|
||||
}
|
||||
|
||||
public function setPdfOption(string $key, string $value): PDFRenderer
|
||||
public function setPdfOption(string $key, string $value): void
|
||||
{
|
||||
$this->pdfOptions[$key] = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,16 +123,25 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface
|
||||
return $this->createPdfResponse($content, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since 2.40.0
|
||||
*/
|
||||
public function setTemplate(string $filename): void
|
||||
{
|
||||
$this->template = $filename;
|
||||
$this->template = '@export/' . $filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since 2.40.0
|
||||
*/
|
||||
public function setId(string $id): void
|
||||
{
|
||||
$this->id = $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since 2.40.0
|
||||
*/
|
||||
public function setTitle(string $title): void
|
||||
{
|
||||
$this->title = $title;
|
||||
|
||||
133
src/Export/Base/PdfTemplateRenderer.php
Normal file
133
src/Export/Base/PdfTemplateRenderer.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?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\Base;
|
||||
|
||||
use App\Entity\ExportableItem;
|
||||
use App\Export\ColumnConverter;
|
||||
use App\Export\ExportFilename;
|
||||
use App\Export\ExportRendererInterface;
|
||||
use App\Export\TemplateInterface;
|
||||
use App\Form\Type\PdfFontType;
|
||||
use App\Pdf\HtmlToPdfConverter;
|
||||
use App\Pdf\PdfContext;
|
||||
use App\Pdf\PdfRendererTrait;
|
||||
use App\Project\ProjectStatisticService;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Exclude;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Translation\LocaleSwitcher;
|
||||
use Twig\Environment;
|
||||
|
||||
#[Exclude]
|
||||
final class PdfTemplateRenderer implements DispositionInlineInterface, ExportRendererInterface
|
||||
{
|
||||
use RendererTrait;
|
||||
use PDFRendererTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly Environment $twig,
|
||||
private readonly HtmlToPdfConverter $converter,
|
||||
private readonly ProjectStatisticService $projectStatisticService,
|
||||
private readonly ColumnConverter $columnConverter,
|
||||
private readonly LocaleSwitcher $localeSwitcher,
|
||||
private readonly TemplateInterface $template
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
public function isInternal(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return 'pdf';
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return $this->template->getTitle();
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return $this->template->getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ExportableItem[] $exportItems
|
||||
*/
|
||||
public function render(array $exportItems, TimesheetQuery $query): Response
|
||||
{
|
||||
$filename = new ExportFilename($query);
|
||||
$context = new PdfContext();
|
||||
$context->setOption('filename', $filename->getFilename());
|
||||
$context->setOption('margin_top', 22);
|
||||
$context->setOption('margin_bottom', 22);
|
||||
$context->setOption('PDFA', true);
|
||||
$context->setOption('PDFAauto', true);
|
||||
|
||||
$options = $this->template->getOptions();
|
||||
|
||||
$summaryColumns = [];
|
||||
if (\array_key_exists('summary_columns', $options) && \is_string($options['summary_columns']) && $options['summary_columns'] !== '') {
|
||||
$summaryColumns = $options['summary_columns'];
|
||||
$summaryColumns = explode(',', $summaryColumns);
|
||||
unset($options['summary_columns']);
|
||||
}
|
||||
$options['summary'] = $summaryColumns;
|
||||
if (\count($summaryColumns) > 0) {
|
||||
$options['summaries'] = $this->calculateSummary($exportItems);
|
||||
$options['budgets'] = $this->calculateProjectBudget($exportItems, $query, $this->projectStatisticService);
|
||||
}
|
||||
|
||||
$font = 'sans-serif';
|
||||
if (\array_key_exists('font', $options) && \in_array($options['font'], PdfFontType::AVAILABLE_FONTS, true)) {
|
||||
$font = $options['font'];
|
||||
}
|
||||
|
||||
$format = 'A4';
|
||||
if (\array_key_exists('pageSize', $options) && \is_string($options['pageSize']) && \in_array($options['pageSize'], ['A4', 'A5', 'A6', 'Legal', 'Letter'])) {
|
||||
$format = $options['pageSize'];
|
||||
}
|
||||
if (\array_key_exists('orientation', $options) && $options['orientation'] === 'landscape') {
|
||||
$format .= '-L';
|
||||
}
|
||||
$context->setOption('format', $format);
|
||||
|
||||
$oldLocale = null;
|
||||
$locale = $this->template->getLocale();
|
||||
if ($locale !== null) {
|
||||
$oldLocale = $this->localeSwitcher->getLocale();
|
||||
$this->localeSwitcher->setLocale($locale);
|
||||
}
|
||||
|
||||
$content = $this->twig->render('export/renderer.pdf.twig', array_merge([
|
||||
'template' => $this->template,
|
||||
'title' => $options['name'] ?? null,
|
||||
'locale' => $locale,
|
||||
'entries' => $exportItems,
|
||||
'query' => $query,
|
||||
'font' => $font,
|
||||
'columns' => $this->columnConverter->getColumns($this->template, $query),
|
||||
], $options));
|
||||
|
||||
if ($oldLocale !== null) {
|
||||
$this->localeSwitcher->setLocale($locale);
|
||||
}
|
||||
|
||||
$pdfOptions = array_merge($context->getOptions(), $options);
|
||||
|
||||
$content = $this->converter->convertToPdf($content, $pdfOptions);
|
||||
|
||||
return $this->createPdfResponse($content, $context);
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ trait RendererTrait
|
||||
* FIXME use statistic events to calculate budgets and do NOT iterate all results!
|
||||
*
|
||||
* @param ExportableItem[] $exportItems
|
||||
* @return array
|
||||
* @return array<mixed>
|
||||
*/
|
||||
protected function calculateSummary(array $exportItems): array
|
||||
{
|
||||
@@ -38,10 +38,12 @@ trait RendererTrait
|
||||
$currency = null;
|
||||
|
||||
if (null !== ($project = $exportItem->getProject())) {
|
||||
$customer = $project->getCustomer();
|
||||
$customerId = $customer->getId();
|
||||
$projectId = $project->getId();
|
||||
$currency = $customer->getCurrency();
|
||||
$customer = $project->getCustomer();
|
||||
if ($customer !== null) {
|
||||
$customerId = $customer->getId();
|
||||
$currency = $customer->getCurrency();
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== ($activity = $exportItem->getActivity())) {
|
||||
@@ -72,9 +74,12 @@ trait RendererTrait
|
||||
'users' => []
|
||||
];
|
||||
|
||||
if ($project !== null) {
|
||||
if ($customer !== null) {
|
||||
$summary[$id]['customer'] = $customer->getName();
|
||||
$summary[$id]['customer_item'] = $customer;
|
||||
}
|
||||
|
||||
if ($project !== null) {
|
||||
$summary[$id]['project'] = $project->getName();
|
||||
$summary[$id]['project_item'] = $project;
|
||||
}
|
||||
@@ -171,7 +176,7 @@ trait RendererTrait
|
||||
* @param ExportableItem[] $exportItems
|
||||
* @param TimesheetQuery $query
|
||||
* @param ProjectStatisticService $projectStatisticService
|
||||
* @return array
|
||||
* @return array<mixed>
|
||||
*/
|
||||
protected function calculateProjectBudget(array $exportItems, TimesheetQuery $query, ProjectStatisticService $projectStatisticService): array
|
||||
{
|
||||
@@ -184,12 +189,16 @@ trait RendererTrait
|
||||
$project = null;
|
||||
$customerId = 'none';
|
||||
$projectId = 'none';
|
||||
$time = 0;
|
||||
$money = 0;
|
||||
|
||||
if (null !== ($project = $exportItem->getProject())) {
|
||||
$customer = $project->getCustomer();
|
||||
$customerId = $customer->getId();
|
||||
$customerId = $customer?->getId();
|
||||
$projectId = $project->getId();
|
||||
$projects[] = $project;
|
||||
$time = $project->getTimeBudget();
|
||||
$money = $project->getBudget();
|
||||
}
|
||||
|
||||
$id = $customerId . '_' . $projectId;
|
||||
@@ -197,8 +206,8 @@ trait RendererTrait
|
||||
if (!isset($summary[$id])) {
|
||||
$summary[$id] = [
|
||||
'totals' => $empty->jsonSerialize(),
|
||||
'time' => $project->getTimeBudget(),
|
||||
'money' => $project->getBudget(),
|
||||
'time' => $time,
|
||||
'money' => $money,
|
||||
'time_left' => null,
|
||||
'money_left' => null,
|
||||
'time_left_total' => null,
|
||||
@@ -213,8 +222,11 @@ trait RendererTrait
|
||||
|
||||
foreach ($allBudgets as $projectId => $statisticModel) {
|
||||
$project = $statisticModel->getProject();
|
||||
$id = $project->getCustomer()->getId() . '_' . $projectId;
|
||||
$id = $project->getCustomer()?->getId() . '_' . $projectId;
|
||||
$total = $statisticModel->getStatisticTotal();
|
||||
if ($total === null) {
|
||||
continue;
|
||||
}
|
||||
$summary[$id]['totals'] = $total->jsonSerialize();
|
||||
if ($statisticModel->hasTimeBudget()) {
|
||||
$summary[$id]['time_left'] = $statisticModel->getTimeBudgetOpenRelative();
|
||||
@@ -256,7 +268,7 @@ trait RendererTrait
|
||||
* @param ExportableItem[] $exportItems
|
||||
* @param TimesheetQuery $query
|
||||
* @param ActivityStatisticService $activityStatisticService
|
||||
* @return array
|
||||
* @return array<mixed>
|
||||
*/
|
||||
protected function calculateActivityBudget(array $exportItems, TimesheetQuery $query, ActivityStatisticService $activityStatisticService): array
|
||||
{
|
||||
@@ -282,7 +294,7 @@ trait RendererTrait
|
||||
|
||||
if (null !== ($project = $exportItem->getProject())) {
|
||||
$projectId = $project->getId();
|
||||
$customerId = $project->getCustomer()->getId();
|
||||
$customerId = $project->getCustomer()?->getId();
|
||||
}
|
||||
|
||||
$id = $customerId . '_' . $projectId;
|
||||
@@ -312,9 +324,14 @@ trait RendererTrait
|
||||
|
||||
foreach ($allBudgets as $activityId => $statisticModel) {
|
||||
$project = $statisticModel->getActivity()->getProject();
|
||||
$id = $project->getCustomer()->getId() . '_' . $project->getId();
|
||||
if ($project === null) {
|
||||
continue;
|
||||
}
|
||||
$id = $project->getCustomer()?->getId() . '_' . $project->getId();
|
||||
$total = $statisticModel->getStatisticTotal();
|
||||
$summary[$id][$activityId]['totals'] = $total->jsonSerialize();
|
||||
if ($total !== null) {
|
||||
$summary[$id][$activityId]['totals'] = $total->jsonSerialize();
|
||||
}
|
||||
if ($statisticModel->hasTimeBudget()) {
|
||||
$summary[$id][$activityId]['time_left'] = $statisticModel->getTimeBudgetOpenRelative();
|
||||
$summary[$id][$activityId]['time_left_total'] = $statisticModel->getTimeBudgetOpen();
|
||||
|
||||
@@ -1,401 +0,0 @@
|
||||
<?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\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;
|
||||
use App\Event\ProjectMetaDisplayEvent;
|
||||
use App\Event\TimesheetMetaDisplayEvent;
|
||||
use App\Event\UserPreferenceDisplayEvent;
|
||||
use App\Export\Package\CellFormatter\ArrayFormatter;
|
||||
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;
|
||||
use App\Export\Package\CellFormatter\TimeFormatter;
|
||||
use App\Export\Package\Column;
|
||||
use App\Export\Package\ColumnWidth;
|
||||
use App\Export\Package\SpreadsheetPackage;
|
||||
use App\Export\Template;
|
||||
use App\Export\TemplateInterface;
|
||||
use App\Repository\Query\ActivityQuery;
|
||||
use App\Repository\Query\CustomerQuery;
|
||||
use App\Repository\Query\ProjectQuery;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
/**
|
||||
* @internal means no BC promise whatsoever!
|
||||
*/
|
||||
final class SpreadsheetRenderer
|
||||
{
|
||||
/**
|
||||
* @var array<string, CellFormatterInterface>
|
||||
*/
|
||||
private array $formatter = [];
|
||||
|
||||
private ?TemplateInterface $template = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly EventDispatcherInterface $eventDispatcher,
|
||||
private readonly Security $voter,
|
||||
private readonly ?LoggerInterface $logger = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function setTemplate(?TemplateInterface $template): void
|
||||
{
|
||||
$this->template = $template;
|
||||
}
|
||||
|
||||
public function getTemplate(?TimesheetQuery $query = null): TemplateInterface
|
||||
{
|
||||
if ($this->template === null) {
|
||||
$template = new Template('default', 'default');
|
||||
$template->setColumns($this->getDefaultColumns($query));
|
||||
$template->setLocale('en');
|
||||
|
||||
$this->template = $template;
|
||||
}
|
||||
|
||||
return $this->template;
|
||||
}
|
||||
|
||||
private function isRenderRate(TimesheetQuery $query): bool
|
||||
{
|
||||
if ($this->voter->getUser() === null) {
|
||||
// for command line export
|
||||
return true;
|
||||
}
|
||||
|
||||
if (null !== $query->getUser()) {
|
||||
return $this->voter->isGranted('view_rate_own_timesheet');
|
||||
}
|
||||
|
||||
return $this->voter->isGranted('view_rate_other_timesheet');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return MetaTableTypeInterface[]
|
||||
*/
|
||||
private function findMetaColumns(MetaDisplayEventInterface $event): array
|
||||
{
|
||||
$this->eventDispatcher->dispatch($event);
|
||||
|
||||
return $event->getFields();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ExportableItem[] $exportItems
|
||||
*/
|
||||
public function writeSpreadsheet(SpreadsheetPackage $spreadsheetPackage, array $exportItems, TimesheetQuery $query): void
|
||||
{
|
||||
$columns = $this->getColumns($query);
|
||||
$spreadsheetPackage->setColumns($columns);
|
||||
|
||||
$currentRow = 1;
|
||||
foreach ($exportItems as $exportItem) {
|
||||
$cells = [];
|
||||
foreach ($columns as $column) {
|
||||
$cells[] = $column->getValue($exportItem);
|
||||
}
|
||||
$spreadsheetPackage->addRow($cells);
|
||||
$currentRow++;
|
||||
}
|
||||
|
||||
if ($currentRow > 1) {
|
||||
$totalColumns = ['duration', 'rate', 'internalRate'];
|
||||
// that should be enough for the near future: the number of array entries must cover the max number of columns
|
||||
$columnNames = [
|
||||
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
|
||||
'AA', 'AB', 'AC', 'AD', 'AE', 'AF', 'AG', 'AH', 'AI', 'AJ', 'AK', 'AL', 'AM', 'AN', 'AO', 'AP', 'AQ', 'AR', 'AS', 'AT', 'AU', 'AV', 'AW', 'AX', 'AY', 'AZ',
|
||||
'BA', 'BB', 'BC', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI', 'BJ', 'BK', 'BL', 'BM', 'BN', 'BO', 'BP', 'BQ', 'BR', 'BS', 'BT', 'BU', 'BV', 'BW', 'BX', 'BY', 'BZ',
|
||||
];
|
||||
$totalRow = [];
|
||||
$totalColumn = 1;
|
||||
foreach ($columns as $column) {
|
||||
$formula = null;
|
||||
if (\in_array($column->getName(), $totalColumns)) {
|
||||
$columnName = $columnNames[$totalColumn - 1];
|
||||
$formula = \sprintf('=SUBTOTAL(9,%s2:%s%s)', $columnName, $columnName, $currentRow);
|
||||
}
|
||||
$totalRow[] = $formula;
|
||||
$totalColumn++;
|
||||
}
|
||||
|
||||
$spreadsheetPackage->addRow($totalRow, ['totals' => true]);
|
||||
}
|
||||
|
||||
$spreadsheetPackage->save();
|
||||
}
|
||||
|
||||
public function registerFormatter(string $name, CellFormatterInterface $cellFormatter): void
|
||||
{
|
||||
$this->formatter[$name] = $cellFormatter;
|
||||
}
|
||||
|
||||
private function getFormatter(string $name): CellFormatterInterface
|
||||
{
|
||||
if (\array_key_exists($name, $this->formatter)) {
|
||||
return $this->formatter[$name];
|
||||
}
|
||||
|
||||
return match ($name) {
|
||||
'date' => new DateFormatter(),
|
||||
'time' => new TimeFormatter(),
|
||||
'duration' => new DurationFormatter('[hh]:mm'),
|
||||
'duration_decimal' => new DurationDecimalFormatter(),
|
||||
'duration_seconds' => new DurationFormatter('[hh]:mm:ss'),
|
||||
default => new DefaultFormatter()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Column>
|
||||
*/
|
||||
private function getColumns(TimesheetQuery $query): array
|
||||
{
|
||||
$showRates = $this->isRenderRate($query);
|
||||
|
||||
$timesheetMeta = [];
|
||||
foreach ($this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$timesheetMeta['timesheet.meta.' . $metaField->getName()] = (new Column('timesheet.meta.' . $metaField->getName(), $this->getFormatter('default')))
|
||||
->withHeader($metaField->getLabel())
|
||||
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
|
||||
return $exportableItem->getMetaField($metaField->getName())?->getValue();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$customerMeta = [];
|
||||
foreach ($this->findMetaColumns(new CustomerMetaDisplayEvent($query->copyTo(new CustomerQuery()), CustomerMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$customerMeta['customer.meta.' . $metaField->getName()] = (new Column('customer.meta.' . $metaField->getName(), $this->getFormatter('default')))
|
||||
->withHeader($metaField->getLabel())
|
||||
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
|
||||
return $exportableItem->getProject()?->getCustomer()?->getMetaField($metaField->getName())?->getValue();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$projectMeta = [];
|
||||
foreach ($this->findMetaColumns(new ProjectMetaDisplayEvent($query->copyTo(new ProjectQuery()), ProjectMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$projectMeta['project.meta.' . $metaField->getName()] = (new Column('project.meta.' . $metaField->getName(), $this->getFormatter('default')))
|
||||
->withHeader($metaField->getLabel())
|
||||
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
|
||||
return $exportableItem->getProject()?->getMetaField($metaField->getName())?->getValue();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$activityMeta = [];
|
||||
foreach ($this->findMetaColumns(new ActivityMetaDisplayEvent($query->copyTo(new ActivityQuery()), ActivityMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$activityMeta['activity.meta.' . $metaField->getName()] = (new Column('activity.meta.' . $metaField->getName(), $this->getFormatter('default')))
|
||||
->withHeader($metaField->getLabel())
|
||||
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
|
||||
return $exportableItem->getActivity()?->getMetaField($metaField->getName())?->getValue();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$userMeta = [];
|
||||
$event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT);
|
||||
$this->eventDispatcher->dispatch($event);
|
||||
foreach ($event->getPreferences() as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$userMeta['user.meta.' . $metaField->getName()] = (new Column('user.meta.' . $metaField->getName(), $this->getFormatter('default')))
|
||||
->withHeader($metaField->getLabel())
|
||||
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
|
||||
return $exportableItem->getUser()?->getPreference($metaField->getName())?->getValue();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$template = $this->getTemplate($query);
|
||||
|
||||
$columns = [];
|
||||
|
||||
$rateColumns = ['currency', 'rate', 'internal_rate', 'hourly_rate', 'fixed_rate'];
|
||||
|
||||
foreach ($template->getColumns() as $column) {
|
||||
if ($column === 'date') {
|
||||
$columns[] = (new Column('date', $this->getFormatter('date')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getBegin());
|
||||
} elseif ($column === 'begin') {
|
||||
$columns[] = (new Column('begin', $this->getFormatter('time')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getBegin())->withColumnWidth(ColumnWidth::SMALL);
|
||||
} elseif ($column === 'end') {
|
||||
$columns[] = (new Column('end', $this->getFormatter('time')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getEnd())->withColumnWidth(ColumnWidth::SMALL);
|
||||
} elseif ($column === 'duration') {
|
||||
$columns[] = (new Column('duration', $this->getFormatter('duration')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL);
|
||||
} elseif ($column === 'duration_decimal') {
|
||||
$columns[] = (new Column('duration', $this->getFormatter('duration_decimal')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL);
|
||||
} elseif ($column === 'duration_seconds') {
|
||||
$columns[] = (new Column('duration', $this->getFormatter('duration_seconds')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL);
|
||||
} elseif ($column === 'break') {
|
||||
// TODO remove method_exists with 3.0
|
||||
$columns[] = (new Column('break', $this->getFormatter('duration')))->withExtractor(fn (ExportableItem $exportableItem) => method_exists($exportableItem, 'getBreak') ? $exportableItem->getBreak() : 0)->withColumnWidth(ColumnWidth::SMALL); // @phpstan-ignore function.alreadyNarrowedType
|
||||
} elseif ($column === 'break_decimal') {
|
||||
// TODO remove method_exists with 3.0
|
||||
$columns[] = (new Column('break', $this->getFormatter('duration_decimal')))->withExtractor(fn (ExportableItem $exportableItem) => method_exists($exportableItem, 'getBreak') ? $exportableItem->getBreak() : 0)->withColumnWidth(ColumnWidth::SMALL); // @phpstan-ignore function.alreadyNarrowedType
|
||||
} elseif ($column === 'break_seconds') {
|
||||
// TODO remove method_exists with 3.0
|
||||
$columns[] = (new Column('break', $this->getFormatter('duration_seconds')))->withExtractor(fn (ExportableItem $exportableItem) => method_exists($exportableItem, 'getBreak') ? $exportableItem->getBreak() : 0)->withColumnWidth(ColumnWidth::SMALL); // @phpstan-ignore function.alreadyNarrowedType
|
||||
} elseif ($column === 'currency' && $showRates) {
|
||||
$columns[] = (new Column('currency', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getCurrency())->withColumnWidth(ColumnWidth::SMALL);
|
||||
} elseif ($column === 'rate' && $showRates) {
|
||||
$columns[] = (new Column('rate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getRate());
|
||||
} elseif ($column === 'internal_rate' && $showRates) {
|
||||
$columns[] = (new Column('internalRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getInternalRate());
|
||||
} elseif ($column === 'hourly_rate' && $showRates) {
|
||||
$columns[] = (new Column('hourlyRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getHourlyRate());
|
||||
} elseif ($column === 'fixed_rate' && $showRates) {
|
||||
$columns[] = (new Column('fixedRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getFixedRate());
|
||||
} elseif ($column === 'user.alias') {
|
||||
$columns[] = (new Column('alias', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getDisplayName())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
} elseif ($column === 'user.name') {
|
||||
$columns[] = (new Column('username', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getUserIdentifier())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
} elseif ($column === 'user.email') {
|
||||
$columns[] = (new Column('email', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getEmail())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
} elseif ($column === 'user.account_number') {
|
||||
$columns[] = (new Column('account_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getAccountNumber());
|
||||
} elseif ($column === 'customer.name') {
|
||||
$columns[] = (new Column('customer', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
} elseif ($column === 'project.name') {
|
||||
$columns[] = (new Column('project', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
} elseif ($column === 'activity.name') {
|
||||
$columns[] = (new Column('activity', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getActivity()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
} elseif ($column === 'description') {
|
||||
$columns[] = (new Column('description', new TextFormatter(true)))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDescription())->withColumnWidth(ColumnWidth::LARGE);
|
||||
} elseif ($column === 'exported') {
|
||||
$columns[] = (new Column('exported', new BooleanFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->isExported());
|
||||
} elseif ($column === 'billable') {
|
||||
$columns[] = (new Column('billable', new BooleanFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->isBillable())->withColumnWidth(ColumnWidth::SMALL);
|
||||
} elseif ($column === 'tags') {
|
||||
$columns[] = (new Column('tags', new ArrayFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getTagsAsArray());
|
||||
} elseif ($column === 'type') {
|
||||
$columns[] = (new Column('type', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getType());
|
||||
} elseif ($column === 'category') {
|
||||
$columns[] = (new Column('category', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getCategory());
|
||||
} elseif ($column === 'customer.number') {
|
||||
$columns[] = (new Column('number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getNumber());
|
||||
} elseif ($column === 'project.number') {
|
||||
$columns[] = (new Column('project_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getNumber());
|
||||
} elseif ($column === 'activity.number') {
|
||||
$columns[] = (new Column('activity_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getActivity()?->getNumber());
|
||||
} elseif ($column === 'customer.vat_id') {
|
||||
$columns[] = (new Column('vat_id', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getVatId());
|
||||
} elseif ($column === 'project.order_number') {
|
||||
$columns[] = (new Column('orderNumber', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getOrderNumber());
|
||||
} elseif (str_starts_with($column, 'timesheet.meta.') && \array_key_exists($column, $timesheetMeta)) {
|
||||
$columns[] = $timesheetMeta[$column];
|
||||
} elseif (str_starts_with($column, 'customer.meta.') && \array_key_exists($column, $customerMeta)) {
|
||||
$columns[] = $customerMeta[$column];
|
||||
} elseif (str_starts_with($column, 'project.meta.') && \array_key_exists($column, $projectMeta)) {
|
||||
$columns[] = $projectMeta[$column];
|
||||
} elseif (str_starts_with($column, 'activity.meta.') && \array_key_exists($column, $activityMeta)) {
|
||||
$columns[] = $activityMeta[$column];
|
||||
} elseif (str_starts_with($column, 'user.meta.') && \array_key_exists($column, $userMeta)) {
|
||||
$columns[] = $userMeta[$column];
|
||||
} else {
|
||||
if ($this->logger !== null && ($showRates || !\in_array($column, $rateColumns, true))) {
|
||||
$this->logger->warning(\sprintf('Unknown column "%s" used in exporter template "%s".', $column, $template->getTitle()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function getDefaultColumns(?TimesheetQuery $query = null): array
|
||||
{
|
||||
// @deprecated from 2.36 - will be removed with 3.0
|
||||
$durationFormatter = 'duration';
|
||||
if (($user = $this->voter->getUser()) instanceof User) {
|
||||
$durationFormatter = $user->isExportDecimal() ? 'duration_decimal' : 'duration';
|
||||
}
|
||||
|
||||
$columns = [
|
||||
'date',
|
||||
'begin',
|
||||
'end',
|
||||
$durationFormatter,
|
||||
'currency',
|
||||
'rate',
|
||||
'internal_rate',
|
||||
'hourly_rate',
|
||||
'fixed_rate',
|
||||
'user.alias',
|
||||
'user.name',
|
||||
'user.email',
|
||||
'user.account_number',
|
||||
'customer.name',
|
||||
'project.name',
|
||||
'activity.name',
|
||||
'description',
|
||||
'billable',
|
||||
'tags',
|
||||
'type',
|
||||
'category',
|
||||
'customer.number',
|
||||
'project.number',
|
||||
'customer.vat_id',
|
||||
'project.order_number',
|
||||
];
|
||||
|
||||
foreach ($this->findMetaColumns(new TimesheetMetaDisplayEvent($query ?? new TimesheetQuery(), TimesheetMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$columns[] = 'timesheet.meta.' . $metaField->getName();
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->findMetaColumns(new CustomerMetaDisplayEvent(new CustomerQuery(), CustomerMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$columns[] = 'customer.meta.' . $metaField->getName();
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->findMetaColumns(new ProjectMetaDisplayEvent(new ProjectQuery(), ProjectMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$columns[] = 'project.meta.' . $metaField->getName();
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->findMetaColumns(new ActivityMetaDisplayEvent(new ActivityQuery(), ActivityMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$columns[] = 'activity.meta.' . $metaField->getName();
|
||||
}
|
||||
}
|
||||
|
||||
$event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT);
|
||||
$this->eventDispatcher->dispatch($event);
|
||||
foreach ($event->getPreferences() as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$columns[] = 'user.meta.' . $metaField->getName();
|
||||
}
|
||||
}
|
||||
|
||||
return $columns;
|
||||
}
|
||||
}
|
||||
@@ -10,53 +10,41 @@
|
||||
namespace App\Export\Base;
|
||||
|
||||
use App\Entity\ExportableItem;
|
||||
use App\Export\ColumnConverter;
|
||||
use App\Export\ExportFilename;
|
||||
use App\Export\ExportRendererInterface;
|
||||
use App\Export\Package\SpoutSpreadsheet;
|
||||
use App\Export\RendererInterface;
|
||||
use App\Export\TimesheetExportInterface;
|
||||
use App\Export\TemplateInterface;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use OpenSpout\Writer\XLSX\Writer;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Exclude;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
final class XlsxRenderer implements RendererInterface, TimesheetExportInterface
|
||||
#[Exclude]
|
||||
final class XlsxRenderer extends AbstractSpreadsheetRenderer implements ExportRendererInterface
|
||||
{
|
||||
use ExportTrait;
|
||||
|
||||
private string $id = 'xlsx';
|
||||
private string $title = 'default';
|
||||
private ?string $locale = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly SpreadsheetRenderer $spreadsheetRenderer,
|
||||
private readonly ColumnConverter $columnConverter,
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly TemplateInterface $template,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
public function setId(string $id): void
|
||||
public function getType(): string
|
||||
{
|
||||
$this->id = $id;
|
||||
return 'xlsx';
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function setTitle(string $title): void
|
||||
{
|
||||
$this->title = $title;
|
||||
}
|
||||
|
||||
public function setLocale(?string $locale): void
|
||||
{
|
||||
$this->locale = $locale;
|
||||
return $this->template->getId();
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return $this->title;
|
||||
return $this->template->getTitle();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,10 +69,10 @@ final class XlsxRenderer implements RendererInterface, TimesheetExportInterface
|
||||
throw new \Exception('Could not open temporary file');
|
||||
}
|
||||
|
||||
$spreadsheet = new SpoutSpreadsheet(new Writer(), $this->translator, $this->locale ?? $this->spreadsheetRenderer->getTemplate($query)->getLocale());
|
||||
$spreadsheet = new SpoutSpreadsheet(new Writer(), $this->translator, $this->template->getLocale());
|
||||
$spreadsheet->open($filename);
|
||||
|
||||
$this->spreadsheetRenderer->writeSpreadsheet($spreadsheet, $exportItems, $query);
|
||||
$this->writeSpreadsheet($this->columnConverter, $this->template, $spreadsheet, $exportItems, $query);
|
||||
|
||||
return new \SplFileInfo($filename);
|
||||
}
|
||||
|
||||
260
src/Export/ColumnConverter.php
Normal file
260
src/Export/ColumnConverter.php
Normal file
@@ -0,0 +1,260 @@
|
||||
<?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;
|
||||
|
||||
use App\Entity\ExportableItem;
|
||||
use App\Entity\MetaTableTypeInterface;
|
||||
use App\Event\ActivityMetaDisplayEvent;
|
||||
use App\Event\CustomerMetaDisplayEvent;
|
||||
use App\Event\MetaDisplayEventInterface;
|
||||
use App\Event\ProjectMetaDisplayEvent;
|
||||
use App\Event\TimesheetMetaDisplayEvent;
|
||||
use App\Event\UserPreferenceDisplayEvent;
|
||||
use App\Export\Package\CellFormatter\ArrayFormatter;
|
||||
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;
|
||||
use App\Export\Package\CellFormatter\TimeFormatter;
|
||||
use App\Export\Package\Column;
|
||||
use App\Export\Package\ColumnWidth;
|
||||
use App\Repository\Query\ActivityQuery;
|
||||
use App\Repository\Query\CustomerQuery;
|
||||
use App\Repository\Query\ProjectQuery;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class ColumnConverter
|
||||
{
|
||||
/**
|
||||
* @var array<string, CellFormatterInterface>
|
||||
*/
|
||||
private array $formatter = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly EventDispatcherInterface $eventDispatcher,
|
||||
private readonly Security $security,
|
||||
private readonly ?LoggerInterface $logger = null,
|
||||
) {
|
||||
}
|
||||
|
||||
private function isRenderRate(TimesheetQuery $query): bool
|
||||
{
|
||||
if ($this->security->getUser() === null) {
|
||||
// for command line export
|
||||
return true;
|
||||
}
|
||||
|
||||
if (null !== $query->getUser()) {
|
||||
return $this->security->isGranted('view_rate_own_timesheet');
|
||||
}
|
||||
|
||||
return $this->security->isGranted('view_rate_other_timesheet');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return MetaTableTypeInterface[]
|
||||
*/
|
||||
private function findMetaColumns(MetaDisplayEventInterface $event): array
|
||||
{
|
||||
$this->eventDispatcher->dispatch($event);
|
||||
|
||||
return $event->getFields();
|
||||
}
|
||||
|
||||
public function registerFormatter(string $name, CellFormatterInterface $cellFormatter): void
|
||||
{
|
||||
$this->formatter[$name] = $cellFormatter;
|
||||
}
|
||||
|
||||
private function getFormatter(string $name): CellFormatterInterface
|
||||
{
|
||||
if (\array_key_exists($name, $this->formatter)) {
|
||||
return $this->formatter[$name];
|
||||
}
|
||||
|
||||
return match ($name) {
|
||||
'date' => new DateFormatter(),
|
||||
'time' => new TimeFormatter(),
|
||||
'duration' => new DurationFormatter('[hh]:mm'),
|
||||
'duration_decimal' => new DurationDecimalFormatter(),
|
||||
'duration_seconds' => new DurationFormatter('[hh]:mm:ss'),
|
||||
default => new DefaultFormatter()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, Column>
|
||||
*/
|
||||
public function getColumns(TemplateInterface $template, TimesheetQuery $query): array
|
||||
{
|
||||
$showRates = $this->isRenderRate($query);
|
||||
|
||||
$timesheetMeta = [];
|
||||
foreach ($this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$timesheetMeta['timesheet.meta.' . $metaField->getName()] = (new Column('timesheet.meta.' . $metaField->getName(), $this->getFormatter('default')))
|
||||
->withHeader($metaField->getLabel())
|
||||
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
|
||||
return $exportableItem->getMetaField($metaField->getName())?->getValue();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$customerMeta = [];
|
||||
foreach ($this->findMetaColumns(new CustomerMetaDisplayEvent($query->copyTo(new CustomerQuery()), CustomerMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$customerMeta['customer.meta.' . $metaField->getName()] = (new Column('customer.meta.' . $metaField->getName(), $this->getFormatter('default')))
|
||||
->withHeader($metaField->getLabel())
|
||||
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
|
||||
return $exportableItem->getProject()?->getCustomer()?->getMetaField($metaField->getName())?->getValue();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$projectMeta = [];
|
||||
foreach ($this->findMetaColumns(new ProjectMetaDisplayEvent($query->copyTo(new ProjectQuery()), ProjectMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$projectMeta['project.meta.' . $metaField->getName()] = (new Column('project.meta.' . $metaField->getName(), $this->getFormatter('default')))
|
||||
->withHeader($metaField->getLabel())
|
||||
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
|
||||
return $exportableItem->getProject()?->getMetaField($metaField->getName())?->getValue();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$activityMeta = [];
|
||||
foreach ($this->findMetaColumns(new ActivityMetaDisplayEvent($query->copyTo(new ActivityQuery()), ActivityMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$activityMeta['activity.meta.' . $metaField->getName()] = (new Column('activity.meta.' . $metaField->getName(), $this->getFormatter('default')))
|
||||
->withHeader($metaField->getLabel())
|
||||
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
|
||||
return $exportableItem->getActivity()?->getMetaField($metaField->getName())?->getValue();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$userMeta = [];
|
||||
$event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT);
|
||||
$this->eventDispatcher->dispatch($event);
|
||||
foreach ($event->getPreferences() as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$userMeta['user.meta.' . $metaField->getName()] = (new Column('user.meta.' . $metaField->getName(), $this->getFormatter('default')))
|
||||
->withHeader($metaField->getLabel())
|
||||
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
|
||||
return $exportableItem->getUser()?->getPreference($metaField->getName())?->getValue();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** @var array<string, Column> $columns */
|
||||
$columns = [];
|
||||
|
||||
$rateColumns = ['currency', 'rate', 'internal_rate', 'hourly_rate', 'fixed_rate'];
|
||||
|
||||
foreach ($template->getColumns($query) as $column) {
|
||||
if ($column === 'date') {
|
||||
$columns[$column] = (new Column('date', $this->getFormatter('date')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getBegin());
|
||||
} elseif ($column === 'begin') {
|
||||
$columns[$column] = (new Column('begin', $this->getFormatter('time')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getBegin())->withColumnWidth(ColumnWidth::SMALL);
|
||||
} elseif ($column === 'end') {
|
||||
$columns[$column] = (new Column('end', $this->getFormatter('time')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getEnd())->withColumnWidth(ColumnWidth::SMALL);
|
||||
} elseif ($column === 'duration') {
|
||||
$columns[$column] = (new Column('duration', $this->getFormatter('duration')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL);
|
||||
} elseif ($column === 'duration_decimal') {
|
||||
$columns[$column] = (new Column('duration', $this->getFormatter('duration_decimal')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL);
|
||||
} elseif ($column === 'duration_seconds') {
|
||||
$columns[$column] = (new Column('duration', $this->getFormatter('duration_seconds')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL);
|
||||
} elseif ($column === 'break') {
|
||||
// TODO remove method_exists with 3.0
|
||||
$columns[$column] = (new Column('break', $this->getFormatter('duration')))->withExtractor(fn (ExportableItem $exportableItem) => method_exists($exportableItem, 'getBreak') ? $exportableItem->getBreak() : 0)->withColumnWidth(ColumnWidth::SMALL); // @phpstan-ignore function.alreadyNarrowedType
|
||||
} elseif ($column === 'break_decimal') {
|
||||
// TODO remove method_exists with 3.0
|
||||
$columns[$column] = (new Column('break', $this->getFormatter('duration_decimal')))->withExtractor(fn (ExportableItem $exportableItem) => method_exists($exportableItem, 'getBreak') ? $exportableItem->getBreak() : 0)->withColumnWidth(ColumnWidth::SMALL); // @phpstan-ignore function.alreadyNarrowedType
|
||||
} elseif ($column === 'break_seconds') {
|
||||
// TODO remove method_exists with 3.0
|
||||
$columns[$column] = (new Column('break', $this->getFormatter('duration_seconds')))->withExtractor(fn (ExportableItem $exportableItem) => method_exists($exportableItem, 'getBreak') ? $exportableItem->getBreak() : 0)->withColumnWidth(ColumnWidth::SMALL); // @phpstan-ignore function.alreadyNarrowedType
|
||||
} elseif ($column === 'currency' && $showRates) {
|
||||
$columns[$column] = (new Column('currency', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getCurrency())->withColumnWidth(ColumnWidth::SMALL);
|
||||
} elseif ($column === 'rate' && $showRates) {
|
||||
$columns[$column] = (new Column('rate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getRate());
|
||||
} elseif ($column === 'internal_rate' && $showRates) {
|
||||
$columns[$column] = (new Column('internalRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getInternalRate());
|
||||
} elseif ($column === 'hourly_rate' && $showRates) {
|
||||
$columns[$column] = (new Column('hourlyRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getHourlyRate());
|
||||
} elseif ($column === 'fixed_rate' && $showRates) {
|
||||
$columns[$column] = (new Column('fixedRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getFixedRate());
|
||||
} elseif ($column === 'user.alias') {
|
||||
$columns[$column] = (new Column('alias', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getDisplayName())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
} elseif ($column === 'user.name') {
|
||||
$columns[$column] = (new Column('username', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getUserIdentifier())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
} elseif ($column === 'user.email') {
|
||||
$columns[$column] = (new Column('email', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getEmail())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
} elseif ($column === 'user.account_number') {
|
||||
$columns[$column] = (new Column('account_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getAccountNumber());
|
||||
} elseif ($column === 'customer.name') {
|
||||
$columns[$column] = (new Column('customer', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
} elseif ($column === 'project.name') {
|
||||
$columns[$column] = (new Column('project', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
} elseif ($column === 'activity.name') {
|
||||
$columns[$column] = (new Column('activity', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getActivity()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
} elseif ($column === 'description') {
|
||||
$columns[$column] = (new Column('description', new TextFormatter(true)))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDescription())->withColumnWidth(ColumnWidth::LARGE);
|
||||
} elseif ($column === 'exported') {
|
||||
$columns[$column] = (new Column('exported', new BooleanFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->isExported());
|
||||
} elseif ($column === 'billable') {
|
||||
$columns[$column] = (new Column('billable', new BooleanFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->isBillable())->withColumnWidth(ColumnWidth::SMALL);
|
||||
} elseif ($column === 'tags') {
|
||||
$columns[$column] = (new Column('tags', new ArrayFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getTagsAsArray());
|
||||
} elseif ($column === 'type') {
|
||||
$columns[$column] = (new Column('type', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getType());
|
||||
} elseif ($column === 'category') {
|
||||
$columns[$column] = (new Column('category', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getCategory());
|
||||
} elseif ($column === 'customer.number') {
|
||||
$columns[$column] = (new Column('number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getNumber());
|
||||
} elseif ($column === 'project.number') {
|
||||
$columns[$column] = (new Column('project_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getNumber());
|
||||
} elseif ($column === 'activity.number') {
|
||||
$columns[$column] = (new Column('activity_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getActivity()?->getNumber());
|
||||
} elseif ($column === 'customer.vat_id') {
|
||||
$columns[$column] = (new Column('vat_id', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getVatId());
|
||||
} elseif ($column === 'project.order_number') {
|
||||
$columns[$column] = (new Column('orderNumber', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getOrderNumber());
|
||||
} elseif ($column === 'id') {
|
||||
$columns[$column] = (new Column('id', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getId());
|
||||
} elseif (str_starts_with($column, 'timesheet.meta.') && \array_key_exists($column, $timesheetMeta)) {
|
||||
$columns[$column] = $timesheetMeta[$column];
|
||||
} elseif (str_starts_with($column, 'customer.meta.') && \array_key_exists($column, $customerMeta)) {
|
||||
$columns[$column] = $customerMeta[$column];
|
||||
} elseif (str_starts_with($column, 'project.meta.') && \array_key_exists($column, $projectMeta)) {
|
||||
$columns[$column] = $projectMeta[$column];
|
||||
} elseif (str_starts_with($column, 'activity.meta.') && \array_key_exists($column, $activityMeta)) {
|
||||
$columns[$column] = $activityMeta[$column];
|
||||
} elseif (str_starts_with($column, 'user.meta.') && \array_key_exists($column, $userMeta)) {
|
||||
$columns[$column] = $userMeta[$column];
|
||||
} else {
|
||||
if ($this->logger !== null && ($showRates || !\in_array($column, $rateColumns, true))) {
|
||||
$this->logger->warning(\sprintf('Unknown column "%s" used in exporter template "%s".', $column, $template->getTitle()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $columns;
|
||||
}
|
||||
}
|
||||
145
src/Export/DefaultTemplate.php
Normal file
145
src/Export/DefaultTemplate.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?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;
|
||||
|
||||
use App\Entity\MetaTableTypeInterface;
|
||||
use App\Entity\User;
|
||||
use App\Event\ActivityMetaDisplayEvent;
|
||||
use App\Event\CustomerMetaDisplayEvent;
|
||||
use App\Event\MetaDisplayEventInterface;
|
||||
use App\Event\ProjectMetaDisplayEvent;
|
||||
use App\Event\TimesheetMetaDisplayEvent;
|
||||
use App\Event\UserPreferenceDisplayEvent;
|
||||
use App\Repository\Query\ActivityQuery;
|
||||
use App\Repository\Query\CustomerQuery;
|
||||
use App\Repository\Query\ProjectQuery;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Exclude;
|
||||
|
||||
#[Exclude]
|
||||
final class DefaultTemplate implements TemplateInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EventDispatcherInterface $eventDispatcher,
|
||||
private readonly string $id,
|
||||
private readonly ?string $locale = 'en',
|
||||
private readonly string $title = 'default',
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getOptions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function getLocale(): ?string
|
||||
{
|
||||
return $this->locale;
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return MetaTableTypeInterface[]
|
||||
*/
|
||||
private function findMetaColumns(MetaDisplayEventInterface $event): array
|
||||
{
|
||||
$this->eventDispatcher->dispatch($event);
|
||||
|
||||
return $event->getFields();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function getColumns(TimesheetQuery $query): array
|
||||
{
|
||||
// @deprecated from 2.36 - will be removed with 3.0
|
||||
$durationFormatter = 'duration';
|
||||
if (($user = $query->getCurrentUser()) instanceof User) {
|
||||
$durationFormatter = $user->isExportDecimal() ? 'duration_decimal' : 'duration';
|
||||
}
|
||||
|
||||
$columns = [
|
||||
'date',
|
||||
'begin',
|
||||
'end',
|
||||
$durationFormatter,
|
||||
'currency',
|
||||
'rate',
|
||||
'internal_rate',
|
||||
'hourly_rate',
|
||||
'fixed_rate',
|
||||
'user.alias',
|
||||
'user.name',
|
||||
'user.email',
|
||||
'user.account_number',
|
||||
'customer.name',
|
||||
'project.name',
|
||||
'activity.name',
|
||||
'description',
|
||||
'billable',
|
||||
'tags',
|
||||
'type',
|
||||
'category',
|
||||
'customer.number',
|
||||
'project.number',
|
||||
'customer.vat_id',
|
||||
'project.order_number',
|
||||
];
|
||||
|
||||
foreach ($this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$columns[] = 'timesheet.meta.' . $metaField->getName();
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->findMetaColumns(new CustomerMetaDisplayEvent(new CustomerQuery(), CustomerMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$columns[] = 'customer.meta.' . $metaField->getName();
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->findMetaColumns(new ProjectMetaDisplayEvent(new ProjectQuery(), ProjectMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$columns[] = 'project.meta.' . $metaField->getName();
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->findMetaColumns(new ActivityMetaDisplayEvent(new ActivityQuery(), ActivityMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$columns[] = 'activity.meta.' . $metaField->getName();
|
||||
}
|
||||
}
|
||||
|
||||
$event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT);
|
||||
$this->eventDispatcher->dispatch($event);
|
||||
foreach ($event->getPreferences() as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$columns[] = 'user.meta.' . $metaField->getName();
|
||||
}
|
||||
}
|
||||
|
||||
return $columns;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,11 @@ use App\Entity\ExportableItem;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* TODO change interface for 3.0
|
||||
* @method getType() string
|
||||
* @method isInternal() bool
|
||||
*/
|
||||
interface ExportRendererInterface
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -10,33 +10,33 @@
|
||||
namespace App\Export\Renderer;
|
||||
|
||||
use App\Export\Base\CsvRenderer;
|
||||
use App\Export\Base\SpreadsheetRenderer;
|
||||
use App\Export\ColumnConverter;
|
||||
use App\Export\DefaultTemplate;
|
||||
use App\Export\TemplateInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
final class CsvRendererFactory
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EventDispatcherInterface $dispatcher,
|
||||
private readonly Security $voter,
|
||||
private readonly ColumnConverter $converter,
|
||||
private readonly EventDispatcherInterface $eventDispatcher,
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly ?LoggerInterface $logger = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function create(TemplateInterface $template): CsvRenderer
|
||||
{
|
||||
$renderer = new SpreadsheetRenderer($this->dispatcher, $this->voter, $this->logger);
|
||||
$renderer->setTemplate($template);
|
||||
|
||||
$renderer = new CsvRenderer($renderer, $this->translator);
|
||||
$renderer->setId($template->getId());
|
||||
$renderer->setTitle($template->getTitle());
|
||||
$renderer->setLocale($template->getLocale());
|
||||
$renderer = new CsvRenderer($this->converter, $this->translator, $template);
|
||||
$renderer->setInternal(true);
|
||||
|
||||
return $renderer;
|
||||
}
|
||||
|
||||
public function createDefault(): CsvRenderer
|
||||
{
|
||||
$template = new DefaultTemplate($this->eventDispatcher, 'csv');
|
||||
|
||||
return new CsvRenderer($this->converter, $this->translator, $template);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,14 @@ final class HtmlRendererFactory
|
||||
|
||||
public function create(string $id, string $template): HtmlRenderer
|
||||
{
|
||||
$renderer = new HtmlRenderer($this->twig, $this->dispatcher, $this->projectStatisticService, $this->activityStatisticService);
|
||||
$renderer->setId($id);
|
||||
$renderer->setTemplate($template);
|
||||
|
||||
return $renderer;
|
||||
return new HtmlRenderer(
|
||||
$this->twig,
|
||||
$this->dispatcher,
|
||||
$this->projectStatisticService,
|
||||
$this->activityStatisticService,
|
||||
$id,
|
||||
'print',
|
||||
$template
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,12 @@
|
||||
namespace App\Export\Renderer;
|
||||
|
||||
use App\Export\Base\PDFRenderer;
|
||||
use App\Export\Base\PdfTemplateRenderer;
|
||||
use App\Export\ColumnConverter;
|
||||
use App\Export\Template;
|
||||
use App\Pdf\HtmlToPdfConverter;
|
||||
use App\Project\ProjectStatisticService;
|
||||
use Symfony\Component\Translation\LocaleSwitcher;
|
||||
use Twig\Environment;
|
||||
|
||||
final class PdfRendererFactory
|
||||
@@ -19,17 +23,33 @@ final class PdfRendererFactory
|
||||
public function __construct(
|
||||
private readonly Environment $twig,
|
||||
private readonly HtmlToPdfConverter $converter,
|
||||
private readonly ProjectStatisticService $projectStatisticService
|
||||
private readonly ProjectStatisticService $projectStatisticService,
|
||||
private readonly LocaleSwitcher $localeSwitcher,
|
||||
private readonly ColumnConverter $columnConverter
|
||||
) {
|
||||
}
|
||||
|
||||
public function create(string $id, string $template): PDFRenderer
|
||||
public function create(string $id, string $template, ?string $title = null): PDFRenderer
|
||||
{
|
||||
$renderer = new PDFRenderer($this->twig, $this->converter, $this->projectStatisticService);
|
||||
$renderer->setId($id);
|
||||
$renderer->setTitle($id);
|
||||
$renderer->setTemplate($template);
|
||||
return new PDFRenderer(
|
||||
$this->twig,
|
||||
$this->converter,
|
||||
$this->projectStatisticService,
|
||||
$id,
|
||||
$title ?? $id,
|
||||
$template,
|
||||
);
|
||||
}
|
||||
|
||||
return $renderer;
|
||||
public function createFromTemplate(Template $template): PdfTemplateRenderer
|
||||
{
|
||||
return new PdfTemplateRenderer(
|
||||
$this->twig,
|
||||
$this->converter,
|
||||
$this->projectStatisticService,
|
||||
$this->columnConverter,
|
||||
$this->localeSwitcher,
|
||||
$template
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,33 +9,33 @@
|
||||
|
||||
namespace App\Export\Renderer;
|
||||
|
||||
use App\Export\Base\SpreadsheetRenderer;
|
||||
use App\Export\Base\XlsxRenderer;
|
||||
use App\Export\ColumnConverter;
|
||||
use App\Export\DefaultTemplate;
|
||||
use App\Export\TemplateInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
final class XlsxRendererFactory
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EventDispatcherInterface $dispatcher,
|
||||
private readonly Security $voter,
|
||||
private readonly ColumnConverter $converter,
|
||||
private readonly EventDispatcherInterface $eventDispatcher,
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly ?LoggerInterface $logger = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function createDefault(): XlsxRenderer
|
||||
{
|
||||
$template = new DefaultTemplate($this->eventDispatcher, 'xlsx');
|
||||
|
||||
return new XlsxRenderer($this->converter, $this->translator, $template);
|
||||
}
|
||||
|
||||
public function create(TemplateInterface $template): XlsxRenderer
|
||||
{
|
||||
$renderer = new SpreadsheetRenderer($this->dispatcher, $this->voter, $this->logger);
|
||||
$renderer->setTemplate($template);
|
||||
|
||||
$renderer = new XlsxRenderer($renderer, $this->translator);
|
||||
$renderer->setId($template->getId());
|
||||
$renderer->setTitle($template->getTitle());
|
||||
$renderer->setLocale($template->getLocale());
|
||||
$renderer = new XlsxRenderer($this->converter, $this->translator, $template);
|
||||
$renderer->setInternal(true);
|
||||
|
||||
return $renderer;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ final class ServiceExport
|
||||
*/
|
||||
private array $renderer = [];
|
||||
/**
|
||||
* @var TimesheetExportInterface[]
|
||||
* @var ExportRendererInterface[]
|
||||
*/
|
||||
private array $timesheetExporter = [];
|
||||
/**
|
||||
@@ -79,7 +79,12 @@ final class ServiceExport
|
||||
*/
|
||||
public function getRenderer(): array
|
||||
{
|
||||
$renderer = [];
|
||||
$renderer = [
|
||||
$this->csvRendererFactory->createDefault(),
|
||||
$this->xlsxRendererFactory->createDefault(),
|
||||
$this->pdfRendererFactory->create('pdf', 'export/pdf-layout.html.twig', 'default'),
|
||||
$this->htmlRendererFactory->create('html', 'export/print.html.twig'),
|
||||
];
|
||||
|
||||
foreach ($this->exportTemplateRepository->findAll() as $template) {
|
||||
$tpl = new Template((string) $template->getId(), $template->getTitle()); // @phpstan-ignore argument.type
|
||||
@@ -96,6 +101,10 @@ final class ServiceExport
|
||||
$renderer[] = $this->xlsxRendererFactory->create($tpl);
|
||||
break;
|
||||
|
||||
case 'pdf':
|
||||
$renderer[] = $this->pdfRendererFactory->createFromTemplate($tpl);
|
||||
break;
|
||||
|
||||
default:
|
||||
$this->logger->error('Unknown export template type: ' . $template->getRenderer());
|
||||
break;
|
||||
@@ -115,7 +124,7 @@ final class ServiceExport
|
||||
continue;
|
||||
}
|
||||
|
||||
$renderer[] = $this->htmlRendererFactory->create($tplName, $tplName);
|
||||
$renderer[] = $this->htmlRendererFactory->create($tplName, '@export/' . $tplName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +136,7 @@ final class ServiceExport
|
||||
continue;
|
||||
}
|
||||
|
||||
$renderer[] = $this->pdfRendererFactory->create($tplName, $tplName);
|
||||
$renderer[] = $this->pdfRendererFactory->create($tplName, '@export/' . $tplName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,20 +155,27 @@ final class ServiceExport
|
||||
return null;
|
||||
}
|
||||
|
||||
public function addTimesheetExporter(TimesheetExportInterface $exporter): void
|
||||
public function addTimesheetExporter(ExportRendererInterface $exporter): void
|
||||
{
|
||||
$this->timesheetExporter[] = $exporter;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return TimesheetExportInterface[]
|
||||
* @return ExportRendererInterface[]
|
||||
*/
|
||||
public function getTimesheetExporter(): array
|
||||
{
|
||||
return $this->timesheetExporter;
|
||||
$exporter = [
|
||||
$this->pdfRendererFactory->create('pdf', '@export/timesheet.pdf.twig'),
|
||||
$this->xlsxRendererFactory->createDefault(),
|
||||
$this->csvRendererFactory->createDefault(),
|
||||
$this->htmlRendererFactory->create('print', 'timesheet/export.html.twig'),
|
||||
];
|
||||
|
||||
return array_merge($this->timesheetExporter, $exporter);
|
||||
}
|
||||
|
||||
public function getTimesheetExporterById(string $id): ?TimesheetExportInterface
|
||||
public function getTimesheetExporterById(string $id): ?ExportRendererInterface
|
||||
{
|
||||
foreach ($this->getTimesheetExporter() as $exporter) {
|
||||
if ($exporter->getId() === $id) {
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
namespace App\Export;
|
||||
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
|
||||
final class Template implements TemplateInterface
|
||||
{
|
||||
private ?string $locale = null;
|
||||
@@ -72,7 +74,7 @@ final class Template implements TemplateInterface
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function getColumns(): array
|
||||
public function getColumns(TimesheetQuery $query): array
|
||||
{
|
||||
return $this->columns;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
namespace App\Export;
|
||||
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
|
||||
interface TemplateInterface
|
||||
{
|
||||
public function getId(): string;
|
||||
@@ -18,7 +20,7 @@ interface TemplateInterface
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function getColumns(): array;
|
||||
public function getColumns(TimesheetQuery $query): array;
|
||||
|
||||
public function getLocale(): ?string;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user