Compare commits

..

2 Commits

Author SHA1 Message Date
Kevin Papst
821000f143 fix test 2025-06-02 14:55:59 +02:00
Kevin Papst
e5ab1f9e78 adds a route to download generated invoice documents 2025-06-01 13:12:26 +02:00
935 changed files with 4485 additions and 13325 deletions

View File

@@ -62,7 +62,7 @@ jobs:
run: APP_ENV=dev bin/console kimai:reload -n
- name: Check codestyles
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
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
- name: Run PHPStan for application
run: vendor/bin/phpstan analyse -c phpstan.neon --no-progress --error-format=checkstyle | cs2pr

View File

@@ -8,7 +8,6 @@ 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)

View File

@@ -21,6 +21,13 @@ authentication via SAML/LDAP/Database, two-factor authentication (2FA) with TOTP
user/customer/project specific rates, advanced search & filtering, money and time budgets, advanced reporting, support for [plugins](https://www.kimai.org/store/)
and so much more.
### Versions
There are two [versions](https://www.kimai.org/documentation/versions.html) of Kimai existing:
- [Version 2](https://github.com/kimai/kimai) — the current stable release (PHP 8.1+)
- [Version 1](https://github.com/kimai/kimai/tree/1.x) — do **NOT** use, EOL since mid of 2023 (PHP 7.4)
### Links
- [Home](https://www.kimai.org) — Kimai project homepage
@@ -29,7 +36,7 @@ and so much more.
### Requirements
- PHP 8.1.3 minimum (support for PHP 8.2, 8.3, 8.4)
- PHP 8.1.3 minimum (support for PHP 8.2 and 8.3)
- MariaDB or MySQL
- A webserver and subdomain (subdirectory is not supported)
- PHP extensions: `gd`, `intl`, `json`, `mbstring`, `pdo`, `tokenizer`, `xml`, `xsl`, `zip`

View File

@@ -1,5 +0,0 @@
require('highlight.js/styles/github-dark.css');
const hljs = require('highlight.js/lib/common');
global.hljs = hljs;

View File

@@ -43,10 +43,7 @@ export default class KimaiDateNowForm extends KimaiFormPlugin {
const formElement = document.getElementById(linkTarget.dataset.target);
if (!formElement.disabled) {
formElement.value = this.getDateUtils().format(linkTarget.dataset.format, null);
// this should usually work
formElement.dispatchEvent(new Event('change', {bubbles: true}));
// this is required for Litepicker to pick the new date (with autoRefresh option)
formElement.dispatchEvent(new Event('keyup', {bubbles: true}));
}
event.preventDefault();

View File

@@ -62,10 +62,13 @@ export default class KimaiFormSelect extends KimaiFormTomselectPlugin {
plugins.push('remove_button');
}
if (node.dataset['order'] !== undefined && node.dataset['order'] === '1') {
//plugins.push('caret_position');
/*
const isOrdering = false;
if (isOrdering) {
plugins.push('caret_position');
plugins.push('drag_drop');
}
*/
let options = {
// see https://github.com/orchidjs/tom-select/issues/543#issuecomment-1664342257
@@ -82,8 +85,6 @@ export default class KimaiFormSelect extends KimaiFormTomselectPlugin {
// see App\Form\Type\TagsType::MAX_AMOUNT_SELECT
maxOptions: 500,
sortField:[{field: '$order'}, {field: '$score'}],
// required so it works in table.responsive, but requires z-index 1056, because bootstrap modal would otherwise hide it
dropdownParent: 'body',
};
let render = {

View File

@@ -202,8 +202,8 @@ export default class KimaiTimesheetForm extends KimaiFormPlugin {
{
let formatted = time.trim();
// replace invalid separators with colon
formatted = formatted.replace(/\.|;|,/g, ':');
// replace dot with colon
formatted = formatted.replace(/\./g, ':');
// uppercase 12-hour format
formatted = formatted.replace(/am/i, 'AM');
formatted = formatted.replace(/pm/i, 'PM');

View File

@@ -49,7 +49,6 @@ import enGbLocale from '@fullcalendar/core/locales/en-gb';
import enUsLocale from '@fullcalendar/core/locales/en-gb';
import KimaiColor from './KimaiColor';
import KimaiContextMenu from "./KimaiContextMenu";
import { DateTime } from 'luxon';
export default class KimaiCalendar {
@@ -73,6 +72,21 @@ 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 = {
@@ -105,8 +119,7 @@ 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: this.toInternalViewName(this.options['initialView']),
initialDate: this.options['initialDate'],
initialView: initialView,
// https://fullcalendar.io/docs/theming
themeSystem: 'bootstrap5',
// https://fullcalendar.io/docs/headerToolbar
@@ -141,9 +154,8 @@ export default class KimaiCalendar {
slotMinTime: this.options['timeframeBegin'] + ':00',
slotMaxTime: this.options['timeframeEnd'] === '23:59' ? '24:00:00' : (this.options['timeframeEnd'] + ':59'),
// 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',
// auto calculation seems to do the better job, therefor deactivated
//slotLabelInterval: this.options['slotDuration'],
// how long should entries look like when they don't have an end
defaultTimedEventDuration: this.options['slotDuration'],
@@ -159,20 +171,14 @@ 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']
@@ -260,12 +266,6 @@ export default class KimaiCalendar {
}
});
},
// called after all events of one source were set, so this can
// and will be called multiple times before the calendar is initialized
eventsSet: (events) => {
this._renderDayAndWeekSum(this.getCalendar().getCurrentData().viewSpec.type, events);
}
};
// ============= DRAG & DROP =============
@@ -528,44 +528,6 @@ 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}
@@ -725,73 +687,4 @@ export default class KimaiCalendar {
});
}
/**
* @param {string} view
* @param {EventApi[]} events
* @private
*/
_renderDayAndWeekSum(view, events) {
if (view === 'dayGridMonth') {
// currently we do not display totals in month view
return;
}
/** @type {KimaiDateUtils} DATES */
const DATES = this.kimai.getPlugin('date');
const durations = {};
if (view === 'timeGridWeek') {
// make sure we have an entry for every day of the week, even days without timesheets
document.querySelectorAll(`th.fc-col-header-cell[data-date]`).forEach(cell => {
durations[cell.dataset.date] = 0;
});
}
events.forEach(item => {
const start = DateTime.fromJSDate(item.start).toUTC();
const dateStr = start.toFormat('yyyy-MM-dd');
if (!durations[dateStr]) {
durations[dateStr] = 0;
}
// absences or public holidays are all day
if (item.end !== null) {
const end = DateTime.fromJSDate(item.end).toUTC();
const duration = end.diff(start, 'hours').as('seconds');
durations[dateStr] += duration;
}
});
const dailyTotals = document.querySelectorAll('.fc-dailytotal');
dailyTotals.forEach(element => element.remove());
for (const dateValue in durations) {
const durationValue = durations[dateValue];
if (view === 'timeGridWeek') { // this is the week view
const headerCells = document.querySelectorAll(`th.fc-col-header-cell[data-date="${dateValue}"]`);
headerCells.forEach(cell => {
const newElement = document.createElement('div');
newElement.classList.add('fc-dailytotal');
newElement.textContent = DATES.formatSeconds(durationValue);
cell.appendChild(newElement);
});
}
}
// this is the day view
if (view === 'timeGridDay') {
const dayEl = document.querySelector('th.fc-day');
const dayDate = dayEl.dataset.date;
const dayTotal = document.querySelectorAll('.fc-dailytotal');
dayTotal.forEach(element => element.remove());
const newElement = document.createElement('div');
newElement.classList.add('fc-dailytotal');
newElement.textContent = DATES.formatSeconds(durations[dayDate]);
dayEl.appendChild(newElement);
}
}
}
}

View File

@@ -93,19 +93,3 @@ table.dataTable thead > tr > th.hw-min {
width: 30%;
}
}
.markdown {
blockquote {
p {
/* Parsedown wraps content in a <p> like <blockquote><p> */
margin: 0;
}
/* Tabler doesn't style blockquotes */
border-left: 15px var(--tblr-border-style) var(--tblr-border-color);
background-color: var(--tblr-bg-surface-secondary);
}
pre code.hljs {
padding: 0;
background-color: var(--tblr-bg-surface-dark);
}
}

View File

@@ -55,19 +55,4 @@ fieldset > .mb-3.row:last-child {
.form-fieldset-light:last-child {
border:none;
}
.dropdown-item {
.dropdown-action {
visibility: hidden;
opacity: 0.5;
}
&:hover {
.dropdown-action {
visibility: visible;
&:hover {
opacity: 1.0;
}
}
}
}

View File

@@ -16,151 +16,23 @@ section.quick-entry-page {
display: none;
}
/* shrinks the dropdown elements for project and activity to fit into the table, instead of adjusting to the longest option element */
.ts-wrapper {
min-height: 36px;
max-height: 36px;
max-width: 150px;
.ts-control {
white-space: nowrap;
.item {
overflow: hidden;
}
}
}
.table tbody td:not(.total) {
padding: 3px;
}
.duration-widget input.duration-input {
min-width: 40px;
display: table;
table-layout: fixed;
}
/* make the duration field and dropdown smaller */
#quick_entry_box {
.duration-widget {
.input-group {
max-width: 50px;
min-width: 50px;
min-width: 85px;
}
.btn-duration-preset {
display: none;
padding: 7px 5px 7px 2px;
padding: 7px 8px 7px 5px;
}
input.duration-input {
padding: 7px 5px;
max-width: 100%;
border-bottom-right-radius: var(--tblr-border-radius);
border-top-right-radius: var(--tblr-border-radius);
}
}
table.dataTable thead th,
table.dataTable td.total {
padding: .75rem 2px;
}
}
/* make sure dropdown never hides behind table responsive */
.dropdown-menu {
z-index: 1056;
}
}
@media (min-width: 900px) {
section.quick-entry-page {
.ts-wrapper {
max-width: 170px;
}
}
}
@media (min-width: 1000px) {
section.quick-entry-page {
.ts-wrapper {
max-width: 180px;
}
}
}
@media (min-width: 1200px) {
section.quick-entry-page {
.ts-wrapper {
max-width: 210px;
}
.duration-widget {
max-width: 100%;
}
}
}
@media (min-width: 1250px) {
section.quick-entry-page {
#quick_entry_box {
.duration-widget {
.input-group {
max-width: 70px;
min-width: 70px;
}
.btn-duration-preset {
display: flex;
}
input.duration-input {
border-bottom-right-radius: 0;
border-top-right-radius: 0;
}
}
table.dataTable thead th,
table.dataTable td.total {
padding: .75rem;
}
}
}
}
@media (min-width: 1300px) {
section.quick-entry-page {
.ts-wrapper {
max-width: 230px;
}
}
}
@media (min-width: 1400px) {
section.quick-entry-page {
.ts-wrapper {
max-width: 270px;
}
}
}
@media (min-width: 1500px) {
section.quick-entry-page {
.ts-wrapper {
max-width: 300px;
}
}
}
@media (min-width: 1600px) {
section.quick-entry-page {
.ts-wrapper {
max-width: 370px;
}
}
}
@media (min-width: 1800px) {
section.quick-entry-page {
.ts-wrapper {
max-width: 450px;
}
#quick_entry_box {
.duration-widget {
.input-group {
max-width: 100%;
}
input {
padding: 7px 3px 7px 7px;
}
}
}

View File

@@ -1,11 +1,7 @@
/*
1. bootstrap thead.sticky-top has z-index 1020 and tomselect dropdowns often hide behind that: use 1021
2. bootstrap modal uses 1055, tomselect with dropdownParent: 'body' (required for use in table.responsive, e.g quick.entry on small devices) needs 1056, so it works in modals
*/
/* bootstrap thead.sticky-top has z-index 1020 and tomselect dropdowns often hide behind that */
.ts-dropdown {
z-index: 1056;
z-index: 1021;
}
/* Tabler defines a min-width of 7rem, what makes many input fields become a two-line field

1560
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,13 +6,6 @@
'rtl' => true,
'translation' => true,
],
'bg' =>
[
'date' => 'd.MM.y',
'time' => 'HH:mm',
'rtl' => false,
'translation' => true,
],
'cs' =>
[
'date' => 'dd.MM.y',

View File

@@ -119,8 +119,8 @@ kimai:
roles:
ROLE_USER: ['view_team_member','time_team_project','create_tag','view_reporting']
ROLE_TEAMLEAD: ['view_rate_own_timesheet','view_rate_other_timesheet','hourly-rate_own_profile','view_team_member','hours_other_profile']
ROLE_ADMIN: ['hourly-rate_own_profile','edit_exported_timesheet','teams_own_profile','view_team_member','view_all_data','contract_other_profile','hours_other_profile','create_export_template']
ROLE_SUPER_ADMIN: ['hourly-rate_own_profile','hourly-rate_other_profile','roles_own_profile','supervisor_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet','teams_own_profile','view_team_member','upload_invoice_template','view_all_data','contract_other_profile','hours_other_profile','create_export_template']
ROLE_ADMIN: ['hourly-rate_own_profile','edit_exported_timesheet','teams_own_profile','view_team_member','view_all_data','contract_other_profile','hours_other_profile']
ROLE_SUPER_ADMIN: ['hourly-rate_own_profile','hourly-rate_other_profile','roles_own_profile','supervisor_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet','teams_own_profile','view_team_member','upload_invoice_template','view_all_data','contract_other_profile','hours_other_profile']
# --------------------------------------------------------------------------------

View File

@@ -47,7 +47,6 @@ tabler:
barcode: fas fa-barcode
bookmark: far fa-star
bookmarked: fas fa-star
break: fas fa-utensils
calendar: far fa-calendar-alt
cancel: fas fa-times
clock: far fa-clock
@@ -69,10 +68,10 @@ tabler:
documentation: fas fa-book
dot: fas fa-circle
download: fas fa-download
duration: fas fa-stopwatch
duration: far fa-hourglass
edit: far fa-edit
end: fas fa-hourglass-end
export: fas fa-download
end: fas fa-stopwatch
export: fas fa-file-export
failure: fas fa-times
fax: fas fa-fax
filter: fas fa-filter

View File

@@ -1,14 +1,16 @@
parameters:
locale: en
# can be regenerated with: bin/console kimai:reset:locales
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']
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']
services:
# default configuration for services in *this* file
_defaults:
autowire: true
autoconfigure: true
public: false
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.
bind:
$projectDirectory: '%kernel.project_dir%'
$kernelEnvironment: '%kernel.environment%'
@@ -204,11 +206,6 @@ services:
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\AccessToken']
App\Repository\ExportTemplateRepository:
class: App\Repository\ExportTemplateRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\ExportTemplate']
monolog.formatter.kimai:
class: Monolog\Formatter\LineFormatter
arguments:

View File

@@ -22,7 +22,6 @@ function update_kimai() {
exit 1
fi
rm -rf var/sessions/ 2>&1
rm -rf var/cache/* 2>&1
git fetch --tags
git checkout "$VERSION"

View File

@@ -1,51 +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 DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* @version 2.36.0
*/
final class Version20250608143244 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create the export template table';
}
public function up(Schema $schema): void
{
$table = $schema->createTable('kimai2_export_templates');
$table->addColumn('id', 'integer', ['autoincrement' => true, 'notnull' => true]);
$table->addColumn('title', 'string', ['notnull' => true, 'length' => 100]);
$table->addColumn('renderer', 'string', ['notnull' => true, 'length' => 20]);
$table->addColumn('language', 'string', ['notnull' => false, 'length' => 6]);
$table->addColumn('columns', 'json', ['notnull' => true]);
$table->addColumn('options', 'json', ['notnull' => true]);
$table->setPrimaryKey(['id']);
$table->addUniqueIndex(['title'], 'UNIQ_2F0CA26F2B36786B');
}
public function down(Schema $schema): void
{
if ($schema->hasTable('kimai2_export_templates')) {
$schema->dropTable('kimai2_export_templates');
}
}
public function isTransactional(): bool
{
return false;
}
}

View File

@@ -45,7 +45,6 @@
"eslint": "^9",
"globals": "^15",
"gridstack": "^7",
"highlight.js": "^11.11.1",
"litepicker": "^2",
"luxon": "^3",
"sass": "^1",

View File

@@ -663,6 +663,11 @@ parameters:
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
@@ -2005,7 +2010,7 @@ parameters:
-
message: "#^Cannot clone mixed\\.$#"
count: 1
count: 2
path: src/Form/QuickEntryForm.php
-
@@ -2833,6 +2838,21 @@ parameters:
count: 1
path: src/Form/Type/QuickEntryTimesheetType.php
-
message: "#^Cannot access offset 'activity' on mixed\\.$#"
count: 1
path: src/Form/Type/QuickEntryWeekType.php
-
message: "#^Cannot access offset 'project' on mixed\\.$#"
count: 1
path: src/Form/Type/QuickEntryWeekType.php
-
message: "#^Cannot clone mixed\\.$#"
count: 1
path: src/Form/Type/QuickEntryWeekType.php
-
message: "#^Method App\\\\Form\\\\Type\\\\SearchTermType\\:\\:buildForm\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
count: 1
@@ -4159,14 +4179,29 @@ parameters:
path: src/Utils/PaginationTemplate.php
-
message: "#^Method App\\\\Utils\\\\Parsedown\\:\\:blockHeader\\(\\) has no return type specified\\.$#"
message: "#^Method App\\\\Utils\\\\ParsedownExtension\\:\\:blockHeader\\(\\) has no return type specified\\.$#"
count: 1
path: src/Utils/Parsedown.php
path: src/Utils/ParsedownExtension.php
-
message: "#^Method App\\\\Utils\\\\Parsedown\\:\\:blockHeader\\(\\) has parameter \\$Line with no type specified\\.$#"
message: "#^Method App\\\\Utils\\\\ParsedownExtension\\:\\:blockHeader\\(\\) has parameter \\$Line with no type specified\\.$#"
count: 1
path: src/Utils/Parsedown.php
path: src/Utils/ParsedownExtension.php
-
message: "#^Method App\\\\Utils\\\\ParsedownExtension\\:\\:blockTable\\(\\) has no return type specified\\.$#"
count: 1
path: src/Utils/ParsedownExtension.php
-
message: "#^Method App\\\\Utils\\\\ParsedownExtension\\:\\:blockTable\\(\\) has parameter \\$Block with no value type specified in iterable type array\\.$#"
count: 1
path: src/Utils/ParsedownExtension.php
-
message: "#^Method App\\\\Utils\\\\ParsedownExtension\\:\\:blockTable\\(\\) has parameter \\$Line with no type specified\\.$#"
count: 1
path: src/Utils/ParsedownExtension.php
-
message: "#^Method App\\\\Utils\\\\ParsedownExtension\\:\\:inlineUrl\\(\\) has parameter \\$Excerpt with no type specified\\.$#"
@@ -4181,7 +4216,7 @@ parameters:
-
message: "#^Parameter \\#1 \\$str of function strtr expects string, string\\|null given\\.$#"
count: 1
path: src/Utils/Parsedown.php
path: src/Utils/ParsedownExtension.php
-
message: "#^Property App\\\\Utils\\\\ParsedownExtension\\:\\:\\$BlockTypes has no type specified\\.$#"

View File

@@ -4,12 +4,7 @@
backupGlobals="false"
colors="true"
bootstrap="tests/bootstrap.php"
cacheDirectory="var/cache/phpunit/"
displayDetailsOnTestsThatTriggerDeprecations="true"
displayDetailsOnTestsThatTriggerErrors="true"
displayDetailsOnTestsThatTriggerNotices="true"
displayDetailsOnTestsThatTriggerWarnings="true"
displayDetailsOnPhpunitDeprecations="true">
cacheDirectory="var/cache/phpunit/">
<php>
<ini name="error_reporting" value="-1"/>
<ini name="max_execution_time" value="-1"/>
@@ -39,15 +34,14 @@
-->
<env name="BOOTSTRAP_RESET_DATABASE" value="true"/>
</php>
<testsuites>
<testsuite name="Kimai">
<directory>tests/</directory>
</testsuite>
</testsuites>
<extensions>
<bootstrap class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
</extensions>
<source>
<coverage>
<include>
<directory suffix=".php">src/</directory>
<directory suffix=".php">templates/</directory>
@@ -64,5 +58,10 @@
<directory suffix=".php">var/</directory>
<directory suffix=".php">vendor/</directory>
</exclude>
</source>
</coverage>
<extensions>
<bootstrap class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
</extensions>
</phpunit>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -3,10 +3,10 @@
"app": {
"js": [
"/build/runtime.6c399d29.js",
"/build/app.dd0a8e58.js"
"/build/app.6bb53337.js"
],
"css": [
"/build/app.8b6629a6.css"
"/build/app.0416ea92.css"
]
},
"app-rtl": {
@@ -15,7 +15,7 @@
"/build/app-rtl.7a875ca7.js"
],
"css": [
"/build/app-rtl.bf451276.css"
"/build/app-rtl.0848906b.css"
]
},
"export-pdf": {
@@ -54,7 +54,7 @@
"calendar": {
"js": [
"/build/runtime.6c399d29.js",
"/build/calendar.d82e420f.js"
"/build/calendar.7415ab21.js"
],
"css": [
"/build/calendar.d757753e.css"
@@ -68,23 +68,14 @@
"css": [
"/build/dashboard.b7129fa1.css"
]
},
"highlight": {
"js": [
"/build/runtime.6c399d29.js",
"/build/highlight.766d3929.js"
],
"css": [
"/build/highlight.98bf3927.css"
]
}
},
"integrity": {
"/build/runtime.6c399d29.js": "sha384-/rm616f12czi8l/27GvWXtb3g608vJZf2XTUKxqCRI4tsa2vUHP+BW90edTok5zC",
"/build/app.dd0a8e58.js": "sha384-k0DlCny0XdsJHHuyL8B5MNlZTl+s8ZvBFvjwD9xrPQhbZ7ryiTx72pHOi+vaEzB8",
"/build/app.8b6629a6.css": "sha384-mgEEi5bDaaMfrI+/iEBBD2S2sQvjdRJhsYN89QlhqPeUhM39pcxiGx9WVVP31jls",
"/build/app.6bb53337.js": "sha384-ZcwEys2VjhW4jwH/pU+/W06vG7rBounyirdkC+TCA4PyMn2QsENR6FIcNoJEpF5X",
"/build/app.0416ea92.css": "sha384-JAIO6+B/vmV8IlpsmQ+zkfO4JsvdKTMsZZaDg6EMlZxkMZ5K3cpEK0mo24D/Wgkh",
"/build/app-rtl.7a875ca7.js": "sha384-T7gLI61h9dGeMgzo63vKu4GiDOeLPct9zSUHrceNbhSwIdUmSSNoZ1+d7fKhJJ4/",
"/build/app-rtl.bf451276.css": "sha384-hWaFBRhgNGAmCoe30xfzWFG1qkmKSAXBjZj/GRXY61Gymsq3p2d9sbyC7M6EwumB",
"/build/app-rtl.0848906b.css": "sha384-O26Xw3P/NSea5iT6lt5v2RaZ6+jP06hf9vYD2pUJbkFSCLGU1iGXnobP25dWjUs/",
"/build/export-pdf.395749ab.js": "sha384-3Hjvmu4FC/0dhHnR8kyRBU7k2xMNy1lxBpGgOkrw8PxXnwyQDM8/5bQmkJbjVT1+",
"/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
"/build/invoice.773af9c4.js": "sha384-QmMqYJ0RP2WOYU7D4lLzKCBFDL6vCvZHQc7wf8K8N+hdCuTJwq1P0GuY81f30/SY",
@@ -92,11 +83,9 @@
"/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.d82e420f.js": "sha384-jJg6+vV6XQn7YlY5wBwvBAujWGgYhSMCS+eDFFxoir+Y69UKB4Lqc69zP+HFzwts",
"/build/calendar.7415ab21.js": "sha384-+iYr7VnMIxSQMERlaruX2Ktp6gWmGSyCxfUAaeSDkoninXdUdiKBjlcZZwhHhZWU",
"/build/calendar.d757753e.css": "sha384-cTmQMgHYjd2gfObFWmEUph7qQLCyXaIkneSf+bQ2mqVmZwqOB+pJOm/UYTyTjALJ",
"/build/dashboard.632f98fb.js": "sha384-PlHarP53f8b+47VZvbQw3LURA2vODFf7UMwpnktvukrhswaHtx93cx2M1BtO56JH",
"/build/dashboard.b7129fa1.css": "sha384-2nn5hLA+3YedgHYBpge62S8Losj8aoPwK9Zk9EvN1xEYatvOUQ7H3rIR2UUJAGOS",
"/build/highlight.766d3929.js": "sha384-/4zEkS1Zk8Y4U14MbMEJPH8rOkSfJbSg3+ZQ+xdVW18TCHzqv0DQC5FXIPOW4aB3",
"/build/highlight.98bf3927.css": "sha384-YgweSwDwN0dI4DEmh478xYVw/TewJYvCO1QTbWHpaHFDPuLrZvYMR0Tc+QfFxVPE"
"/build/dashboard.b7129fa1.css": "sha384-2nn5hLA+3YedgHYBpge62S8Losj8aoPwK9Zk9EvN1xEYatvOUQ7H3rIR2UUJAGOS"
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{background:#0d1117;color:#c9d1d9}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#79c0ff}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-code,.hljs-comment,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{background-color:#033a16;color:#aff5b4}.hljs-deletion{background-color:#67060c;color:#ffdcd7}

View File

@@ -1,7 +1,7 @@
{
"build/app.css": "/build/app.8b6629a6.css",
"build/app.js": "/build/app.dd0a8e58.js",
"build/app-rtl.css": "/build/app-rtl.bf451276.css",
"build/app.css": "/build/app.0416ea92.css",
"build/app.js": "/build/app.6bb53337.js",
"build/app-rtl.css": "/build/app-rtl.0848906b.css",
"build/app-rtl.js": "/build/app-rtl.7a875ca7.js",
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
"build/export-pdf.js": "/build/export-pdf.395749ab.js",
@@ -11,11 +11,9 @@
"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.d82e420f.js",
"build/calendar.js": "/build/calendar.7415ab21.js",
"build/dashboard.css": "/build/dashboard.b7129fa1.css",
"build/dashboard.js": "/build/dashboard.632f98fb.js",
"build/highlight.css": "/build/highlight.98bf3927.css",
"build/highlight.js": "/build/highlight.766d3929.js",
"build/runtime.js": "/build/runtime.6c399d29.js",
"build/fonts/fa-solid-900.ttf": "/build/fonts/fa-solid-900.ad1782c7.ttf",
"build/fonts/fa-brands-400.ttf": "/build/fonts/fa-brands-400.26b80c88.ttf",

View File

@@ -1,47 +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\API;
use App\Entity\ExportTemplate;
use App\Repository\ExportTemplateRepository;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route(path: '/export')]
#[IsGranted('API')]
#[OA\Tag(name: 'Export')]
final class ExportController extends BaseApiController
{
public function __construct(
private readonly ViewHandlerInterface $viewHandler,
private readonly ExportTemplateRepository $repository,
) {
}
/**
* Delete export template
*/
#[IsGranted('create_export_template')]
#[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
{
$this->repository->removeExportTemplate($exportTemplate);
$view = new View(null, Response::HTTP_NO_CONTENT);
return $this->viewHandler->handle($view);
}
}

View File

@@ -10,6 +10,7 @@
namespace App\API;
use App\Entity\Invoice;
use App\Invoice\ServiceInvoice;
use App\Repository\CustomerRepository;
use App\Repository\InvoiceRepository;
use App\Repository\Query\InvoiceArchiveQuery;
@@ -18,6 +19,7 @@ use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use OpenApi\Attributes as OA;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -25,6 +27,7 @@ use Symfony\Component\Validator\Constraints;
#[Route(path: '/invoices')]
#[IsGranted('API')]
#[IsGranted('view_invoice')]
#[OA\Tag(name: 'Invoice')]
final class InvoiceController extends BaseApiController
{
@@ -40,7 +43,6 @@ final class InvoiceController extends BaseApiController
/**
* Fetch invoices
*/
#[IsGranted('view_invoice')]
#[OA\Response(response: 200, description: 'Returns a collection of invoices', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/InvoiceCollection')))]
#[Route(methods: ['GET'], path: '', name: 'get_invoices')]
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records after this date will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
@@ -89,7 +91,6 @@ final class InvoiceController extends BaseApiController
/**
* Fetch invoice
*/
#[IsGranted('view_invoice')]
#[OA\Response(response: 200, description: 'Returns one invoice', content: new OA\JsonContent(ref: '#/components/schemas/Invoice'))]
#[Route(methods: ['GET'], path: '/{id}', name: 'get_invoice', requirements: ['id' => '\d+'])]
public function getAction(Invoice $invoice): Response
@@ -99,4 +100,28 @@ final class InvoiceController extends BaseApiController
return $this->viewHandler->handle($view);
}
/**
* Download invoice
*
* The returned `content-type` depends on the type of invoice document.
* Could be anything from `application/pdf` to `application/vnd.openxmlformats-officedocument.wordprocessingml.document`.
*
* Use the response header to detect the filename, e.g. `content-disposition: attachment; filename=2025-001-acmme_company.pdf`.
*/
#[OA\Response(response: 200, description: 'Returns the (binary) invoice document', content: new OA\MediaType(mediaType: 'application/octet-stream', schema: new OA\Schema(type: 'string', format: 'binary')))]
#[Route(path: '/{id}/download', requirements: ['id' => '\d+'], methods: ['GET'])]
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')]
public function download(Invoice $invoice, ServiceInvoice $service): Response
{
$file = $service->getInvoiceFile($invoice);
if (null === $file) {
throw $this->createNotFoundException(
\sprintf('Invoice file "%s" could not be found for invoice ID "%s"', $invoice->getInvoiceFilename(), $invoice->getId())
);
}
return $this->file($file->getRealPath(), $file->getBasename());
}
}

View File

@@ -63,6 +63,24 @@ final class ValidationFailedExceptionErrorHandler implements SubscribingHandlerI
}
public function serializeValidationExceptionToJson(JsonSerializationVisitor $visitor, ValidationFailedException $exception, array $type, Context $context)
{
$errors = [];
/** @var ConstraintViolationInterface $error */
foreach (iterator_to_array($exception->getViolations()) as $error) {
$errors[$error->getPropertyPath()]['errors'][] = $this->getErrorMessage($error);
}
return [
'code' => '400',
'message' => $this->translator->trans($exception->getMessage(), [], 'validators'),
'errors' => [
'children' => $errors
],
];
}
private function getErrorMessage(ConstraintViolationInterface $error): string
{
$locale = \Locale::getDefault();
/** @var User $user */
@@ -72,24 +90,6 @@ final class ValidationFailedExceptionErrorHandler implements SubscribingHandlerI
$locale = $user->getLanguage();
}
$errors = [];
/** @var ConstraintViolationInterface $error */
foreach (iterator_to_array($exception->getViolations()) as $error) {
$errors[$error->getPropertyPath()]['errors'][] = $this->getErrorMessage($error, $locale);
}
return [
'code' => '400',
'message' => $this->translator->trans($exception->getMessage(), [], 'validators', $locale),
'errors' => [
'children' => $errors
],
];
}
private function getErrorMessage(ConstraintViolationInterface $error, string $locale): string
{
if (null !== $error->getPlural()) {
return $this->translator->trans($error->getMessageTemplate(), ['%count%' => $error->getPlural()] + $error->getParameters(), 'validators', $locale);
}

View File

@@ -315,7 +315,7 @@ final class TimesheetController extends BaseApiController
if ($form->isValid()) {
try {
$this->service->saveTimesheet($timesheet);
$this->service->saveNewTimesheet($timesheet);
$view = new View($timesheet, 200);
@@ -372,7 +372,7 @@ final class TimesheetController extends BaseApiController
return $this->viewHandler->handle($view);
}
$this->service->saveTimesheet($timesheet);
$this->service->updateTimesheet($timesheet);
$view = new View($timesheet, Response::HTTP_OK);
$view->getContext()->setGroups(self::GROUPS_ENTITY);
@@ -547,7 +547,7 @@ final class TimesheetController extends BaseApiController
$copyTimesheet = clone $timesheet;
$this->dispatcher->dispatch(new TimesheetDuplicatePreEvent($copyTimesheet, $timesheet));
$this->service->saveTimesheet($copyTimesheet);
$this->service->saveNewTimesheet($copyTimesheet);
$this->dispatcher->dispatch(new TimesheetDuplicatePostEvent($copyTimesheet, $timesheet));
$view = new View($copyTimesheet, 200);
@@ -571,7 +571,7 @@ final class TimesheetController extends BaseApiController
$timesheet->setExported(!$timesheet->isExported());
$this->service->saveTimesheet($timesheet);
$this->service->updateTimesheet($timesheet);
$view = new View($timesheet, 200);
$view->getContext()->setGroups(self::GROUPS_ENTITY);
@@ -601,7 +601,7 @@ final class TimesheetController extends BaseApiController
$meta->setValue($paramFetcher->get('value'));
$this->service->saveTimesheet($timesheet);
$this->service->updateTimesheet($timesheet);
$view = new View($timesheet, 200);
$view->getContext()->setGroups(self::GROUPS_ENTITY);

View File

@@ -97,7 +97,7 @@ class ActivityService
$errors = $this->validator->validate($activity, null, $groups);
if ($errors->count() > 0) {
throw new ValidationFailedException($errors);
throw new ValidationFailedException($errors, 'Validation Failed');
}
}

View File

@@ -20,7 +20,7 @@ use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\QueryBuilder;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
/**
* @final

View File

@@ -1,21 +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\Audit;
#[\Attribute(\Attribute::TARGET_CLASS)]
final class Loggable
{
/**
* @param class-string|null $customFieldClass
*/
public function __construct(public ?string $customFieldClass = null)
{
}
}

View File

@@ -1,18 +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\Audit;
#[\Attribute(\Attribute::TARGET_PROPERTY)]
final class Versioned
{
public function __construct()
{
}
}

View File

@@ -1,55 +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\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;
}
}

View File

@@ -20,16 +20,19 @@ use Symfony\Component\Console\Style\SymfonyStyle;
abstract class AbstractRoleCommand extends Command
{
public function __construct(private readonly UserService $userService)
public function __construct(private UserService $userService)
{
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('username', InputArgument::REQUIRED, 'The username');
$this->addArgument('role', InputArgument::OPTIONAL, 'The role');
$this->addOption('super', null, InputOption::VALUE_NONE, 'Instead specifying role, use this to quickly add the super administrator role');
$this
->setDefinition([
new InputArgument('username', InputArgument::REQUIRED, 'The username'),
new InputArgument('role', InputArgument::OPTIONAL, 'The role'),
new InputOption('super', null, InputOption::VALUE_NONE, 'Instead specifying role, use this to quickly add the super administrator role'),
]);
}
protected function execute(InputInterface $input, OutputInterface $output): int

View File

@@ -26,7 +26,7 @@ abstract class AbstractUserCommand extends Command
$passwordQuestion = new Question('Please enter the password: ');
$passwordQuestion->setHidden(true);
$passwordQuestion->setHiddenFallback(true);
$passwordQuestion->setHiddenFallback(false);
$passwordQuestion->setValidator(function (?string $value) {
$password = trim($value);
if (empty($password)) {

View File

@@ -17,7 +17,7 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'kimai:user:activate', description: 'Activate a user')]
#[AsCommand(name: 'kimai:user:activate')]
final class ActivateUserCommand extends Command
{
public function __construct(private UserService $userService)
@@ -28,7 +28,10 @@ final class ActivateUserCommand extends Command
protected function configure(): void
{
$this
->addArgument('username', InputArgument::REQUIRED, 'The username')
->setDescription('Activate a user')
->setDefinition([
new InputArgument('username', InputArgument::REQUIRED, 'The username'),
])
->setHelp(
<<<'EOT'
The <info>kimai:user:activate</info> command activates a user (so they will be able to log in):

View File

@@ -18,7 +18,7 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'kimai:user:password', description: 'Change the password of a user')]
#[AsCommand(name: 'kimai:user:password')]
final class ChangePasswordCommand extends AbstractUserCommand
{
public function __construct(private UserService $userService)
@@ -29,8 +29,11 @@ final class ChangePasswordCommand extends AbstractUserCommand
protected function configure(): void
{
$this
->addArgument('username', InputArgument::REQUIRED, 'The username')
->addArgument('password', InputArgument::OPTIONAL, 'The password')
->setDescription('Change the password of a user.')
->setDefinition([
new InputArgument('username', InputArgument::REQUIRED, 'The username'),
new InputArgument('password', InputArgument::OPTIONAL, 'The password'),
])
->setHelp(
<<<'EOT'
The <info>kimai:user:password</info> command changes the password of a user:

View File

@@ -20,7 +20,7 @@ use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'kimai:user:create', description: 'Create a new user')]
#[AsCommand(name: 'kimai:user:create')]
final class CreateUserCommand extends AbstractUserCommand
{
public function __construct(private UserService $userService)
@@ -33,6 +33,7 @@ final class CreateUserCommand extends AbstractUserCommand
$roles = implode(',', [User::DEFAULT_ROLE, User::ROLE_ADMIN]);
$this
->setDescription('Create a new user')
->setHelp('This command allows you to create a new user.')
->addArgument('username', InputArgument::REQUIRED, 'A name for the new user (must be unique)')
->addArgument('email', InputArgument::REQUIRED, 'Email address of the new user (must be unique)')

View File

@@ -17,7 +17,7 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'kimai:user:deactivate', description: 'Deactivate a user')]
#[AsCommand(name: 'kimai:user:deactivate')]
final class DeactivateUserCommand extends Command
{
public function __construct(private UserService $userService)
@@ -28,7 +28,10 @@ final class DeactivateUserCommand extends Command
protected function configure(): void
{
$this
->addArgument('username', InputArgument::REQUIRED, 'The username')
->setDescription('Deactivate a user')
->setDefinition([
new InputArgument('username', InputArgument::REQUIRED, 'The username'),
])
->setHelp(
<<<'EOT'
The <info>kimai:user:deactivate</info> command deactivates a user (will not be able to log in)

View File

@@ -14,7 +14,7 @@ use App\User\UserService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'kimai:user:demote', description: 'Demote a user by removing a role')]
#[AsCommand(name: 'kimai:user:demote')]
final class DemoteUserCommand extends AbstractRoleCommand
{
protected function configure(): void
@@ -22,6 +22,7 @@ final class DemoteUserCommand extends AbstractRoleCommand
parent::configure();
$this
->setDescription('Demote a user by removing a role')
->setHelp(
<<<'EOT'
The <info>kimai:user:demote</info> command demotes a user by removing a role

View File

@@ -31,7 +31,7 @@ use Symfony\Component\Mailer\MailerInterface;
use Symfony\Contracts\Translation\LocaleAwareInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
#[AsCommand(name: 'kimai:export:create', description: 'Create exports')]
#[AsCommand(name: 'kimai:export:create')]
final class ExportCreateCommand extends Command
{
public function __construct(
@@ -49,6 +49,7 @@ final class ExportCreateCommand extends Command
protected function configure(): void
{
$this
->setDescription('Create exports')
->setHelp('Create exports by several different filters and sent them via email.')
->addOption('username', null, InputOption::VALUE_REQUIRED, 'The user to be used for generating the export (e.g. used for permissions and decimal setting)')
->addOption('start', null, InputOption::VALUE_OPTIONAL, 'Start date (format: 2020-01-01, default: start of the month)', null)

View File

@@ -34,19 +34,19 @@ use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
#[AsCommand(name: 'kimai:invoice:create', description: 'Create invoices')]
#[AsCommand(name: 'kimai:invoice:create')]
final class InvoiceCreateCommand extends Command
{
private ?string $previewDirectory = null;
private bool $previewUniqueFile = false;
public function __construct(
private readonly ServiceInvoice $serviceInvoice,
private readonly CustomerRepository $customerRepository,
private readonly ProjectRepository $projectRepository,
private readonly InvoiceTemplateRepository $invoiceTemplateRepository,
private readonly UserRepository $userRepository,
private readonly EventDispatcherInterface $eventDispatcher
private ServiceInvoice $serviceInvoice,
private CustomerRepository $customerRepository,
private ProjectRepository $projectRepository,
private InvoiceTemplateRepository $invoiceTemplateRepository,
private UserRepository $userRepository,
private EventDispatcherInterface $eventDispatcher
) {
parent::__construct();
}
@@ -54,6 +54,7 @@ final class InvoiceCreateCommand extends Command
protected function configure(): void
{
$this
->setDescription('Create invoices')
->setHelp('This command allows to create invoices by several different filters.')
->addOption('user', null, InputOption::VALUE_REQUIRED, 'The user to be used for generating the invoices')
->addOption('start', null, InputOption::VALUE_OPTIONAL, 'Start date (format: 2020-01-01, default: start of the month)', null)
@@ -255,6 +256,9 @@ final class InvoiceCreateCommand extends Command
/**
* @param Project[] $projects
* @param InvoiceQuery $defaultQuery
* @param InputInterface $input
* @param OutputInterface $output
* @return Invoice[]
* @throws \Exception
*/
@@ -283,16 +287,10 @@ final class InvoiceCreateCommand extends Command
$query->setTemplate($tpl);
try {
$model = $this->serviceInvoice->createModel($query);
// this check makes sure to only fetch invoices with records
if (\count($model->getEntries()) === 0) {
continue;
}
if (null !== $this->previewDirectory) {
$invoices[] = $this->saveInvoicePreview($this->serviceInvoice->renderInvoice($model, $this->eventDispatcher));
$invoices[] = $this->saveInvoicePreview($this->serviceInvoice->renderInvoice($this->serviceInvoice->createModel($query), $this->eventDispatcher));
} else {
$invoices[] = $this->serviceInvoice->createInvoice($model, $this->eventDispatcher);
$invoices[] = $this->serviceInvoice->createInvoice($this->serviceInvoice->createModel($query), $this->eventDispatcher);
}
} catch (\Exception $ex) {
$io->error(\sprintf('Failed to create invoice for project "%s" with: %s', $project->getName(), $ex->getMessage()));
@@ -336,6 +334,8 @@ final class InvoiceCreateCommand extends Command
/**
* @param Customer[] $customers
* @param InvoiceQuery $defaultQuery
* @param InputInterface $input
* @return Invoice[]
* @throws \Exception
*/
@@ -358,16 +358,10 @@ final class InvoiceCreateCommand extends Command
$query->setTemplate($tpl);
try {
$model = $this->serviceInvoice->createModel($query);
// this check makes sure to only fetch invoices with records
if (\count($model->getEntries()) === 0) {
continue;
}
if (null !== $this->previewDirectory) {
$invoices[] = $this->saveInvoicePreview($this->serviceInvoice->renderInvoice($model, $this->eventDispatcher));
$invoices[] = $this->saveInvoicePreview($this->serviceInvoice->renderInvoice($this->serviceInvoice->createModel($query), $this->eventDispatcher));
} else {
$invoices[] = $this->serviceInvoice->createInvoice($model, $this->eventDispatcher);
$invoices[] = $this->serviceInvoice->createInvoice($this->serviceInvoice->createModel($query), $this->eventDispatcher);
}
} catch (\Exception $ex) {
$io->error(\sprintf('Failed to create invoice for customer "%s" with: %s', $customer->getName(), $ex->getMessage()));
@@ -378,7 +372,10 @@ final class InvoiceCreateCommand extends Command
}
/**
* @param InputInterface $input
* @param OutputInterface $output
* @param Invoice[] $invoices
* @return int
*/
protected function renderInvoiceResult(InputInterface $input, OutputInterface $output, array $invoices): int
{
@@ -452,6 +449,7 @@ final class InvoiceCreateCommand extends Command
}
/**
* @param InvoiceQuery $invoiceQuery
* @return Customer[]
*/
private function getActiveCustomers(InvoiceQuery $invoiceQuery): array
@@ -469,6 +467,7 @@ final class InvoiceCreateCommand extends Command
}
/**
* @param InvoiceQuery $invoiceQuery
* @return Project[]
*/
private function getActiveProjects(InvoiceQuery $invoiceQuery): array

View File

@@ -16,6 +16,7 @@ use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Mime\Email;
@@ -30,6 +31,7 @@ final class MailTestCommand extends Command
protected function configure(): void
{
$this->addArgument('to', InputArgument::REQUIRED, 'The email address to send the email to');
$this->addOption('from', null, InputOption::VALUE_OPTIONAL, 'Deprecated: uses the MAILER_FROM env variable.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
@@ -39,6 +41,10 @@ final class MailTestCommand extends Command
throw new \InvalidArgumentException('Need a non-empty "to" address');
}
if ($input->getOption('from') !== null) {
throw new \InvalidArgumentException('The "from" option is deprecated and will be ignored');
}
$message = new Email();
$message->to($to);
$message->subject('Test email - ' . Constants::SOFTWARE);

View File

@@ -14,7 +14,7 @@ use App\User\UserService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'kimai:user:promote', description: 'Promotes a user by adding a role')]
#[AsCommand(name: 'kimai:user:promote')]
final class PromoteUserCommand extends AbstractRoleCommand
{
protected function configure(): void
@@ -22,6 +22,7 @@ final class PromoteUserCommand extends AbstractRoleCommand
parent::configure();
$this
->setDescription('Promotes a user by adding a role')
->setHelp(
<<<'EOT'
The <info>kimai:user:promote</info> command promotes a user by adding a role

View File

@@ -40,7 +40,7 @@ final class RegenerateLocalesCommand extends Command
*
* @var string[]
*/
private array $noRegionCode = ['ar', 'id', 'pa', 'sl', 'ca', 'ta', 'bg'];
private array $noRegionCode = ['ar', 'id', 'pa', 'sl', 'ca', 'ta'];
/**
* A list of locales that will be activated no matter if translation files exist for them.
*
@@ -153,9 +153,6 @@ 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

View File

@@ -19,14 +19,19 @@ use Symfony\Component\Console\Style\SymfonyStyle;
/**
* @codeCoverageIgnore
*/
#[AsCommand(name: 'kimai:timesheet:stop-all', description: 'Stop all running timesheets immediately')]
#[AsCommand(name: 'kimai:timesheet:stop-all')]
final class TimesheetStopAllCommand extends Command
{
public function __construct(private readonly TimesheetService $timesheetService)
public function __construct(private TimesheetService $timesheetService)
{
parent::__construct();
}
protected function configure(): void
{
$this->setDescription('Stop all running timesheets immediately');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$amount = $this->timesheetService->stopAll();

View File

@@ -25,7 +25,7 @@ use Symfony\Component\HttpClient\HttpClient;
*
* @codeCoverageIgnore
*/
#[AsCommand(name: 'kimai:translations', description: 'Automated translation adjustments')]
#[AsCommand(name: 'kimai:translations')]
final class TranslationCommand extends Command
{
public function __construct(
@@ -40,6 +40,7 @@ final class TranslationCommand extends Command
protected function configure(): void
{
$this
->setDescription('Translation adjustments')
->addOption('resname', null, InputOption::VALUE_NONE, 'Fix the resname vs. id attribute')
->addOption('duplicates', null, InputOption::VALUE_NONE, 'Find duplicate translation keys')
->addOption('delete-resname', null, InputOption::VALUE_REQUIRED, 'Deletes the translation by resname')

View File

@@ -17,14 +17,17 @@ use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'kimai:version', description: 'Show version information')]
#[AsCommand(name: 'kimai:version')]
final class VersionCommand extends Command
{
protected function configure(): void
{
$this->setHelp('This command allows you to fetch various version information about Kimai.');
$this->addOption('short', null, InputOption::VALUE_NONE, 'Display the version only');
$this->addOption('number', null, InputOption::VALUE_NONE, 'Display the version identifier only only');
$this
->setDescription('Receive version information')
->setHelp('This command allows you to fetch various version information about Kimai.')
->addOption('short', null, InputOption::VALUE_NONE, 'Display the version only')
->addOption('number', null, InputOption::VALUE_NONE, 'Display the version identifier only only')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int

View File

@@ -458,11 +458,6 @@ final class SystemConfiguration
return (bool) $this->find('timesheet.rules.break_time_active');
}
public function getExportTimeout(): int
{
return (int) $this->find('export.timeout');
}
// ========== Company configurations ==========
public function getFinancialYearStart(): ?string

View File

@@ -17,11 +17,11 @@ final class Constants
/**
* The current release version
*/
public const VERSION = '2.39.0';
public const VERSION = '2.35.1';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 23900;
public const VERSION_ID = 23501;
/**
* The software name
*/

View File

@@ -13,7 +13,6 @@ use App\Configuration\SamlConfigurationInterface;
use App\Saml\SamlAuthFactory;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
@@ -77,7 +76,7 @@ final class SamlController extends AbstractController
throw new \RuntimeException('SAML login failed');
}
return new RedirectResponse($url);
return $this->redirect($url);
}
#[Route(path: '/metadata', name: 'saml_metadata')]

View File

@@ -9,18 +9,15 @@
namespace App\Controller;
use App\Calendar\CalendarQuery;
use App\Calendar\CalendarService;
use App\Configuration\SystemConfiguration;
use App\Entity\User;
use App\Form\Toolbar\CalendarToolbarForm;
use App\Form\Type\CalendarViewType;
use App\Form\CalendarForm;
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;
/**
@@ -38,41 +35,34 @@ final class CalendarController extends AbstractController
#[Route(path: '/{profile}', name: 'calendar_user', methods: ['GET'])]
public function userCalendar(Request $request): Response
{
$currentUser = $this->getUser();
$profile = $currentUser;
$canChangeUser = $this->isGranted('view_other_timesheet');
$form = null;
$profile = $this->getUser();
$query = new CalendarQuery();
$query->setUser($profile);
$query->setDate($this->getDateTimeFactory($profile)->create());
if ($this->isGranted('view_other_timesheet')) {
$form = $this->createFormForGetRequest(CalendarForm::class, ['user' => $profile], [
'action' => $this->generateUrl('calendar'),
]);
$defaultView = CalendarViewType::DEFAULT_VIEW;
$userView = $profile->getPreference('calendar_initial_view')?->getValue();
if ($userView !== null) {
$defaultView = (string) $userView;
}
$query->setView($defaultView);
$form->handleRequest($request);
$form = $this->createFormForGetRequest(CalendarToolbarForm::class, $query, [
'action' => $this->generateUrl('calendar'),
'change_user' => $canChangeUser,
]);
if ($form->isSubmitted() && $form->isValid()) {
$values = $form->getData();
if ($values['user'] instanceof User) {
$profile = $values['user'];
}
}
$form->submit($request->query->all(), false);
$form = $form->createView();
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');
// hide if the current user is the only available one
if (\count($form->offsetGet('user')->vars['choices']) < 2) {
$form = null;
$profile = $this->getUser();
}
}
$mode = $this->service->getActiveMode();
$factory = $this->getDateTimeFactory($profile);
$factory = $this->getDateTimeFactory();
// if now is default time, we do not pass it on, so it can be re-calculated for each new entry
$defaultStart = null;
@@ -99,9 +89,7 @@ final class CalendarController extends AbstractController
return $this->render('calendar/user.html.twig', [
'page_setup' => $page,
'initial_view' => $query->getView(),
'initial_date' => $query->getDate(),
'form' => $form->createView(),
'form' => $form,
'user' => $profile,
'config' => $config,
'dragAndDrop' => $dragAndDrop,

View File

@@ -83,25 +83,8 @@ 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', [
'days' => $days,
'withWorkHourConfiguration' => $hasConfiguration,
'withWorkHourConfiguration' => $profile->hasWorkHourConfiguration(),
'box_configuration' => $boxConfiguration,
'page_setup' => $page,
'decimal' => $boxConfiguration->isDecimal(),

View File

@@ -9,15 +9,11 @@
namespace App\Controller;
use App\Configuration\SystemConfiguration;
use App\Entity\ExportableItem;
use App\Entity\ExportTemplate;
use App\Export\Base\DispositionInlineInterface;
use App\Export\ServiceExport;
use App\Export\TooManyItemsExportException;
use App\Form\ExportTemplateSpreadsheetForm;
use App\Form\Toolbar\ExportToolbarForm;
use App\Repository\ExportTemplateRepository;
use App\Repository\Query\ExportQuery;
use App\Utils\PageSetup;
use Symfony\Component\Form\FormInterface;
@@ -33,7 +29,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('create_export')]
final class ExportController extends AbstractController
{
public function __construct(private readonly ServiceExport $export)
public function __construct(private ServiceExport $export)
{
}
@@ -83,23 +79,6 @@ final class ExportController extends AbstractController
$page = new PageSetup('export');
$page->setHelp('export.html');
$buttons = [];
foreach ($this->export->getRenderer() as $renderer) {
$class = \get_class($renderer);
$pos = strrpos($class, '\\');
if ($pos !== false) {
$class = substr($class, $pos + 1);
}
$class = strtolower(str_replace('Renderer', '', $class));
$buttons[$class][$renderer->getId()] = $renderer->getTitle();
}
if ($this->isGranted('view_other_timesheet')) {
$showRates = $this->isGranted('view_rate_other_timesheet');
} else {
$showRates = $this->isGranted('view_rate_own_timesheet');
}
return $this->render('export/index.html.twig', [
'page_setup' => $page,
'too_many' => $tooManyResults,
@@ -107,15 +86,15 @@ final class ExportController extends AbstractController
'query' => $query,
'entries' => $entries,
'form' => $form->createView(),
'buttons' => $buttons,
'renderer' => $this->export->getRenderer(),
'preview_limit' => $maxItemsPreview,
'preview_show' => $showPreview,
'show_rates' => $showRates,
'decimal' => $this->getUser()->isExportDecimal(),
]);
}
#[Route(path: '/data', name: 'export_data', methods: ['POST'])]
public function export(Request $request, SystemConfiguration $systemConfiguration): Response
public function export(Request $request): Response
{
$query = $this->getDefaultQuery();
@@ -133,9 +112,6 @@ final class ExportController extends AbstractController
throw $this->createNotFoundException('Unknown export renderer');
}
$oldMaxExecTime = \ini_get('max_execution_time');
ini_set('max_execution_time', $systemConfiguration->getExportTimeout());
// display file inline if supported and `markAsExported` is not set
if ($renderer instanceof DispositionInlineInterface && !$query->isMarkAsExported()) {
$renderer->setDispositionInline(true);
@@ -148,8 +124,6 @@ final class ExportController extends AbstractController
$this->export->setExported($entries);
}
ini_set('max_execution_time', $oldMaxExecTime);
return $response;
}
@@ -167,6 +141,7 @@ final class ExportController extends AbstractController
}
/**
* @param ExportQuery $query
* @return ExportableItem[]
* @throws TooManyItemsExportException
*/
@@ -183,6 +158,8 @@ final class ExportController extends AbstractController
}
/**
* @param ExportQuery $query
* @param string $method
* @return FormInterface<ExportQuery>
*/
private function getToolbarForm(ExportQuery $query, string $method): FormInterface
@@ -198,40 +175,4 @@ final class ExportController extends AbstractController
]
]);
}
#[Route(path: '/template-create', name: 'export_template_create', methods: ['GET', 'POST'])]
public function createExportTemplate(Request $request, ExportTemplateRepository $repository): Response
{
return $this->editExportForm($this->generateUrl('export_template_create'), $request, $repository, new ExportTemplate());
}
#[Route(path: '/template-edit/{exportTemplate}', name: 'export_template_edit', methods: ['GET', 'POST'])]
public function editExportTemplate(ExportTemplate $exportTemplate, Request $request, ExportTemplateRepository $repository): Response
{
return $this->editExportForm($this->generateUrl('export_template_edit', ['exportTemplate' => $exportTemplate->getId()]), $request, $repository, $exportTemplate);
}
private function editExportForm(string $url, Request $request, ExportTemplateRepository $repository, ExportTemplate $exportTemplate): Response
{
$form = $this->createForm(ExportTemplateSpreadsheetForm::class, $exportTemplate, [
'action' => $url,
'method' => 'POST',
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
try {
$repository->saveExportTemplate($exportTemplate);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('export');
} catch (\Exception $ex) {
$this->handleFormUpdateException($ex, $form);
}
}
return $this->render('export/template.html.twig', [
'form' => $form->createView()
]);
}
}

View File

@@ -106,15 +106,15 @@ final class PermissionController extends AbstractController
$other = [];
foreach ($event->getSections() as $section) {
$permissionSorted[$section->getTitle()] = []; // @phpstan-ignore method.deprecatedInterface
$permissionSorted[$section->getTitle()] = [];
}
foreach ($this->manager->getPermissions() as $permission) {
$found = false;
foreach (array_reverse($event->getSections()) as $section) {
if ($section->filter($permission)) { // @phpstan-ignore method.deprecatedInterface
$permissionSorted[$section->getTitle()][] = $permission; // @phpstan-ignore method.deprecatedInterface
if ($section->filter($permission)) {
$permissionSorted[$section->getTitle()][] = $permission;
$found = true;
break;
}

View File

@@ -20,7 +20,6 @@ use App\Repository\TimesheetRepository;
use App\Timesheet\FavoriteRecordService;
use App\Timesheet\TimesheetService;
use App\Utils\PageSetup;
use App\WorkingTime\WorkingTimeService;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -39,7 +38,6 @@ final class QuickEntryController extends AbstractController
private readonly TimesheetRepository $repository,
private readonly FavoriteRecordService $favoriteRecordService,
private readonly EventDispatcherInterface $dispatcher,
private readonly WorkingTimeService $workingTimeService,
)
{
}
@@ -116,46 +114,37 @@ final class QuickEntryController extends AbstractController
ksort($rows);
// this should also check via lock service
$locked = $this->workingTimeService->isApproved($user, $endWeek);
// attach recent activities
$amount = $this->configuration->getQuickEntriesRecentAmount();
if ($amount > 0) {
$takeOverWeeks = $this->configuration->find('quick_entry.recent_activity_weeks');
$startFrom = null;
if ($takeOverWeeks !== null && \intval($takeOverWeeks) > 0) {
$startFrom = clone $startWeek;
$startFrom->modify(\sprintf('-%s weeks', $takeOverWeeks));
}
if (!$locked) {
// attach recent activities
$amount = $this->configuration->getQuickEntriesRecentAmount();
if ($amount > 0) {
$takeOverWeeks = $this->configuration->find('quick_entry.recent_activity_weeks');
$startFrom = null;
if ($takeOverWeeks !== null && \intval($takeOverWeeks) > 0) {
$startFrom = clone $startWeek;
$startFrom->modify(\sprintf('-%s weeks', $takeOverWeeks));
$favorites = $this->favoriteRecordService->favoriteEntries($user, $amount);
foreach ($favorites as $favorite) {
$timesheet = $favorite->getTimesheet();
if ($startFrom !== null && !$favorite->isFavorite() && $startFrom > $timesheet->getBegin()) {
continue;
}
$favorites = $this->favoriteRecordService->favoriteEntries($user, $amount);
foreach ($favorites as $favorite) {
$timesheet = $favorite->getTimesheet();
if ($startFrom !== null && !$favorite->isFavorite() && $startFrom > $timesheet->getBegin()) {
continue;
}
$id = $timesheet->getProject()->getId() . '_' . $timesheet->getActivity()->getId();
if (\array_key_exists($id, $rows)) {
continue;
}
// edge case: a project that starts and ends between the start and end date allows to select it from the dropdown,
// but it is better to hide a row than displaying already ended projects
if ($timesheet->getProject() !== null && (!$timesheet->getProject()->isVisibleAtDate($startWeek) && !$timesheet->getProject()->isVisibleAtDate($endWeek))) {
continue;
}
// make sure no invisible entries are included
if (!$this->isGranted('start', $timesheet)) {
continue;
}
$rows[$id] = [
'days' => $week,
'project' => $timesheet->getProject(),
'activity' => $timesheet->getActivity()
];
$id = $timesheet->getProject()->getId() . '_' . $timesheet->getActivity()->getId();
if (\array_key_exists($id, $rows)) {
continue;
}
// there is an edge case possible with a project that starts and ends between the start and end date
// user could still select it from the dropdown, but it is better to hide a row than displaying already ended projects
if ($timesheet->getProject() !== null && (!$timesheet->getProject()->isVisibleAtDate($startWeek) && !$timesheet->getProject()->isVisibleAtDate($endWeek))) {
continue;
}
$rows[$id] = [
'days' => $week,
'project' => $timesheet->getProject(),
'activity' => $timesheet->getActivity()
];
}
}
@@ -176,7 +165,6 @@ final class QuickEntryController extends AbstractController
if (!\array_key_exists('entry', $day)) {
// fill all rows and columns to make sure we do not have missing records
$tmp = $this->timesheetService->createNewTimesheet($user);
$tmp->setDuration(null);
$tmp->setProject($row['project']);
$tmp->setActivity($row['activity']);
$newTime = \DateTime::createFromInterface($day['day']);
@@ -197,7 +185,6 @@ final class QuickEntryController extends AbstractController
$empty->markAsPrototype();
foreach ($week as $dayId => $day) {
$tmp = $this->timesheetService->createNewTimesheet($user);
$tmp->setDuration(null);
$newTime = \DateTime::createFromInterface($day['day']);
$newTime = $newTime->setTime($defaultHour, $defaultMinute, 0, 0);
$tmp->setBegin($newTime);
@@ -207,20 +194,18 @@ final class QuickEntryController extends AbstractController
// add empty rows for simpler starting
$minRows = \intval($this->configuration->find('quick_entry.minimum_rows'));
if (!$locked && $formModel->countRows() < $minRows) {
if ($formModel->countRows() < $minRows) {
$newRows = $minRows - $formModel->countRows();
for ($a = 0; $a < $newRows; $a++) {
$model = $formModel->addRow($user);
foreach ($week as $dayId => $day) {
$tmp = $this->timesheetService->createNewTimesheet($user);
$tmp->setDuration(null);
$newTime = \DateTime::createFromInterface($day['day']);
$newTime = $newTime->setTime($defaultHour, $defaultMinute, 0, 0);
$tmp->setBegin($newTime);
$this->timesheetService->prepareNewTimesheet($tmp);
$model->addTimesheet($tmp);
}
$model->setMetaFields($metaFields);
}
}
@@ -244,14 +229,12 @@ final class QuickEntryController extends AbstractController
foreach ($tmpModel->getTimesheets() as $timesheet) {
if ($timesheet->getId() !== null) {
$duration = $timesheet->getDuration(false);
// running timesheets also have a empty duration.
// we distinguish them from temporary ones, to make sure they will not be deleted
// previously running timesheets were deleted, which was wrong
// so now we distinguish between running timesheets and null duration
if ($timesheet->isRunning()) {
$saveTimesheets[] = $timesheet;
} elseif ($duration === null) {
if ($this->isGranted('delete', $timesheet)) {
$deleteTimesheets[] = $timesheet;
}
$deleteTimesheets[] = $timesheet;
} else {
$saveTimesheets[] = $timesheet;
}
@@ -265,22 +248,14 @@ final class QuickEntryController extends AbstractController
try {
$saved = false;
if (\count($deleteTimesheets) > 0) {
if (\count($deleteTimesheets) > 0 && $this->isGranted('delete_own_timesheet')) {
$this->timesheetService->deleteMultipleTimesheets($deleteTimesheets);
$saved = true;
}
if (\count($saveTimesheets) > 0) {
$saveMe = [];
foreach ($saveTimesheets as $timesheet) {
if ($timesheet->getId() === null || $this->isGranted('edit', $timesheet)) {
$saveMe[] = $timesheet;
}
}
if (\count($saveMe) > 0) {
$this->timesheetService->updateMultipleTimesheets($saveMe);
$saved = true;
}
$this->timesheetService->updateMultipleTimesheets($saveTimesheets);
$saved = true;
}
if ($saved) {
@@ -303,7 +278,6 @@ final class QuickEntryController extends AbstractController
'days' => $week,
'form' => $form->createView(),
'metaColumns' => $metaFields,
'locked' => $locked,
]);
}
}

View File

@@ -12,8 +12,8 @@ namespace App\Controller\Security;
use App\Configuration\SystemConfiguration;
use App\Controller\AbstractController;
use App\Entity\User;
use App\Event\EmailEvent;
use App\Event\EmailPasswordResetEvent;
use App\Event\UserEmailEvent;
use App\User\UserService;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
@@ -112,7 +112,7 @@ final class PasswordResetController extends AbstractController
$this->eventDispatcher->dispatch($event);
// this will send the email
$this->eventDispatcher->dispatch(new UserEmailEvent($user, $event->getEmail()));
$this->eventDispatcher->dispatch(new EmailEvent($event->getEmail()));
$user->markPasswordRequested();
$user->setRequiresPasswordReset(true);

View File

@@ -37,7 +37,6 @@ 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;
@@ -349,10 +348,6 @@ final class SystemConfigurationController extends AbstractController
->setConstraints([
new GreaterThanOrEqual(['value' => 0])
]),
(new Configuration('timesheet.rules.break_time_active'))
->setLabel('break')
->setType(YesNoType::class)
->setOptions(['help' => 'Beta']),
]),
(new SystemConfigurationModel('quick_entry'))
->setTranslation('quick_entry.title')
@@ -588,8 +583,7 @@ final class SystemConfigurationController extends AbstractController
->setConstraints([new NotBlank(), new TimeFormat()]),
(new Configuration('calendar.slot_duration'))
->setTranslationDomain('system-configuration')
->setType(ChoiceType::class)
->setOptions(['choices' => ['00:15' => '00:15:00', '00:30' => '00:30:00', '01:00' => '01:00:00']])
->setType(TextType::class)
->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')

View File

@@ -41,11 +41,11 @@ use Symfony\Component\HttpFoundation\Response;
abstract class TimesheetAbstractController extends AbstractController
{
public function __construct(
protected readonly TimesheetRepository $repository,
protected readonly EventDispatcherInterface $dispatcher,
protected readonly TimesheetService $service,
protected readonly SystemConfiguration $configuration,
protected readonly TagRepository $tagRepository
protected TimesheetRepository $repository,
protected EventDispatcherInterface $dispatcher,
protected TimesheetService $service,
protected SystemConfiguration $configuration,
protected TagRepository $tagRepository
) {
}
@@ -152,7 +152,7 @@ abstract class TimesheetAbstractController extends AbstractController
if ($editForm->isSubmitted() && $editForm->isValid()) {
try {
$this->service->saveTimesheet($entry);
$this->service->updateTimesheet($entry);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute($this->getTimesheetRoute());
@@ -184,7 +184,7 @@ abstract class TimesheetAbstractController extends AbstractController
if ($createForm->isSubmitted() && $createForm->isValid()) {
try {
$this->service->saveTimesheet($entry);
$this->service->saveNewTimesheet($entry);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute($this->getTimesheetRoute());
@@ -216,7 +216,7 @@ abstract class TimesheetAbstractController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) {
try {
$this->dispatcher->dispatch(new TimesheetDuplicatePreEvent($copyTimesheet, $timesheet));
$this->service->saveTimesheet($copyTimesheet);
$this->service->saveNewTimesheet($copyTimesheet);
$this->dispatcher->dispatch(new TimesheetDuplicatePostEvent($copyTimesheet, $timesheet));
$this->flashSuccess('action.update.success');
@@ -263,14 +263,7 @@ abstract class TimesheetAbstractController extends AbstractController
$entries = $this->repository->getTimesheetResult($query);
$oldMaxExecTime = \ini_get('max_execution_time');
ini_set('max_execution_time', $this->configuration->getExportTimeout());
$response = $exporter->render($entries->getResults(), $query);
ini_set('max_execution_time', $oldMaxExecTime);
return $response;
return $exporter->render($entries->getResults(), $query);
}
protected function multiUpdate(Request $request): Response
@@ -365,7 +358,9 @@ abstract class TimesheetAbstractController extends AbstractController
}
if ($dto->isRecalculateRates()) {
$timesheet->resetRates();
$timesheet->setFixedRate(null);
$timesheet->setHourlyRate(null);
$timesheet->setInternalRate(null);
$execute = true;
} elseif (null !== $dto->getFixedRate()) {
$timesheet->setFixedRate($dto->getFixedRate());

View File

@@ -126,7 +126,7 @@ final class TimesheetTeamController extends TimesheetAbstractController
}
foreach ($newTimesheets as $newTimesheet) {
$this->service->saveTimesheet($newTimesheet);
$this->service->saveNewTimesheet($newTimesheet);
}
$this->flashSuccess('action.update.success');

View File

@@ -101,7 +101,7 @@ final class CustomerService
$errors = $this->validator->validate($customer, null, $groups);
if ($errors->count() > 0) {
throw new ValidationFailedException($errors);
throw new ValidationFailedException($errors, 'Validation Failed');
}
}

View File

@@ -19,9 +19,9 @@ use App\Timesheet\DateTimeFactory;
use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
/**
* @final
@@ -112,7 +112,7 @@ class CustomerStatisticService
$qb = $this->timesheetRepository->createQueryBuilder('t');
$qb
->select('IDENTITY(p.customer) AS id')
->join(Project::class, 'p', Join::WITH, 't.project = p.id')
->join(Project::class, 'p', Query\Expr\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')

View File

@@ -396,10 +396,6 @@ final class Configuration implements ConfigurationInterface
->scalarPrototype()->end()
->defaultValue([])
->end()
->integerNode('timeout')
->defaultValue(60)
->end()
->end()
;

View File

@@ -1,167 +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\Entity;
use App\Repository\ExportTemplateRepository;
use App\Validator\Constraints\ExportRenderer;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_export_templates')]
#[ORM\UniqueConstraint(columns: ['title'])]
#[ORM\Entity(repositoryClass: ExportTemplateRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity('title')]
class ExportTemplate
{
#[ORM\Column(name: 'id', type: Types::INTEGER)]
#[ORM\Id]
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
private ?int $id = null;
#[ORM\Column(name: 'title', type: Types::STRING, length: 100, nullable: false)]
#[Assert\NotBlank]
#[Assert\Length(min: 1, max: 100)]
private ?string $title = null;
#[ORM\Column(name: 'renderer', type: Types::STRING, length: 20, nullable: false)]
#[ExportRenderer]
#[Assert\NotBlank]
private string $renderer = 'csv';
/**
* Used for header column translation.
*/
#[ORM\Column(name: 'language', type: Types::STRING, length: 6, nullable: true)]
#[Assert\Locale]
private ?string $language = null;
/**
* @var array<int, string>
*/
#[ORM\Column(name: 'columns', type: Types::JSON, nullable: false)]
#[Assert\Count(min: 1)]
#[Assert\NotNull]
private array $columns = [];
/**
* @var array<string, int|string|null|bool>
*/
#[ORM\Column(name: 'options', type: Types::JSON, nullable: false)]
#[Assert\NotNull]
private array $options = [];
public function getId(): ?int
{
return $this->id;
}
public function setTitle(?string $title): void
{
$this->title = $title;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setRenderer(string $renderer): void
{
$this->renderer = $renderer;
}
public function getRenderer(): string
{
return $this->renderer;
}
public function getLanguage(): ?string
{
return $this->language;
}
public function setLanguage(?string $language): void
{
$this->language = $language;
}
/**
* @return array<int, string>
*/
public function getColumns(): array
{
return $this->columns;
}
/**
* @param array<int, string>|null $columns
*/
public function setColumns(?array $columns): void
{
$this->columns = $columns ?? [];
}
public function getOption(string $key, int|string|bool $default): int|string|bool
{
if (\array_key_exists($key, $this->options)) {
return $this->options[$key] ?? $default;
}
return $default;
}
public function setOption(string $key, int|string|null|bool $value): void
{
$this->options[$key] = $value;
}
/**
* @return array<string, int|string|null|bool>
*/
public function getOptions(): array
{
return $this->options;
}
/**
* @param array<string, int|string|null|bool> $options
*/
public function setOptions(?array $options): void
{
$this->options = $options ?? [];
}
/**
* Only used for CSV export
*/
public function setSeparator(string $separator): void
{
if (!\in_array($separator, [',', ';'], true)) {
throw new \InvalidArgumentException('Invalid separator, comma and semicolon are allowed.');
}
$this->setOption('separator', $separator);
}
public function getSeparator(): string
{
return (string) $this->getOption('separator', ',');
}
public function __toString(): string
{
return $this->title ?? 'New';
}
public function __clone()
{
if ($this->id) {
$this->id = null;
}
}
}

View File

@@ -11,10 +11,6 @@ namespace App\Entity;
use Doctrine\Common\Collections\Collection;
/**
* @method getTags() array
* @method getBreak() int
*/
interface ExportableItem
{
public function getId(): ?int;
@@ -89,18 +85,4 @@ interface ExportableItem
* Example: "work"
*/
public function getCategory(): string;
/*
* Returns all assigned tags.
* @TODO activate for 3.0
* @return Collection<Tag>
*/
//public function getTags(): array;
/*
* Returns the break duration.
* @TODO activate for 3.0
* @return Collection<Tag>
*/
//public function getBreak(): int;
}

View File

@@ -126,6 +126,8 @@ class Invoice implements EntityWithMetaFields
#[ORM\Column(name: 'invoice_filename', type: Types::STRING, length: 150, nullable: false)]
#[Assert\NotNull]
#[Assert\Length(min: 1, max: 150)]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'file', type: 'string')]
private ?string $invoiceFilename = null;
private bool $localized = false;

View File

@@ -75,7 +75,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[ORM\Column(name: 'order_number', type: Types::TEXT, length: 50, nullable: true)]
#[Assert\Length(max: 50)]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
#[Serializer\Groups(['Project_Entity'])]
#[Exporter\Expose(label: 'orderNumber')]
private ?string $orderNumber = null;
/**

View File

@@ -131,7 +131,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
* @var Collection<UserPreference>|null
*/
#[ORM\OneToMany(mappedBy: 'user', targetEntity: UserPreference::class, cascade: ['persist'])]
private ?Collection $preferences = null;
private ?Collection $preferences;
/**
* List of all team memberships.
*
@@ -504,6 +504,10 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
return $allowNull ? $value : ($value ?? $default);
}
/**
* @param UserPreference $preference
* @return User
*/
public function addPreference(UserPreference $preference): User
{
if (null === $this->preferences) {
@@ -582,7 +586,9 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
}
/**
* Checks if the user is a member of any team.
* Checks if the user is member of any team.
*
* @return bool
*/
public function hasTeamAssignment(): bool
{
@@ -593,6 +599,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
* Checks is the user is teamlead in any of the assigned teams.
*
* @see User::hasTeamleadRole()
* @return bool
*/
public function isTeamlead(): bool
{
@@ -607,6 +614,9 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
/**
* Checks if the given user is a team member.
*
* @param User $user
* @return bool
*/
public function hasTeamMember(User $user): bool
{
@@ -670,6 +680,8 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
/**
* Required in the User profile screen to edit his teams.
*
* @param Team $team
*/
public function addTeam(Team $team): void
{
@@ -688,6 +700,8 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
/**
* Required in the User profile screen to edit his teams.
*
* @param Team $team
*/
public function removeTeam(Team $team): void
{
@@ -914,11 +928,6 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
return $this;
}
/**
* Alias for setUserIdentifier()
*
* The visible username is setAlias())
*/
public function setUsername(string $username): void
{
$this->username = $username;

View File

@@ -1,25 +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\Event;
use App\Entity\Invoice;
use Symfony\Contracts\EventDispatcher\Event;
class AbstractInvoiceEvent extends Event
{
public function __construct(private readonly Invoice $invoice)
{
}
public function getInvoice(): Invoice
{
return $this->invoice;
}
}

View File

@@ -9,9 +9,9 @@
namespace App\Event;
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'activity.created', description: 'Triggered after a new activity was created', payload: 'object.getActivity()')]
/**
* Triggered for activity instances, which were just saved.
*/
final class ActivityCreatePostEvent extends AbstractActivityEvent
{
}

View File

@@ -9,9 +9,9 @@
namespace App\Event;
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'activity.deleted', description: 'Triggered right before an activity will be deleted', payload: 'object.getActivity()')]
/**
* Triggered right before a activity will be deleted.
*/
final class ActivityDeleteEvent extends AbstractActivityEvent
{
}

View File

@@ -9,9 +9,9 @@
namespace App\Event;
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'activity.updated', description: 'Triggered after an activity was updated', payload: 'object.getActivity()')]
/**
* Triggered for activity instances, which were updated.
*/
final class ActivityUpdatePostEvent extends AbstractActivityEvent
{
}

View File

@@ -9,9 +9,9 @@
namespace App\Event;
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'customer.created', description: 'Triggered after a customer was created', payload: 'object.getCustomer()')]
/**
* Triggered for customer instances, which were just saved.
*/
final class CustomerCreatePostEvent extends AbstractCustomerEvent
{
}

View File

@@ -9,9 +9,9 @@
namespace App\Event;
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'customer.deleted', description: 'Triggered right before a customer will be deleted', payload: 'object.getCustomer()')]
/**
* Triggered right before a customer will be deleted.
*/
final class CustomerDeleteEvent extends AbstractCustomerEvent
{
}

View File

@@ -9,9 +9,9 @@
namespace App\Event;
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'customer.updated', description: 'Triggered after a customer was updated', payload: 'object.getCustomer()')]
/**
* Triggered for customer instances, which were just updated.
*/
final class CustomerUpdatePostEvent extends AbstractCustomerEvent
{
}

View File

@@ -11,14 +11,17 @@ namespace App\Event;
use App\Entity\Invoice;
use App\Invoice\InvoiceModel;
use App\Webhook\Attribute\AsWebhook;
use Symfony\Contracts\EventDispatcher\Event;
#[AsWebhook(name: 'invoice.created', description: 'Triggered after an invoice was created', payload: 'object.getInvoice()')]
final class InvoiceCreatedEvent extends AbstractInvoiceEvent
final class InvoiceCreatedEvent extends Event
{
public function __construct(Invoice $invoice, private readonly InvoiceModel $model)
public function __construct(private readonly Invoice $invoice, private readonly InvoiceModel $model)
{
parent::__construct($invoice);
}
public function getInvoice(): Invoice
{
return $this->invoice;
}
public function getInvoiceModel(): InvoiceModel

View File

@@ -9,9 +9,17 @@
namespace App\Event;
use App\Webhook\Attribute\AsWebhook;
use App\Entity\Invoice;
use Symfony\Contracts\EventDispatcher\Event;
#[AsWebhook(name: 'invoice.deleted', description: 'Triggered after an invoice was deleted', payload: 'object.getInvoice()')]
final class InvoiceDeleteEvent extends AbstractInvoiceEvent
final class InvoiceDeleteEvent extends Event
{
public function __construct(private Invoice $invoice)
{
}
public function getInvoice(): Invoice
{
return $this->invoice;
}
}

View File

@@ -13,7 +13,7 @@ use App\Entity\Invoice;
use Symfony\Contracts\EventDispatcher\Event;
/**
* This event can be used, to dynamically add meta-fields to invoices
* This event can be used, to dynamically add meta fields to invoices
*/
final class InvoiceMetaDefinitionEvent extends Event
{

View File

@@ -20,9 +20,9 @@ final class PermissionSectionsEvent extends Event
/**
* @var array<PermissionSectionInterface>
*/
private array $sections = []; // @phpstan-ignore property.deprecatedInterface
private array $sections = [];
public function addSection(PermissionSectionInterface $section): PermissionSectionsEvent // @phpstan-ignore parameter.deprecatedInterface
public function addSection(PermissionSectionInterface $section): PermissionSectionsEvent
{
$this->sections[] = $section;
@@ -32,7 +32,7 @@ final class PermissionSectionsEvent extends Event
/**
* @return PermissionSectionInterface[]
*/
public function getSections(): array // @phpstan-ignore return.deprecatedInterface
public function getSections(): array
{
return $this->sections;
}

View File

@@ -9,9 +9,9 @@
namespace App\Event;
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'project.created', description: 'Triggered after a project was created', payload: 'object.getProject()')]
/**
* Triggered for project instances, which were just saved.
*/
final class ProjectCreatePostEvent extends AbstractProjectEvent
{
}

View File

@@ -9,9 +9,9 @@
namespace App\Event;
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'project.deleted', description: 'Triggered right before a project will be deleted', payload: 'object.getProject()')]
/**
* Triggered right before a project will be deleted.
*/
final class ProjectDeleteEvent extends AbstractProjectEvent
{
}

View File

@@ -9,9 +9,9 @@
namespace App\Event;
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'project.updated', description: 'Triggered after a project was updated', payload: 'object.getProject()')]
/**
* Triggered for project instances, which were just updated.
*/
final class ProjectUpdatePostEvent extends AbstractProjectEvent
{
}

View File

@@ -13,7 +13,7 @@ use App\Form\Model\SystemConfiguration;
use Symfony\Contracts\EventDispatcher\Event;
/**
* Adjust system configurations dynamically.
* This event should be used, if system configurations should be changed/added dynamically.
*/
final class SystemConfigurationEvent extends Event
{
@@ -32,6 +32,10 @@ final class SystemConfigurationEvent extends Event
return $this->configurations;
}
/**
* @param SystemConfiguration $configuration
* @return SystemConfigurationEvent
*/
public function addConfiguration(SystemConfiguration $configuration): SystemConfigurationEvent
{
$this->configurations[] = $configuration;

View File

@@ -9,9 +9,6 @@
namespace App\Event;
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'timesheet.created', description: 'Triggered after a timesheet was created', payload: 'object.getTimesheet()')]
final class TimesheetCreatePostEvent extends AbstractTimesheetEvent
{
}

View File

@@ -9,9 +9,6 @@
namespace App\Event;
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'timesheet.stopped', description: 'Triggered after a timesheet was stopped', payload: 'object.getTimesheet()')]
final class TimesheetStopPostEvent extends AbstractTimesheetEvent
{
}

View File

@@ -9,9 +9,6 @@
namespace App\Event;
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'timesheet.updated', description: 'Triggered after a timesheet was updated', payload: 'object.getTimesheet()')]
final class TimesheetUpdatePostEvent extends AbstractTimesheetEvent
{
}

View File

@@ -9,9 +9,9 @@
namespace App\Event;
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'user.created', description: 'Triggered after a user was created', payload: 'object.getUser()')]
/**
* Triggered for new user instances, which were just saved.
*/
final class UserCreatePostEvent extends AbstractUserEvent
{
}

View File

@@ -9,9 +9,9 @@
namespace App\Event;
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'user.deleted', description: 'Triggered after a user was deleted', payload: 'object.getUser()')]
/**
* Triggered for user instances which were just deleted.
*/
final class UserDeletePostEvent extends UserDeleteEvent
{
}

View File

@@ -9,9 +9,9 @@
namespace App\Event;
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'user.updated', description: 'Triggered after a user was updated', payload: 'object.getUser()')]
/**
* Triggered for user instances, which were just updated.
*/
final class UserUpdatePostEvent extends AbstractUserEvent
{
}

View File

@@ -22,10 +22,7 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
*/
final class RedirectToLocaleSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly UrlGeneratorInterface $urlGenerator,
private readonly LocaleService $localeService
)
public function __construct(private UrlGeneratorInterface $urlGenerator, private LocaleService $localeService)
{
}

Some files were not shown because too many files have changed in this diff Show More