Compare commits
2 Commits
2.39.0
...
download-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
821000f143 | ||
|
|
e5ab1f9e78 |
2
.github/workflows/testing.yaml
vendored
2
.github/workflows/testing.yaml
vendored
@@ -62,7 +62,7 @@ jobs:
|
||||
run: APP_ENV=dev bin/console kimai:reload -n
|
||||
|
||||
- name: Check codestyles
|
||||
run: 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
require('highlight.js/styles/github-dark.css');
|
||||
|
||||
const hljs = require('highlight.js/lib/common');
|
||||
|
||||
global.hljs = hljs;
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
1560
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||
|
||||
@@ -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']
|
||||
# --------------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
1
kimai.sh
1
kimai.sh
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,6 @@
|
||||
"eslint": "^9",
|
||||
"globals": "^15",
|
||||
"gridstack": "^7",
|
||||
"highlight.js": "^11.11.1",
|
||||
"litepicker": "^2",
|
||||
"luxon": "^3",
|
||||
"sass": "^1",
|
||||
|
||||
47
phpstan.neon
47
phpstan.neon
@@ -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\\.$#"
|
||||
|
||||
@@ -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
2
public/build/app.6bb53337.js
Normal file
2
public/build/app.6bb53337.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
public/build/calendar.7415ab21.js
Normal file
2
public/build/calendar.7415ab21.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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
@@ -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}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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')]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -396,10 +396,6 @@ final class Configuration implements ConfigurationInterface
|
||||
->scalarPrototype()->end()
|
||||
->defaultValue([])
|
||||
->end()
|
||||
->integerNode('timeout')
|
||||
->defaultValue(60)
|
||||
->end()
|
||||
|
||||
->end()
|
||||
;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user