Compare commits
22 Commits
2.31.0
...
download-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
821000f143 | ||
|
|
e5ab1f9e78 | ||
|
|
12190141d7 | ||
|
|
0420eb27c7 | ||
|
|
81107377f4 | ||
|
|
e9c172daea | ||
|
|
f42d42f512 | ||
|
|
b38c6fbb0e | ||
|
|
e2a1146670 | ||
|
|
e69ac6968b | ||
|
|
87168de4f1 | ||
|
|
7b158d57b5 | ||
|
|
1e0fbf0b73 | ||
|
|
6e26a37c4d | ||
|
|
fac5a910d8 | ||
|
|
dfd97fd6f3 | ||
|
|
452a8d9390 | ||
|
|
2a9de506a1 | ||
|
|
e663cc2b02 | ||
|
|
a70c803158 | ||
|
|
9ca69e1114 | ||
|
|
2e6b700b43 |
3
.github/release-drafter.yml
vendored
3
.github/release-drafter.yml
vendored
@@ -7,6 +7,7 @@ exclude-labels:
|
||||
- 'release'
|
||||
exclude-contributors:
|
||||
- 'dependabot'
|
||||
- 'weblate'
|
||||
change-template: '- $TITLE (#$NUMBER)'
|
||||
change-title-escapes: '\<*_&`#@'
|
||||
version-resolver:
|
||||
@@ -25,4 +26,4 @@ template: |
|
||||
|
||||
$CHANGES
|
||||
|
||||
Involved in this release: $CONTRIBUTORS - thank you!
|
||||
Involved in this release: $CONTRIBUTORS
|
||||
|
||||
@@ -40,17 +40,27 @@ export default class KimaiTimesheetForm extends KimaiFormPlugin {
|
||||
|
||||
if (this._beginTime !== undefined) {
|
||||
this._beginTime.removeEventListener('change', this._beginListener);
|
||||
delete this._beginListener;
|
||||
this._beginTime.removeEventListener('blur', this._beginBlurListener);
|
||||
delete this._beginBlurListener;
|
||||
delete this._beginTime;
|
||||
}
|
||||
|
||||
if (this._endTime !== undefined) {
|
||||
this._endTime.removeEventListener('change', this._endListener);
|
||||
delete this._endListener;
|
||||
this._endTime.removeEventListener('blur', this._endBlurListener);
|
||||
delete this._endBlurListener;
|
||||
delete this._endTime;
|
||||
}
|
||||
|
||||
if (this._duration !== undefined) {
|
||||
this._duration.removeEventListener('change', this._durationListener);
|
||||
delete this._durationListener;
|
||||
this._duration.removeEventListener('keydown', this._durationKeyListener);
|
||||
delete this._durationKeyListener;
|
||||
this._duration.removeEventListener('blur', this._durationBlurListener);
|
||||
delete this._durationBlurListener;
|
||||
delete this._duration;
|
||||
}
|
||||
|
||||
@@ -108,13 +118,21 @@ export default class KimaiTimesheetForm extends KimaiFormPlugin {
|
||||
}
|
||||
|
||||
this._beginListener = () => this._changedBegin();
|
||||
this._beginBlurListener = () => this._parseBeginTime();
|
||||
this._endListener = () => this._changedEnd();
|
||||
this._endBlurListener = () => this._parseEndTime();
|
||||
this._durationListener = () => this._changedDuration();
|
||||
this._durationKeyListener = (event) => this._changeDurationOnKeypress(event);
|
||||
this._durationBlurListener = () => this._parseDuration();
|
||||
|
||||
this._beginDate.addEventListener('change', this._beginListener);
|
||||
this._beginTime.addEventListener('change', this._beginListener);
|
||||
this._beginTime.addEventListener('blur', this._beginBlurListener);
|
||||
this._endTime.addEventListener('change', this._endListener);
|
||||
this._endTime.addEventListener('blur', this._endBlurListener);
|
||||
this._duration.addEventListener('change', this._durationListener);
|
||||
this._duration.addEventListener('keydown', this._durationKeyListener);
|
||||
this._duration.addEventListener('blur', this._durationBlurListener);
|
||||
|
||||
if (this._duration !== null && this._durationToggle !== null) {
|
||||
this._durationToggleListener = () => {
|
||||
@@ -124,6 +142,159 @@ export default class KimaiTimesheetForm extends KimaiFormPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
_parseBeginTime()
|
||||
{
|
||||
if (this._beginTime.value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
let newBeginTime = this._formatTimeForParsing(this._beginTime.value, this._beginTime.dataset['format']);
|
||||
if (newBeginTime !== this._beginTime.value) {
|
||||
this._beginTime.value = newBeginTime;
|
||||
this._changedBegin();
|
||||
}
|
||||
}
|
||||
|
||||
_parseEndTime()
|
||||
{
|
||||
if (this._endTime.value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
let newEndTime = this._formatTimeForParsing(this._endTime.value, this._endTime.dataset['format']);
|
||||
if (newEndTime !== this._endTime.value) {
|
||||
this._endTime.value = newEndTime;
|
||||
this._changedEnd();
|
||||
}
|
||||
}
|
||||
|
||||
_parseDuration()
|
||||
{
|
||||
if (this._duration.value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
this._setDurationAsString(this._getParsedDuration());
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives a time, written by a human, probably in an invalid format.
|
||||
* This method supports 12-hour or 24-hour format, the format string contains an uppercase "A" in case of the 12-hour format.
|
||||
* If it is 12-hour format, then always en-US locallized with AM/PM.
|
||||
*
|
||||
* Ruleset:
|
||||
* - Some locales use a dot instead of a colon, always replace the dot in HH.mm with a colon as in HH:mm
|
||||
* - If there is an "am" or "pm", always uppercase them
|
||||
* - Split the string into time and prefix: if AM/PM is included remove it and remember for later
|
||||
* - If the time is a 1 or 2 character long number: use as hours
|
||||
* - If the time now is 3 character long: use the 1 char as hour and the 2 and 3 char as minute
|
||||
* - If the time now is 4 character long: use the 1 and 2 char as hour and the 3 and 4 char as minutes
|
||||
* - If the format is 12-hour: try to identify the correct time and suffix
|
||||
* - If the format is 12-hour and misses the AM/PM: try to detect whether it
|
||||
* - If the time contains AM or PM, make sure that it is always prefixed by a space character
|
||||
*
|
||||
* @param {string} time
|
||||
* @param {string} format
|
||||
* @returns {string}
|
||||
* @private
|
||||
*/
|
||||
_formatTimeForParsing(time, format)
|
||||
{
|
||||
let formatted = time.trim();
|
||||
|
||||
// replace dot with colon
|
||||
formatted = formatted.replace(/\./g, ':');
|
||||
// uppercase 12-hour format
|
||||
formatted = formatted.replace(/am/i, 'AM');
|
||||
formatted = formatted.replace(/pm/i, 'PM');
|
||||
|
||||
// Split time and AM/PM suffix if present
|
||||
let suffix = '';
|
||||
let hour = 0;
|
||||
let minute = 0;
|
||||
|
||||
let timePart = formatted;
|
||||
const ampmMatch = formatted.match(/\s*(AM|PM)$/i);
|
||||
if (ampmMatch) {
|
||||
suffix = ampmMatch[1].toUpperCase();
|
||||
timePart = formatted.replace(/\s*(AM|PM)$/i, '').trim();
|
||||
}
|
||||
|
||||
if (timePart.indexOf(':') !== -1) {
|
||||
const match = timePart.match(/(?:(\d+):)?(\d+)/);
|
||||
hour = parseInt(match?.[1] || 0, 10);
|
||||
minute = parseInt(match?.[2] || 0, 10);
|
||||
} else {
|
||||
timePart = timePart.replace(/:/, '');
|
||||
|
||||
if (/^\d{1,2}$/.test(timePart)) {
|
||||
hour = timePart;
|
||||
}
|
||||
|
||||
if (/^\d{3}$/.test(timePart)) {
|
||||
hour = timePart.slice(0, 1);
|
||||
minute = timePart.slice(1);
|
||||
}
|
||||
|
||||
if (/^\d{4}$/.test(timePart)) {
|
||||
hour = timePart.slice(0, 2);
|
||||
minute = timePart.slice(2);
|
||||
}
|
||||
}
|
||||
|
||||
hour = parseInt(hour);
|
||||
minute = parseInt(minute);
|
||||
|
||||
// just in case a person entered a wrong time like 35 hours
|
||||
hour = hour % 24;
|
||||
minute = minute % 60;
|
||||
|
||||
// format is 12-hour
|
||||
if (format.toUpperCase().indexOf('A') !== -1) {
|
||||
// time entered in 24-hour: convert to 12-hour format
|
||||
if (hour > 12 && hour < 24) {
|
||||
hour = hour - 12;
|
||||
suffix = 'PM';
|
||||
}
|
||||
|
||||
// if the person forgot to add a suffix, calculate it and convert time
|
||||
if (suffix === '') {
|
||||
if (hour === 0) {
|
||||
hour = 12;
|
||||
suffix = 'AM';
|
||||
} else if (hour === 12) {
|
||||
suffix = 'PM';
|
||||
} else {
|
||||
suffix = 'AM';
|
||||
}
|
||||
}
|
||||
|
||||
if (suffix === 'PM' && hour === 0) {
|
||||
hour = 12;
|
||||
}
|
||||
|
||||
} else {
|
||||
// this is the 34-hour format branch
|
||||
|
||||
// check if the person entered time in 12-hour format and convert it
|
||||
if (suffix === 'AM' && hour === 12) {
|
||||
hour = 0;
|
||||
} else if (suffix === 'PM' && hour !== 12) {
|
||||
hour = (hour + 12) % 24;
|
||||
}
|
||||
|
||||
// make sure we have no suffix
|
||||
suffix = '';
|
||||
}
|
||||
|
||||
formatted = hour + ':' + minute.toString().padStart(2, '0');
|
||||
if (suffix !== '') {
|
||||
formatted = formatted + ' ' + suffix.trim();
|
||||
}
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
||||
_isDurationConnected()
|
||||
{
|
||||
if (this._duration === null && this._durationToggle === null) {
|
||||
@@ -287,14 +458,14 @@ export default class KimaiTimesheetForm extends KimaiFormPlugin {
|
||||
|
||||
/**
|
||||
* Ruleset:
|
||||
* - invalid duration => skip
|
||||
* - invalid or empty duration => skip
|
||||
* - if begin and end are empty: set begin to now and end to duration
|
||||
* - if begin is empty and end is not empty: set begin to end minus duration
|
||||
* - if begin is not empty and end is empty and duration is > 0 (running records = 0): set end to begin plus duration
|
||||
*/
|
||||
_changedDuration()
|
||||
{
|
||||
if (!this._isDurationConnected()) {
|
||||
if (!this._isDurationConnected() || this._duration.value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -315,11 +486,11 @@ export default class KimaiTimesheetForm extends KimaiFormPlugin {
|
||||
if (begin === null && end === null) {
|
||||
const newBegin = DateTime.now();
|
||||
this._applyDateToField(newBegin, this._beginDate, this._beginTime);
|
||||
this._applyDateToField(newBegin.plus({seconds: seconds}), null, this._endTime);
|
||||
this._addSecondsToEndDate(newBegin, seconds);
|
||||
} else if (begin === null && end !== null) {
|
||||
this._applyDateToField(end.minus({seconds: seconds}), this._beginDate, this._beginTime);
|
||||
} else if (begin !== null && seconds >= 0) {
|
||||
this._applyDateToField(begin.plus({seconds: seconds}), null, this._endTime);
|
||||
this._addSecondsToEndDate(begin, seconds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,7 +538,23 @@ export default class KimaiTimesheetForm extends KimaiFormPlugin {
|
||||
*/
|
||||
_getParsedDuration()
|
||||
{
|
||||
return this.getDateUtils().parseDuration(this._duration.value.toUpperCase());
|
||||
return this.getDateUtils().parseDuration(this._duration.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DateTime} dateTime
|
||||
* @param {int} seconds
|
||||
* @private
|
||||
*/
|
||||
_addSecondsToEndDate(dateTime, seconds)
|
||||
{
|
||||
// if the duration is longer than one day, the end field should be empty
|
||||
// so kimai can calculate it after submitting the data from start + duration
|
||||
if (seconds < 86400) {
|
||||
this._applyDateToField(dateTime.plus({seconds: seconds}), null, this._endTime);
|
||||
} else {
|
||||
this._endTime.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -390,4 +577,122 @@ export default class KimaiTimesheetForm extends KimaiFormPlugin {
|
||||
timeField.value = this.getDateUtils().format(timeField.dataset['format'], dateTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} event
|
||||
* @private
|
||||
*/
|
||||
_changeDurationOnKeypress(event)
|
||||
{
|
||||
switch (event.key) {
|
||||
case 'ArrowUp':
|
||||
case 'ArrowDown':
|
||||
case 'PageUp':
|
||||
case 'PageDown':
|
||||
case 'Home':
|
||||
case 'End':
|
||||
this._setDurationAsString(this._getParsedDuration());
|
||||
break;
|
||||
default:
|
||||
return; // Ignore other keys
|
||||
}
|
||||
|
||||
this._changeTimeOnKeypress(event, this._duration, 99999, this._durationListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method helps the user to change a duration field with simple keyboard interaction:
|
||||
* - Read the current duration from the given timeField input in format HH:MM (no seconds)
|
||||
* - Change the duration based on the rules below
|
||||
* - Write the new duration back to the field
|
||||
* - If the field is empty or invalid it uses 00:00 as start-time
|
||||
* - Duration cannot exceed maxtime (which is given in minutes)
|
||||
* - Duration cannot drop below 00:00
|
||||
* - Read the position of the cursor and decide whether to increase minutes or hours: if the cursor is in the hour section (before the colon) change hours, if the cursor is in the minute section (after the colon) change minutes
|
||||
* - It reads the pressed key from the given KeyboardEvent and changes the duration accordingly to the rules below
|
||||
*
|
||||
* Rules to apply when a key is pressed:
|
||||
* - ArrowUp key to increase the duration (either 5 minutes or 1 hour, depending on the cursor position)
|
||||
* - ArrowDown key to decrease the duration (either 5 minutes or 1 hour, depending on the cursor position)
|
||||
* - PageUp key to increase the duration by 1 hour
|
||||
* - PageDown key to decrease the duration by 1 hour
|
||||
* - Home key to set the duration to 08:00
|
||||
* - End key to set the duration to 00:00
|
||||
* - all other keys are ignored
|
||||
*
|
||||
* @param {KeyboardEvent} event
|
||||
* @param {HTMLElement} timeField
|
||||
* @param {int} maxTime
|
||||
* @param {function} changeCallback
|
||||
* @private
|
||||
*/
|
||||
_changeTimeOnKeypress(event, timeField, maxTime, changeCallback)
|
||||
{
|
||||
// Parse current value or default to 00:00
|
||||
let value = timeField.value || '00:00';
|
||||
let [hours, minutes] = value.split(':').map(Number);
|
||||
if (isNaN(hours)) { hours = 0; }
|
||||
if (isNaN(minutes)) { minutes = 0; }
|
||||
|
||||
// Cursor position: before or after colon
|
||||
const cursorPos = timeField.selectionStart || 0;
|
||||
const colonPos = value.indexOf(':');
|
||||
const inHour = cursorPos <= colonPos;
|
||||
|
||||
// Helper to clamp values
|
||||
const clamp = (h, m) => {
|
||||
let total = h * 60 + m;
|
||||
if (total < 0) { total = 0; }
|
||||
if (total > maxTime) { total = maxTime; }
|
||||
h = Math.floor(total / 60);
|
||||
m = total % 60;
|
||||
return [h, m];
|
||||
};
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowUp':
|
||||
if (inHour) {
|
||||
[hours, minutes] = clamp(hours + 1, minutes);
|
||||
} else {
|
||||
[hours, minutes] = clamp(hours, minutes + 5);
|
||||
}
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
if (inHour) {
|
||||
[hours, minutes] = clamp(hours - 1, minutes);
|
||||
} else {
|
||||
[hours, minutes] = clamp(hours, minutes - 5);
|
||||
}
|
||||
break;
|
||||
case 'PageUp':
|
||||
[hours, minutes] = clamp(hours + 1, minutes);
|
||||
event.preventDefault();
|
||||
break;
|
||||
case 'PageDown':
|
||||
[hours, minutes] = clamp(hours - 1, minutes);
|
||||
event.preventDefault();
|
||||
break;
|
||||
case 'Home':
|
||||
// TODO this should use the configured working time for today
|
||||
hours = 8;
|
||||
minutes = 0;
|
||||
event.preventDefault();
|
||||
break;
|
||||
case 'End':
|
||||
hours = 0;
|
||||
minutes = 0;
|
||||
event.preventDefault();
|
||||
break;
|
||||
default:
|
||||
return; // Ignore other keys
|
||||
}
|
||||
|
||||
// Format and set value
|
||||
timeField.value = `${hours}:${minutes.toString().padStart(2, '0')}`;
|
||||
// trigger update of linked fields
|
||||
changeCallback(timeField);
|
||||
// Move cursor to original position if possible
|
||||
setTimeout(() => {
|
||||
timeField.setSelectionRange(cursorPos, cursorPos);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,7 +273,10 @@ export default class KimaiDateUtils extends KimaiPlugin {
|
||||
let luxonDuration = null;
|
||||
|
||||
if (duration.indexOf(':') !== -1) {
|
||||
const [, hours, minutes, seconds] = duration.match(/(\d+):(\d+)(?::(\d+))*/);
|
||||
const match = duration.match(/(?:(\d+):)?(\d+)(?::(\d+))?/);
|
||||
const hours = parseInt(match?.[1] || 0, 10);
|
||||
const minutes = parseInt(match?.[2] || 0, 10);
|
||||
const seconds = parseInt(match?.[3] || 0, 10);
|
||||
luxonDuration = Duration.fromObject({hours: hours, minutes: minutes, seconds: seconds});
|
||||
} else if (duration.indexOf('.') !== -1 || duration.indexOf(',') !== -1) {
|
||||
duration = duration.replace(/,/, '.');
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"doctrine/doctrine-bundle": "^2.7",
|
||||
"doctrine/doctrine-migrations-bundle": "^3.3",
|
||||
"doctrine/orm": "^2.8",
|
||||
"easybill/zugferd-php": "^2.1",
|
||||
"endroid/qr-code": "^4.8",
|
||||
"erusev/parsedown": "^1.6",
|
||||
"friendsofsymfony/rest-bundle": "^3.0",
|
||||
@@ -38,7 +39,7 @@
|
||||
"kevinpapst/tabler-bundle": "^1.4",
|
||||
"league/csv": "^9.4",
|
||||
"mpdf/mpdf": "^8.0",
|
||||
"nelmio/api-doc-bundle": "^4.0",
|
||||
"nelmio/api-doc-bundle": "^5.0",
|
||||
"nelmio/cors-bundle": "^2.0",
|
||||
"onelogin/php-saml": "^4.0",
|
||||
"openspout/openspout": "^4.0",
|
||||
|
||||
1030
composer.lock
generated
1030
composer.lock
generated
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
nelmio_api_doc:
|
||||
operation_id_generation: conditionally_prepend
|
||||
models:
|
||||
use_jms: true
|
||||
names:
|
||||
@@ -43,20 +44,28 @@ nelmio_api_doc:
|
||||
- { alias: Invoice, type: App\Entity\Invoice, groups: [Default, Entity, Invoice, Invoice_Entity] }
|
||||
- { alias: InvoiceCollection, type: App\Entity\Invoice, groups: [Default, Collection, Invoice] }
|
||||
areas:
|
||||
path_patterns:
|
||||
- ^/api(?!/doc)
|
||||
default:
|
||||
path_patterns:
|
||||
- ^/api(?!/doc)
|
||||
security:
|
||||
bearer:
|
||||
type: 'http'
|
||||
description: 'API Token'
|
||||
bearerFormat: 'KIMAI'
|
||||
scheme: 'bearer'
|
||||
documentation:
|
||||
info:
|
||||
title: Kimai - API
|
||||
description: |
|
||||
JSON API for the Kimai time-tracking software. Read our [API documentation](https://www.kimai.org/documentation/rest-api.html) and download the [Open API definition](doc.json) to import into your API client.
|
||||
version: '1.0'
|
||||
components:
|
||||
securitySchemes:
|
||||
bearer:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: KIMAI
|
||||
description: API Token
|
||||
JSON API for the Kimai time-tracking software. Find more infos in our [API documentation](https://www.kimai.org/documentation/rest-api.html).
|
||||
version: '1.1'
|
||||
security:
|
||||
- bearer: []
|
||||
html_config:
|
||||
# assets_mode: cdn
|
||||
# https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/
|
||||
swagger_ui_config: []
|
||||
# https://redocly.com/docs/redoc/config/
|
||||
redocly_config: []
|
||||
# https://docs.stoplight.io/docs/elements/b074dc47b2826-elements-configuration-options
|
||||
stoplight_config: { basePath: '/api/doc', router: 'memory', logo: '/touch-icon-192x192.png', hideInternal: true }
|
||||
|
||||
@@ -8,12 +8,7 @@ controllers:
|
||||
api.swagger_ui:
|
||||
path: /api/doc
|
||||
methods: GET
|
||||
defaults: { _controller: nelmio_api_doc.controller.swagger_ui }
|
||||
|
||||
api.swagger:
|
||||
path: /api/doc.json
|
||||
methods: GET
|
||||
defaults: { _controller: nelmio_api_doc.controller.swagger }
|
||||
defaults: { _controller: nelmio_api_doc.controller.stoplight }
|
||||
|
||||
api:
|
||||
resource: ../src/API/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
parameters:
|
||||
locale: en
|
||||
# can be regenerated with: bin/console kimai:reset:locales
|
||||
kimai_locales: ['ar', 'ca', 'cs', 'da', 'de', 'de_CH', 'el', 'en', 'eo', 'es', 'eu', 'fa', 'fi', 'fo', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'nb_NO', 'nl', 'pa', 'pl', 'pt', 'pt_BR', 'ro', 'ru', 'sk', 'sl', 'sv', 'tr', 'uk', 'vi', 'zh_CN', 'zh_Hant', 'zh_Hant_TW', 'ca_AD', 'ca_ES', 'ca_FR', 'ca_IT', 'cs_CZ', 'da_DK', 'da_GL', 'de_AT', 'de_BE', 'de_CH', 'de_DE', 'de_IT', 'de_LI', 'de_LU', 'el_CY', 'el_GR', 'en_AE', 'en_AG', 'en_AI', 'en_AS', 'en_AT', 'en_AU', 'en_BB', 'en_BE', 'en_BI', 'en_BM', 'en_BS', 'en_BW', 'en_BZ', 'en_CA', 'en_CC', 'en_CH', 'en_CK', 'en_CM', 'en_CX', 'en_CY', 'en_DE', 'en_DG', 'en_DK', 'en_DM', 'en_ER', 'en_FI', 'en_FJ', 'en_FK', 'en_FM', 'en_GB', 'en_GD', 'en_GG', 'en_GH', 'en_GI', 'en_GM', 'en_GU', 'en_GY', 'en_HK', 'en_ID', 'en_IE', 'en_IL', 'en_IM', 'en_IN', 'en_IO', 'en_JE', 'en_JM', 'en_KE', 'en_KI', 'en_KN', 'en_KY', 'en_LC', 'en_LR', 'en_LS', 'en_MG', 'en_MH', 'en_MO', 'en_MP', 'en_MS', 'en_MT', 'en_MU', 'en_MV', 'en_MW', 'en_MY', 'en_NA', 'en_NF', 'en_NG', 'en_NH', 'en_NL', 'en_NR', 'en_NU', 'en_NZ', 'en_PG', 'en_PH', 'en_PK', 'en_PN', 'en_PR', 'en_PW', 'en_RH', 'en_RW', 'en_SB', 'en_SC', 'en_SD', 'en_SE', 'en_SG', 'en_SH', 'en_SI', 'en_SL', 'en_SS', 'en_SX', 'en_SZ', 'en_TC', 'en_TK', 'en_TO', 'en_TT', 'en_TV', 'en_TZ', 'en_UG', 'en_UM', 'en_US', 'en_VC', 'en_VG', 'en_VI', 'en_VU', 'en_WS', 'en_ZA', 'en_ZM', 'en_ZW', 'es_AR', 'es_BO', 'es_BR', 'es_BZ', 'es_CL', 'es_CO', 'es_CR', 'es_CU', 'es_DO', 'es_EA', 'es_EC', 'es_ES', 'es_GQ', 'es_GT', 'es_HN', 'es_IC', 'es_MX', 'es_NI', 'es_PA', 'es_PE', 'es_PH', 'es_PR', 'es_PY', 'es_SV', 'es_US', 'es_UY', 'es_VE', 'eu_ES', 'fa_AF', 'fa_IR', 'fi_FI', 'fo_DK', 'fo_FO', 'fr_BE', 'fr_BF', 'fr_BI', 'fr_BJ', 'fr_BL', 'fr_CA', 'fr_CD', 'fr_CF', 'fr_CG', 'fr_CH', 'fr_CI', 'fr_CM', 'fr_DJ', 'fr_DZ', 'fr_FR', 'fr_GA', 'fr_GF', 'fr_GN', 'fr_GP', 'fr_GQ', 'fr_HT', 'fr_KM', 'fr_LU', 'fr_MA', 'fr_MC', 'fr_MF', 'fr_MG', 'fr_ML', 'fr_MQ', 'fr_MR', 'fr_MU', 'fr_NC', 'fr_NE', 'fr_PF', 'fr_PM', 'fr_RE', 'fr_RW', 'fr_SC', 'fr_SN', 'fr_SY', 'fr_TD', 'fr_TG', 'fr_TN', 'fr_VU', 'fr_WF', 'fr_YT', 'he_IL', 'hr_BA', 'hr_HR', 'hu_HU', 'it_CH', 'it_IT', 'it_SM', 'it_VA', 'ja_JP', 'ko_CN', 'ko_KP', 'ko_KR', 'nl_AW', 'nl_BE', 'nl_BQ', 'nl_CW', 'nl_NL', 'nl_SR', 'nl_SX', 'pl_PL', 'pt_AO', 'pt_BR', 'pt_CH', 'pt_CV', 'pt_GQ', 'pt_GW', 'pt_LU', 'pt_MO', 'pt_MZ', 'pt_PT', 'pt_ST', 'pt_TL', 'ro_MD', 'ro_RO', 'ru_BY', 'ru_KG', 'ru_KZ', 'ru_MD', 'ru_RU', 'ru_UA', 'sk_SK', 'sv_AX', 'sv_FI', 'sv_SE', 'tr_CY', 'tr_TR', 'uk_UA', 'vi_VN']
|
||||
kimai_locales: ['ar', 'cs', 'da', 'de', 'de_CH', 'el', 'en', 'eo', 'es', 'eu', 'fa', 'fi', 'fo', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'nb_NO', 'nl', 'pa', 'pl', 'pt', 'pt_BR', 'ro', 'ru', 'sk', 'sl', 'sv', 'ta', 'tr', 'uk', 'vi', 'zh_CN', 'zh_Hant', 'zh_Hant_TW', 'cs_CZ', 'da_DK', 'da_GL', 'de_AT', 'de_BE', 'de_CH', 'de_DE', 'de_IT', 'de_LI', 'de_LU', 'el_CY', 'el_GR', 'en_AE', 'en_AG', 'en_AI', 'en_AS', 'en_AT', 'en_AU', 'en_BB', 'en_BE', 'en_BI', 'en_BM', 'en_BS', 'en_BW', 'en_BZ', 'en_CA', 'en_CC', 'en_CH', 'en_CK', 'en_CM', 'en_CX', 'en_CY', 'en_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
|
||||
|
||||
171
phpstan.neon
171
phpstan.neon
@@ -42,10 +42,6 @@ parameters:
|
||||
containerXmlPath: %rootDir%/../../../var/cache/dev/App_KernelDevDebugContainer.xml
|
||||
ignoreErrors:
|
||||
- '#^Method .*\(\) has parameter \$builder with generic interface Symfony\\Component\\Form\\FormBuilderInterface but does not specify its types\: TData$#'
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$user of class App\\\\Event\\\\PageActionsEvent constructor expects App\\\\Entity\\\\User, App\\\\Entity\\\\User\\|null given\\.$#"
|
||||
count: 4
|
||||
path: src/API/ActionsController.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$name of method App\\\\Entity\\\\Activity\\:\\:getMetaField\\(\\) expects string, mixed given\\.$#"
|
||||
@@ -62,11 +58,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/API/Authentication/TokenAuthenticator.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$user of static method App\\\\Timesheet\\\\DateTimeFactory\\:\\:createByUser\\(\\) expects App\\\\Entity\\\\User, App\\\\Entity\\\\User\\|null given\\.$#"
|
||||
count: 1
|
||||
path: src/API/BaseApiController.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$name of method App\\\\Entity\\\\Customer\\:\\:getMetaField\\(\\) expects string, mixed given\\.$#"
|
||||
count: 1
|
||||
@@ -137,21 +128,6 @@ parameters:
|
||||
count: 3
|
||||
path: src/API/TimesheetController.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$user of class App\\\\Event\\\\RecentActivityEvent constructor expects App\\\\Entity\\\\User, App\\\\Entity\\\\User\\|null given\\.$#"
|
||||
count: 1
|
||||
path: src/API/TimesheetController.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$user of method App\\\\Repository\\\\TimesheetRepository\\:\\:getRecentActivities\\(\\) expects App\\\\Entity\\\\User, App\\\\Entity\\\\User\\|null given\\.$#"
|
||||
count: 1
|
||||
path: src/API/TimesheetController.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\User\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/API/UserController.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#2 \\$statistic of class App\\\\Event\\\\ActivityStatisticEvent constructor expects App\\\\Model\\\\ActivityStatistic, App\\\\Model\\\\ActivityStatistic\\|null given\\.$#"
|
||||
count: 1
|
||||
@@ -169,7 +145,7 @@ parameters:
|
||||
|
||||
-
|
||||
message: "#^Cannot call method find\\(\\) on Symfony\\\\Component\\\\Console\\\\Application\\|null\\.$#"
|
||||
count: 2
|
||||
count: 1
|
||||
path: src/Command/AbstractBundleInstallerCommand.php
|
||||
|
||||
-
|
||||
@@ -447,11 +423,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Command/ReloadCommand.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method find\\(\\) on Symfony\\\\Component\\\\Console\\\\Application\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Command/ResetDevelopmentCommand.php
|
||||
|
||||
-
|
||||
message: "#^Access to an undefined property SimpleXMLElement\\|false\\:\\:\\$file\\.$#"
|
||||
count: 1
|
||||
@@ -1907,46 +1878,16 @@ parameters:
|
||||
count: 1
|
||||
path: src/Form/Extension/SelectWithApiDataExtension.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$haystack of function stripos expects string, bool\\|float\\|int\\|string given\\.$#"
|
||||
count: 2
|
||||
path: src/Form/Helper/ActivityHelper.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\<string\\>\\|string, bool\\|float\\|int\\|string given\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Helper/ActivityHelper.php
|
||||
|
||||
-
|
||||
message: "#^Property App\\\\Form\\\\Helper\\\\ActivityHelper\\:\\:\\$pattern \\(string\\|null\\) does not accept bool\\|float\\|int\\|string\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Helper/ActivityHelper.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$haystack of function stripos expects string, bool\\|float\\|int\\|string given\\.$#"
|
||||
count: 2
|
||||
path: src/Form/Helper/CustomerHelper.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\<string\\>\\|string, bool\\|float\\|int\\|string given\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Helper/CustomerHelper.php
|
||||
|
||||
-
|
||||
message: "#^Property App\\\\Form\\\\Helper\\\\CustomerHelper\\:\\:\\$pattern \\(string\\|null\\) does not accept bool\\|float\\|int\\|string\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Helper/CustomerHelper.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$haystack of function stripos expects string, bool\\|float\\|int\\|string given\\.$#"
|
||||
count: 2
|
||||
path: src/Form/Helper/ProjectHelper.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\<string\\>\\|string, bool\\|float\\|int\\|string given\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Helper/ProjectHelper.php
|
||||
|
||||
-
|
||||
message: "#^Property App\\\\Form\\\\Helper\\\\ProjectHelper\\:\\:\\$pattern \\(string\\|null\\) does not accept bool\\|float\\|int\\|string\\|null\\.$#"
|
||||
count: 1
|
||||
@@ -2097,11 +2038,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Form/TimesheetAdminEditForm.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method format\\(\\) on mixed\\.$#"
|
||||
count: 4
|
||||
path: src/Form/TimesheetEditForm.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getEnd\\(\\) on App\\\\Entity\\\\Timesheet\\|null\\.$#"
|
||||
count: 2
|
||||
@@ -2122,11 +2058,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Form/TimesheetEditForm.php
|
||||
|
||||
-
|
||||
message: "#^Cannot clone non\\-object variable \\$end of type mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Form/TimesheetEditForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\TimesheetEditForm\\:\\:addActivity\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
@@ -3027,46 +2958,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelCustomerHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getAddress\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getCompany\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getContact\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getDueDays\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getLanguage\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getName\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getPaymentDetails\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getPaymentTerms\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getSubtotal\\(\\) on App\\\\Invoice\\\\CalculatorInterface\\|null\\.$#"
|
||||
count: 1
|
||||
@@ -3082,11 +2973,6 @@ parameters:
|
||||
count: 2
|
||||
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getTitle\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getTotal\\(\\) on App\\\\Invoice\\\\CalculatorInterface\\|null\\.$#"
|
||||
count: 1
|
||||
@@ -3097,11 +2983,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getVatId\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Invoice\\\\Hydrator\\\\InvoiceModelDefaultHydrator\\:\\:hydrate\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
@@ -3322,11 +3203,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Ldap/LdapAuthenticator.php
|
||||
|
||||
-
|
||||
message: "#^Instanceof between Symfony\\\\Component\\\\Security\\\\Http\\\\Authenticator\\\\Passport\\\\Passport and Symfony\\\\Component\\\\Security\\\\Http\\\\Authenticator\\\\Passport\\\\Passport will always evaluate to true\\.$#"
|
||||
count: 1
|
||||
path: src/Ldap/LdapCredentialsSubscriber.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Ldap\\\\LdapCredentialsSubscriber\\:\\:onCheckPassport\\(\\) has no return type specified\\.$#"
|
||||
count: 1
|
||||
@@ -3432,26 +3308,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Model/Statistic/Month.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Pdf\\\\HtmlToPdfConverter\\:\\:convertToPdf\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Pdf/HtmlToPdfConverter.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Pdf\\\\MPdfConverter\\:\\:convertToPdf\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Pdf/MPdfConverter.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Pdf\\\\MPdfConverter\\:\\:sanitizeOptions\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Pdf/MPdfConverter.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Pdf\\\\MPdfConverter\\:\\:sanitizeOptions\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Pdf/MPdfConverter.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Pdf\\\\PdfContext\\:\\:getOption\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
@@ -3992,26 +3848,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Timesheet/TimesheetStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Timesheet\\\\TrackingMode\\\\AbstractTrackingMode\\:\\:setBeginEndFromRequest\\(\\) has no return type specified\\.$#"
|
||||
count: 1
|
||||
path: src/Timesheet/TrackingMode/AbstractTrackingMode.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Timesheet\\\\TrackingMode\\\\AbstractTrackingMode\\:\\:setFromToFromRequest\\(\\) has no return type specified\\.$#"
|
||||
count: 1
|
||||
path: src/Timesheet/TrackingMode/AbstractTrackingMode.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$datetime of class DateTime constructor expects string, mixed given\\.$#"
|
||||
count: 2
|
||||
path: src/Timesheet/TrackingMode/AbstractTrackingMode.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#2 \\$datetime of static method DateTime\\:\\:createFromFormat\\(\\) expects string, mixed given\\.$#"
|
||||
count: 2
|
||||
path: src/Timesheet/TrackingMode/AbstractTrackingMode.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Twig\\\\Configuration\\:\\:__call\\(\\) has no return type specified\\.$#"
|
||||
count: 1
|
||||
@@ -4452,11 +4288,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Utils/ReleaseVersion.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Utils\\\\SearchTerm\\:\\:getSearchFields\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/SearchTerm.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset 'pattern' on mixed\\.$#"
|
||||
count: 1
|
||||
|
||||
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
@@ -3,10 +3,10 @@
|
||||
"app": {
|
||||
"js": [
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/app.7719f352.js"
|
||||
"/build/app.6bb53337.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/app.899af573.css"
|
||||
"/build/app.0416ea92.css"
|
||||
]
|
||||
},
|
||||
"app-rtl": {
|
||||
@@ -15,7 +15,7 @@
|
||||
"/build/app-rtl.7a875ca7.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/app-rtl.bb694a34.css"
|
||||
"/build/app-rtl.0848906b.css"
|
||||
]
|
||||
},
|
||||
"export-pdf": {
|
||||
@@ -72,10 +72,10 @@
|
||||
},
|
||||
"integrity": {
|
||||
"/build/runtime.6c399d29.js": "sha384-/rm616f12czi8l/27GvWXtb3g608vJZf2XTUKxqCRI4tsa2vUHP+BW90edTok5zC",
|
||||
"/build/app.7719f352.js": "sha384-lj0mGChJMeCxDtjDy96KBKaW5E61RIqYc2+3RlVz+wcsIroUZxMApq0PsW8QcKFN",
|
||||
"/build/app.899af573.css": "sha384-8FV7dixselI1s8FeZCs88+w6hVO9bJ7U1Ai12BBPE7O8bQOxqkhKsWHsCD+WCtdn",
|
||||
"/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.bb694a34.css": "sha384-0ANmqhe0IP4aUvWOWo/KZUgAqueE4hH/ZU0EREtgrtKXWM/JPMmRFBEXk8iZTEcd",
|
||||
"/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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"build/app.css": "/build/app.899af573.css",
|
||||
"build/app.js": "/build/app.7719f352.js",
|
||||
"build/app-rtl.css": "/build/app-rtl.bb694a34.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",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Kimai Time-Tracker",
|
||||
"name": "Kimai",
|
||||
"short_name": "Kimai",
|
||||
"icons": [
|
||||
{
|
||||
|
||||
@@ -38,8 +38,6 @@ final class ActionsController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PageActionsEvent $event
|
||||
* @param string $locale
|
||||
* @return array<PageAction>
|
||||
*/
|
||||
private function convertEvent(PageActionsEvent $event, string $locale): array
|
||||
@@ -65,12 +63,13 @@ final class ActionsController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all item actions for the given Timesheet [for internal use]
|
||||
* Fetch item actions for Timesheet
|
||||
*/
|
||||
#[OA\Response(response: 200, description: 'Returns item actions for the timesheet', content: new OA\JsonContent(ref: new Model(type: PageAction::class)))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet ID to fetch', required: true)]
|
||||
#[OA\Parameter(name: 'view', in: 'path', description: 'View to display the actions at (e.g. index, custom)', required: true)]
|
||||
#[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
|
||||
#[OA\Get(x: ['internal' => true])]
|
||||
#[Route(methods: ['GET'], path: '/timesheet/{id}/{view}/{locale}', name: 'get_timesheet_actions', requirements: ['id' => '\d+'])]
|
||||
public function getTimesheetActions(Timesheet $timesheet, string $view, string $locale): Response
|
||||
{
|
||||
@@ -83,12 +82,13 @@ final class ActionsController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all item actions for the given Activity [for internal use]
|
||||
* Fetch item actions for Activity
|
||||
*/
|
||||
#[OA\Response(response: 200, description: 'Returns item actions for the activity', content: new OA\JsonContent(ref: new Model(type: PageAction::class)))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Activity ID to fetch', required: true)]
|
||||
#[OA\Parameter(name: 'view', in: 'path', description: 'View to display the actions at (e.g. index, custom)', required: true)]
|
||||
#[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
|
||||
#[OA\Get(x: ['internal' => true])]
|
||||
#[Route(methods: ['GET'], path: '/activity/{id}/{view}/{locale}', name: 'get_activity_actions', requirements: ['id' => '\d+'])]
|
||||
public function getActivityActions(Activity $activity, string $view, string $locale): Response
|
||||
{
|
||||
@@ -101,12 +101,13 @@ final class ActionsController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all item actions for the given Project [for internal use]
|
||||
* Fetch item actions for Project
|
||||
*/
|
||||
#[OA\Response(response: 200, description: 'Returns item actions for the project', content: new OA\JsonContent(ref: new Model(type: PageAction::class)))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Project ID to fetch', required: true)]
|
||||
#[OA\Parameter(name: 'view', in: 'path', description: 'View to display the actions at (e.g. index, custom)', required: true)]
|
||||
#[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
|
||||
#[OA\Get(x: ['internal' => true])]
|
||||
#[Route(methods: ['GET'], path: '/project/{id}/{view}/{locale}', name: 'get_project_actions', requirements: ['id' => '\d+'])]
|
||||
public function getProjectActions(Project $project, string $view, string $locale): Response
|
||||
{
|
||||
@@ -119,12 +120,13 @@ final class ActionsController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all item actions for the given Customer [for internal use]
|
||||
* Fetch item actions for Customer
|
||||
*/
|
||||
#[OA\Response(response: 200, description: 'Returns item actions for the customer', content: new OA\JsonContent(ref: new Model(type: PageAction::class)))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Customer ID to fetch', required: true)]
|
||||
#[OA\Parameter(name: 'view', in: 'path', description: 'View to display the actions at (e.g. index, custom)', required: true)]
|
||||
#[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
|
||||
#[OA\Get(x: ['internal' => true])]
|
||||
#[Route(methods: ['GET'], path: '/customer/{id}/{view}/{locale}', name: 'get_customer_actions', requirements: ['id' => '\d+'])]
|
||||
public function getCustomerActions(Customer $customer, string $view, string $locale): Response
|
||||
{
|
||||
|
||||
@@ -53,7 +53,7 @@ final class ActivityController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a collection of activities (which are visible to the user)
|
||||
* Fetch activities
|
||||
*/
|
||||
#[OA\Response(response: 200, description: 'Returns a collection of activities', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/ActivityCollection')))]
|
||||
#[Route(methods: ['GET'], path: '', name: 'get_activities')]
|
||||
@@ -122,7 +122,7 @@ final class ActivityController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns one activity
|
||||
* Fetch activity
|
||||
*/
|
||||
#[OA\Response(response: 200, description: 'Returns one activity entity', content: new OA\JsonContent(ref: '#/components/schemas/ActivityEntity'))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Activity ID to fetch', required: true)]
|
||||
@@ -137,7 +137,7 @@ final class ActivityController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new activity
|
||||
* Create activity
|
||||
*/
|
||||
#[OA\Post(description: 'Creates a new activity and returns it afterwards', responses: [new OA\Response(response: 200, description: 'Returns the new created activity', content: new OA\JsonContent(ref: '#/components/schemas/ActivityEntity'))])]
|
||||
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/ActivityEditForm'))]
|
||||
@@ -176,7 +176,7 @@ final class ActivityController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing activity
|
||||
* Update activity
|
||||
*/
|
||||
#[IsGranted('edit', 'activity')]
|
||||
#[OA\Patch(description: 'Update an existing activity, you can pass all or just a subset of all attributes', responses: [new OA\Response(response: 200, description: 'Returns the updated activity', content: new OA\JsonContent(ref: '#/components/schemas/ActivityEntity'))])]
|
||||
@@ -212,10 +212,10 @@ final class ActivityController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an existing activity
|
||||
* Delete activity
|
||||
*
|
||||
* [DANGER] This will also delete ALL linked timesheets.
|
||||
* Maybe use `PATCH` instead and mark it as inactive with `visible=false`?
|
||||
* Do you want to use `PATCH` instead and mark it as inactive with `{visible: false}` instead?
|
||||
*/
|
||||
#[IsGranted('delete', 'activity')]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one activity')])]
|
||||
@@ -231,7 +231,7 @@ final class ActivityController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the value of a meta-field for an existing activity
|
||||
* Update activity custom-field
|
||||
*/
|
||||
#[IsGranted('edit', 'activity')]
|
||||
#[OA\Response(response: 200, description: 'Sets the value of an existing/configured meta-field. You cannot create unknown meta-fields, if the given name is not a configured meta-field, this will return an exception.', content: new OA\JsonContent(ref: '#/components/schemas/ActivityEntity'))]
|
||||
@@ -262,7 +262,7 @@ final class ActivityController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a collection of all rates for one activity
|
||||
* Fetch rates for activity
|
||||
*/
|
||||
#[IsGranted('edit', 'activity')]
|
||||
#[OA\Response(response: 200, description: 'Returns a collection of activity rate entities', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/ActivityRate')))]
|
||||
@@ -279,7 +279,7 @@ final class ActivityController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes one rate for an activity
|
||||
* Delete rate for activity
|
||||
*/
|
||||
#[IsGranted('edit', 'activity')]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Returns no content: 204 on successful delete')])]
|
||||
@@ -300,7 +300,7 @@ final class ActivityController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new rate to an activity
|
||||
* Add rate for activity
|
||||
*/
|
||||
#[IsGranted('edit', 'activity')]
|
||||
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Returns the new created rate', content: new OA\JsonContent(ref: '#/components/schemas/ActivityRate'))])]
|
||||
|
||||
@@ -9,18 +9,13 @@
|
||||
|
||||
namespace App\API\Authentication;
|
||||
|
||||
use App\Entity\User;
|
||||
use Symfony\Component\Security\Core\Exception\LogicException;
|
||||
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface;
|
||||
|
||||
final class ApiTokenUpgradeBadge implements BadgeInterface
|
||||
{
|
||||
/**
|
||||
* @param string|null $plaintextApiToken
|
||||
* @param PasswordUpgraderInterface<User> $passwordUpgrader
|
||||
*/
|
||||
public function __construct(private ?string $plaintextApiToken, private PasswordUpgraderInterface $passwordUpgrader)
|
||||
public function __construct(private ?string $plaintextApiToken, private readonly PasswordUpgraderInterface $passwordUpgrader)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -36,9 +31,6 @@ final class ApiTokenUpgradeBadge implements BadgeInterface
|
||||
return $password;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return PasswordUpgraderInterface<User>
|
||||
*/
|
||||
public function getPasswordUpgrader(): PasswordUpgraderInterface
|
||||
{
|
||||
return $this->passwordUpgrader;
|
||||
|
||||
@@ -20,15 +20,22 @@ use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\Form\FormTypeInterface;
|
||||
|
||||
/**
|
||||
* @method null|User getUser()
|
||||
*/
|
||||
abstract class BaseApiController extends AbstractController
|
||||
{
|
||||
public const DATE_ONLY_FORMAT = 'yyyy-MM-dd';
|
||||
public const DATE_FORMAT = DateTimeType::HTML5_FORMAT;
|
||||
public const DATE_FORMAT_PHP = 'Y-m-d\TH:i:s';
|
||||
|
||||
protected function getUser(): User
|
||||
{
|
||||
$user = parent::getUser();
|
||||
if (!$user instanceof User) {
|
||||
throw $this->createAccessDeniedException('Need a user for API access');
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template TFormType of FormTypeInterface<TData>
|
||||
* @template TData of BaseQuery
|
||||
|
||||
@@ -28,7 +28,7 @@ final class ConfigurationController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the timesheet configuration
|
||||
* Fetch timesheet configuration
|
||||
*/
|
||||
#[OA\Response(response: 200, description: 'Returns the instance specific timesheet configuration', content: new OA\JsonContent(ref: new Model(type: TimesheetConfig::class)))]
|
||||
#[Route(path: '/config/timesheet', methods: ['GET'])]
|
||||
@@ -48,7 +48,7 @@ final class ConfigurationController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the configured color codes and names
|
||||
* Fetch configured color codes
|
||||
*/
|
||||
#[OA\Response(response: 200, description: 'Returns the configured color codes and names', content: new OA\JsonContent(type: 'object', example: ['Red' => '#ff0000'], additionalProperties: new OA\AdditionalProperties(type: 'string')))]
|
||||
#[Route(path: '/config/colors', methods: ['GET'])]
|
||||
|
||||
@@ -52,7 +52,7 @@ final class CustomerController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a collection of customers (which are visible to the user)
|
||||
* Fetch customers
|
||||
*/
|
||||
#[OA\Response(response: 200, description: 'Returns a collection of customers', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/CustomerCollection')))]
|
||||
#[Route(methods: ['GET'], path: '', name: 'get_customers')]
|
||||
@@ -98,7 +98,7 @@ final class CustomerController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns one customer
|
||||
* Fetch customer
|
||||
*/
|
||||
#[OA\Response(response: 200, description: 'Returns one customer entity', content: new OA\JsonContent(ref: '#/components/schemas/CustomerEntity'))]
|
||||
#[Route(methods: ['GET'], path: '/{id}', name: 'get_customer', requirements: ['id' => '\d+'])]
|
||||
@@ -112,7 +112,7 @@ final class CustomerController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new customer
|
||||
* Create customer
|
||||
*/
|
||||
#[OA\Post(description: 'Creates a new customer and returns it afterwards', responses: [new OA\Response(response: 200, description: 'Returns the new created customer', content: new OA\JsonContent(ref: '#/components/schemas/CustomerEntity'))])]
|
||||
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/CustomerEditForm'))]
|
||||
@@ -151,7 +151,7 @@ final class CustomerController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing customer
|
||||
* Update customer
|
||||
*/
|
||||
#[IsGranted('edit', 'customer')]
|
||||
#[OA\Patch(description: 'Update an existing customer, you can pass all or just a subset of all attributes', responses: [new OA\Response(response: 200, description: 'Returns the updated customer', content: new OA\JsonContent(ref: '#/components/schemas/CustomerEntity'))])]
|
||||
@@ -187,10 +187,10 @@ final class CustomerController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an existing customer
|
||||
* Delete customer
|
||||
*
|
||||
* [DANGER] This will also delete ALL linked projects, project activities and timesheets.
|
||||
* Maybe use `PATCH` instead and mark it as inactive with `visible=false`?
|
||||
* Do you want to use `PATCH` instead and mark it as inactive with `{visible: false}` instead?
|
||||
*/
|
||||
#[IsGranted('delete', 'customer')]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one customer')])]
|
||||
@@ -206,7 +206,7 @@ final class CustomerController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the value of a meta-field for an existing customer
|
||||
* Update customer custom-field
|
||||
*/
|
||||
#[IsGranted('edit', 'customer')]
|
||||
#[OA\Response(response: 200, description: 'Sets the value of an existing/configured meta-field. You cannot create unknown meta-fields, if the given name is not a configured meta-field, this will return an exception.', content: new OA\JsonContent(ref: '#/components/schemas/CustomerEntity'))]
|
||||
@@ -237,7 +237,7 @@ final class CustomerController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a collection of all rates for one customer
|
||||
* Fetch rates for customer
|
||||
*/
|
||||
#[IsGranted('edit', 'customer')]
|
||||
#[OA\Response(response: 200, description: 'Returns a collection of customer rate entities', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/CustomerRate')))]
|
||||
@@ -254,7 +254,7 @@ final class CustomerController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes one rate for a customer
|
||||
* Delete rate for customer
|
||||
*/
|
||||
#[IsGranted('edit', 'customer')]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Returns no content: 204 on successful delete')])]
|
||||
@@ -275,7 +275,7 @@ final class CustomerController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new rate to a customer
|
||||
* Add rate for customer
|
||||
*/
|
||||
#[IsGranted('edit', 'customer')]
|
||||
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Returns the new created rate', content: new OA\JsonContent(ref: '#/components/schemas/CustomerRate'))])]
|
||||
|
||||
@@ -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
|
||||
{
|
||||
@@ -38,11 +41,8 @@ final class InvoiceController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a paginated collection of invoices.
|
||||
*
|
||||
* Needs permission: view_invoice
|
||||
* 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,11 +89,8 @@ final class InvoiceController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns one invoice.
|
||||
*
|
||||
* Needs permission: view_invoice
|
||||
* 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
|
||||
@@ -103,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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ final class ProjectController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a collection of projects (which are visible to the user)
|
||||
* Fetch projects
|
||||
*/
|
||||
#[OA\Response(response: 200, description: 'Returns a collection of projects', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/ProjectCollection')))]
|
||||
#[Route(methods: ['GET'], path: '', name: 'get_projects')]
|
||||
@@ -151,7 +151,7 @@ final class ProjectController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns one project
|
||||
* Fetch project
|
||||
*/
|
||||
#[OA\Response(response: 200, description: 'Returns one project entity', content: new OA\JsonContent(ref: '#/components/schemas/ProjectEntity'))]
|
||||
#[Route(methods: ['GET'], path: '/{id}', name: 'get_project', requirements: ['id' => '\d+'])]
|
||||
@@ -165,7 +165,7 @@ final class ProjectController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new project
|
||||
* Create project
|
||||
*/
|
||||
#[OA\Post(description: 'Creates a new project and returns it afterwards', responses: [new OA\Response(response: 200, description: 'Returns the new created project', content: new OA\JsonContent(ref: '#/components/schemas/ProjectEntity'))])]
|
||||
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/ProjectEditForm'))]
|
||||
@@ -188,7 +188,7 @@ final class ProjectController extends BaseApiController
|
||||
$form->submit($request->request->all());
|
||||
|
||||
if ($form->isValid()) {
|
||||
$this->projectService->saveNewProject($project);
|
||||
$this->projectService->saveProject($project);
|
||||
|
||||
$view = new View($project, 200);
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||
@@ -203,7 +203,7 @@ final class ProjectController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing project
|
||||
* Update project
|
||||
*/
|
||||
#[IsGranted('edit', 'project')]
|
||||
#[OA\Patch(description: 'Update an existing project, you can pass all or just a subset of all attributes', responses: [new OA\Response(response: 200, description: 'Returns the updated project', content: new OA\JsonContent(ref: '#/components/schemas/ProjectEntity'))])]
|
||||
@@ -232,7 +232,7 @@ final class ProjectController extends BaseApiController
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
$this->projectService->updateProject($project);
|
||||
$this->projectService->saveProject($project);
|
||||
|
||||
$view = new View($project, Response::HTTP_OK);
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||
@@ -241,10 +241,10 @@ final class ProjectController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an existing project
|
||||
* Delete project
|
||||
*
|
||||
* [DANGER] This will also delete ALL linked activities and timesheets.
|
||||
* Maybe use `PATCH` instead and mark it as inactive with `visible=false`?
|
||||
* Do you want to use `PATCH` instead and mark it as inactive with `{visible: false}` instead?
|
||||
*/
|
||||
#[IsGranted('delete', 'project')]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one project')])]
|
||||
@@ -260,7 +260,7 @@ final class ProjectController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the value of a meta-field for an existing project
|
||||
* Update project custom-field
|
||||
*/
|
||||
#[IsGranted('edit', 'project')]
|
||||
#[OA\Response(response: 200, description: 'Sets the value of an existing/configured meta-field. You cannot create unknown meta-fields, if the given name is not a configured meta-field, this will return an exception.', content: new OA\JsonContent(ref: '#/components/schemas/ProjectEntity'))]
|
||||
@@ -282,7 +282,7 @@ final class ProjectController extends BaseApiController
|
||||
|
||||
$meta->setValue($value);
|
||||
|
||||
$this->projectService->updateProject($project);
|
||||
$this->projectService->saveProject($project);
|
||||
|
||||
$view = new View($project, 200);
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||
@@ -291,7 +291,7 @@ final class ProjectController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a collection of all rates for one project
|
||||
* Fetch rates for project
|
||||
*/
|
||||
#[IsGranted('edit', 'project')]
|
||||
#[OA\Response(response: 200, description: 'Returns a collection of project rate entities', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/ProjectRate')))]
|
||||
@@ -308,7 +308,7 @@ final class ProjectController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes one rate for a project
|
||||
* Delete rate for project
|
||||
*/
|
||||
#[IsGranted('edit', 'project')]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Returns no content: 204 on successful delete')])]
|
||||
@@ -329,7 +329,7 @@ final class ProjectController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new rate to a project
|
||||
* Add rate for project
|
||||
*/
|
||||
#[IsGranted('edit', 'project')]
|
||||
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Returns the new created rate', content: new OA\JsonContent(ref: '#/components/schemas/ProjectRate'))])]
|
||||
|
||||
@@ -23,7 +23,11 @@ use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
final class ValidationFailedExceptionErrorHandler implements SubscribingHandlerInterface
|
||||
{
|
||||
public function __construct(private TranslatorInterface $translator, private FlattenExceptionHandler $exceptionHandler, private Security $security)
|
||||
public function __construct(
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly FlattenExceptionHandler $exceptionHandler, // @phpstan-ignore-line
|
||||
private readonly Security $security
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -47,7 +51,7 @@ final class ValidationFailedExceptionErrorHandler implements SubscribingHandlerI
|
||||
public function serializeExceptionToJson(JsonSerializationVisitor $visitor, FlattenException $exception, array $type, Context $context)
|
||||
{
|
||||
if ($exception->getClass() !== ValidationFailedException::class) {
|
||||
return $this->exceptionHandler->serializeToJson($visitor, $exception, $type, $context);
|
||||
return $this->exceptionHandler->serializeToJson($visitor, $exception, $type, $context); // @phpstan-ignore method.internalClass
|
||||
}
|
||||
|
||||
$original = $context->getAttribute('exception');
|
||||
@@ -55,7 +59,7 @@ final class ValidationFailedExceptionErrorHandler implements SubscribingHandlerI
|
||||
return $this->serializeValidationExceptionToJson($visitor, $original, $type, $context);
|
||||
}
|
||||
|
||||
return $this->exceptionHandler->serializeToJson($visitor, $exception, $type, $context);
|
||||
return $this->exceptionHandler->serializeToJson($visitor, $exception, $type, $context); // @phpstan-ignore method.internalClass
|
||||
}
|
||||
|
||||
public function serializeValidationExceptionToJson(JsonSerializationVisitor $visitor, ValidationFailedException $exception, array $type, Context $context)
|
||||
|
||||
@@ -29,7 +29,7 @@ final class StatusController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* A testing route for the API
|
||||
* Testing route for the API
|
||||
*/
|
||||
#[OA\Response(response: 200, description: "A simple route that returns a 'pong', which you can use for testing the API", content: new OA\JsonContent(example: "{'message': 'pong'}"))]
|
||||
#[Route(methods: ['GET'], path: '/ping')]
|
||||
@@ -41,7 +41,7 @@ final class StatusController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns information about the Kimai release
|
||||
* Fetch Kimai release
|
||||
*/
|
||||
#[OA\Response(response: 200, description: 'Returns version information about the current release', content: new OA\JsonContent(ref: new Model(type: Version::class)))]
|
||||
#[Route(methods: ['GET'], path: '/version')]
|
||||
@@ -51,7 +51,7 @@ final class StatusController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns information about installed Plugins
|
||||
* Fetch installed Plugins
|
||||
*/
|
||||
#[OA\Response(response: 200, description: 'Returns a list of plugin names and versions', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: new Model(type: Plugin::class))))]
|
||||
#[Route(methods: ['GET'], path: '/plugins')]
|
||||
|
||||
@@ -39,9 +39,10 @@ final class TagController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Deprecated: Fetch tags by filter as string collection
|
||||
* Deprecated: Fetch tags as strings
|
||||
*/
|
||||
#[OA\Response(response: 200, description: 'Returns the collection of all existing tags as string array', content: new OA\JsonContent(type: 'array', items: new OA\Items(type: 'string')))]
|
||||
#[OA\Response(response: 200, description: 'DEPRECATED: Returns existing tags as string array', content: new OA\JsonContent(type: 'array', items: new OA\Items(type: 'string')))]
|
||||
#[OA\Get(x: ['internal' => true])]
|
||||
#[Route(methods: ['GET'], name: 'get_tags')]
|
||||
#[Rest\QueryParam(name: 'name', strict: true, nullable: true, description: 'Search term to filter tag list')]
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
|
||||
@@ -58,7 +59,7 @@ final class TagController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch tags by filter (as full entities)
|
||||
* Fetch tags
|
||||
*/
|
||||
#[OA\Response(response: 200, description: 'Find the collection of all matching tags', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TagEntity')))]
|
||||
#[Route(path: '/find', name: 'get_tags_full', methods: ['GET'])]
|
||||
@@ -79,7 +80,7 @@ final class TagController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new tag
|
||||
* Create tag
|
||||
*/
|
||||
#[OA\Post(description: 'Creates a new tag and returns it afterwards', responses: [new OA\Response(response: 200, description: 'Returns the new created tag', content: new OA\JsonContent(ref: '#/components/schemas/TagEntity'))])]
|
||||
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/TagEditForm'))]
|
||||
@@ -112,7 +113,7 @@ final class TagController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a tag
|
||||
* Delete tag
|
||||
*/
|
||||
#[IsGranted('delete_tag')]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'HTTP code 204 for a successful delete')])]
|
||||
|
||||
@@ -47,10 +47,10 @@ final class TeamController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all existing teams (which are visible to the user)
|
||||
* Fetch teams
|
||||
*/
|
||||
#[IsGranted('view_team')]
|
||||
#[OA\Response(response: 200, description: 'Returns the collection of teams', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TeamCollection')))]
|
||||
#[OA\Response(response: 200, description: 'Returns a collection of teams', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TeamCollection')))]
|
||||
#[Route(methods: ['GET'], path: '', name: 'get_teams')]
|
||||
public function cgetAction(): Response
|
||||
{
|
||||
@@ -66,7 +66,7 @@ final class TeamController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns one team
|
||||
* Fetch team
|
||||
*/
|
||||
#[IsGranted('view_team')]
|
||||
#[OA\Response(response: 200, description: 'Returns one team entity', content: new OA\JsonContent(ref: '#/components/schemas/Team'))]
|
||||
@@ -80,7 +80,7 @@ final class TeamController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a team
|
||||
* Delete team
|
||||
*/
|
||||
#[IsGranted('delete_team')]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one team')])]
|
||||
@@ -96,7 +96,7 @@ final class TeamController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new team
|
||||
* Create team
|
||||
*/
|
||||
#[IsGranted('create_team')]
|
||||
#[OA\Post(description: 'Creates a new team and returns it afterwards', responses: [new OA\Response(response: 200, description: 'Returns the new created team', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
|
||||
@@ -125,7 +125,7 @@ final class TeamController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing team
|
||||
* Update team
|
||||
*/
|
||||
#[IsGranted('edit_team')]
|
||||
#[OA\Patch(description: 'Update an existing team, you can pass all or just a subset of all attributes (passing members will replace all existing ones)', responses: [new OA\Response(response: 200, description: 'Returns the updated team', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
|
||||
@@ -163,7 +163,7 @@ final class TeamController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new member to a team
|
||||
* Add team member
|
||||
*/
|
||||
#[IsGranted('edit_team')]
|
||||
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Adds a new user to a team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
|
||||
@@ -187,7 +187,7 @@ final class TeamController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a member from the team
|
||||
* Remove team member
|
||||
*/
|
||||
#[IsGranted('edit_team')]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Removes a user from the team. The teamlead cannot be removed.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
|
||||
@@ -215,7 +215,7 @@ final class TeamController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant the team access to a customer
|
||||
* Grant team access to customer
|
||||
*/
|
||||
#[IsGranted('edit_team')]
|
||||
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Adds a new customer to a team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
|
||||
@@ -238,7 +238,7 @@ final class TeamController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes access for a customer from a team
|
||||
* Revoke customer access from team
|
||||
*/
|
||||
#[IsGranted('edit_team')]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Removes a customer from the team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
|
||||
@@ -261,7 +261,7 @@ final class TeamController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant the team access to a project
|
||||
* Grant team access to project
|
||||
*/
|
||||
#[IsGranted('edit_team')]
|
||||
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Adds a new project to a team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
|
||||
@@ -284,7 +284,7 @@ final class TeamController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes access for a project from a team
|
||||
* Revoke project access from team
|
||||
*/
|
||||
#[IsGranted('edit_team')]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Removes a project from the team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
|
||||
@@ -307,7 +307,7 @@ final class TeamController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant the team access to an activity
|
||||
* Grant team access to activity
|
||||
*/
|
||||
#[IsGranted('edit_team')]
|
||||
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Adds a new activity to a team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
|
||||
@@ -330,7 +330,7 @@ final class TeamController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes access for an activity from a team
|
||||
* Revoke activity access from team
|
||||
*/
|
||||
#[IsGranted('edit_team')]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Removes a activity from the team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
|
||||
|
||||
@@ -68,10 +68,10 @@ final class TimesheetController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a collection of timesheet records (which are visible to the user)
|
||||
* Fetch timesheets
|
||||
*/
|
||||
#[IsGranted(new Expression("is_granted('view_own_timesheet') or is_granted('view_other_timesheet')"))]
|
||||
#[OA\Response(response: 200, description: 'Returns a collection of timesheet records. The datetime fields are given in the users local time including the timezone offset (ISO-8601).', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TimesheetCollection')))]
|
||||
#[OA\Response(response: 200, description: 'Returns a collection of timesheets. The datetime fields are given in the users local time including the timezone offset (ISO-8601).', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TimesheetCollection')))]
|
||||
#[Route(methods: ['GET'], path: '', name: 'get_timesheets')]
|
||||
#[Rest\QueryParam(name: 'user', requirements: '\d+|all', strict: true, nullable: true, description: "User ID to filter timesheets. Needs permission 'view_other_timesheet', pass 'all' to fetch data for all user (default: current user)")]
|
||||
#[Rest\QueryParam(name: 'users', map: true, requirements: '\d+', strict: true, nullable: true, default: [], description: 'List of user IDs to filter, e.g.: users[]=1&users[]=2 (ignored if user=all)')]
|
||||
@@ -270,11 +270,11 @@ final class TimesheetController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns one timesheet record
|
||||
* Fetch timesheet
|
||||
*/
|
||||
#[IsGranted('view', 'timesheet')]
|
||||
#[OA\Response(response: 200, description: 'Returns one timesheet record. Be aware that the datetime fields are given in the users local time including the timezone offset via ISO 8601.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to fetch', required: true)]
|
||||
#[OA\Response(response: 200, description: 'Returns one timesheet. Be aware that the datetime fields are given in the users local time including the timezone offset via ISO 8601.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet ID to fetch', required: true)]
|
||||
#[Route(methods: ['GET'], path: '/{id}', name: 'get_timesheet', requirements: ['id' => '\d+'])]
|
||||
public function getAction(Timesheet $timesheet): Response
|
||||
{
|
||||
@@ -285,10 +285,10 @@ final class TimesheetController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new timesheet record
|
||||
* Create timesheet
|
||||
*/
|
||||
#[IsGranted('create_own_timesheet')]
|
||||
#[OA\Post(description: 'Creates a new timesheet record for the current user and returns it afterwards.', responses: [new OA\Response(response: 200, description: 'Returns the new created timesheet', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))])]
|
||||
#[OA\Post(description: 'Creates a new timesheet for the current user and returns it afterwards.', responses: [new OA\Response(response: 200, description: 'Returns the new created timesheet', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))])]
|
||||
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEditForm'))]
|
||||
#[Route(methods: ['POST'], path: '', name: 'post_timesheet')]
|
||||
#[Rest\QueryParam(name: 'full', strict: true, nullable: true, description: 'Allows to fetch fully serialized objects including subresources (TimesheetExpanded). Allowed values: true (default: false)')]
|
||||
@@ -338,11 +338,11 @@ final class TimesheetController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing timesheet record
|
||||
* Update timesheet
|
||||
*/
|
||||
#[IsGranted('edit', 'timesheet')]
|
||||
#[OA\Patch(description: 'Update an existing timesheet record, you can pass all or just a subset of the attributes.', responses: [new OA\Response(response: 200, description: 'Returns the updated timesheet', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))])]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to update', required: true)]
|
||||
#[OA\Patch(description: 'Update timesheet, you can pass all or just a subset of the attributes.', responses: [new OA\Response(response: 200, description: 'Returns the updated timesheet', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))])]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet ID to update', required: true)]
|
||||
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEditForm'))]
|
||||
#[Route(methods: ['PATCH'], path: '/{id}', name: 'patch_timesheet', requirements: ['id' => '\d+'])]
|
||||
public function patchAction(Request $request, Timesheet $timesheet): Response
|
||||
@@ -381,11 +381,11 @@ final class TimesheetController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an existing timesheet record
|
||||
* Delete timesheet
|
||||
*/
|
||||
#[IsGranted('delete', 'timesheet')]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one timesheet record')])]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to delete', required: true)]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one timesheet')])]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet ID to delete', required: true)]
|
||||
#[Route(methods: ['DELETE'], path: '/{id}', name: 'delete_timesheet', requirements: ['id' => '\d+'])]
|
||||
public function deleteAction(Timesheet $timesheet): Response
|
||||
{
|
||||
@@ -397,10 +397,10 @@ final class TimesheetController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the collection of recent user activities
|
||||
* Fetch recent user activities
|
||||
*/
|
||||
#[IsGranted('view_own_timesheet')]
|
||||
#[OA\Response(response: 200, description: 'Returns the collection of recent user activities (always the latest entry of a unique working set grouped by customer, project and activity)', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TimesheetCollectionExpanded')))]
|
||||
#[OA\Response(response: 200, description: 'Returns a collection of recent user activities (always the latest entry of a unique working set grouped by customer, project and activity)', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TimesheetCollectionExpanded')))]
|
||||
#[Route(methods: ['GET'], path: '/recent', name: 'recent_timesheet')]
|
||||
#[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. Default: today - 1 year (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
|
||||
#[Rest\QueryParam(name: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries (default: 10)')]
|
||||
@@ -431,10 +431,10 @@ final class TimesheetController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the collection of active timesheet records
|
||||
* Fetch active timesheets
|
||||
*/
|
||||
#[IsGranted('view_own_timesheet')]
|
||||
#[OA\Response(response: 200, description: 'Returns the collection of active timesheet records for the current user', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TimesheetCollectionExpanded')))]
|
||||
#[OA\Response(response: 200, description: 'Returns a collection of active timesheets for the current user', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TimesheetCollectionExpanded')))]
|
||||
#[Route(methods: ['GET'], path: '/active', name: 'active_timesheet')]
|
||||
public function activeAction(): Response
|
||||
{
|
||||
@@ -450,16 +450,17 @@ final class TimesheetController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops an active timesheet record.
|
||||
* Stop active timesheet
|
||||
*
|
||||
* This route is available via GET and PATCH, as users over and over again run into errors when stopping.
|
||||
* Likely caused by a slow JS engine and a fast-click after page reload.
|
||||
*/
|
||||
#[IsGranted('stop', 'timesheet')]
|
||||
#[OA\Response(response: 200, description: 'Stops an active timesheet record and returns it afterwards.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to stop', required: true)]
|
||||
#[OA\Response(response: 200, description: 'Stops an active timesheet and returns it afterwards.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet ID to stop', required: true)]
|
||||
#[Route(methods: ['GET'], path: '/{id}/stop', name: 'stop_timesheet_get', requirements: ['id' => '\d+'])]
|
||||
#[Route(methods: ['PATCH'], path: '/{id}/stop', name: 'stop_timesheet', requirements: ['id' => '\d+'])]
|
||||
#[OA\Get(x: ['internal' => true])]
|
||||
public function stopAction(Timesheet $timesheet): Response
|
||||
{
|
||||
$this->service->stopTimesheet($timesheet);
|
||||
@@ -471,11 +472,14 @@ final class TimesheetController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Restarts a previously stopped timesheet record for the current user
|
||||
* Restart timesheet
|
||||
*
|
||||
* The restarted timesheet will be created for the current user
|
||||
*/
|
||||
#[IsGranted('start', 'timesheet')]
|
||||
#[OA\Response(response: 200, description: 'Restarts a timesheet record for the same customer, project, activity combination. The current user will be the owner of the new record. Kimai tries to stop running records, which is expected to fail depending on the configured rules. Data will be copied from the original record if requested.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to restart', required: true)]
|
||||
#[OA\Response(response: 200, description: 'Restart a timesheet for the same customer, project, activity combination. The current user will be the owner of the new record. Kimai tries to stop running records, which is expected to fail depending on the configured rules. Data will be copied from the original record if requested.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet ID to restart', required: true)]
|
||||
#[OA\Get(x: ['internal' => true])]
|
||||
#[Route(methods: ['GET'], path: '/{id}/restart', name: 'restart_timesheet_get', requirements: ['id' => '\d+'])]
|
||||
#[Route(methods: ['PATCH'], path: '/{id}/restart', name: 'restart_timesheet', requirements: ['id' => '\d+'])]
|
||||
#[Rest\RequestParam(name: 'copy', requirements: 'all', strict: true, nullable: true, description: 'Whether data should be copied to the new entry. Allowed values: all (default: nothing is copied)')]
|
||||
@@ -532,11 +536,11 @@ final class TimesheetController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicates an existing timesheet record
|
||||
* Duplicate timesheet
|
||||
*/
|
||||
#[IsGranted('duplicate', 'timesheet')]
|
||||
#[OA\Response(response: 200, description: 'Duplicates a timesheet record, resetting the export state only.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to duplicate', required: true)]
|
||||
#[OA\Response(response: 200, description: 'Duplicates a timesheet, resetting the export state only.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet ID to duplicate', required: true)]
|
||||
#[Route(methods: ['PATCH'], path: '/{id}/duplicate', name: 'duplicate_timesheet', requirements: ['id' => '\d+'])]
|
||||
public function duplicateAction(Timesheet $timesheet): Response
|
||||
{
|
||||
@@ -553,11 +557,11 @@ final class TimesheetController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the export state of a timesheet record to (un-)lock it
|
||||
* Toggle timesheet export state
|
||||
*/
|
||||
#[IsGranted('edit_export', 'timesheet')]
|
||||
#[OA\Response(response: 200, description: 'Switches the exported state on the record and therefor locks / unlocks it for further updates. Needs edit_export_*_timesheet permission.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to switch export state', required: true)]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet ID to switch export state', required: true)]
|
||||
#[Route(methods: ['PATCH'], path: '/{id}/export', name: 'export_timesheet', requirements: ['id' => '\d+'])]
|
||||
public function exportAction(Timesheet $timesheet): Response
|
||||
{
|
||||
@@ -576,11 +580,11 @@ final class TimesheetController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the value of a meta-field for an existing timesheet.
|
||||
* Update timesheet custom-field
|
||||
*/
|
||||
#[IsGranted('edit', 'timesheet')]
|
||||
#[OA\Response(response: 200, description: 'Sets the value of an existing/configured meta-field. You cannot create unknown meta-fields, if the given name is not a configured meta-field, this will return an exception.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to set the meta-field value for', required: true)]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet ID to set the meta-field value for', required: true)]
|
||||
#[Route(methods: ['PATCH'], path: '/{id}/meta', requirements: ['id' => '\d+'])]
|
||||
#[Rest\RequestParam(name: 'name', strict: true, nullable: false, description: 'The meta-field name')]
|
||||
#[Rest\RequestParam(name: 'value', strict: true, nullable: false, description: 'The meta-field value')]
|
||||
|
||||
@@ -47,10 +47,10 @@ final class UserController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the collection of users (which are visible to the user)
|
||||
* Fetch users
|
||||
*/
|
||||
#[IsGranted('view_user')]
|
||||
#[OA\Response(response: 200, description: 'Returns the collection of users. Required permission: view_user', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/UserCollection')))]
|
||||
#[OA\Response(response: 200, description: 'Returns a collection of users. Required permission: view_user', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/UserCollection')))]
|
||||
#[Route(methods: ['GET'], path: '', name: 'get_users')]
|
||||
#[Rest\QueryParam(name: 'visible', requirements: '1|2|3', default: 1, strict: true, nullable: true, description: 'Visibility status to filter users: 1=visible, 2=hidden, 3=all')]
|
||||
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|username|alias|email', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, username, alias, email (default: username)')]
|
||||
@@ -97,7 +97,7 @@ final class UserController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Return one user entity
|
||||
* Fetch user
|
||||
*/
|
||||
#[IsGranted('view', 'profile')]
|
||||
#[OA\Response(response: 200, description: 'Return one user entity.', content: new OA\JsonContent(ref: '#/components/schemas/UserEntity'))]
|
||||
@@ -116,7 +116,7 @@ final class UserController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current user entity
|
||||
* Fetch current user
|
||||
*/
|
||||
#[OA\Response(response: 200, description: 'Return the current user entity.', content: new OA\JsonContent(ref: '#/components/schemas/UserEntity'))]
|
||||
#[Route(methods: ['GET'], path: '/me', name: 'me_user')]
|
||||
@@ -129,7 +129,7 @@ final class UserController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new user
|
||||
* Create user
|
||||
*/
|
||||
#[IsGranted('create_user')]
|
||||
#[OA\Post(description: 'Creates a new user and returns it afterwards')]
|
||||
@@ -199,7 +199,9 @@ final class UserController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an API token for the current user
|
||||
* Delete API token
|
||||
*
|
||||
* This ONLY works if the given API token exists and belongs to the current user
|
||||
*/
|
||||
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Success if the token could be deleted.')])]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'The API token ID to remove', required: true)]
|
||||
|
||||
@@ -55,6 +55,18 @@ class ActivityService
|
||||
return $activity;
|
||||
}
|
||||
|
||||
public function saveActivity(Activity $activity): Activity
|
||||
{
|
||||
if ($activity->isNew()) {
|
||||
return $this->saveNewActivity($activity); // @phpstan-ignore method.deprecated
|
||||
} else {
|
||||
return $this->updateActivity($activity); // @phpstan-ignore method.deprecated
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since 2.35 - use saveActivity() instead
|
||||
*/
|
||||
public function saveNewActivity(Activity $activity): Activity
|
||||
{
|
||||
if (null !== $activity->getId()) {
|
||||
@@ -89,6 +101,9 @@ class ActivityService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since 2.35 - use saveActivity() instead
|
||||
*/
|
||||
public function updateActivity(Activity $activity): Activity
|
||||
{
|
||||
$this->validateActivity($activity);
|
||||
@@ -120,12 +135,23 @@ class ActivityService
|
||||
// we cannot use max(number) because a varchar column returns unexpected results
|
||||
$start = $this->repository->countActivity();
|
||||
$i = 0;
|
||||
$createDate = new \DateTimeImmutable();
|
||||
|
||||
do {
|
||||
$start++;
|
||||
|
||||
$numberGenerator = new NumberGenerator($format, function (string $originalFormat, string $format, int $increaseBy) use ($start): string|int {
|
||||
$numberGenerator = new NumberGenerator($format, function (string $originalFormat, string $format, int $increaseBy) use ($start, $createDate): string|int {
|
||||
return match ($format) {
|
||||
'Y' => $createDate->format('Y'),
|
||||
'y' => $createDate->format('y'),
|
||||
'M' => $createDate->format('m'),
|
||||
'm' => $createDate->format('n'),
|
||||
'D' => $createDate->format('d'),
|
||||
'd' => $createDate->format('j'),
|
||||
'YY' => (int) $createDate->format('Y') + $increaseBy,
|
||||
'yy' => (int) $createDate->format('y') + $increaseBy,
|
||||
'MM' => (int) $createDate->format('m') + $increaseBy,
|
||||
'DD' => (int) $createDate->format('d') + $increaseBy,
|
||||
'ac' => $start + $increaseBy,
|
||||
default => $originalFormat,
|
||||
};
|
||||
|
||||
@@ -14,9 +14,11 @@ use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Exception\LogicException;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\Filesystem\Exception\FileNotFoundException;
|
||||
use Symfony\Component\Process\PhpSubprocess;
|
||||
|
||||
/**
|
||||
* Extend this class if you have a plugin that requires installation steps.
|
||||
@@ -25,8 +27,6 @@ abstract class AbstractBundleInstallerCommand extends Command
|
||||
{
|
||||
/**
|
||||
* Returns the base directory to the Kimai installation.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getRootDirectory(): string
|
||||
{
|
||||
@@ -37,20 +37,27 @@ abstract class AbstractBundleInstallerCommand extends Command
|
||||
}
|
||||
|
||||
/**
|
||||
* If your bundle ships assets, that need to be available in the public/ directory,
|
||||
* then overwrite this method and return: <true>.
|
||||
*
|
||||
* @return bool
|
||||
* Return <true> if your bundle ships assets for the public/ directory.
|
||||
*/
|
||||
protected function hasAssets(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
private function hasMigrations(SymfonyStyle $io): bool
|
||||
{
|
||||
$config = $this->getMigrationConfigFilename();
|
||||
|
||||
if ($config === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an absolute filename to your doctrine migrations configuration, if you want to install database tables.
|
||||
*
|
||||
* @return string|null
|
||||
* Returns an absolute filename to your doctrine migrations configuration
|
||||
* if you want to run database migrations.
|
||||
*/
|
||||
protected function getMigrationConfigFilename(): ?string
|
||||
{
|
||||
@@ -59,16 +66,12 @@ abstract class AbstractBundleInstallerCommand extends Command
|
||||
|
||||
/**
|
||||
* Returns the bundle short name for the installer command.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
abstract protected function getBundleCommandNamePart(): string;
|
||||
|
||||
/**
|
||||
* Returns the full name fo this command.
|
||||
* Please stick to the standard and overwrite getBundleCommandNamePart() only.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getInstallerCommandName(): string
|
||||
{
|
||||
@@ -77,8 +80,6 @@ abstract class AbstractBundleInstallerCommand extends Command
|
||||
|
||||
/**
|
||||
* Returns the bundles real name (same as your namespace).
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getBundleName(): string
|
||||
{
|
||||
@@ -100,6 +101,8 @@ abstract class AbstractBundleInstallerCommand extends Command
|
||||
->setName($this->getInstallerCommandName())
|
||||
->setDescription('Install the bundle: ' . $this->getBundleName())
|
||||
->setHelp('This command will perform the basic installation steps to get the bundle up and running.')
|
||||
->addOption('database', null, InputOption::VALUE_NONE, 'Only run the database installation')
|
||||
->addOption('assets', null, InputOption::VALUE_NONE, 'Only run the asset installation')
|
||||
;
|
||||
}
|
||||
|
||||
@@ -107,6 +110,15 @@ abstract class AbstractBundleInstallerCommand extends Command
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$noAssets = $input->getOption('database');
|
||||
$onlyAssets = $input->getOption('assets');
|
||||
|
||||
if ($noAssets && $onlyAssets) {
|
||||
$io->error('Options --assets and --database are mutually exclusive');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// many users execute the bin/console command from arbitrary locations
|
||||
// this will make sure that relative paths (like doctrine migrations) work as expected
|
||||
$path = getcwd();
|
||||
@@ -117,17 +129,19 @@ abstract class AbstractBundleInstallerCommand extends Command
|
||||
\sprintf('Starting installation of plugin: %s ...', $bundleName)
|
||||
);
|
||||
|
||||
try {
|
||||
$this->importMigrations($io, $output);
|
||||
} catch (\Exception $ex) {
|
||||
$io->error(
|
||||
\sprintf('Failed to install database for bundle %s. %s', $bundleName, $ex->getMessage())
|
||||
);
|
||||
if (!$onlyAssets && $this->hasMigrations($io)) {
|
||||
try {
|
||||
$this->importMigrations($io, $output);
|
||||
} catch (\Exception $ex) {
|
||||
$io->error(
|
||||
\sprintf('Failed to install database for bundle %s. %s', $bundleName, $ex->getMessage())
|
||||
);
|
||||
|
||||
return Command::FAILURE;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->hasAssets()) {
|
||||
if (!$noAssets && $this->hasAssets()) {
|
||||
try {
|
||||
$this->installAssets($io, $output);
|
||||
} catch (\Exception $ex) {
|
||||
@@ -148,7 +162,7 @@ abstract class AbstractBundleInstallerCommand extends Command
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
protected function installAssets(SymfonyStyle $io, OutputInterface $output): void
|
||||
private function installAssets(SymfonyStyle $io, OutputInterface $output): void
|
||||
{
|
||||
$command = $this->getApplication()->find('assets:install');
|
||||
$cmdInput = new ArrayInput([]);
|
||||
@@ -160,11 +174,11 @@ abstract class AbstractBundleInstallerCommand extends Command
|
||||
$io->writeln('');
|
||||
}
|
||||
|
||||
protected function importMigrations(SymfonyStyle $io, OutputInterface $output): void
|
||||
private function importMigrations(SymfonyStyle $io, OutputInterface $output): void
|
||||
{
|
||||
$config = $this->getMigrationConfigFilename();
|
||||
|
||||
if (null === $config) {
|
||||
if ($config === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -175,13 +189,28 @@ abstract class AbstractBundleInstallerCommand extends Command
|
||||
// prevent windows from breaking
|
||||
$config = str_replace('/', DIRECTORY_SEPARATOR, $config);
|
||||
|
||||
$command = $this->getApplication()->find('doctrine:migrations:migrate');
|
||||
$cmdInput = new ArrayInput(['--allow-no-migration' => true, '--configuration' => $config]);
|
||||
$cmdInput->setInteractive(false);
|
||||
if (0 !== $command->run($cmdInput, $output)) {
|
||||
$process = new PhpSubprocess(
|
||||
[
|
||||
'bin/console',
|
||||
'doctrine:migrations:migrate',
|
||||
'--allow-no-migration',
|
||||
'--no-interaction',
|
||||
'--configuration=' . $config
|
||||
],
|
||||
$this->getRootDirectory()
|
||||
);
|
||||
$process->run();
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
$io->error('Failed to install bundle database: ' . PHP_EOL . $config);
|
||||
$io->error($process->getErrorOutput());
|
||||
throw new \Exception('Problem occurred while executing migrations.');
|
||||
}
|
||||
|
||||
if ($io->isVerbose()) {
|
||||
$io->write($process->getOutput());
|
||||
}
|
||||
|
||||
$io->writeln('');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ abstract class AbstractResetCommand extends Command
|
||||
}
|
||||
|
||||
try {
|
||||
$command = $this->getApplication()->find('doctrine:query:sql');
|
||||
$command = $this->getApplication()->find('dbal:run-sql');
|
||||
$command->run(new ArrayInput(['sql' => 'DROP TABLE IF EXISTS migration_versions']), $output);
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to drop migration_versions table: ' . $ex->getMessage());
|
||||
@@ -125,7 +125,7 @@ abstract class AbstractResetCommand extends Command
|
||||
}
|
||||
|
||||
try {
|
||||
$command = $this->getApplication()->find('doctrine:query:sql');
|
||||
$command = $this->getApplication()->find('dbal:run-sql');
|
||||
$command->run(new ArrayInput(['sql' => 'DROP TABLE IF EXISTS kimai2_sessions']), $output);
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to drop kimai2_sessions table: ' . $ex->getMessage());
|
||||
|
||||
@@ -25,7 +25,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
#[AsCommand(name: 'kimai:install', description: 'Kimai installation command', aliases: ['kimai:update'])]
|
||||
#[AsCommand(name: 'kimai:install', description: 'Install and update Kimai', aliases: ['kimai:update'])]
|
||||
final class InstallCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly Connection $connection)
|
||||
|
||||
@@ -26,7 +26,8 @@ final class PluginCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PluginManager $pluginManager,
|
||||
private readonly PackageManager $packageManager
|
||||
private readonly PackageManager $packageManager,
|
||||
private readonly string $projectDirectory
|
||||
)
|
||||
{
|
||||
parent::__construct();
|
||||
@@ -69,17 +70,16 @@ final class PluginCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
// using getApplication()->find('doctrine:migrations:migrate') does NOT work here
|
||||
// because the Doctrine command can only be executed once
|
||||
// if run more than once it fails with a "Container is frozen" exception
|
||||
|
||||
$process = new PhpSubprocess([
|
||||
'bin/console',
|
||||
'doctrine:migrations:migrate',
|
||||
'--allow-no-migration',
|
||||
'--no-interaction',
|
||||
'--configuration=' . $config
|
||||
]);
|
||||
$process = new PhpSubprocess(
|
||||
[
|
||||
'bin/console',
|
||||
'doctrine:migrations:migrate',
|
||||
'--allow-no-migration',
|
||||
'--no-interaction',
|
||||
'--configuration=' . $config
|
||||
],
|
||||
$this->projectDirectory
|
||||
);
|
||||
$process->run();
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
namespace App\Command;
|
||||
|
||||
use App\Configuration\LocaleService;
|
||||
use App\Utils\LocaleFormatter;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
@@ -39,19 +40,19 @@ final class RegenerateLocalesCommand extends Command
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
private array $noRegionCode = ['ar', 'id', 'pa', 'sl', 'ca'];
|
||||
private array $noRegionCode = ['ar', 'id', 'pa', 'sl', 'ca', 'ta'];
|
||||
/**
|
||||
* A list of locales that will be activated, no matter if translation files exist for them.
|
||||
* A list of locales that will be activated no matter if translation files exist for them.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
private array $addLocaleToList = ['zh_Hant_TW'];
|
||||
/**
|
||||
* A list of locales that will NOT be activated, as no translations exist by now.
|
||||
* A list of locales that will NOT be activated, as not enough translations exist by now.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
private array $skipLocale = ['ca'];
|
||||
private array $skipLocale = ['ca', 'et'];
|
||||
|
||||
public function __construct(
|
||||
private readonly string $projectDirectory,
|
||||
@@ -86,6 +87,7 @@ final class RegenerateLocalesCommand extends Command
|
||||
$firstLevelLocales[] = $l;
|
||||
}
|
||||
$firstLevelLocales = array_unique(array_merge($firstLevelLocales, $this->addLocaleToList));
|
||||
|
||||
$io->title('First level locales found');
|
||||
$io->writeln(implode('|', $firstLevelLocales));
|
||||
|
||||
@@ -121,14 +123,17 @@ final class RegenerateLocalesCommand extends Command
|
||||
$appLocales[$locale] = LocaleService::DEFAULT_SETTINGS;
|
||||
}
|
||||
|
||||
$timeFormats = [];
|
||||
$dateFormats = [];
|
||||
|
||||
// make sure all keys are registered for every locale
|
||||
foreach ($appLocales as $locale => $settings) {
|
||||
$settings['translation'] = \in_array($locale, $firstLevelLocales, true);
|
||||
|
||||
// these are completely new since v2
|
||||
// calculate everything with IntlFormatter
|
||||
$shortDate = new \IntlDateFormatter($locale, \IntlDateFormatter::SHORT, \IntlDateFormatter::NONE);
|
||||
$shortTime = new \IntlDateFormatter($locale, \IntlDateFormatter::NONE, \IntlDateFormatter::SHORT);
|
||||
$shortDate = new \IntlDateFormatter($locale, LocaleFormatter::DATE_PATTERN, \IntlDateFormatter::NONE);
|
||||
$shortTime = new \IntlDateFormatter($locale, \IntlDateFormatter::NONE, LocaleFormatter::TIME_PATTERN);
|
||||
|
||||
$settings['date'] = $shortDate->getPattern();
|
||||
if ($settings['date'] === false) {
|
||||
@@ -141,14 +146,29 @@ final class RegenerateLocalesCommand extends Command
|
||||
continue;
|
||||
}
|
||||
|
||||
// see https://github.com/kimai/kimai/issues/4402 - Korean time format failed parsing
|
||||
// special case when time pattern starts with A / a => this will lead to an error
|
||||
// CHINESE: contains the format character B - see https://github.com/kimai/kimai/issues/5496
|
||||
// It is an equivalent for "a" and acts like am/pm but will be prefixed instead of written after the time.
|
||||
// This clashes with PHP Date format "B" (Swatch Internet time) and fails in other places, so we convert it into 24-hour format.
|
||||
if (str_contains($settings['time'], 'Bh')) {
|
||||
$settings['time'] = str_replace('Bh', 'H', $settings['time']);
|
||||
}
|
||||
|
||||
// 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
|
||||
if (str_contains($settings['time'], 'a ')) {
|
||||
$settings['time'] = str_replace('a ', '', $settings['time']) . ' a';
|
||||
}
|
||||
$settings['time'] = str_replace("\u{202f}", ' ', $settings['time']);
|
||||
|
||||
// keep it simple, we don't need to convert it during runtime
|
||||
// ISO-8601 defines that 24-hour format should always use a leading zero: use HH instead of H
|
||||
$settings['time'] = str_replace('HH', 'H', $settings['time']);
|
||||
$settings['time'] = str_replace('H', 'HH', $settings['time']);
|
||||
|
||||
// format the year always with 4 letters - ISO-8601
|
||||
$settings['date'] = str_replace('yy', 'y', $settings['date']);
|
||||
|
||||
// make sure that sub-locales of a RTL language are also flagged as RTL
|
||||
$rtlLocale = $locale;
|
||||
if (substr_count($rtlLocale, '_') === 1) {
|
||||
@@ -159,6 +179,9 @@ final class RegenerateLocalesCommand extends Command
|
||||
|
||||
// pre-fill all formats with the default locale settings
|
||||
$appLocales[$locale] = $settings;
|
||||
|
||||
$timeFormats[$settings['time']] = $settings['time'];
|
||||
$dateFormats[$settings['date']] = $settings['date'];
|
||||
}
|
||||
|
||||
$removableDuplicates = [];
|
||||
@@ -198,8 +221,23 @@ final class RegenerateLocalesCommand extends Command
|
||||
|
||||
// in the future this list should be reduced to the list of available translations, but for a long time users
|
||||
// could choose from the entire list of all locales, so we likely have to keep that forever ...
|
||||
$io->title('List of "kimai_locales" for services.yaml');
|
||||
$io->writeln("['" . implode("', '", $locales) . "']");
|
||||
$listOfLocales = array_map(fn ($locale) => "'$locale'", $locales);
|
||||
$filename = 'config/services.yaml';
|
||||
$targetFile = $this->projectDirectory . DIRECTORY_SEPARATOR . $filename;
|
||||
$content = file_get_contents($targetFile);
|
||||
if ($content === false) {
|
||||
$io->error('Failed reading configuration file at ' . $filename);
|
||||
} else {
|
||||
$content = preg_replace(
|
||||
'/^(\s*kimai_locales:\s*\[).*?(\])$/m',
|
||||
'${1}' . implode(', ', $listOfLocales) . '${2}',
|
||||
$content
|
||||
);
|
||||
|
||||
file_put_contents($targetFile, $content);
|
||||
|
||||
$io->success('Replaced locale definitions in: ' . $filename);
|
||||
}
|
||||
|
||||
ksort($appLocales);
|
||||
|
||||
@@ -214,6 +252,12 @@ final class RegenerateLocalesCommand extends Command
|
||||
|
||||
$io->success('Created new locale definition at: ' . $filename);
|
||||
|
||||
$io->writeln(\sprintf('Found %s date formats:', \count($dateFormats)));
|
||||
$io->listing(array_keys($dateFormats));
|
||||
|
||||
$io->writeln(\sprintf('Found %s time formats:', \count($timeFormats)));
|
||||
$io->listing(array_keys($timeFormats));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,10 +22,13 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
#[AsCommand(name: 'kimai:reload')]
|
||||
#[AsCommand(name: 'kimai:reload', description: 'Reload Kimai caches')]
|
||||
final class ReloadCommand extends Command
|
||||
{
|
||||
public function __construct(private string $projectDirectory, private string $kernelEnvironment)
|
||||
public function __construct(
|
||||
private readonly string $projectDirectory,
|
||||
private readonly string $kernelEnvironment
|
||||
)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -42,10 +45,7 @@ final class ReloadCommand extends Command
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setDescription('Reload Kimai caches')
|
||||
->setHelp('This command will validate the configurations and translations and then clear and rebuild the application cache.')
|
||||
;
|
||||
$this->setHelp('This will validate configurations and translations and then clear and rebuild the application cache.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
namespace App\Command;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\Process\PhpSubprocess;
|
||||
|
||||
/**
|
||||
* Command used to execute all the basic application bootstrapping AFTER "composer install" was executed.
|
||||
@@ -24,16 +25,36 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
#[AsCommand(name: 'kimai:reset:dev', description: 'Resets the "development" environment')]
|
||||
final class ResetDevelopmentCommand extends AbstractResetCommand
|
||||
{
|
||||
public function __construct(string $kernelEnvironment)
|
||||
public function __construct(string $kernelEnvironment, private readonly string $projectDirectory)
|
||||
{
|
||||
parent::__construct($kernelEnvironment);
|
||||
}
|
||||
|
||||
protected function loadData(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$command = $this->getApplication()->find('doctrine:fixtures:load');
|
||||
$cmdInput = new ArrayInput([]);
|
||||
$cmdInput->setInteractive(false);
|
||||
$command->run($cmdInput, $output);
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$io->writeln('Importing fixtures, this will take a while ... please be patient ...');
|
||||
|
||||
$process = new PhpSubprocess(
|
||||
[
|
||||
'bin/console',
|
||||
'doctrine:fixtures:load',
|
||||
'--no-interaction',
|
||||
],
|
||||
$this->projectDirectory
|
||||
);
|
||||
$process->run();
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
$io->error('Failed to load fixtures');
|
||||
$io->error($process->getErrorOutput());
|
||||
} else {
|
||||
if ($io->isVerbose()) {
|
||||
$io->write($process->getOutput());
|
||||
} else {
|
||||
$io->success('Fixtures loaded');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,10 +454,12 @@ final class TranslationCommand extends Command
|
||||
|
||||
foreach ($xml->file->body->{'trans-unit'} as $unit) {
|
||||
$source = $unit->source;
|
||||
if (!isset($unit['resname'])) {
|
||||
if (!isset($unit['resname']) && $source !== null) {
|
||||
$unit['resname'] = $source;
|
||||
}
|
||||
$unit['id'] = $this->generateId($unit['resname']);
|
||||
if ($unit['resname'] !== null) {
|
||||
$unit['id'] = $this->generateId($unit['resname']); // @phpstan-ignore offsetAssign.valueType
|
||||
}
|
||||
}
|
||||
|
||||
$xmlDocument = new \DOMDocument('1.0');
|
||||
@@ -491,7 +493,7 @@ final class TranslationCommand extends Command
|
||||
);
|
||||
}
|
||||
$unit->target[0] = $translations[$key];
|
||||
$unit->target['state'] = 'needs-translation';
|
||||
$unit->target['state'] = 'needs-translation'; // @phpstan-ignore assign.propertyType
|
||||
$foundEmpty = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,12 +10,13 @@
|
||||
namespace App\Configuration;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Utils\Duration;
|
||||
|
||||
final class LocaleService
|
||||
{
|
||||
public const DEFAULT_SETTINGS = [
|
||||
'date' => 'dd.MM.y',
|
||||
'time' => 'HH:mm',
|
||||
'time' => 'H:mm',
|
||||
'rtl' => false,
|
||||
'translation' => false,
|
||||
];
|
||||
@@ -55,10 +56,7 @@ final class LocaleService
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the locale specific date format, which should be used in combination with the twig filter "|date".
|
||||
*
|
||||
* @param string $locale
|
||||
* @return string
|
||||
* Returns the locale-specific date format, which should be used in combination with the twig filter "|date".
|
||||
*/
|
||||
public function getDateFormat(string $locale): string
|
||||
{
|
||||
@@ -66,10 +64,7 @@ final class LocaleService
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the locale specific time format, which should be used in combination with the twig filter "|time".
|
||||
*
|
||||
* @param string $locale
|
||||
* @return string
|
||||
* Returns the locale-specific time format, which should be used in combination with the twig filter "|time".
|
||||
*/
|
||||
public function getTimeFormat(string $locale): string
|
||||
{
|
||||
@@ -77,10 +72,7 @@ final class LocaleService
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the locale specific datetime format, which should be used in combination with the twig filter "|date".
|
||||
*
|
||||
* @param string $locale
|
||||
* @return string
|
||||
* Returns the locale-specific datetime format, which should be used in combination with the twig filter "|date".
|
||||
*/
|
||||
public function getDateTimeFormat(string $locale): string
|
||||
{
|
||||
@@ -89,13 +81,10 @@ final class LocaleService
|
||||
|
||||
/**
|
||||
* Returns the format used in the "|duration" twig filter to display a Timesheet duration.
|
||||
*
|
||||
* @param string $locale
|
||||
* @return string
|
||||
*/
|
||||
public function getDurationFormat(string $locale): string
|
||||
{
|
||||
return '%h:%m';
|
||||
return Duration::FORMAT_DEFAULT;
|
||||
}
|
||||
|
||||
public function isRightToLeft(string $locale): bool
|
||||
|
||||
@@ -483,25 +483,17 @@ final class SystemConfiguration
|
||||
return (bool) $this->find('theme.avatar_url');
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal will be made private soon after 2.18.0 - do not access this method directly, but through getThemeColors()
|
||||
*/
|
||||
public function getThemeColorChoices(): string
|
||||
{
|
||||
$config = $this->find('theme.color_choices');
|
||||
if (\is_string($config) && $config !== '') {
|
||||
return $config;
|
||||
}
|
||||
|
||||
return 'Silver|#c0c0c0';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getThemeColors(): array
|
||||
{
|
||||
$config = explode(',', $this->getThemeColorChoices());
|
||||
$config = $this->find('theme.color_choices');
|
||||
if (!\is_string($config) || $config === '') {
|
||||
return ['Silver' => '#c0c0c0'];
|
||||
}
|
||||
|
||||
$config = explode(',', $config);
|
||||
|
||||
$colors = [];
|
||||
foreach ($config as $item) {
|
||||
|
||||
@@ -17,11 +17,11 @@ final class Constants
|
||||
/**
|
||||
* The current release version
|
||||
*/
|
||||
public const VERSION = '2.31.0';
|
||||
public const VERSION = '2.35.1';
|
||||
/**
|
||||
* The current release: major * 10000 + minor * 100 + patch
|
||||
*/
|
||||
public const VERSION_ID = 23100;
|
||||
public const VERSION_ID = 23501;
|
||||
/**
|
||||
* The software name
|
||||
*/
|
||||
|
||||
@@ -271,7 +271,7 @@ final class ActivityController extends AbstractController
|
||||
|
||||
if ($editForm->isSubmitted() && $editForm->isValid()) {
|
||||
try {
|
||||
$this->activityService->saveNewActivity($activity);
|
||||
$this->activityService->saveActivity($activity);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRouteAfterCreate('activity_details', ['id' => $activity->getId()]);
|
||||
@@ -300,7 +300,7 @@ final class ActivityController extends AbstractController
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
try {
|
||||
$this->activityService->updateActivity($activity);
|
||||
$this->activityService->saveActivity($activity);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
if ($this->isGranted('view', $activity)) {
|
||||
@@ -355,7 +355,7 @@ final class ActivityController extends AbstractController
|
||||
|
||||
if ($editForm->isSubmitted() && $editForm->isValid()) {
|
||||
try {
|
||||
$this->activityService->updateActivity($activity);
|
||||
$this->activityService->saveActivity($activity);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
if ($this->isGranted('view', $activity)) {
|
||||
|
||||
@@ -63,6 +63,8 @@ final class ContractController extends AbstractController
|
||||
|
||||
/** @var \DateTime $yearDate */
|
||||
$yearDate = $values->getDate();
|
||||
// make sure we use the correct datetime for the selected user
|
||||
$yearDate = $this->getDateTimeFactory($profile)->createStartOfYear($yearDate);
|
||||
$year = $workingTimeService->getYear($profile, $yearDate, $now);
|
||||
|
||||
$page = new PageSetup('work_times');
|
||||
|
||||
@@ -32,6 +32,8 @@ final class HelpController extends AbstractController
|
||||
$table = new DataTable('help_locales', new BaseQuery());
|
||||
$table->addColumn('name', ['class' => 'alwaysVisible', 'orderBy' => false]);
|
||||
$table->addColumn('description', ['class' => 'd-none', 'orderBy' => false]);
|
||||
$table->addColumn('date_format', ['class' => 'd-none w-min', 'orderBy' => false, 'title' => 'Date format']);
|
||||
$table->addColumn('time_format', ['class' => 'd-none w-min', 'orderBy' => false, 'title' => 'Time format']);
|
||||
$table->addColumn('language', ['class' => 'd-none w-min', 'orderBy' => false]);
|
||||
$table->addColumn('date', ['class' => 'alwaysVisible w-min', 'orderBy' => false]);
|
||||
$table->addColumn('time', ['class' => 'alwaysVisible w-min text-center', 'orderBy' => false]);
|
||||
@@ -73,6 +75,8 @@ final class HelpController extends AbstractController
|
||||
'language' => $locale,
|
||||
'name' => Locales::getName($locale, $locale),
|
||||
'description' => Locales::getName($locale, $requestLocale),
|
||||
'time_format' => $service->getTimeFormat($locale),
|
||||
'date_format' => $service->getDateFormat($locale),
|
||||
'date' => $formatter->dateShort($now),
|
||||
'time' => $formatter->time($now),
|
||||
'duration' => $formatter->duration(46120),
|
||||
|
||||
@@ -60,10 +60,10 @@ use Twig\Environment;
|
||||
final class InvoiceController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private ServiceInvoice $service,
|
||||
private InvoiceTemplateRepository $templateRepository,
|
||||
private InvoiceRepository $invoiceRepository,
|
||||
private EventDispatcherInterface $dispatcher
|
||||
private readonly ServiceInvoice $service,
|
||||
private readonly InvoiceTemplateRepository $templateRepository,
|
||||
private readonly InvoiceRepository $invoiceRepository,
|
||||
private readonly EventDispatcherInterface $dispatcher
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -164,6 +164,7 @@ final class InvoiceController extends AbstractController
|
||||
try {
|
||||
$query->setCustomers([$customer]);
|
||||
$model = $this->service->createModel($query);
|
||||
$model->setPreview(true);
|
||||
|
||||
return $this->service->renderInvoice($model, $this->dispatcher, true);
|
||||
} catch (Exception $ex) {
|
||||
@@ -332,7 +333,7 @@ final class InvoiceController extends AbstractController
|
||||
{
|
||||
$invoice = null;
|
||||
|
||||
if (null !== ($id = $request->get('id'))) {
|
||||
if (null !== ($id = $request->query->get('id'))) {
|
||||
$invoice = $this->invoiceRepository->find($id);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,17 +28,31 @@ final class PluginController extends AbstractController
|
||||
$installed = [];
|
||||
$plugins = $manager->getPlugins();
|
||||
foreach ($plugins as $plugin) {
|
||||
$installed[] = $plugin->getId();
|
||||
$installed[$plugin->getId()] = $plugin;
|
||||
}
|
||||
|
||||
$page = new PageSetup('menu.plugin');
|
||||
$page->setHelp('plugins.html');
|
||||
|
||||
$all = $this->getPluginInformation($client, $cache);
|
||||
$bundles = [];
|
||||
$updates = [];
|
||||
foreach ($all as $item) {
|
||||
if ($item['bundle'] !== null) {
|
||||
$bundles[$item['bundle']] = $item;
|
||||
if (\array_key_exists($item['bundle'], $installed)) {
|
||||
$updates[$item['bundle']] = version_compare($installed[$item['bundle']]->getMetadata()->getVersion(), $item['latest_release']) === -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('plugin/index.html.twig', [
|
||||
'page_setup' => $page,
|
||||
'plugins' => $plugins,
|
||||
'installed' => $installed,
|
||||
'extensions' => $this->getPluginInformation($client, $cache)
|
||||
'installed' => array_keys($installed),
|
||||
'extensions' => $all,
|
||||
'bundles' => $bundles,
|
||||
'updates' => $updates,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@ final class ProjectController extends AbstractController
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
try {
|
||||
$this->projectService->updateProject($project);
|
||||
$this->projectService->saveProject($project);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
if ($this->isGranted('view', $project)) {
|
||||
@@ -197,7 +197,7 @@ final class ProjectController extends AbstractController
|
||||
|
||||
if ($editForm->isSubmitted() && $editForm->isValid()) {
|
||||
try {
|
||||
$this->projectService->saveNewProject($project, new Context($this->getUser()));
|
||||
$this->projectService->saveProject($project, new Context($this->getUser()));
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRouteAfterCreate('project_details', ['id' => $project->getId()]);
|
||||
@@ -454,7 +454,7 @@ final class ProjectController extends AbstractController
|
||||
|
||||
if ($editForm->isSubmitted() && $editForm->isValid()) {
|
||||
try {
|
||||
$this->projectService->updateProject($project);
|
||||
$this->projectService->saveProject($project);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
if ($this->isGranted('view', $project)) {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Event\QuickEntryMetaDisplayEvent;
|
||||
use App\Form\QuickEntryForm;
|
||||
use App\Form\WeekByUserForm;
|
||||
use App\Model\QuickEntryWeek;
|
||||
@@ -19,6 +20,7 @@ use App\Repository\TimesheetRepository;
|
||||
use App\Timesheet\FavoriteRecordService;
|
||||
use App\Timesheet\TimesheetService;
|
||||
use App\Utils\PageSetup;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
@@ -34,7 +36,8 @@ final class QuickEntryController extends AbstractController
|
||||
private readonly SystemConfiguration $configuration,
|
||||
private readonly TimesheetService $timesheetService,
|
||||
private readonly TimesheetRepository $repository,
|
||||
private readonly FavoriteRecordService $favoriteRecordService
|
||||
private readonly FavoriteRecordService $favoriteRecordService,
|
||||
private readonly EventDispatcherInterface $dispatcher,
|
||||
)
|
||||
{
|
||||
}
|
||||
@@ -149,6 +152,11 @@ final class QuickEntryController extends AbstractController
|
||||
$defaultHour = (int) $defaultBegin->format('H');
|
||||
$defaultMinute = (int) $defaultBegin->format('i');
|
||||
|
||||
// find additional meta-fields exclusively for QuickEntry view
|
||||
$event = new QuickEntryMetaDisplayEvent($query);
|
||||
$this->dispatcher->dispatch($event);
|
||||
$metaFields = $event->getFields();
|
||||
|
||||
$formModel = new QuickEntryWeek($startWeek);
|
||||
|
||||
foreach ($rows as $id => $row) {
|
||||
@@ -168,10 +176,12 @@ final class QuickEntryController extends AbstractController
|
||||
$model->addTimesheet($day['entry']);
|
||||
}
|
||||
}
|
||||
$model->setMetaFields($metaFields);
|
||||
}
|
||||
|
||||
// create prototype model
|
||||
$empty = $formModel->createRow($user);
|
||||
$empty->setMetaFields($metaFields);
|
||||
$empty->markAsPrototype();
|
||||
foreach ($week as $dayId => $day) {
|
||||
$tmp = $this->timesheetService->createNewTimesheet($user);
|
||||
@@ -219,7 +229,11 @@ final class QuickEntryController extends AbstractController
|
||||
foreach ($tmpModel->getTimesheets() as $timesheet) {
|
||||
if ($timesheet->getId() !== null) {
|
||||
$duration = $timesheet->getDuration(false);
|
||||
if ($duration === null || $timesheet->isRunning()) {
|
||||
// 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) {
|
||||
$deleteTimesheets[] = $timesheet;
|
||||
} else {
|
||||
$saveTimesheets[] = $timesheet;
|
||||
@@ -263,6 +277,7 @@ final class QuickEntryController extends AbstractController
|
||||
'page_setup' => $page,
|
||||
'days' => $week,
|
||||
'form' => $form->createView(),
|
||||
'metaColumns' => $metaFields,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,9 +147,7 @@ abstract class TimesheetAbstractController extends AbstractController
|
||||
$event = new TimesheetMetaDefinitionEvent($entry);
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
$page = $request->get('page');
|
||||
$page = is_numeric($page) ? (int) $page : 1;
|
||||
$editForm = $this->getEditForm($entry, $page);
|
||||
$editForm = $this->getEditForm($entry);
|
||||
$editForm->handleRequest($request);
|
||||
|
||||
if ($editForm->isSubmitted() && $editForm->isValid()) {
|
||||
@@ -157,7 +155,7 @@ abstract class TimesheetAbstractController extends AbstractController
|
||||
$this->service->updateTimesheet($entry);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute($this->getTimesheetRoute(), ['page' => $request->get('page', 1)]);
|
||||
return $this->redirectToRoute($this->getTimesheetRoute());
|
||||
} catch (\Exception $ex) {
|
||||
$this->flashUpdateException($ex);
|
||||
}
|
||||
@@ -494,14 +492,13 @@ abstract class TimesheetAbstractController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
private function getEditForm(Timesheet $entry, int $page): FormInterface
|
||||
private function getEditForm(Timesheet $entry): FormInterface
|
||||
{
|
||||
$mode = $this->getTrackingMode();
|
||||
|
||||
return $this->createForm($this->getEditFormClassName(), $entry, [
|
||||
'action' => $this->generateUrl($this->getEditRoute(), [
|
||||
'id' => $entry->getId(),
|
||||
'page' => $page,
|
||||
]),
|
||||
'include_rate' => $this->isGranted('edit_rate', $entry),
|
||||
'include_exported' => $this->isGranted('edit_export', $entry),
|
||||
|
||||
@@ -59,6 +59,18 @@ final class CustomerService
|
||||
return $customer;
|
||||
}
|
||||
|
||||
public function saveCustomer(Customer $customer): Customer
|
||||
{
|
||||
if ($customer->isNew()) {
|
||||
return $this->saveNewCustomer($customer); // @phpstan-ignore method.deprecated
|
||||
} else {
|
||||
return $this->updateCustomer($customer); // @phpstan-ignore method.deprecated
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since 2.35 - use saveCustomer() instead
|
||||
*/
|
||||
public function saveNewCustomer(Customer $customer): Customer
|
||||
{
|
||||
if (null !== $customer->getId()) {
|
||||
@@ -93,6 +105,9 @@ final class CustomerService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since 2.35 - use saveCustomer() instead
|
||||
*/
|
||||
public function updateCustomer(Customer $customer): Customer
|
||||
{
|
||||
$this->validateCustomer($customer);
|
||||
@@ -137,12 +152,23 @@ final class CustomerService
|
||||
// we cannot use max(number) because a varchar column returns unexpected results
|
||||
$start = $this->repository->countCustomer();
|
||||
$i = 0;
|
||||
$createDate = new \DateTimeImmutable();
|
||||
|
||||
do {
|
||||
$start++;
|
||||
|
||||
$numberGenerator = new NumberGenerator($format, function (string $originalFormat, string $format, int $increaseBy) use ($start): string|int {
|
||||
$numberGenerator = new NumberGenerator($format, function (string $originalFormat, string $format, int $increaseBy) use ($start, $createDate): string|int {
|
||||
return match ($format) {
|
||||
'Y' => $createDate->format('Y'),
|
||||
'y' => $createDate->format('y'),
|
||||
'M' => $createDate->format('m'),
|
||||
'm' => $createDate->format('n'),
|
||||
'D' => $createDate->format('d'),
|
||||
'd' => $createDate->format('j'),
|
||||
'YY' => (int) $createDate->format('Y') + $increaseBy,
|
||||
'yy' => (int) $createDate->format('y') + $increaseBy,
|
||||
'MM' => (int) $createDate->format('m') + $increaseBy,
|
||||
'DD' => (int) $createDate->format('d') + $increaseBy,
|
||||
'cc' => $start + $increaseBy,
|
||||
default => $originalFormat,
|
||||
};
|
||||
|
||||
@@ -9,9 +9,6 @@
|
||||
|
||||
namespace App\Doctrine\Behavior;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
interface CreatedAt
|
||||
{
|
||||
public function getCreatedAt(): ?\DateTimeImmutable;
|
||||
|
||||
@@ -25,4 +25,9 @@ trait ModifiedTrait
|
||||
{
|
||||
$this->modifiedAt = $dateTime;
|
||||
}
|
||||
|
||||
public function markAsModified(): void
|
||||
{
|
||||
$this->setModifiedAt(new \DateTimeImmutable('now', new \DateTimeZone('UTC')));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\AccessTokenRepository;
|
||||
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;
|
||||
@@ -21,7 +22,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[UniqueEntity(fields: ['token'])]
|
||||
class AccessToken
|
||||
{
|
||||
#[ORM\Column(name: 'id', type: 'integer')]
|
||||
#[ORM\Column(name: 'id', type: Types::INTEGER)]
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
|
||||
private ?int $id = null;
|
||||
@@ -29,17 +30,17 @@ class AccessToken
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
private User $user;
|
||||
#[ORM\Column(name: 'token', type: 'string', length: 100, nullable: false)]
|
||||
#[ORM\Column(name: 'token', type: Types::STRING, length: 100, nullable: false)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(min: 2, max: 100)]
|
||||
private string $token;
|
||||
#[ORM\Column(name: 'name', type: 'string', length: 50, nullable: false)]
|
||||
#[ORM\Column(name: 'name', type: Types::STRING, length: 50, nullable: false)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(min: 2, max: 50)]
|
||||
private ?string $name = null;
|
||||
#[ORM\Column(name: 'last_usage', type: 'datetime_immutable', nullable: true)]
|
||||
#[ORM\Column(name: 'last_usage', type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $lastUsage = null;
|
||||
#[ORM\Column(name: 'expires_at', type: 'datetime_immutable', nullable: true)]
|
||||
#[ORM\Column(name: 'expires_at', type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $expiresAt = null;
|
||||
|
||||
public function __construct(User $user, string $token)
|
||||
|
||||
@@ -16,6 +16,7 @@ use App\Repository\ActivityRepository;
|
||||
use App\Validator\Constraints as Constraints;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
use OpenApi\Attributes as OA;
|
||||
@@ -42,7 +43,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
/**
|
||||
* Unique activity ID
|
||||
*/
|
||||
#[ORM\Column(name: 'id', type: 'integer')]
|
||||
#[ORM\Column(name: 'id', type: Types::INTEGER)]
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
|
||||
#[Serializer\Expose]
|
||||
@@ -58,7 +59,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
/**
|
||||
* Name of this activity
|
||||
*/
|
||||
#[ORM\Column(name: 'name', type: 'string', length: 150, nullable: false)]
|
||||
#[ORM\Column(name: 'name', type: Types::STRING, length: 150, nullable: false)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(min: 2, max: 150)]
|
||||
#[Serializer\Expose]
|
||||
@@ -68,7 +69,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
/**
|
||||
* Description of this activity
|
||||
*/
|
||||
#[ORM\Column(name: 'comment', type: 'text', nullable: true)]
|
||||
#[ORM\Column(name: 'comment', type: Types::TEXT, nullable: true)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'comment')]
|
||||
@@ -76,13 +77,13 @@ class Activity implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
/**
|
||||
* Whether this activity is visible and can be selected
|
||||
*/
|
||||
#[ORM\Column(name: 'visible', type: 'boolean', nullable: false, options: ['default' => true])]
|
||||
#[ORM\Column(name: 'visible', type: Types::BOOLEAN, nullable: false, options: ['default' => true])]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'visible', type: 'boolean')]
|
||||
private bool $visible = true;
|
||||
#[ORM\Column(name: 'billable', type: 'boolean', nullable: false, options: ['default' => true])]
|
||||
#[ORM\Column(name: 'billable', type: Types::BOOLEAN, nullable: false, options: ['default' => true])]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
@@ -113,9 +114,9 @@ class Activity implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
#[Serializer\Groups(['Activity'])]
|
||||
#[OA\Property(type: 'array', items: new OA\Items(ref: '#/components/schemas/Team'))]
|
||||
private Collection $teams;
|
||||
#[ORM\Column(name: 'invoice_text', type: 'text', nullable: true)]
|
||||
#[ORM\Column(name: 'invoice_text', type: Types::TEXT, nullable: true)]
|
||||
private ?string $invoiceText = null;
|
||||
#[ORM\Column(name: 'number', type: 'string', length: 10, nullable: true)]
|
||||
#[ORM\Column(name: 'number', type: Types::STRING, length: 10, nullable: true)]
|
||||
#[Assert\Length(max: 10)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
@@ -134,6 +135,11 @@ class Activity implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function isNew(): bool
|
||||
{
|
||||
return $this->id === null;
|
||||
}
|
||||
|
||||
public function getProject(): ?Project
|
||||
{
|
||||
return $this->project;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\BookmarkRepository;
|
||||
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;
|
||||
@@ -25,7 +26,7 @@ class Bookmark
|
||||
public const COLUMN_VISIBILITY = 'columns';
|
||||
public const TIMESHEET = 'timesheet';
|
||||
|
||||
#[ORM\Column(name: 'id', type: 'integer')]
|
||||
#[ORM\Column(name: 'id', type: Types::INTEGER)]
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
|
||||
private ?int $id = null;
|
||||
@@ -33,15 +34,15 @@ class Bookmark
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
private ?User $user = null;
|
||||
#[ORM\Column(name: 'type', type: 'string', length: 20, nullable: false)]
|
||||
#[ORM\Column(name: 'type', type: Types::STRING, length: 20, nullable: false)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(min: 2, max: 20)]
|
||||
private ?string $type = null;
|
||||
#[ORM\Column(name: 'name', type: 'string', length: 50, nullable: false)]
|
||||
#[ORM\Column(name: 'name', type: Types::STRING, length: 50, nullable: false)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(min: 2, max: 50)]
|
||||
private ?string $name = null;
|
||||
#[ORM\Column(name: 'content', type: 'text', nullable: false)]
|
||||
#[ORM\Column(name: 'content', type: Types::TEXT, nullable: false)]
|
||||
private ?string $content = null;
|
||||
|
||||
public function getId(): ?int
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Export\Annotation as Exporter;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
@@ -19,7 +20,7 @@ trait BudgetTrait
|
||||
/**
|
||||
* The total monetary budget, will be zero if not configured.
|
||||
*/
|
||||
#[ORM\Column(name: 'budget', type: 'float', nullable: false)]
|
||||
#[ORM\Column(name: 'budget', type: Types::FLOAT, nullable: false)]
|
||||
#[Assert\Range(min: 0.00, max: 900000000000.00)]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
@@ -29,7 +30,7 @@ trait BudgetTrait
|
||||
/**
|
||||
* The time budget in seconds, will be zero if not configured.
|
||||
*/
|
||||
#[ORM\Column(name: 'time_budget', type: 'integer', nullable: false)]
|
||||
#[ORM\Column(name: 'time_budget', type: Types::INTEGER, nullable: false)]
|
||||
#[Assert\Range(min: 0, max: 2145600000)]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
@@ -41,7 +42,7 @@ trait BudgetTrait
|
||||
* - null = default / full time
|
||||
* - month = monthly budget
|
||||
*/
|
||||
#[ORM\Column(name: 'budget_type', type: 'string', length: 10, nullable: true)]
|
||||
#[ORM\Column(name: 'budget_type', type: Types::STRING, length: 10, nullable: true)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Activity_Entity', 'Project_Entity', 'Customer_Entity'])]
|
||||
#[Exporter\Expose(label: 'budgetType')]
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace App\Entity;
|
||||
use App\Constants;
|
||||
use App\Export\Annotation as Exporter;
|
||||
use App\Validator\Constraints as Constraints;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
|
||||
@@ -20,7 +21,7 @@ trait ColorTrait
|
||||
/**
|
||||
* The assigned color in HTML hex format, e.g. #dd1d00
|
||||
*/
|
||||
#[ORM\Column(name: 'color', type: 'string', length: 7, nullable: true)]
|
||||
#[ORM\Column(name: 'color', type: Types::STRING, length: 7, nullable: true)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'color')]
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
@@ -16,19 +17,19 @@ trait CommentTableTypeTrait
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(name: 'id', type: 'integer')]
|
||||
#[ORM\Column(name: 'id', type: Types::INTEGER)]
|
||||
private ?int $id = null;
|
||||
#[ORM\Column(name: 'message', type: 'text', nullable: false)]
|
||||
#[ORM\Column(name: 'message', type: Types::TEXT, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
private ?string $message = null;
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
private ?User $createdBy = null;
|
||||
#[ORM\Column(name: 'created_at', type: 'datetime', nullable: false)]
|
||||
#[ORM\Column(name: 'created_at', type: Types::DATETIME_MUTABLE, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
private ?\DateTime $createdAt = null;
|
||||
#[ORM\Column(name: 'pinned', type: 'boolean', nullable: false, options: ['default' => false])]
|
||||
#[ORM\Column(name: 'pinned', type: Types::BOOLEAN, nullable: false, options: ['default' => false])]
|
||||
#[Assert\NotNull]
|
||||
private bool $pinned = false;
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\ConfigurationRepository;
|
||||
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;
|
||||
@@ -23,13 +24,13 @@ class Configuration
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(name: 'id', type: 'integer')]
|
||||
#[ORM\Column(name: 'id', type: Types::INTEGER)]
|
||||
private ?int $id = null;
|
||||
#[ORM\Column(name: 'name', type: 'string', length: 100, nullable: false)]
|
||||
#[ORM\Column(name: 'name', type: Types::STRING, length: 100, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
#[Assert\Length(min: 2, max: 100)]
|
||||
private ?string $name = null;
|
||||
#[ORM\Column(name: 'value', type: 'text', length: 65535, nullable: true)]
|
||||
#[ORM\Column(name: 'value', type: Types::TEXT, length: 65535, nullable: true)]
|
||||
#[Assert\Length(max: 65535)]
|
||||
private ?string $value = null;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ use App\Repository\CustomerRepository;
|
||||
use App\Validator\Constraints as Constraints;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
use OpenApi\Attributes as OA;
|
||||
@@ -36,50 +37,50 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
use ColorTrait;
|
||||
use CreatedTrait;
|
||||
|
||||
#[ORM\Column(name: 'id', type: 'integer')]
|
||||
#[ORM\Column(name: 'id', type: Types::INTEGER)]
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'id', type: 'integer')]
|
||||
private ?int $id = null;
|
||||
#[ORM\Column(name: 'name', type: 'string', length: 150, nullable: false)]
|
||||
#[ORM\Column(name: 'name', type: Types::STRING, length: 150, nullable: false)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(min: 2, max: 150)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'name')]
|
||||
private ?string $name = null;
|
||||
#[ORM\Column(name: 'number', type: 'string', length: 50, nullable: true)]
|
||||
#[ORM\Column(name: 'number', type: Types::STRING, length: 50, nullable: true)]
|
||||
#[Assert\Length(max: 50)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'number')]
|
||||
private ?string $number = null;
|
||||
#[ORM\Column(name: 'comment', type: 'text', nullable: true)]
|
||||
#[ORM\Column(name: 'comment', type: Types::TEXT, nullable: true)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'comment')]
|
||||
private ?string $comment = null;
|
||||
#[ORM\Column(name: 'visible', type: 'boolean', nullable: false)]
|
||||
#[ORM\Column(name: 'visible', type: Types::BOOLEAN, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'visible', type: 'boolean')]
|
||||
private bool $visible = true;
|
||||
#[ORM\Column(name: 'billable', type: 'boolean', nullable: false, options: ['default' => true])]
|
||||
#[ORM\Column(name: 'billable', type: Types::BOOLEAN, nullable: false, options: ['default' => true])]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'billable', type: 'boolean')]
|
||||
private bool $billable = true;
|
||||
#[ORM\Column(name: 'company', type: 'string', length: 100, nullable: true)]
|
||||
#[ORM\Column(name: 'company', type: Types::STRING, length: 100, nullable: true)]
|
||||
#[Assert\Length(max: 100)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Customer_Entity'])]
|
||||
#[Exporter\Expose(label: 'company')]
|
||||
private ?string $company = null;
|
||||
#[ORM\Column(name: 'vat_id', type: 'string', length: 50, nullable: true)]
|
||||
#[ORM\Column(name: 'vat_id', type: Types::STRING, length: 50, nullable: true)]
|
||||
#[Assert\Length(max: 50)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Customer_Entity'])]
|
||||
@@ -96,7 +97,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
#[Serializer\Groups(['Customer_Entity'])]
|
||||
#[Exporter\Expose(label: 'address')]
|
||||
private ?string $address = null;
|
||||
#[ORM\Column(name: 'country', type: 'string', length: 2, nullable: false)]
|
||||
#[ORM\Column(name: 'country', type: Types::STRING, length: 2, nullable: false)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Country]
|
||||
#[Assert\Length(max: 2)]
|
||||
@@ -104,7 +105,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
#[Serializer\Groups(['Customer_Entity'])]
|
||||
#[Exporter\Expose(label: 'country')]
|
||||
private ?string $country = null;
|
||||
#[ORM\Column(name: 'currency', type: 'string', length: 3, nullable: false)]
|
||||
#[ORM\Column(name: 'currency', type: Types::STRING, length: 3, nullable: false)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Currency]
|
||||
#[Assert\Length(max: 3)]
|
||||
@@ -112,19 +113,19 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
#[Serializer\Groups(['Customer'])]
|
||||
#[Exporter\Expose(label: 'currency')]
|
||||
private ?string $currency = self::DEFAULT_CURRENCY;
|
||||
#[ORM\Column(name: 'phone', type: 'string', length: 30, nullable: true)]
|
||||
#[ORM\Column(name: 'phone', type: Types::STRING, length: 30, nullable: true)]
|
||||
#[Assert\Length(max: 30)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Customer_Entity'])]
|
||||
#[Exporter\Expose(label: 'phone')]
|
||||
private ?string $phone = null;
|
||||
#[ORM\Column(name: 'fax', type: 'string', length: 30, nullable: true)]
|
||||
#[ORM\Column(name: 'fax', type: Types::STRING, length: 30, nullable: true)]
|
||||
#[Assert\Length(max: 30)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Customer_Entity'])]
|
||||
#[Exporter\Expose(label: 'fax')]
|
||||
private ?string $fax = null;
|
||||
#[ORM\Column(name: 'mobile', type: 'string', length: 30, nullable: true)]
|
||||
#[ORM\Column(name: 'mobile', type: Types::STRING, length: 30, nullable: true)]
|
||||
#[Assert\Length(max: 30)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Customer_Entity'])]
|
||||
@@ -133,13 +134,13 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
/**
|
||||
* Customers contact email
|
||||
*/
|
||||
#[ORM\Column(name: 'email', type: 'string', length: 75, nullable: true)]
|
||||
#[ORM\Column(name: 'email', type: Types::STRING, length: 75, nullable: true)]
|
||||
#[Assert\Length(max: 75)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Customer_Entity'])]
|
||||
#[Exporter\Expose(label: 'email')]
|
||||
private ?string $email = null;
|
||||
#[ORM\Column(name: 'homepage', type: 'string', length: 100, nullable: true)]
|
||||
#[ORM\Column(name: 'homepage', type: Types::STRING, length: 100, nullable: true)]
|
||||
#[Assert\Length(max: 100)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Customer_Entity'])]
|
||||
@@ -148,7 +149,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
/**
|
||||
* Timezone of begin and end
|
||||
*/
|
||||
#[ORM\Column(name: 'timezone', type: 'string', length: 64, nullable: false)]
|
||||
#[ORM\Column(name: 'timezone', type: Types::STRING, length: 64, nullable: false)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Timezone]
|
||||
#[Assert\Length(max: 64)]
|
||||
@@ -187,7 +188,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
#[ORM\ManyToOne(targetEntity: InvoiceTemplate::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
private ?InvoiceTemplate $invoiceTemplate = null;
|
||||
#[ORM\Column(name: 'invoice_text', type: 'text', nullable: true)]
|
||||
#[ORM\Column(name: 'invoice_text', type: Types::TEXT, nullable: true)]
|
||||
private ?string $invoiceText = null;
|
||||
|
||||
public function __construct(string $name)
|
||||
@@ -203,6 +204,11 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function isNew(): bool
|
||||
{
|
||||
return $this->id === null;
|
||||
}
|
||||
|
||||
public function setName(?string $name): void
|
||||
{
|
||||
$this->name = $name;
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Invoice\InvoiceModel;
|
||||
use App\Repository\InvoiceRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
use OpenApi\Attributes as OA;
|
||||
@@ -44,20 +45,20 @@ class Invoice implements EntityWithMetaFields
|
||||
/**
|
||||
* Unique invoice ID
|
||||
*/
|
||||
#[ORM\Column(name: 'id', type: 'integer')]
|
||||
#[ORM\Column(name: 'id', type: Types::INTEGER)]
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'id', type: 'integer')]
|
||||
private ?int $id = null;
|
||||
#[ORM\Column(name: 'invoice_number', type: 'string', length: 50, nullable: false)]
|
||||
#[ORM\Column(name: 'invoice_number', type: Types::STRING, length: 50, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'invoice.number', type: 'string')]
|
||||
private ?string $invoiceNumber = null;
|
||||
#[ORM\Column(name: 'comment', type: 'text', nullable: true)]
|
||||
#[ORM\Column(name: 'comment', type: Types::TEXT, nullable: true)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Invoice'])]
|
||||
#[Exporter\Expose(label: 'comment')]
|
||||
@@ -76,55 +77,57 @@ class Invoice implements EntityWithMetaFields
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[OA\Property(ref: '#/components/schemas/User')]
|
||||
private ?User $user = null;
|
||||
#[ORM\Column(name: 'created_at', type: 'datetime', nullable: false)]
|
||||
#[ORM\Column(name: 'created_at', type: Types::DATETIME_MUTABLE, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
private ?\DateTime $createdAt = null;
|
||||
#[ORM\Column(name: 'timezone', type: 'string', length: 64, nullable: false)]
|
||||
#[ORM\Column(name: 'timezone', type: Types::STRING, length: 64, nullable: false)]
|
||||
private ?string $timezone = null;
|
||||
#[ORM\Column(name: 'total', type: 'float', nullable: false)]
|
||||
#[ORM\Column(name: 'total', type: Types::FLOAT, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'total_rate', type: 'float')]
|
||||
private float $total = 0.00;
|
||||
#[ORM\Column(name: 'tax', type: 'float', nullable: false)]
|
||||
#[ORM\Column(name: 'tax', type: Types::FLOAT, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'invoice.tax', type: 'float')]
|
||||
private float $tax = 0.00;
|
||||
#[ORM\Column(name: 'currency', type: 'string', length: 3, nullable: false)]
|
||||
#[ORM\Column(name: 'currency', type: Types::STRING, length: 3, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
#[Assert\Length(max: 3)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'currency', type: 'string')]
|
||||
private ?string $currency = null;
|
||||
#[ORM\Column(name: 'due_days', type: 'integer', length: 3, nullable: false)]
|
||||
#[ORM\Column(name: 'due_days', type: Types::INTEGER, length: 3, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
#[Assert\Range(min: 0, max: 999)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Invoice'])]
|
||||
#[Exporter\Expose(label: 'due_days', type: 'integer')]
|
||||
private int $dueDays = 30;
|
||||
#[ORM\Column(name: 'vat', type: 'float', nullable: false)]
|
||||
#[ORM\Column(name: 'vat', type: Types::FLOAT, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
#[Assert\Range(min: 0.0, max: 99.99)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'tax_rate', type: 'float')]
|
||||
private float $vat = 0.00;
|
||||
#[ORM\Column(name: 'status', type: 'string', length: 20, nullable: false)]
|
||||
#[ORM\Column(name: 'status', type: Types::STRING, length: 20, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'status', type: 'string')]
|
||||
private string $status = self::STATUS_NEW;
|
||||
#[ORM\Column(name: 'invoice_filename', type: 'string', length: 150, nullable: false)]
|
||||
#[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;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\InvoiceTemplateRepository;
|
||||
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;
|
||||
@@ -21,15 +22,15 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[UniqueEntity('name')]
|
||||
class InvoiceTemplate
|
||||
{
|
||||
#[ORM\Column(name: 'id', type: 'integer')]
|
||||
#[ORM\Column(name: 'id', type: Types::INTEGER)]
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
|
||||
private ?int $id = null;
|
||||
#[ORM\Column(name: 'name', type: 'string', length: 60, nullable: false)]
|
||||
#[ORM\Column(name: 'name', type: Types::STRING, length: 60, nullable: false)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(min: 1, max: 60)]
|
||||
private ?string $name = null;
|
||||
#[ORM\Column(name: 'title', type: 'string', length: 255, nullable: false)]
|
||||
#[ORM\Column(name: 'title', type: Types::STRING, length: 255, nullable: false)]
|
||||
#[Assert\Length(max: 255)]
|
||||
#[Assert\NotBlank]
|
||||
private ?string $title = null;
|
||||
@@ -37,41 +38,41 @@ class InvoiceTemplate
|
||||
#[Assert\Length(max: 255)]
|
||||
#[Assert\NotBlank]
|
||||
private ?string $company = null;
|
||||
#[ORM\Column(name: 'vat_id', type: 'string', length: 50, nullable: true)]
|
||||
#[ORM\Column(name: 'vat_id', type: Types::STRING, length: 50, nullable: true)]
|
||||
#[Assert\Length(max: 50)]
|
||||
private ?string $vatId = null;
|
||||
#[ORM\Column(name: 'address', type: 'text', nullable: true)]
|
||||
#[ORM\Column(name: 'address', type: Types::TEXT, nullable: true)]
|
||||
private ?string $address = null;
|
||||
#[ORM\Column(name: 'contact', type: 'text', nullable: true)]
|
||||
#[ORM\Column(name: 'contact', type: Types::TEXT, nullable: true)]
|
||||
private ?string $contact = null;
|
||||
#[ORM\Column(name: 'due_days', type: 'integer', length: 3, nullable: false)]
|
||||
#[ORM\Column(name: 'due_days', type: Types::INTEGER, length: 3, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
#[Assert\Range(min: 0, max: 999)]
|
||||
private ?int $dueDays = 30;
|
||||
#[ORM\Column(name: 'vat', type: 'float', nullable: false)]
|
||||
#[ORM\Column(name: 'vat', type: Types::FLOAT, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
#[Assert\Range(min: 0.0, max: 99.99)]
|
||||
private ?float $vat = 0.00;
|
||||
#[ORM\Column(name: 'calculator', type: 'string', length: 20, nullable: false)]
|
||||
#[ORM\Column(name: 'calculator', type: Types::STRING, length: 20, nullable: false)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(max: 20)]
|
||||
private string $calculator = 'default';
|
||||
#[ORM\Column(name: 'number_generator', type: 'string', length: 20, nullable: false)]
|
||||
#[ORM\Column(name: 'number_generator', type: Types::STRING, length: 20, nullable: false)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(max: 20)]
|
||||
private string $numberGenerator = 'default';
|
||||
#[ORM\Column(name: 'renderer', type: 'string', length: 20, nullable: false)]
|
||||
#[ORM\Column(name: 'renderer', type: Types::STRING, length: 20, nullable: false)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(max: 20)]
|
||||
private string $renderer = 'default';
|
||||
#[ORM\Column(name: 'payment_terms', type: 'text', nullable: true)]
|
||||
#[ORM\Column(name: 'payment_terms', type: Types::TEXT, nullable: true)]
|
||||
private ?string $paymentTerms = null;
|
||||
#[ORM\Column(name: 'payment_details', type: 'text', nullable: true)]
|
||||
#[ORM\Column(name: 'payment_details', type: Types::TEXT, nullable: true)]
|
||||
private ?string $paymentDetails = null;
|
||||
/**
|
||||
* Used for translations and formatting money, numbers, dates and time.
|
||||
*/
|
||||
#[ORM\Column(name: 'language', type: 'string', length: 6, nullable: false)]
|
||||
#[ORM\Column(name: 'language', type: Types::STRING, length: 6, nullable: false)]
|
||||
#[Assert\NotBlank]
|
||||
private ?string $language = 'en';
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Form\Type\YesNoType;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
@@ -22,13 +23,13 @@ trait MetaTableTypeTrait
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(name: 'id', type: 'integer')]
|
||||
#[ORM\Column(name: 'id', type: Types::INTEGER)]
|
||||
#[Serializer\Exclude]
|
||||
private ?int $id = null;
|
||||
/**
|
||||
* Name of the meta (custom) field
|
||||
*/
|
||||
#[ORM\Column(name: 'name', type: 'string', length: 50, nullable: false)]
|
||||
#[ORM\Column(name: 'name', type: Types::STRING, length: 50, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
#[Assert\Length(min: 2, max: 50)]
|
||||
#[Serializer\Expose]
|
||||
@@ -37,12 +38,12 @@ trait MetaTableTypeTrait
|
||||
/**
|
||||
* Value of the meta (custom) field
|
||||
*/
|
||||
#[ORM\Column(name: 'value', type: 'text', length: 65535, nullable: true)]
|
||||
#[ORM\Column(name: 'value', type: Types::TEXT, length: 65535, nullable: true)]
|
||||
#[Assert\Length(max: 65535)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
private ?string $value = null;
|
||||
#[ORM\Column(name: 'visible', type: 'boolean', nullable: false, options: ['default' => false])]
|
||||
#[ORM\Column(name: 'visible', type: Types::BOOLEAN, nullable: false, options: ['default' => false])]
|
||||
#[Assert\NotNull]
|
||||
private bool $visible = false;
|
||||
private ?string $label = null;
|
||||
|
||||
@@ -16,6 +16,7 @@ use App\Repository\ProjectRepository;
|
||||
use App\Validator\Constraints as Constraints;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
use OpenApi\Attributes as OA;
|
||||
@@ -41,7 +42,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
/**
|
||||
* Unique Project ID
|
||||
*/
|
||||
#[ORM\Column(name: 'id', type: 'integer')]
|
||||
#[ORM\Column(name: 'id', type: Types::INTEGER)]
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
|
||||
#[Serializer\Expose]
|
||||
@@ -61,7 +62,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
/**
|
||||
* Project name
|
||||
*/
|
||||
#[ORM\Column(name: 'name', type: 'string', length: 150, nullable: false)]
|
||||
#[ORM\Column(name: 'name', type: Types::STRING, length: 150, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
#[Assert\Length(min: 2, max: 150)]
|
||||
#[Serializer\Expose]
|
||||
@@ -71,7 +72,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
/**
|
||||
* Project order number
|
||||
*/
|
||||
#[ORM\Column(name: 'order_number', type: 'text', length: 50, nullable: true)]
|
||||
#[ORM\Column(name: 'order_number', type: Types::TEXT, length: 50, nullable: true)]
|
||||
#[Assert\Length(max: 50)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Project_Entity'])]
|
||||
@@ -82,7 +83,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
*
|
||||
* Attention: Accessor MUST be used, otherwise date will be serialized in UTC.
|
||||
*/
|
||||
#[ORM\Column(name: 'order_date', type: 'datetime', nullable: true)]
|
||||
#[ORM\Column(name: 'order_date', type: Types::DATETIME_MUTABLE, nullable: true)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Project_Entity'])]
|
||||
#[Serializer\Type(name: "DateTime<'Y-m-d'>")]
|
||||
@@ -93,7 +94,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
*
|
||||
* Attention: Accessor MUST be used, otherwise date will be serialized in UTC.
|
||||
*/
|
||||
#[ORM\Column(name: 'start', type: 'datetime', nullable: true)]
|
||||
#[ORM\Column(name: 'start', type: Types::DATETIME_MUTABLE, nullable: true)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Project'])]
|
||||
#[Serializer\Type(name: "DateTime<'Y-m-d'>")]
|
||||
@@ -104,16 +105,16 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
*
|
||||
* Attention: Accessor MUST be used, otherwise date will be serialized in UTC.
|
||||
*/
|
||||
#[ORM\Column(name: 'end', type: 'datetime', nullable: true)]
|
||||
#[ORM\Column(name: 'end', type: Types::DATETIME_MUTABLE, nullable: true)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Project'])]
|
||||
#[Serializer\Type(name: "DateTime<'Y-m-d'>")]
|
||||
#[Serializer\Accessor(getter: 'getEnd')]
|
||||
private ?\DateTime $end = null;
|
||||
#[ORM\Column(name: 'timezone', type: 'string', length: 64, nullable: true)]
|
||||
#[ORM\Column(name: 'timezone', type: Types::STRING, length: 64, nullable: true)]
|
||||
private ?string $timezone = null;
|
||||
private bool $localized = false;
|
||||
#[ORM\Column(name: 'comment', type: 'text', nullable: true)]
|
||||
#[ORM\Column(name: 'comment', type: Types::TEXT, nullable: true)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'comment')]
|
||||
@@ -121,13 +122,13 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
/**
|
||||
* If the project is not visible, times cannot be recorded
|
||||
*/
|
||||
#[ORM\Column(name: 'visible', type: 'boolean', nullable: false)]
|
||||
#[ORM\Column(name: 'visible', type: Types::BOOLEAN, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'visible', type: 'boolean')]
|
||||
private bool $visible = true;
|
||||
#[ORM\Column(name: 'billable', type: 'boolean', nullable: false, options: ['default' => true])]
|
||||
#[ORM\Column(name: 'billable', type: Types::BOOLEAN, nullable: false, options: ['default' => true])]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
@@ -158,17 +159,17 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
#[Serializer\Groups(['Project'])]
|
||||
#[OA\Property(type: 'array', items: new OA\Items(ref: '#/components/schemas/Team'))]
|
||||
private Collection $teams;
|
||||
#[ORM\Column(name: 'invoice_text', type: 'text', nullable: true)]
|
||||
#[ORM\Column(name: 'invoice_text', type: Types::TEXT, nullable: true)]
|
||||
private ?string $invoiceText = null;
|
||||
/**
|
||||
* Whether this project allows booking of global activities
|
||||
*/
|
||||
#[ORM\Column(name: 'global_activities', type: 'boolean', nullable: false, options: ['default' => true])]
|
||||
#[ORM\Column(name: 'global_activities', type: Types::BOOLEAN, nullable: false, options: ['default' => true])]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
private bool $globalActivities = true;
|
||||
#[ORM\Column(name: 'number', type: 'string', length: 10, nullable: true)]
|
||||
#[ORM\Column(name: 'number', type: Types::STRING, length: 10, nullable: true)]
|
||||
#[Assert\Length(max: 10)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
@@ -187,6 +188,11 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function isNew(): bool
|
||||
{
|
||||
return $this->id === null;
|
||||
}
|
||||
|
||||
public function getCustomer(): ?Customer
|
||||
{
|
||||
return $this->customer;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
use OpenApi\Attributes as OA;
|
||||
@@ -16,7 +17,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
trait Rate
|
||||
{
|
||||
#[ORM\Column(name: 'id', type: 'integer')]
|
||||
#[ORM\Column(name: 'id', type: Types::INTEGER)]
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
|
||||
#[Serializer\Expose]
|
||||
@@ -28,16 +29,16 @@ trait Rate
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[OA\Property(ref: '#/components/schemas/User')]
|
||||
private ?User $user = null;
|
||||
#[ORM\Column(name: 'rate', type: 'float', nullable: false)]
|
||||
#[ORM\Column(name: 'rate', type: Types::FLOAT, nullable: false)]
|
||||
#[Assert\GreaterThanOrEqual(0)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
private float $rate = 0.00;
|
||||
#[ORM\Column(name: 'internal_rate', type: 'float', nullable: true)]
|
||||
#[ORM\Column(name: 'internal_rate', type: Types::FLOAT, nullable: true)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
private ?float $internalRate = null;
|
||||
#[ORM\Column(name: 'fixed', type: 'boolean', nullable: false)]
|
||||
#[ORM\Column(name: 'fixed', type: Types::BOOLEAN, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\RoleRepository;
|
||||
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;
|
||||
@@ -21,11 +22,11 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[UniqueEntity('name')]
|
||||
class Role
|
||||
{
|
||||
#[ORM\Column(name: 'id', type: 'integer')]
|
||||
#[ORM\Column(name: 'id', type: Types::INTEGER)]
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
|
||||
private ?int $id = null;
|
||||
#[ORM\Column(name: 'name', type: 'string', length: 50, nullable: false)]
|
||||
#[ORM\Column(name: 'name', type: Types::STRING, length: 50, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(min: 5, max: 50)]
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\RolePermissionRepository;
|
||||
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;
|
||||
@@ -21,7 +22,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[UniqueEntity(['role', 'permission'])]
|
||||
class RolePermission
|
||||
{
|
||||
#[ORM\Column(name: 'id', type: 'integer')]
|
||||
#[ORM\Column(name: 'id', type: Types::INTEGER)]
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
|
||||
private ?int $id = null;
|
||||
@@ -29,10 +30,10 @@ class RolePermission
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
private ?Role $role = null;
|
||||
#[ORM\Column(name: 'permission', type: 'string', length: 50, nullable: false)]
|
||||
#[ORM\Column(name: 'permission', type: Types::STRING, length: 50, nullable: false)]
|
||||
#[Assert\Length(max: 50)]
|
||||
private ?string $permission = null;
|
||||
#[ORM\Column(name: 'allowed', type: 'boolean', nullable: false, options: ['default' => false])]
|
||||
#[ORM\Column(name: 'allowed', type: Types::BOOLEAN, nullable: false, options: ['default' => false])]
|
||||
#[Assert\NotNull]
|
||||
private bool $allowed = false;
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ namespace App\Entity;
|
||||
|
||||
use App\Repository\TagRepository;
|
||||
use App\Utils\Color;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
@@ -28,7 +29,7 @@ class Tag
|
||||
/**
|
||||
* Internal Tag ID
|
||||
*/
|
||||
#[ORM\Column(name: 'id', type: 'integer')]
|
||||
#[ORM\Column(name: 'id', type: Types::INTEGER)]
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
|
||||
#[Serializer\Expose]
|
||||
@@ -37,14 +38,14 @@ class Tag
|
||||
/**
|
||||
* The tag name
|
||||
*/
|
||||
#[ORM\Column(name: 'name', type: 'string', length: 100, nullable: false)]
|
||||
#[ORM\Column(name: 'name', type: Types::STRING, length: 100, nullable: false)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(min: 2, max: 100, normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: '/,/', message: 'Tag name cannot contain comma', match: false)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
private ?string $name = null;
|
||||
#[ORM\Column(name: 'visible', type: 'boolean', nullable: false, options: ['default' => true])]
|
||||
#[ORM\Column(name: 'visible', type: Types::BOOLEAN, nullable: false, options: ['default' => true])]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Repository\TeamRepository;
|
||||
use App\Validator\Constraints as Constraints;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
use OpenApi\Attributes as OA;
|
||||
@@ -28,7 +29,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[Constraints\Team]
|
||||
class Team
|
||||
{
|
||||
#[ORM\Column(name: 'id', type: 'integer')]
|
||||
#[ORM\Column(name: 'id', type: Types::INTEGER)]
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
|
||||
#[Serializer\Expose]
|
||||
@@ -37,7 +38,7 @@ class Team
|
||||
/**
|
||||
* Team name
|
||||
*/
|
||||
#[ORM\Column(name: 'name', type: 'string', length: 100, nullable: false)]
|
||||
#[ORM\Column(name: 'name', type: Types::STRING, length: 100, nullable: false)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(min: 2, max: 100)]
|
||||
#[Serializer\Expose]
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
use OpenApi\Attributes as OA;
|
||||
@@ -21,7 +22,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
class TeamMember
|
||||
{
|
||||
#[ORM\Column(name: 'id', type: 'integer')]
|
||||
#[ORM\Column(name: 'id', type: Types::INTEGER)]
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
|
||||
private ?int $id = null;
|
||||
@@ -39,7 +40,7 @@ class TeamMember
|
||||
#[Serializer\Groups(['Default', 'Entity', 'User_Entity'])]
|
||||
#[OA\Property(ref: '#/components/schemas/Team')]
|
||||
private ?Team $team = null;
|
||||
#[ORM\Column(name: 'teamlead', type: 'boolean', nullable: false, options: ['default' => false])]
|
||||
#[ORM\Column(name: 'teamlead', type: Types::BOOLEAN, nullable: false, options: ['default' => false])]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default', 'Entity', 'Team_Entity', 'User_Entity'])]
|
||||
|
||||
@@ -17,6 +17,7 @@ use DateTime;
|
||||
use DateTimeZone;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
use OpenApi\Attributes as OA;
|
||||
@@ -81,7 +82,7 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
|
||||
/**
|
||||
* Unique Timesheet ID
|
||||
*/
|
||||
#[ORM\Column(name: 'id', type: 'integer')]
|
||||
#[ORM\Column(name: 'id', type: Types::INTEGER)]
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
|
||||
#[Serializer\Expose]
|
||||
@@ -91,7 +92,7 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
|
||||
* Reflects the date in the user timezone (not in UTC).
|
||||
* This value is automatically set through the begin column and ONLY used in statistic queries.
|
||||
*/
|
||||
#[ORM\Column(name: 'date_tz', type: 'date_immutable', nullable: false)]
|
||||
#[ORM\Column(name: 'date_tz', type: Types::DATE_IMMUTABLE, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
private ?\DateTimeImmutable $date = null;
|
||||
/**
|
||||
@@ -99,7 +100,7 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
|
||||
*
|
||||
* Attention: Accessor MUST be used, otherwise date will be serialized in UTC.
|
||||
*/
|
||||
#[ORM\Column(name: 'start_time', type: 'datetime', nullable: false)]
|
||||
#[ORM\Column(name: 'start_time', type: Types::DATETIME_MUTABLE, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
@@ -111,7 +112,7 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
|
||||
*
|
||||
* Attention: Accessor MUST be used, otherwise date will be serialized in UTC.
|
||||
*/
|
||||
#[ORM\Column(name: 'end_time', type: 'datetime', nullable: true)]
|
||||
#[ORM\Column(name: 'end_time', type: Types::DATETIME_MUTABLE, nullable: true)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Serializer\Type(name: 'DateTime')]
|
||||
@@ -120,18 +121,18 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
|
||||
/**
|
||||
* @internal for storing the timezone of "begin" and "end" date
|
||||
*/
|
||||
#[ORM\Column(name: 'timezone', type: 'string', length: 64, nullable: false)]
|
||||
#[ORM\Column(name: 'timezone', type: Types::STRING, length: 64, nullable: false)]
|
||||
#[Assert\Timezone]
|
||||
private ?string $timezone = null;
|
||||
/**
|
||||
* @internal for storing the localized state of dates (see $timezone)
|
||||
*/
|
||||
private bool $localized = false;
|
||||
#[ORM\Column(name: 'duration', type: 'integer', nullable: true)]
|
||||
#[ORM\Column(name: 'duration', type: Types::INTEGER, nullable: true)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
private ?int $duration = 0;
|
||||
#[ORM\Column(name: 'break', type: 'integer', nullable: true)]
|
||||
#[ORM\Column(name: 'break', type: Types::INTEGER, nullable: true)]
|
||||
private ?int $break = 0;
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(name: '`user`', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
@@ -154,36 +155,36 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
|
||||
#[Serializer\Groups(['Subresource', 'Expanded'])]
|
||||
#[OA\Property(ref: '#/components/schemas/ProjectExpanded')]
|
||||
private ?Project $project = null;
|
||||
#[ORM\Column(name: 'description', type: 'text', nullable: true)]
|
||||
#[ORM\Column(name: 'description', type: Types::TEXT, nullable: true)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
private ?string $description = null;
|
||||
#[ORM\Column(name: 'rate', type: 'float', nullable: false)]
|
||||
#[ORM\Column(name: 'rate', type: Types::FLOAT, nullable: false)]
|
||||
#[Assert\GreaterThanOrEqual(0)]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
private float $rate = 0.00;
|
||||
#[ORM\Column(name: 'internal_rate', type: 'float', nullable: true)]
|
||||
#[ORM\Column(name: 'internal_rate', type: Types::FLOAT, nullable: true)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
private ?float $internalRate = null;
|
||||
#[ORM\Column(name: 'fixed_rate', type: 'float', nullable: true)]
|
||||
#[ORM\Column(name: 'fixed_rate', type: Types::FLOAT, nullable: true)]
|
||||
#[Assert\GreaterThanOrEqual(0)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Entity'])]
|
||||
private ?float $fixedRate = null;
|
||||
#[ORM\Column(name: 'hourly_rate', type: 'float', nullable: true)]
|
||||
#[ORM\Column(name: 'hourly_rate', type: Types::FLOAT, nullable: true)]
|
||||
#[Assert\GreaterThanOrEqual(0)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Entity'])]
|
||||
private ?float $hourlyRate = null;
|
||||
#[ORM\Column(name: 'exported', type: 'boolean', nullable: false, options: ['default' => false])]
|
||||
#[ORM\Column(name: 'exported', type: Types::BOOLEAN, nullable: false, options: ['default' => false])]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
private bool $exported = false;
|
||||
#[ORM\Column(name: 'billable', type: 'boolean', nullable: false, options: ['default' => true])]
|
||||
#[ORM\Column(name: 'billable', type: Types::BOOLEAN, nullable: false, options: ['default' => true])]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
@@ -193,7 +194,7 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
|
||||
*/
|
||||
#[Assert\NotNull]
|
||||
private ?string $billableMode = self::BILLABLE_DEFAULT;
|
||||
#[ORM\Column(name: 'category', type: 'string', length: 10, nullable: false, options: ['default' => 'work'])]
|
||||
#[ORM\Column(name: 'category', type: Types::STRING, length: 10, nullable: false, options: ['default' => 'work'])]
|
||||
#[Assert\NotNull]
|
||||
private ?string $category = self::WORK;
|
||||
/**
|
||||
@@ -224,7 +225,7 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
|
||||
{
|
||||
$this->tags = new ArrayCollection();
|
||||
$this->meta = new ArrayCollection();
|
||||
$this->setModifiedAt(new \DateTimeImmutable('now', new \DateTimeZone('UTC')));
|
||||
$this->markAsModified();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -611,7 +612,7 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
|
||||
{
|
||||
// this needs to be done, otherwise doctrine will not see the item as changed
|
||||
// and the calculators will not run
|
||||
$this->setModifiedAt(new \DateTimeImmutable('now', new \DateTimeZone('UTC')));
|
||||
$this->markAsModified();
|
||||
|
||||
if (null === ($current = $this->getMetaField($meta->getName()))) {
|
||||
$meta->setEntity($this);
|
||||
@@ -659,7 +660,7 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
|
||||
$this->id = null;
|
||||
}
|
||||
|
||||
$this->setModifiedAt(new \DateTimeImmutable('now', new \DateTimeZone('UTC')));
|
||||
$this->markAsModified();
|
||||
$this->exported = false;
|
||||
|
||||
$currentMeta = $this->meta;
|
||||
|
||||
@@ -17,6 +17,7 @@ use App\WorkingTime\Mode\WorkingTimeModeNone;
|
||||
use DateTime;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Exception;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
@@ -70,7 +71,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
*/
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(name: 'id', type: 'integer')]
|
||||
#[ORM\Column(name: 'id', type: Types::INTEGER)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'id', type: 'integer')]
|
||||
@@ -78,7 +79,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
/**
|
||||
* The user alias will be displayed in the frontend instead of the username
|
||||
*/
|
||||
#[ORM\Column(name: 'alias', type: 'string', length: 60, nullable: true)]
|
||||
#[ORM\Column(name: 'alias', type: Types::STRING, length: 60, nullable: true)]
|
||||
#[Assert\Length(max: 60)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
@@ -87,13 +88,13 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
/**
|
||||
* Registration date for the user
|
||||
*/
|
||||
#[ORM\Column(name: 'registration_date', type: 'datetime', nullable: true)]
|
||||
#[ORM\Column(name: 'registration_date', type: Types::DATETIME_MUTABLE, nullable: true)]
|
||||
#[Exporter\Expose(label: 'profile.registration_date', type: 'datetime')]
|
||||
private ?\DateTime $registeredAt = null;
|
||||
/**
|
||||
* An additional title for the user, like the Job position or Department
|
||||
*/
|
||||
#[ORM\Column(name: 'title', type: 'string', length: 50, nullable: true)]
|
||||
#[ORM\Column(name: 'title', type: Types::STRING, length: 50, nullable: true)]
|
||||
#[Assert\Length(max: 50)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
@@ -102,7 +103,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
/**
|
||||
* URL to the user avatar, will be auto-generated if empty
|
||||
*/
|
||||
#[ORM\Column(name: 'avatar', type: 'string', length: 255, nullable: true)]
|
||||
#[ORM\Column(name: 'avatar', type: Types::STRING, length: 255, nullable: true)]
|
||||
#[Assert\Length(max: 255, groups: ['Profile'])]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['User_Entity'])]
|
||||
@@ -110,7 +111,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
/**
|
||||
* API token (password) for this user
|
||||
*/
|
||||
#[ORM\Column(name: 'api_token', type: 'string', length: 255, nullable: true)]
|
||||
#[ORM\Column(name: 'api_token', type: Types::STRING, length: 255, nullable: true)]
|
||||
private ?string $apiToken = null;
|
||||
/**
|
||||
* @internal to be set via form, must not be persisted
|
||||
@@ -148,7 +149,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
*
|
||||
* @internal for internal usage only
|
||||
*/
|
||||
#[ORM\Column(name: 'auth', type: 'string', length: 20, nullable: true)]
|
||||
#[ORM\Column(name: 'auth', type: Types::STRING, length: 20, nullable: true)]
|
||||
#[Assert\Length(max: 20)]
|
||||
private ?string $auth = self::AUTH_INTERNAL;
|
||||
/**
|
||||
@@ -157,32 +158,32 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
* @internal has no database mapping as the value is calculated from a permission
|
||||
*/
|
||||
private ?bool $isAllowedToSeeAllData = null;
|
||||
#[ORM\Column(name: 'username', type: 'string', length: 180, nullable: false)]
|
||||
#[ORM\Column(name: 'username', type: Types::STRING, length: 180, nullable: false)]
|
||||
#[Assert\NotBlank(groups: ['Registration', 'UserCreate', 'Profile'])]
|
||||
#[Assert\Regex(pattern: '/\//', match: false, groups: ['Registration', 'UserCreate', 'Profile'])]
|
||||
#[Assert\Length(min: 2, max: 64, groups: ['Registration', 'UserCreate', 'Profile'])]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
private ?string $username = null;
|
||||
#[ORM\Column(name: 'email', type: 'string', length: 180, nullable: false)]
|
||||
#[ORM\Column(name: 'email', type: Types::STRING, length: 180, nullable: false)]
|
||||
#[Assert\NotBlank(groups: ['Registration', 'UserCreate', 'Profile'])]
|
||||
#[Assert\Length(min: 2, max: 180)]
|
||||
#[Assert\Email(mode: 'html5', groups: ['Registration', 'UserCreate', 'Profile'])]
|
||||
private ?string $email = null;
|
||||
#[ORM\Column(name: 'account', type: 'string', length: 30, nullable: true)]
|
||||
#[ORM\Column(name: 'account', type: Types::STRING, length: 30, nullable: true)]
|
||||
#[Assert\Length(max: 30)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'account_number')]
|
||||
private ?string $accountNumber = null;
|
||||
#[ORM\Column(name: 'enabled', type: 'boolean', nullable: false)]
|
||||
#[ORM\Column(name: 'enabled', type: Types::BOOLEAN, nullable: false)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
private bool $enabled = false;
|
||||
/**
|
||||
* Encrypted password. Must be persisted.
|
||||
*/
|
||||
#[ORM\Column(name: 'password', type: 'string', nullable: false)]
|
||||
#[ORM\Column(name: 'password', type: Types::STRING, nullable: false)]
|
||||
private ?string $password = null;
|
||||
/**
|
||||
* Plain password. Used for model validation, not persisted.
|
||||
@@ -190,20 +191,20 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
#[Assert\NotBlank(groups: ['Registration', 'PasswordUpdate', 'UserCreate'])]
|
||||
#[Assert\Length(min: 8, max: 60, groups: ['Registration', 'PasswordUpdate', 'UserCreate', 'ResetPassword', 'ChangePassword'])]
|
||||
private ?string $plainPassword = null;
|
||||
#[ORM\Column(name: 'last_login', type: 'datetime', nullable: true)]
|
||||
#[ORM\Column(name: 'last_login', type: Types::DATETIME_MUTABLE, nullable: true)]
|
||||
private ?DateTime $lastLogin = null;
|
||||
/**
|
||||
* Random string sent to the user email address in order to verify it.
|
||||
*/
|
||||
#[ORM\Column(name: 'confirmation_token', type: 'string', length: 180, unique: true, nullable: true)]
|
||||
#[ORM\Column(name: 'confirmation_token', type: Types::STRING, length: 180, unique: true, nullable: true)]
|
||||
#[Assert\Length(max: 180)]
|
||||
private ?string $confirmationToken = null;
|
||||
#[ORM\Column(name: 'password_requested_at', type: 'datetime_immutable', nullable: true)]
|
||||
#[ORM\Column(name: 'password_requested_at', type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $passwordRequestedAt = null;
|
||||
/**
|
||||
* List of all role names
|
||||
*/
|
||||
#[ORM\Column(name: 'roles', type: 'array', nullable: false)]
|
||||
#[ORM\Column(name: 'roles', type: Types::ARRAY, nullable: false)] // @phpstan-ignore classConstant.deprecated
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['User_Entity'])]
|
||||
#[Serializer\Type('array<string>')]
|
||||
@@ -213,11 +214,11 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
* If not empty two-factor authentication is enabled.
|
||||
* TODO reduce the length, which was initially forgotten and set to 255, as this is the default for MySQL with Doctrine (see migration Version20230126002049)
|
||||
*/
|
||||
#[ORM\Column(name: 'totp_secret', type: 'string', length: 255, nullable: true)]
|
||||
#[ORM\Column(name: 'totp_secret', type: Types::STRING, length: 255, nullable: true)]
|
||||
private ?string $totpSecret = null;
|
||||
#[ORM\Column(name: 'totp_enabled', type: 'boolean', nullable: false, options: ['default' => false])]
|
||||
#[ORM\Column(name: 'totp_enabled', type: Types::BOOLEAN, nullable: false, options: ['default' => false])]
|
||||
private bool $totpEnabled = false;
|
||||
#[ORM\Column(name: 'system_account', type: 'boolean', nullable: false, options: ['default' => false])]
|
||||
#[ORM\Column(name: 'system_account', type: Types::BOOLEAN, nullable: false, options: ['default' => false])]
|
||||
private bool $systemAccount = false;
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
|
||||
@@ -11,6 +11,7 @@ namespace App\Entity;
|
||||
|
||||
use App\Form\Type\YesNoType;
|
||||
use App\WorkingTime\Calculator\WorkingTimeCalculatorDay;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
@@ -53,19 +54,19 @@ class UserPreference
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(name: 'id', type: 'integer')]
|
||||
#[ORM\Column(name: 'id', type: Types::INTEGER)]
|
||||
private ?int $id = null;
|
||||
#[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'preferences')]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
private ?User $user = null;
|
||||
#[ORM\Column(name: 'name', type: 'string', length: 50, nullable: false)]
|
||||
#[ORM\Column(name: 'name', type: Types::STRING, length: 50, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
#[Assert\Length(min: 2, max: 50)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
private string $name;
|
||||
#[ORM\Column(name: 'value', type: 'string', length: 255, nullable: true)]
|
||||
#[ORM\Column(name: 'value', type: Types::STRING, length: 255, nullable: true)]
|
||||
#[Assert\Length(max: 250)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\WorkingTimeRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
@@ -23,25 +24,25 @@ class WorkingTime
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(name: 'id', type: 'integer')]
|
||||
#[ORM\Column(name: 'id', type: Types::INTEGER)]
|
||||
private ?int $id = null;
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
private ?User $user = null;
|
||||
#[ORM\Column(name: 'date', type: 'date', nullable: false)]
|
||||
#[ORM\Column(name: 'date', type: Types::DATE_MUTABLE, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
private \DateTimeInterface $date;
|
||||
#[ORM\Column(name: 'expected', type: 'integer', nullable: false)]
|
||||
#[ORM\Column(name: 'expected', type: Types::INTEGER, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
private int $expectedTime = 0;
|
||||
#[ORM\Column(name: 'actual', type: 'integer', nullable: false)]
|
||||
#[ORM\Column(name: 'actual', type: Types::INTEGER, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
private int $actualTime = 0;
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(name: 'approved_by', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?User $approvedBy = null;
|
||||
#[ORM\Column(name: 'approved_at', type: 'datetime_immutable', nullable: true)]
|
||||
#[ORM\Column(name: 'approved_at', type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
#[Assert\NotNull]
|
||||
private ?\DateTimeImmutable $approvedAt = null;
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ use Symfony\Contracts\EventDispatcher\Event;
|
||||
|
||||
final class InvoiceCreatedEvent extends Event
|
||||
{
|
||||
public function __construct(private Invoice $invoice, private InvoiceModel $model)
|
||||
public function __construct(private readonly Invoice $invoice, private readonly InvoiceModel $model)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,9 @@ final class InvoiceDocumentsEvent extends Event
|
||||
$this->documents = $documents;
|
||||
}
|
||||
|
||||
/**
|
||||
* @CloudRequired
|
||||
*/
|
||||
public function setMaximumAllowedDocuments(int $max): void
|
||||
{
|
||||
$this->maximum = $max;
|
||||
|
||||
@@ -16,7 +16,11 @@ use Symfony\Contracts\EventDispatcher\Event;
|
||||
|
||||
final class InvoicePreRenderEvent extends Event
|
||||
{
|
||||
public function __construct(private InvoiceModel $model, private InvoiceDocument $document, private RendererInterface $renderer)
|
||||
public function __construct(
|
||||
private readonly InvoiceModel $model,
|
||||
private readonly InvoiceDocument $document,
|
||||
private readonly RendererInterface $renderer
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
25
src/Event/QuickEntryMetaDisplayEvent.php
Normal file
25
src/Event/QuickEntryMetaDisplayEvent.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?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\Repository\Query\TimesheetQuery;
|
||||
|
||||
/**
|
||||
* Dynamically find possible meta fields for the quick-entry screen.
|
||||
*
|
||||
* @method TimesheetQuery getQuery()
|
||||
*/
|
||||
final class QuickEntryMetaDisplayEvent extends AbstractMetaDisplayEvent
|
||||
{
|
||||
public function __construct(TimesheetQuery $query)
|
||||
{
|
||||
parent::__construct($query, 'quick-entry');
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ namespace App\Event;
|
||||
|
||||
use Symfony\Contracts\EventDispatcher\Event;
|
||||
|
||||
/**
|
||||
* @deprecated since 2.32 - use TranslatorInterface directly
|
||||
*/
|
||||
final class ThemeJavascriptTranslationsEvent extends Event
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -15,13 +15,19 @@ use Symfony\Contracts\EventDispatcher\Event;
|
||||
|
||||
final class WorkingTimeApproveMonthEvent extends Event
|
||||
{
|
||||
public function __construct(private User $user, private Month $month, private \DateTimeInterface $approvalDate, private User $approver)
|
||||
public function __construct(
|
||||
private readonly Month $month,
|
||||
private readonly User $approvedBy
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use getMonth()->getUser() instead)
|
||||
*/
|
||||
public function getUser(): User
|
||||
{
|
||||
return $this->user;
|
||||
return $this->getMonth()->getUser();
|
||||
}
|
||||
|
||||
public function getMonth(): Month
|
||||
@@ -29,13 +35,8 @@ final class WorkingTimeApproveMonthEvent extends Event
|
||||
return $this->month;
|
||||
}
|
||||
|
||||
public function getApprovalDate(): \DateTimeInterface
|
||||
public function getApprovedBy(): User
|
||||
{
|
||||
return $this->approvalDate;
|
||||
}
|
||||
|
||||
public function getApprover(): User
|
||||
{
|
||||
return $this->approver;
|
||||
return $this->approvedBy;
|
||||
}
|
||||
}
|
||||
|
||||
34
src/Event/WorkingTimeUnlockMonthEvent.php
Normal file
34
src/Event/WorkingTimeUnlockMonthEvent.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Event;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\WorkingTime\Model\Month;
|
||||
use Symfony\Contracts\EventDispatcher\Event;
|
||||
|
||||
final class WorkingTimeUnlockMonthEvent extends Event
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Month $month,
|
||||
private readonly User $unlockedBy
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
public function getMonth(): Month
|
||||
{
|
||||
return $this->month;
|
||||
}
|
||||
|
||||
public function getUnlockedBy(): User
|
||||
{
|
||||
return $this->unlockedBy;
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
*/
|
||||
final class MenuBuilderSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(private MenuService $menuService)
|
||||
public function __construct(private readonly MenuService $menuService)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ final class MenuBuilderSubscriber implements EventSubscriberInterface
|
||||
$event->addItem($menuEvent->getSystemMenu());
|
||||
}
|
||||
|
||||
$route = $event->getRequest()->get('_route');
|
||||
$route = $event->getRequest()->attributes->get('_route');
|
||||
if (!\is_string($route)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace App\Export\Base;
|
||||
use App\Entity\ExportableItem;
|
||||
use App\Export\ExportFilename;
|
||||
use App\Export\Package\CellFormatter\DateStringFormatter;
|
||||
use App\Export\Package\CellFormatter\DurationPlainFormatter;
|
||||
use App\Export\Package\SpoutSpreadsheet;
|
||||
use App\Export\RendererInterface;
|
||||
use App\Export\TimesheetExportInterface;
|
||||
@@ -71,6 +72,7 @@ final class CsvRenderer implements RendererInterface, TimesheetExportInterface
|
||||
$spreadsheet->open($filename);
|
||||
|
||||
$this->spreadsheetRenderer->registerFormatter('date', new DateStringFormatter());
|
||||
$this->spreadsheetRenderer->registerFormatter('duration', new DurationPlainFormatter());
|
||||
$this->spreadsheetRenderer->writeSpreadsheet($spreadsheet, $exportItems, $query);
|
||||
|
||||
return new \SplFileInfo($filename);
|
||||
|
||||
@@ -105,7 +105,7 @@ final class SpreadsheetRenderer
|
||||
$formula = null;
|
||||
if (\in_array($column->getName(), $totalColumns)) {
|
||||
$columnName = $columnNames[$totalColumn - 1];
|
||||
$formula = \sprintf('=SUM(%s2:%s%s)', $columnName, $columnName, $currentRow);
|
||||
$formula = \sprintf('=SUBTOTAL(9,%s2:%s%s)', $columnName, $columnName, $currentRow);
|
||||
}
|
||||
$totalRow[] = $formula;
|
||||
$totalColumn++;
|
||||
@@ -164,7 +164,8 @@ final class SpreadsheetRenderer
|
||||
$columns[] = (new Column('fixedRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getFixedRate());
|
||||
}
|
||||
|
||||
$columns[] = (new Column('username', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getDisplayName())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
$columns[] = (new Column('alias', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getDisplayName())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
$columns[] = (new Column('username', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getUserIdentifier())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
$columns[] = (new Column('account_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getAccountNumber());
|
||||
$columns[] = (new Column('customer', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
$columns[] = (new Column('project', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
|
||||
15
src/Export/Package/CellFormatter/CellWithFormatInterface.php
Normal file
15
src/Export/Package/CellFormatter/CellWithFormatInterface.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Export\Package\CellFormatter;
|
||||
|
||||
interface CellWithFormatInterface
|
||||
{
|
||||
public function getFormat(): string;
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
namespace App\Export\Package\CellFormatter;
|
||||
|
||||
final class DateFormatter implements CellFormatterInterface
|
||||
final class DateFormatter implements CellFormatterInterface, CellWithFormatInterface
|
||||
{
|
||||
public function formatValue(mixed $value): mixed
|
||||
{
|
||||
@@ -23,4 +23,9 @@ final class DateFormatter implements CellFormatterInterface
|
||||
|
||||
throw new \InvalidArgumentException('Only DateTimeInterface can be formatted');
|
||||
}
|
||||
|
||||
public function getFormat(): string
|
||||
{
|
||||
return 'yyyy-mm-dd';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,23 +9,34 @@
|
||||
|
||||
namespace App\Export\Package\CellFormatter;
|
||||
|
||||
use App\Utils\Duration;
|
||||
|
||||
final class DurationFormatter implements CellFormatterInterface
|
||||
final class DurationFormatter implements CellFormatterInterface, CellWithFormatInterface
|
||||
{
|
||||
private Duration $duration;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->duration = new Duration();
|
||||
}
|
||||
|
||||
public function formatValue(mixed $value): mixed
|
||||
{
|
||||
if (is_numeric($value)) {
|
||||
return $this->duration->format((int) $value);
|
||||
if (!is_numeric($value) || (int) $value === 0) {
|
||||
return new \DateInterval('PT0S');
|
||||
}
|
||||
|
||||
return $this->duration->format(0);
|
||||
$value = (int) $value;
|
||||
|
||||
$seconds = abs($value);
|
||||
$hours = intdiv($seconds, 3600);
|
||||
$seconds %= 3600;
|
||||
$minutes = intdiv($seconds, 60);
|
||||
$seconds %= 60;
|
||||
|
||||
$intervalSpec = \sprintf('PT%dH%dM%dS', $hours, $minutes, $seconds);
|
||||
$interval = new \DateInterval($intervalSpec);
|
||||
|
||||
if ($value < 0) {
|
||||
$interval->invert = 1;
|
||||
}
|
||||
|
||||
return $interval;
|
||||
}
|
||||
|
||||
public function getFormat(): string
|
||||
{
|
||||
return '[hh]:mm:ss';
|
||||
}
|
||||
}
|
||||
|
||||
37
src/Export/Package/CellFormatter/DurationPlainFormatter.php
Normal file
37
src/Export/Package/CellFormatter/DurationPlainFormatter.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Export\Package\CellFormatter;
|
||||
|
||||
final class DurationPlainFormatter implements CellFormatterInterface
|
||||
{
|
||||
public function formatValue(mixed $value): mixed
|
||||
{
|
||||
if (!is_numeric($value) || (int) $value === 0) {
|
||||
return '0:00:00';
|
||||
}
|
||||
|
||||
$value = (int) $value;
|
||||
|
||||
$seconds = abs($value);
|
||||
$hours = intdiv($seconds, 3600);
|
||||
$seconds %= 3600;
|
||||
$minutes = intdiv($seconds, 60);
|
||||
$seconds %= 60;
|
||||
|
||||
$intervalSpec = \sprintf('PT%dH%dM%dS', $hours, $minutes, $seconds);
|
||||
$interval = new \DateInterval($intervalSpec);
|
||||
|
||||
if ($value < 0) {
|
||||
$interval->invert = 1;
|
||||
}
|
||||
|
||||
return $interval->format('%r%h:%I:%S');
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ namespace App\Export\Package;
|
||||
|
||||
use App\Entity\ExportableItem;
|
||||
use App\Export\Package\CellFormatter\CellFormatterInterface;
|
||||
use App\Export\Package\CellFormatter\CellWithFormatInterface;
|
||||
|
||||
class Column
|
||||
{
|
||||
@@ -71,4 +72,13 @@ class Column
|
||||
{
|
||||
return $this->header ?? $this->name;
|
||||
}
|
||||
|
||||
public function getFormat(): ?string
|
||||
{
|
||||
if ($this->formatter instanceof CellWithFormatInterface) {
|
||||
return $this->formatter->getFormat();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,8 @@ use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class SpoutSpreadsheet implements SpreadsheetPackage
|
||||
{
|
||||
private Style $dateStyle;
|
||||
/** @var array<int, Style|null> */
|
||||
private array $styles = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly WriterInterface $writer,
|
||||
@@ -34,7 +35,6 @@ class SpoutSpreadsheet implements SpreadsheetPackage
|
||||
)
|
||||
{
|
||||
$this->writer->setCreator(Constants::SOFTWARE);
|
||||
$this->dateStyle = (new Style())->setFormat('yyyy-mm-dd');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,9 +48,15 @@ class SpoutSpreadsheet implements SpreadsheetPackage
|
||||
}
|
||||
|
||||
$tmp = [];
|
||||
$i = 0;
|
||||
foreach ($columns as $column) {
|
||||
$title = $this->translator->trans($column->getHeader());
|
||||
$tmp[] = Cell::fromValue($title);
|
||||
$style = null;
|
||||
if ($column->getFormat() !== null) {
|
||||
$style = (new Style())->setFormat($column->getFormat());
|
||||
}
|
||||
$this->styles[$i++] = $style;
|
||||
}
|
||||
|
||||
$style = new Style();
|
||||
@@ -100,12 +106,9 @@ class SpoutSpreadsheet implements SpreadsheetPackage
|
||||
}
|
||||
|
||||
$tmp = [];
|
||||
$i = 0;
|
||||
foreach ($columns as $column) {
|
||||
if ($column instanceof \DateTimeInterface) {
|
||||
$tmp[] = Cell::fromValue($column, $this->dateStyle);
|
||||
} else {
|
||||
$tmp[] = Cell::fromValue($column); // @phpstan-ignore argument.type
|
||||
}
|
||||
$tmp[] = Cell::fromValue($column, $this->styles[$i++]); // @phpstan-ignore argument.type
|
||||
}
|
||||
|
||||
$this->writer->addRow(new Row($tmp, $style));
|
||||
|
||||
@@ -27,7 +27,11 @@ final class TimesheetApiEditForm extends TimesheetEditForm
|
||||
return;
|
||||
}
|
||||
|
||||
$builder->add('billable', BillableType::class);
|
||||
$builder->add('billable', BillableType::class, [
|
||||
'documentation' => [
|
||||
'description' => 'If true, this timesheet will be flagged as billable'
|
||||
]
|
||||
]);
|
||||
|
||||
$builder->addEventListener(
|
||||
FormEvents::PRE_SUBMIT,
|
||||
@@ -56,6 +60,7 @@ final class TimesheetApiEditForm extends TimesheetEditForm
|
||||
$builder->remove('metaFields');
|
||||
}
|
||||
|
||||
// TODO this is only a quick fix, see bugs reports
|
||||
if ($builder->has('duration')) {
|
||||
$builder->remove('duration');
|
||||
}
|
||||
@@ -64,11 +69,6 @@ final class TimesheetApiEditForm extends TimesheetEditForm
|
||||
$builder->get('user')->setRequired(false);
|
||||
}
|
||||
|
||||
// TODO this is only a quick fix, see bugs reports
|
||||
if ($builder->has('duration')) {
|
||||
$builder->remove('duration');
|
||||
}
|
||||
|
||||
if ($builder->has('tags')) {
|
||||
$builder->remove('tags');
|
||||
// @deprecated for BC reasons here, arrays will be supported in 2.0
|
||||
@@ -82,6 +82,10 @@ final class TimesheetApiEditForm extends TimesheetEditForm
|
||||
{
|
||||
$builder->add('begin', DateTimeApiType::class, array_merge($dateTimeOptions, [
|
||||
'label' => 'begin',
|
||||
'required' => false,
|
||||
'documentation' => [
|
||||
'description' => 'If no begin date-time is set, the users current timestamp will be used'
|
||||
]
|
||||
]));
|
||||
}
|
||||
|
||||
@@ -90,6 +94,9 @@ final class TimesheetApiEditForm extends TimesheetEditForm
|
||||
$builder->add('end', DateTimeApiType::class, array_merge($dateTimeOptions, [
|
||||
'label' => 'end',
|
||||
'required' => false,
|
||||
'documentation' => [
|
||||
'description' => 'If no end date-time is set, the timesheet will be running'
|
||||
]
|
||||
]));
|
||||
}
|
||||
|
||||
|
||||
@@ -183,8 +183,8 @@ final class TimesheetMultiUpdate extends AbstractType
|
||||
'label' => 'mark_as_exported',
|
||||
'required' => false,
|
||||
'choices' => [
|
||||
'entryState.exported' => true,
|
||||
'entryState.not_exported' => false
|
||||
'yes' => true,
|
||||
'no' => false
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,18 @@ final class QuickEntryForm extends AbstractType
|
||||
$builder->addModelTransformer(new CallbackTransformer(
|
||||
function ($value) {
|
||||
// page is loaded, nothing to do
|
||||
if (!$value instanceof QuickEntryWeek) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
foreach ($value->getRows() as $row) {
|
||||
foreach ($row->getTimesheets() as $timesheet) {
|
||||
foreach ($timesheet->getMetaFields() as $metaField) {
|
||||
$row->setMetaFieldValue($metaField);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
},
|
||||
function (?QuickEntryWeek $value) {
|
||||
@@ -49,6 +61,24 @@ final class QuickEntryForm extends AbstractType
|
||||
foreach ($row->getTimesheets() as $timesheet) {
|
||||
$timesheet->setProject($project);
|
||||
$timesheet->setActivity($activity);
|
||||
foreach ($row->getMetaFields() as $metaField) {
|
||||
if (null === ($name = $metaField->getName())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (null === $timesheet->getMetaField($name)) {
|
||||
$timesheet->setMetaField($metaField);
|
||||
}
|
||||
|
||||
if (null === ($tmpField = $timesheet->getMetaField($name))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($tmpField->getValue() !== $metaField->getValue()) {
|
||||
$tmpField->setValue($metaField->getValue());
|
||||
$timesheet->markAsModified();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,10 @@ class TimesheetEditForm extends AbstractType
|
||||
{
|
||||
use FormTrait;
|
||||
|
||||
public function __construct(private CustomerRepository $customers, private SystemConfiguration $systemConfiguration)
|
||||
public function __construct(
|
||||
private readonly CustomerRepository $customers,
|
||||
private readonly SystemConfiguration $systemConfiguration
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -207,15 +210,22 @@ class TimesheetEditForm extends AbstractType
|
||||
|
||||
/** @var \DateTime|null $date */
|
||||
$date = $event->getForm()->get('begin_date')->getData();
|
||||
/** @var \DateTime|null $time */
|
||||
$time = $event->getForm()->get('begin_time')->getData();
|
||||
|
||||
if ($date === null || $time === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$seconds = 0;
|
||||
// if the user did not change the time, make sure to keep the seconds (ONLY if the timesheet is already existing)
|
||||
if ($data->getBegin()?->format('H:i') === $time->format('H:i') && $data->getId() !== null) {
|
||||
$seconds = $data->getBegin()->format('s') ?? 0;
|
||||
}
|
||||
|
||||
// mutable datetime are a problem for doctrine
|
||||
$newDate = clone $date;
|
||||
$newDate->setTime($time->format('H'), $time->format('i'));
|
||||
$newDate->setTime((int) $time->format('H'), (int) $time->format('i'), (int) $seconds);
|
||||
|
||||
if ($data->getBegin() === null || $data->getBegin()->getTimestamp() !== $newDate->getTimestamp()) {
|
||||
$data->setBegin($newDate);
|
||||
@@ -251,6 +261,7 @@ class TimesheetEditForm extends AbstractType
|
||||
$timesheet = $event->getData();
|
||||
$oldEnd = $timesheet->getEnd();
|
||||
|
||||
/** @var \DateTime|null $end */
|
||||
$end = $event->getForm()->get('end_time')->getData();
|
||||
if ($end === null || $end === false) {
|
||||
$timesheet->setEnd(null);
|
||||
@@ -267,8 +278,15 @@ class TimesheetEditForm extends AbstractType
|
||||
if ($time === null) {
|
||||
throw new \Exception('Cannot work with timesheets without start time');
|
||||
}
|
||||
|
||||
$seconds = 0;
|
||||
// if the user did not change the time, make sure to keep the seconds (ONLY if the timesheet is already existing)
|
||||
if ($oldEnd !== null && $oldEnd->format('H:i') === $end->format('H:i') && $timesheet->getId() !== null) {
|
||||
$seconds = $oldEnd->format('s') ?? 0;
|
||||
}
|
||||
|
||||
$newEnd = clone $time;
|
||||
$newEnd->setTime($end->format('H'), $end->format('i'));
|
||||
$newEnd->setTime((int) $end->format('H'), (int) $end->format('i'), (int) $seconds);
|
||||
|
||||
if ($newEnd < $time) {
|
||||
$newEnd->modify('+ 1 day');
|
||||
@@ -360,16 +378,13 @@ class TimesheetEditForm extends AbstractType
|
||||
return;
|
||||
}
|
||||
|
||||
$moneyOptions = ['currency' => $currency];
|
||||
|
||||
$builder
|
||||
->add('fixedRate', FixedRateType::class, [
|
||||
'currency' => $currency,
|
||||
])
|
||||
->add('hourlyRate', HourlyRateType::class, [
|
||||
'currency' => $currency,
|
||||
])
|
||||
->add('internalRate', InternalRateType::class, [
|
||||
'currency' => $currency,
|
||||
]);
|
||||
->add('fixedRate', FixedRateType::class, $moneyOptions)
|
||||
->add('hourlyRate', HourlyRateType::class, $moneyOptions)
|
||||
//->add('internalRate', InternalRateType::class, $moneyOptions)
|
||||
;
|
||||
}
|
||||
|
||||
protected function addUser(FormBuilderInterface $builder, array $options): void
|
||||
@@ -388,14 +403,17 @@ class TimesheetEditForm extends AbstractType
|
||||
}
|
||||
|
||||
$builder->add('exported', YesNoType::class, [
|
||||
'label' => 'exported'
|
||||
'label' => 'exported',
|
||||
'documentation' => [
|
||||
'description' => 'If true, this timesheet will be flagged as being exported'
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
protected function addBillable(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
if ($options['include_billable']) {
|
||||
$builder->add('billableMode', TimesheetBillableType::class, []);
|
||||
$builder->add('billableMode', TimesheetBillableType::class);
|
||||
}
|
||||
|
||||
$builder->addModelTransformer(new CallbackTransformer(
|
||||
|
||||
@@ -33,6 +33,11 @@ final class DurationType extends AbstractType
|
||||
'toggle' => false,
|
||||
'max_hours' => 24,
|
||||
'icon' => 'clock',
|
||||
'documentation' => [
|
||||
'type' => 'string',
|
||||
'description' => 'Duration - supports various formats: https://www.kimai.org/documentation/duration-format.html',
|
||||
'example' => '01:30',
|
||||
]
|
||||
]);
|
||||
$resolver->setAllowedTypes('max_hours', 'int');
|
||||
}
|
||||
|
||||
@@ -92,6 +92,8 @@ final class QuickEntryWeekType extends AbstractType
|
||||
};
|
||||
$builder->addEventListener(FormEvents::PRE_SUBMIT, $activityPreSubmitFunction);
|
||||
|
||||
$builder->add('metaFields', MetaFieldsCollectionType::class);
|
||||
|
||||
$builder->add('timesheets', CollectionType::class, [
|
||||
'entry_type' => QuickEntryTimesheetType::class,
|
||||
'label' => false,
|
||||
|
||||
@@ -11,6 +11,7 @@ namespace App\Form\Type;
|
||||
|
||||
use App\Configuration\LocaleService;
|
||||
use App\Utils\FormFormatConverter;
|
||||
use App\Utils\JavascriptFormatConverter;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\CallbackTransformer;
|
||||
use Symfony\Component\Form\Exception\TransformationFailedException;
|
||||
@@ -18,33 +19,47 @@ use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\Form\FormView;
|
||||
use Symfony\Component\OptionsResolver\Options;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
final class TimePickerType extends AbstractType
|
||||
{
|
||||
public function __construct(private LocaleService $localeService)
|
||||
public function __construct(private readonly LocaleService $localeService)
|
||||
{
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$format = $this->localeService->getTimeFormat(\Locale::getDefault());
|
||||
$converter = new FormFormatConverter();
|
||||
$formFormat = $converter->convert($format);
|
||||
|
||||
$resolver->setDefaults([
|
||||
'input' => 'string',
|
||||
'format' => $formFormat,
|
||||
'placeholder' => $formFormat, // $format
|
||||
'locale' => \Locale::getDefault(),
|
||||
'model_timezone' => date_default_timezone_get(),
|
||||
'view_timezone' => date_default_timezone_get(),
|
||||
'block_prefix' => 'time'
|
||||
]);
|
||||
|
||||
$resolver->setDefault('time_format', function (Options $options): string {
|
||||
// We used the configured time format via "getTimeFormat()" for entering times before, but it caused issues.
|
||||
// So now we only allow two different input types: 12-hour with AM/PM suffix and 24-hour
|
||||
return $this->localeService->is24Hour($options['locale']) ? 'HH:mm' : 'h:mm a';
|
||||
});
|
||||
|
||||
$resolver->setDefault('format', function (Options $options): string {
|
||||
$converter = new FormFormatConverter();
|
||||
|
||||
return $converter->convert($options['time_format']);
|
||||
});
|
||||
|
||||
$resolver->setDefault('placeholder', function (Options $options): string {
|
||||
return $options['time_format'];
|
||||
});
|
||||
}
|
||||
|
||||
public function buildView(FormView $view, FormInterface $form, array $options): void
|
||||
{
|
||||
$view->vars['format'] = $options['format'];
|
||||
$view->vars['time_format'] = $options['time_format'];
|
||||
$view->vars['js_format'] = (new JavascriptFormatConverter())->convert($options['time_format']); // @phpstan-ignore argument.type
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user