Release 2.0.2 (#3856)

* allow to overwrite global spreadsheet styles
* bump version
* fix deprecations in vcard download
* bump composer packages
* added help page for all registered locales
* fix menu id's, cleanup times route, fix configurable homepage redirect
* format duration without leading zero in hours (unify javascript with php behavior)
* fix active records in all screen sizes
* improved responsiveness in XS
* fixed invoice number for customer null fields
* fix javascript respects multiple recent-activity dropdowns
* show recent activities on small screens
* fix user-profile layout column in XS
* fix search is always marked as active
This commit is contained in:
Kevin Papst
2023-02-21 19:21:49 +01:00
committed by GitHub
parent e474087257
commit 25469113fd
61 changed files with 821 additions and 580 deletions

View File

@@ -83,7 +83,7 @@ export default class KimaiLoader {
kimai.registerPlugin(new KimaiAlternativeLinks('.alternative-link'));
kimai.registerPlugin(new KimaiAjaxModalForm('.modal-ajax-form'));
kimai.registerPlugin(new KimaiRecentActivities());
kimai.registerPlugin(new KimaiActiveRecords('header .messages-menu', 'header .messages-menu-empty'));
kimai.registerPlugin(new KimaiActiveRecords());
kimai.registerPlugin(new KimaiAPILink('api-link'));
kimai.registerPlugin(new KimaiMultiUpdateTable());
kimai.registerPlugin(new KimaiThemeInitializer());

View File

@@ -13,26 +13,28 @@ import KimaiPlugin from '../KimaiPlugin';
export default class KimaiActiveRecords extends KimaiPlugin {
constructor(selector, selectorEmpty) {
constructor()
{
super();
this._selector = selector;
this._selectorEmpty = selectorEmpty;
this._selector = '.ticktac-menu';
this._selectorEmpty = '.ticktac-menu-empty';
}
getId() {
/**
* @returns {string}
*/
getId()
{
return 'active-records';
}
init() {
this._menu = document.querySelector(this._selector);
init()
{
// the menu can be hidden if user has no permissions to see it
if (this._menu === null) {
if (document.querySelector(this._selector) === null) {
return;
}
this.attributes = this._menu.dataset;
const handleUpdate = () => {
this.reloadActiveRecords();
};
@@ -49,7 +51,9 @@ export default class KimaiActiveRecords extends KimaiPlugin {
// -----------------------------------------------------------------------
// handle duration in the visible UI
this._updateBrowserTitle = !!this.getConfiguration('updateBrowserTitle');
this._updateDuration();
// deactivated direct duration update, becuase it is unclear
// this._updateDuration();
const handle = () => {
this._updateDuration();
};
@@ -63,8 +67,15 @@ export default class KimaiActiveRecords extends KimaiPlugin {
// clearInterval(this._updatesHandler);
// }
_updateDuration() {
const activeRecords = this._menu.querySelectorAll('[data-since]:not([data-since=""])');
/**
* Updates the duration of all running entries, both in the ticktac menus and in the listing pages.
*
* @private
*/
_updateDuration()
{
// needs to search in document, to find all running entries, both in "ticktac" and listing pages
const activeRecords = document.querySelectorAll('[data-since]:not([data-since=""])');
if (activeRecords.length === 0) {
if (this._updateBrowserTitle) {
@@ -94,57 +105,70 @@ export default class KimaiActiveRecords extends KimaiPlugin {
return;
}
if (!this._updateBrowserTitle) {
return;
if (this._updateBrowserTitle) {
// only show the first found record, even if we have more
document.title = durations.shift();
}
}
let title = durations.shift();
for (const duration of durations.slice(0, 2)) {
title += ' | ' + duration;
}
document.title = title;
}
_setEntries(entries) {
/**
* Adapts the ticktac menus accordingg to the given entries (amount and duration).
* Does not influence listing pages, as those refresh themselves.
*
* @param {array} entries
* @private
*/
_setEntries(entries)
{
const hasEntries = entries.length > 0;
this._menu.style.display = hasEntries ? 'inline-block' : 'none';
// these contain the "start" button
for (let menuEmpty of document.querySelectorAll(this._selectorEmpty)) {
menuEmpty.style.display = !hasEntries ? 'inline-block' : 'none';
}
// and they contain the "stop" button
for (let menu of document.querySelectorAll(this._selector)) {
menu.style.display = hasEntries ? 'inline-block' : 'none';
if (!hasEntries) {
// make sure that template entries in the menu are removed, otherwise they
// might still be shown in the browsers title
for (let record of this._menu.querySelectorAll('[data-since]')) {
for (let record of menu.querySelectorAll('[data-since]')) {
record.dataset['since'] = '';
}
}
const menuEmpty = document.querySelector(this._selectorEmpty);
if (menuEmpty !== null) {
menuEmpty.style.display = !hasEntries ? 'inline-block' : 'none';
}
const stop = this._menu.querySelector('.ticktac-stop');
const stop = menu.querySelector('.ticktac-stop');
if (!hasEntries) {
if (stop) {
stop.accesskey = null;
}
return;
continue;
}
if (stop) {
stop.accesskey = 's';
}
this._replaceInNode(this._menu, entries[0]);
this._replaceInNode(menu, entries[0]);
}
this._updateDuration();
}
_replaceInNode(node, timesheet) {
/**
* @param {HTMLElement} node
* @param {object} timesheet
* @private
*/
_replaceInNode(node, timesheet)
{
const date = this.getDateUtils();
const allReplacer = node.querySelectorAll('[data-replacer]');
for (let link of allReplacer) {
const replacerName = link.dataset['replacer'];
if (replacerName === 'url') {
link.href = this.attributes['href'].replace('000', timesheet.id);
link.href = node.dataset['href'].replace('000', timesheet.id);
} else if (replacerName === 'activity') {
link.innerText = timesheet.activity.name;
} else if (replacerName === 'project') {
@@ -158,11 +182,15 @@ export default class KimaiActiveRecords extends KimaiPlugin {
}
}
reloadActiveRecords() {
reloadActiveRecords()
{
/** @type {KimaiAPI} API */
const API = this.getContainer().getPlugin('api');
API.get(this.attributes['api'], {}, (result) => {
// TODO using the first found "ticktac" menu is working, but can be done better
const apiUrl = document.querySelector(this._selector).dataset['api'];
API.get(apiUrl, {}, (result) => {
this._setEntries(result);
});
}

View File

@@ -243,8 +243,7 @@ export default class KimaiDateUtils extends KimaiPlugin {
format = '-' + format;
}
return format.replace('%h', (hours < 10 ? '0' + hours : hours)).replace('%m', ('0' + minutes).slice(-2));
//return format.replace('%h', (hours < 10 ? '0' + hours : hours)).replace('%m', ('0' + minutes).slice(-2)).replace('%s', ('0' + seconds).slice(-2));
return format.replace('%h', hours.toString()).replace('%m', ('0' + minutes).slice(-2));
}
/**

View File

@@ -13,6 +13,15 @@ import KimaiPlugin from '../KimaiPlugin';
export default class KimaiRecentActivities extends KimaiPlugin {
constructor()
{
super();
this._selector = '.notifications-menu';
}
/**
* @returns {string}
*/
getId()
{
return 'recent-activities';
@@ -20,15 +29,17 @@ export default class KimaiRecentActivities extends KimaiPlugin {
init()
{
this.menu = document.querySelector('header .notifications-menu');
const menus = document.querySelectorAll(this._selector);
// the menu can be hidden if user has no permissions to see it
// or no timesheet was recorded yet
if (this.menu === null || this.menu.dataset['reload'] === undefined) {
if (menus.length === 0 || menus[0].dataset['reload'] === undefined) {
return;
}
const handle = () => {
this._reloadMenu(this.menu.dataset['reload']);
// TODO this works but using the first menu is not ideal, pass in the reload URL?
this._reloadMenu(menus[0].dataset['reload']);
};
document.addEventListener('kimai.recentActivities', handle);
@@ -44,9 +55,12 @@ export default class KimaiRecentActivities extends KimaiPlugin {
this._attachAddRemoveFavorite();
}
/**
* @private
*/
_attachAddRemoveFavorite()
{
[].slice.call(this.menu.querySelectorAll('a.list-group-item-actions')).map((element) => {
[].slice.call(document.querySelectorAll(this._selector + ' a.list-group-item-actions')).map((element) => {
element.addEventListener('click', (event) => {
this._reloadMenu(event.currentTarget.href);
@@ -58,26 +72,33 @@ export default class KimaiRecentActivities extends KimaiPlugin {
});
}
/**
* Reload all ercent activities and update the existing menus.
*
* @param {string} url
* @private
*/
_reloadMenu(url)
{
this.fetch(url, {method: 'GET'})
.then(response => {
if (!response.ok) {
//this.menu.remove();
return;
}
return response.text().then(html => {
const newFormHtml = document.createElement('div');
newFormHtml.innerHTML = html;
this.menu.replaceWith(newFormHtml.firstElementChild);
this.menu = document.querySelector('header .notifications-menu');
for (let menu of document.querySelectorAll(this._selector)) {
menu.replaceWith(newFormHtml.firstElementChild.cloneNode(true));
}
this._attachAddRemoveFavorite();
});
})
.catch(() => {
//this.menu.remove();
.catch((reason) => {
console.log('Failed to log recent activities', reason);
});
}
}

View File

@@ -6,10 +6,10 @@
*/
$fa-font-path: "~@fortawesome/fontawesome-free/webfonts/";
@import '~@fortawesome/fontawesome-free/scss/fontawesome';
@import '~@fortawesome/fontawesome-free/scss/regular';
@import '~@fortawesome/fontawesome-free/scss/solid';
@import '~@fortawesome/fontawesome-free/scss/brands';
@import "@fortawesome/fontawesome-free/scss/fontawesome";
@import "@fortawesome/fontawesome-free/scss/regular";
@import "@fortawesome/fontawesome-free/scss/solid";
@import "@fortawesome/fontawesome-free/scss/brands";
/* all icon-buttons with fontawesome icons */
.btn-icon i.icon {

View File

@@ -5,17 +5,17 @@
* file that was distributed with this source code.
*/
@import "~bootstrap/scss/functions";
@import "~bootstrap/scss/variables";
@import "~bootstrap/scss/maps";
@import "~bootstrap/scss/mixins";
@import "~bootstrap/scss/reboot";
@import "~bootstrap/scss/type";
@import "~bootstrap/scss/grid";
@import "~bootstrap/scss/tables";
@import "~bootstrap/scss/list-group";
@import "~bootstrap/scss/card";
@import "~bootstrap/scss/utilities";
@import "bootstrap/scss/functions";
@import "bootstrap/scss/variables";
@import "bootstrap/scss/maps";
@import "bootstrap/scss/mixins";
@import "bootstrap/scss/reboot";
@import "bootstrap/scss/type";
@import "bootstrap/scss/grid";
@import "bootstrap/scss/tables";
@import "bootstrap/scss/list-group";
@import "bootstrap/scss/card";
@import "bootstrap/scss/utilities";
body {
font-family: $font-family-sans-serif;

View File

@@ -5,11 +5,22 @@
border-right: 0;
}
}
@include media-breakpoint-down(sm) {
.notifications-menu .dropdown-menu-card {
width: 100vw;
right: 0;
position: fixed;
top: 56px;
}
}
@media (min-width: 360px) {
.inline-search {
max-width: 235px;
}
}
@include media-breakpoint-up(md) {
.inline-search {
max-width: 325px;

View File

@@ -1,4 +1,4 @@
@import '~tom-select/dist/scss/tom-select.bootstrap5';
@import "tom-select/dist/scss/tom-select.bootstrap5";
/* bootstrap thead.sticky-top has z-index 1020 and tomselect dropdowns often hide behind that */
.ts-dropdown {

View File

@@ -1,4 +1,4 @@
@import '~@tabler/core/src/scss/variables';
@import "@tabler/core/src/scss/variables";
/* fix for vertical labels in date-time pickers, if they are used in half-column forms, e.g. "create tasks" or "create expenses" */
fieldset legend.col-form-label {

View File

@@ -29,6 +29,7 @@
.ticktac-stop {
i {
animation: ticktac-blink 2s step-end infinite;
font-size: 140%;
}
}

View File

@@ -5,12 +5,12 @@
* file that was distributed with this source code.
*/
@import "~bootstrap/scss/functions";
@import "~bootstrap/scss/variables";
@import "~bootstrap/scss/mixins";
@import "bootstrap/scss/functions";
@import "bootstrap/scss/variables";
@import "bootstrap/scss/mixins";
@import '~@tabler/core/src/scss/variables';
@import '~@tabler/core/src/scss/mixins';
@import "@tabler/core/src/scss/variables";
@import "@tabler/core/src/scss/mixins";
/* For highlighting rows, fieldsets and so on */
$highlight-bg: #f5f5f5;

42
composer.lock generated
View File

@@ -2055,16 +2055,16 @@
},
{
"name": "gedmo/doctrine-extensions",
"version": "v3.11.0",
"version": "v3.11.1",
"source": {
"type": "git",
"url": "https://github.com/doctrine-extensions/DoctrineExtensions.git",
"reference": "a2364710d2f367f74d9f041c3356f2929b8c6690"
"reference": "ae4bdf0d567e06b6bb1902a560ee78961b230953"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine-extensions/DoctrineExtensions/zipball/a2364710d2f367f74d9f041c3356f2929b8c6690",
"reference": "a2364710d2f367f74d9f041c3356f2929b8c6690",
"url": "https://api.github.com/repos/doctrine-extensions/DoctrineExtensions/zipball/ae4bdf0d567e06b6bb1902a560ee78961b230953",
"reference": "ae4bdf0d567e06b6bb1902a560ee78961b230953",
"shasum": ""
},
"require": {
@@ -2136,16 +2136,18 @@
"email": "david@liip.ch"
}
],
"description": "Doctrine2 behavioral extensions",
"description": "Doctrine behavioral extensions",
"homepage": "http://gediminasm.org/",
"keywords": [
"Blameable",
"behaviors",
"doctrine2",
"doctrine",
"extensions",
"gedmo",
"loggable",
"nestedset",
"odm",
"orm",
"sluggable",
"sortable",
"timestampable",
@@ -2156,10 +2158,10 @@
"support": {
"email": "gediminas.morkevicius@gmail.com",
"issues": "https://github.com/doctrine-extensions/DoctrineExtensions/issues",
"source": "https://github.com/doctrine-extensions/DoctrineExtensions/tree/v3.11.0",
"source": "https://github.com/doctrine-extensions/DoctrineExtensions/tree/v3.11.1",
"wiki": "https://github.com/Atlantic18/DoctrineExtensions/tree/main/doc"
},
"time": "2023-01-26T00:14:07+00:00"
"time": "2023-02-20T19:24:07+00:00"
},
{
"name": "jeroendesloovere/vcard",
@@ -6179,16 +6181,16 @@
},
{
"name": "symfony/flex",
"version": "v2.2.4",
"version": "v2.2.5",
"source": {
"type": "git",
"url": "https://github.com/symfony/flex.git",
"reference": "52baff1adb29faf443c6710cb775bd88b9627381"
"reference": "2ff8465e7172790a47ab3c129f2b514eb2d8a286"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/flex/zipball/52baff1adb29faf443c6710cb775bd88b9627381",
"reference": "52baff1adb29faf443c6710cb775bd88b9627381",
"url": "https://api.github.com/repos/symfony/flex/zipball/2ff8465e7172790a47ab3c129f2b514eb2d8a286",
"reference": "2ff8465e7172790a47ab3c129f2b514eb2d8a286",
"shasum": ""
},
"require": {
@@ -6224,7 +6226,7 @@
"description": "Composer plugin for Symfony",
"support": {
"issues": "https://github.com/symfony/flex/issues",
"source": "https://github.com/symfony/flex/tree/v2.2.4"
"source": "https://github.com/symfony/flex/tree/v2.2.5"
},
"funding": [
{
@@ -6240,7 +6242,7 @@
"type": "tidelift"
}
],
"time": "2022-12-20T07:19:39+00:00"
"time": "2023-02-18T08:03:15+00:00"
},
{
"name": "symfony/form",
@@ -10360,16 +10362,16 @@
},
{
"name": "zircote/swagger-php",
"version": "4.6.2",
"version": "4.7.0",
"source": {
"type": "git",
"url": "https://github.com/zircote/swagger-php.git",
"reference": "14d6c5e2bde957e03a3a5496a3b18419a0480199"
"reference": "f2bcbd5aa9d838d28bf5665c70f88eda6adc22cb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/zircote/swagger-php/zipball/14d6c5e2bde957e03a3a5496a3b18419a0480199",
"reference": "14d6c5e2bde957e03a3a5496a3b18419a0480199",
"url": "https://api.github.com/repos/zircote/swagger-php/zipball/f2bcbd5aa9d838d28bf5665c70f88eda6adc22cb",
"reference": "f2bcbd5aa9d838d28bf5665c70f88eda6adc22cb",
"shasum": ""
},
"require": {
@@ -10432,9 +10434,9 @@
],
"support": {
"issues": "https://github.com/zircote/swagger-php/issues",
"source": "https://github.com/zircote/swagger-php/tree/4.6.2"
"source": "https://github.com/zircote/swagger-php/tree/4.7.0"
},
"time": "2023-02-14T22:54:06+00:00"
"time": "2023-02-20T23:05:35+00:00"
}
],
"packages-dev": [

View File

@@ -1040,21 +1040,6 @@ parameters:
count: 2
path: src/Controller/CalendarController.php
-
message: "#^Parameter \\#1 \\$address of method JeroenDesloovere\\\\VCard\\\\VCard\\:\\:addEmail\\(\\) expects string, string\\|null given\\.$#"
count: 1
path: src/Controller/CustomerController.php
-
message: "#^Parameter \\#1 \\$company of method JeroenDesloovere\\\\VCard\\\\VCard\\:\\:addCompany\\(\\) expects string, string\\|null given\\.$#"
count: 1
path: src/Controller/CustomerController.php
-
message: "#^Parameter \\#1 \\$country of static method Symfony\\\\Component\\\\Intl\\\\Countries\\:\\:getName\\(\\) expects string, string\\|null given\\.$#"
count: 1
path: src/Controller/CustomerController.php
-
message: "#^Parameter \\#1 \\$filename of static method App\\\\Utils\\\\FileHelper\\:\\:convertToAsciiFilename\\(\\) expects string, string\\|null given\\.$#"
count: 1
@@ -1075,11 +1060,6 @@ parameters:
count: 1
path: src/Controller/CustomerController.php
-
message: "#^Parameter \\#2 \\$string of function explode expects string, string\\|null given\\.$#"
count: 1
path: src/Controller/CustomerController.php
-
message: "#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\\.$#"
count: 2
@@ -1165,11 +1145,6 @@ parameters:
count: 1
path: src/Controller/ExportController.php
-
message: "#^Parameter \\#1 \\$route of method Symfony\\\\Bundle\\\\FrameworkBundle\\\\Controller\\\\AbstractController\\:\\:redirectToRoute\\(\\) expects string, bool\\|float\\|int\\|string\\|null given\\.$#"
count: 1
path: src/Controller/HomepageController.php
-
message: "#^Cannot call method getEntries\\(\\) on App\\\\Invoice\\\\CalculatorInterface\\|null\\.$#"
count: 1
@@ -2900,151 +2875,6 @@ parameters:
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\AbstractSpreadsheetRenderer\\:\\:getColumns\\(\\) has parameter \\$columns with no value type specified in iterable type array\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\AbstractSpreadsheetRenderer\\:\\:getColumns\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\AbstractSpreadsheetRenderer\\:\\:setDuration\\(\\) has parameter \\$column with no type specified\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\AbstractSpreadsheetRenderer\\:\\:setDuration\\(\\) has parameter \\$duration with no type specified\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\AbstractSpreadsheetRenderer\\:\\:setDuration\\(\\) has parameter \\$row with no type specified\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\AbstractSpreadsheetRenderer\\:\\:setDurationTotal\\(\\) has parameter \\$column with no type specified\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\AbstractSpreadsheetRenderer\\:\\:setDurationTotal\\(\\) has parameter \\$endCoordinate with no type specified\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\AbstractSpreadsheetRenderer\\:\\:setDurationTotal\\(\\) has parameter \\$row with no type specified\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\AbstractSpreadsheetRenderer\\:\\:setDurationTotal\\(\\) has parameter \\$startCoordinate with no type specified\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\AbstractSpreadsheetRenderer\\:\\:setFormattedDate\\(\\) has parameter \\$column with no type specified\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\AbstractSpreadsheetRenderer\\:\\:setFormattedDate\\(\\) has parameter \\$row with no type specified\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\AbstractSpreadsheetRenderer\\:\\:setFormattedDateTime\\(\\) has parameter \\$column with no type specified\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\AbstractSpreadsheetRenderer\\:\\:setFormattedDateTime\\(\\) has parameter \\$row with no type specified\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\AbstractSpreadsheetRenderer\\:\\:setFormattedTime\\(\\) has parameter \\$column with no type specified\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\AbstractSpreadsheetRenderer\\:\\:setFormattedTime\\(\\) has parameter \\$row with no type specified\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\AbstractSpreadsheetRenderer\\:\\:setRate\\(\\) has parameter \\$column with no type specified\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\AbstractSpreadsheetRenderer\\:\\:setRate\\(\\) has parameter \\$currency with no type specified\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\AbstractSpreadsheetRenderer\\:\\:setRate\\(\\) has parameter \\$rate with no type specified\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\AbstractSpreadsheetRenderer\\:\\:setRate\\(\\) has parameter \\$row with no type specified\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\AbstractSpreadsheetRenderer\\:\\:setRateStyle\\(\\) has parameter \\$column with no type specified\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\AbstractSpreadsheetRenderer\\:\\:setRateStyle\\(\\) has parameter \\$currency with no type specified\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\AbstractSpreadsheetRenderer\\:\\:setRateStyle\\(\\) has parameter \\$rate with no type specified\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\AbstractSpreadsheetRenderer\\:\\:setRateStyle\\(\\) has parameter \\$row with no type specified\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\AbstractSpreadsheetRenderer\\:\\:setRateTotal\\(\\) has parameter \\$column with no type specified\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\AbstractSpreadsheetRenderer\\:\\:setRateTotal\\(\\) has parameter \\$endCoordinate with no type specified\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\AbstractSpreadsheetRenderer\\:\\:setRateTotal\\(\\) has parameter \\$row with no type specified\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\AbstractSpreadsheetRenderer\\:\\:setRateTotal\\(\\) has parameter \\$startCoordinate with no type specified\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Parameter \\#1 \\$columnIndex of method PhpOffice\\\\PhpSpreadsheet\\\\Worksheet\\\\Worksheet\\:\\:getCellByColumnAndRow\\(\\) expects int, float\\|int given\\.$#"
count: 6
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Parameter \\#1 \\$columnIndex1 of method PhpOffice\\\\PhpSpreadsheet\\\\Worksheet\\\\Worksheet\\:\\:getStyleByColumnAndRow\\(\\) expects int, float\\|int given\\.$#"
count: 3
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Parameter \\#1 \\$file of class Symfony\\\\Component\\\\HttpFoundation\\\\BinaryFileResponse constructor expects SplFileInfo\\|string, mixed given\\.$#"
count: 1
@@ -3080,41 +2910,6 @@ parameters:
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\CsvRenderer\\:\\:setDuration\\(\\) has parameter \\$column with no type specified\\.$#"
count: 1
path: src/Export/Base/CsvRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\CsvRenderer\\:\\:setDuration\\(\\) has parameter \\$duration with no type specified\\.$#"
count: 1
path: src/Export/Base/CsvRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\CsvRenderer\\:\\:setDuration\\(\\) has parameter \\$row with no type specified\\.$#"
count: 1
path: src/Export/Base/CsvRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\CsvRenderer\\:\\:setRateStyle\\(\\) has parameter \\$column with no type specified\\.$#"
count: 1
path: src/Export/Base/CsvRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\CsvRenderer\\:\\:setRateStyle\\(\\) has parameter \\$currency with no type specified\\.$#"
count: 1
path: src/Export/Base/CsvRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\CsvRenderer\\:\\:setRateStyle\\(\\) has parameter \\$rate with no type specified\\.$#"
count: 1
path: src/Export/Base/CsvRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\CsvRenderer\\:\\:setRateStyle\\(\\) has parameter \\$row with no type specified\\.$#"
count: 1
path: src/Export/Base/CsvRenderer.php
-
message: "#^Cannot call method getBudget\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#"
count: 1

View File

@@ -1,6 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- https://phpunit.de/manual/current/en/appendixes.configuration.html -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd" backupGlobals="false" colors="true" bootstrap="tests/bootstrap.php">
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
backupGlobals="false"
colors="true"
bootstrap="tests/bootstrap.php">
<php>
<ini name="error_reporting" value="-1"/>
<ini name="max_execution_time" value="-1"/>
@@ -12,12 +15,6 @@
<env name="APP_ENV" value="test" force="true"/>
<env name="APP_DEBUG" value="0" force="true"/>
<env name="APP_SECRET" value="5a79a1c866efef9ca1800f971d689f3e" force="true"/>
<!--
CREATE DATABASE IF NOT EXISTS `kimai2_test`;
CREATE USER IF NOT EXISTS `kimai2_test`@127.0.0.1;
ALTER USER `kimai2_test`@127.0.0.1 IDENTIFIED BY "kimai2_test";
GRANT execute,select,insert,update,delete,create,alter,drop,index,references ON `kimai2_test`.* TO kimai2_test@127.0.0.1;
-->
<env name="DATABASE_URL" value="mysql://kimai2_test:kimai2_test@127.0.0.1:3306/kimai2_test"/>
<env name="CORS_ALLOW_ORIGIN" value="^https?://localhost(:[0-9]+)?$"/>
<env name="MAILER_URL" value="null://null"/>
@@ -25,10 +22,14 @@
<!--
REINSTALL THE TEST DATABASE, eg. AFTER CHANGING STRUCTURE!
Simple:
- change to "true"
- composer tests
- change back to "false"
CREATE DATABASE IF NOT EXISTS `kimai2_test`;
CREATE USER IF NOT EXISTS `kimai2_test`@127.0.0.1;
ALTER USER `kimai2_test`@127.0.0.1 IDENTIFIED BY "kimai2_test";
GRANT execute,select,insert,update,delete,create,alter,drop,index,references ON `kimai2_test`.* TO kimai2_test@127.0.0.1;
-->
<env name="BOOTSTRAP_RESET_DATABASE" value="true"/>
</php>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -3,10 +3,10 @@
"app": {
"js": [
"/build/runtime.f0079159.js",
"/build/app.8b0c21dc.js"
"/build/app.50e94a4a.js"
],
"css": [
"/build/app.73c27235.css"
"/build/app.2c6c5920.css"
]
},
"export-pdf": {
@@ -45,7 +45,7 @@
"calendar": {
"js": [
"/build/runtime.f0079159.js",
"/build/calendar.7dd54a5f.js"
"/build/calendar.3041e41e.js"
],
"css": [
"/build/calendar.bb473428.css"
@@ -63,8 +63,8 @@
},
"integrity": {
"/build/runtime.f0079159.js": "sha384-H22sAW1aTvyIPqvHOvGXWSWTxf0y6mptp+MsVmyXCfjx/WJjBbhX9gbUZ+qIuihV",
"/build/app.8b0c21dc.js": "sha384-k0LUmYHZUK6bFVymGMzcHSq+utW09iR+YG6VMzqCiXfBGtVvb2Sdr0IB7w6hR+I6",
"/build/app.73c27235.css": "sha384-0vdzXrL3JuXTfiYJUdY17wIGFIbUTzQWfBQXbhcXcFRVmpmJYY1TZYSauxVUNwev",
"/build/app.50e94a4a.js": "sha384-HR3cG9DvdBAyo08dhybZE8eNOvFY+3Yvp2zxFC43hcwTK6dGoAOLTyNhTdoGi8CR",
"/build/app.2c6c5920.css": "sha384-qiubu7JONCsPTbVOb8bSJ4GYxioCwIUvlsI0usMmIUyoTxawn6jsjG7KK64iej5S",
"/build/export-pdf.587575e7.js": "sha384-J50GStmmfVwUTN4dIRQ02eg9hyzGFPSzpTtpPody92j0V6zCqw+s5l8+ZhVTugeW",
"/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
"/build/invoice.c8ae95ad.js": "sha384-2eVY7MBiMQxo1vhfizU+fYfEZbz9bYUdzqxnpTQhxpYiLaxeSV4WnaObq2G7/Pks",
@@ -72,7 +72,7 @@
"/build/invoice-pdf.d86b82ee.js": "sha384-A0HJqP+MvEqQr1uG8wViCeEWxBRKyS6l8D+Ao4pFYHUA12gCC1gRYhk9I+SJPvZq",
"/build/invoice-pdf.c88953bb.css": "sha384-ZvSi1e+ZKGzvZJUtAPLjzOSTh13N9zRevq44GKdYdBja/DAplGE55saY2Ur+83yv",
"/build/chart.f5becfac.js": "sha384-GSqETm8wULiVXyizvwRompfwu63r/C0Qd/AvrHDE4cqAKiIGCssb3QyBtGu1WN+W",
"/build/calendar.7dd54a5f.js": "sha384-oUWTIvkHr+k1UrlriT4+iA+irValKNLFoxva7ljQ/bsbczzaql3uWD0dlC4mF0ss",
"/build/calendar.3041e41e.js": "sha384-vdX0khIprtUhM4P8aiSsY5H3YSjHM1OMNdiYIhoKy/fm//5gwaEPkSbZDsTbe5dT",
"/build/calendar.bb473428.css": "sha384-900W9o8666Qpw21rLP7hn5Ql8tWd40k0IcE3QpFm7E7V6bKva7xQgGlyzVuuh4GK",
"/build/dashboard.6774a712.js": "sha384-lBwkNqUPv+IBbznagiFakY2BOIueq/Bg89wHO2XeM9YAZ51rPkrvdMd+XmFupHsM",
"/build/dashboard.18f5a8b7.css": "sha384-PBD8ftb2yBoSjRe2sN5Xb4dEz045kOEUfcIufdSis+tWoWdAx5j4Yic+F/6CYbrP"

View File

@@ -1,6 +1,6 @@
{
"build/app.css": "/build/app.73c27235.css",
"build/app.js": "/build/app.8b0c21dc.js",
"build/app.css": "/build/app.2c6c5920.css",
"build/app.js": "/build/app.50e94a4a.js",
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
"build/export-pdf.js": "/build/export-pdf.587575e7.js",
"build/invoice.css": "/build/invoice.3c80ee80.css",
@@ -9,7 +9,7 @@
"build/invoice-pdf.js": "/build/invoice-pdf.d86b82ee.js",
"build/chart.js": "/build/chart.f5becfac.js",
"build/calendar.css": "/build/calendar.bb473428.css",
"build/calendar.js": "/build/calendar.7dd54a5f.js",
"build/calendar.js": "/build/calendar.3041e41e.js",
"build/dashboard.css": "/build/dashboard.18f5a8b7.css",
"build/dashboard.js": "/build/dashboard.6774a712.js",
"build/runtime.js": "/build/runtime.f0079159.js"

View File

@@ -17,11 +17,11 @@ class Constants
/**
* The current release version
*/
public const VERSION = '2.0.1';
public const VERSION = '2.0.2';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 20001;
public const VERSION_ID = 20002;
/**
* The software name
*/

View File

@@ -368,20 +368,39 @@ final class CustomerController extends AbstractController
$vcard = new VCard();
$contact = $customer->getContact() ?? $customer->getName();
if ($contact === null || \strlen($contact) === 0) {
$contact = 'Unknown';
}
$contact = explode(' ', $contact);
$lastname = array_pop($contact);
$firstname = \count($contact) > 0 ? $contact[0] : $lastname;
$firstname = \count($contact) > 0 ? $contact[0] : '';
$vcard->addName($lastname, $firstname);
$note = $customer->getComment();
if ($note !== null) {
$note .= PHP_EOL;
}
$vcard->addName($lastname, $firstname);
$vcard->addNote($note . $customer->getAddress());
$vcard->addAddress(null, null, null, null, null, null, Countries::getName($customer->getCountry()));
$address = $customer->getAddress();
if (!empty($note) || !empty($address)) {
$vcard->addNote($note . $address);
}
$vcard->addCompany($customer->getCompany() ?? $customer->getName());
$vcard->addEmail($customer->getEmail());
$country = $customer->getCountry();
if ($country !== null) {
$vcard->addAddress(null, null, null, null, null, null, Countries::getName($country));
}
$company = $customer->getCompany();
if ($company !== null) {
$vcard->addCompany($company);
}
$email = $customer->getEmail();
if ($email !== null) {
$vcard->addEmail($email);
}
$hasPref = false;

View File

@@ -0,0 +1,86 @@
<?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\Controller;
use App\Configuration\LocaleService;
use App\Repository\Query\BaseQuery;
use App\Utils\DataTable;
use App\Utils\LocaleFormatter;
use App\Utils\PageSetup;
use App\Utils\Pagination;
use Pagerfanta\Adapter\ArrayAdapter;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Intl\Locales;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route(path: '/help')]
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')]
final class HelpController extends AbstractController
{
#[Route(path: '/locales', defaults: [], name: 'help_locales', methods: ['GET'])]
public function helpLocale(Request $request, LocaleService $service): Response
{
$table = new DataTable('help_locales', new BaseQuery());
$table->addColumn('name', ['class' => 'alwaysVisible', 'orderBy' => false]);
$table->addColumn('description', ['class' => 'd-none', 'orderBy' => false]);
$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]);
$table->addColumn('duration', ['class' => 'd-none w-min text-end', 'orderBy' => false]);
$table->addColumn('money', ['class' => 'w-min text-end', 'orderBy' => false, 'title' => 'rate']);
$table->addColumn('hour_24', ['class' => 'alwaysVisible w-min text-center', 'orderBy' => false]);
$table->addColumn('rtl', ['class' => 'd-none w-min text-center', 'orderBy' => false, 'title' => 'RTL']);
$page = new PageSetup('help_locales');
$page->setDataTable($table);
$page->setActionName('help_locales');
$data = $this->buildLocales($request, $service);
$pagination = new Pagination(new ArrayAdapter($data));
$pagination->setMaxPerPage(9999);
$table->setPagination($pagination);
return $this->render('help/index.html.twig', [
'page_setup' => $page,
'dataTable' => $table,
]);
}
/**
* @param Request $request
* @param LocaleService $service
* @return array<string, array<string, string|bool|null>>
*/
private function buildLocales(Request $request, LocaleService $service): array
{
$requestLocale = $request->getLocale();
$data = [];
$now = $this->getDateTimeFactory()->createDateTime();
foreach ($service->getAllLocales() as $locale) {
$formatter = new LocaleFormatter($service, $locale);
$data[$locale] = [
'language' => $locale,
'name' => Locales::getName($locale, $locale),
'description' => Locales::getName($locale, $requestLocale),
'date' => $formatter->dateShort($now),
'time' => $formatter->time($now),
'duration' => $formatter->duration(46120),
'money' => $formatter->money(2794.83, 'EUR'),
'hour_24' => $service->is24Hour($locale),
'rtl' => $service->isRightToLeft($locale),
];
}
return $data;
}
}

View File

@@ -11,6 +11,8 @@ namespace App\Controller;
use App\Configuration\LocaleService;
use App\Entity\User;
use App\Event\ConfigureMainMenuEvent;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
@@ -26,11 +28,9 @@ final class HomepageController extends AbstractController
public const DEFAULT_ROUTE = 'timesheet';
#[Route(path: '', defaults: [], name: 'homepage', methods: ['GET'])]
public function indexAction(Request $request, LocaleService $service): Response
public function indexAction(Request $request, LocaleService $service, EventDispatcherInterface $eventDispatcher): Response
{
/** @var User $user */
$user = $this->getUser();
$userRoute = $user->getPreferenceValue('login_initial_view', self::DEFAULT_ROUTE, false);
$userLanguage = $user->getLanguage();
$requestLanguage = $request->getLocale();
@@ -48,13 +48,23 @@ final class HomepageController extends AbstractController
$userLanguage = $service->getDefaultLocale();
}
$routes = [
[$userRoute, $userLanguage],
[$userRoute, $requestLanguage],
[$userRoute, User::DEFAULT_LANGUAGE],
[self::DEFAULT_ROUTE, $userLanguage],
[self::DEFAULT_ROUTE, $requestLanguage],
];
$routes = [];
$userRoute = $user->getPreferenceValue('login_initial_view');
if (\is_string($userRoute)) {
$event = new ConfigureMainMenuEvent();
$eventDispatcher->dispatch($event);
$menu = $event->findById($userRoute);
if ($menu !== null && \count($menu->getRouteArgs()) === 0 && $menu->getRoute() !== null) {
$userRoute = $menu->getRoute();
}
$routes[] = [$userRoute, $userLanguage];
$routes[] = [$userRoute, $requestLanguage];
$routes[] = [$userRoute, User::DEFAULT_LANGUAGE];
}
$routes[] = [self::DEFAULT_ROUTE, $userLanguage];
$routes[] = [self::DEFAULT_ROUTE, $requestLanguage];
foreach ($routes as $routeSettings) {
$route = $routeSettings[0];

View File

@@ -21,6 +21,7 @@ final class ConfigureMainMenuEvent extends Event
private MenuItemModel $apps;
private MenuItemModel $admin;
private MenuItemModel $system;
private ?MenuItemModel $root = null;
public function __construct()
{
@@ -30,6 +31,19 @@ final class ConfigureMainMenuEvent extends Event
$this->system = new MenuItemModel('system', 'menu.system', '', [], 'configuration');
}
public function findById(string $identifier): ?MenuItemModel
{
if ($this->root === null) {
$this->root = new MenuItemModel('root', 'root');
$this->root->addChild($this->menu);
$this->root->addChild($this->apps);
$this->root->addChild($this->admin);
$this->root->addChild($this->system);
}
return $this->root->findChild($identifier);
}
public function getMenu(): MenuItemModel
{
return $this->menu;
@@ -37,7 +51,7 @@ final class ConfigureMainMenuEvent extends Event
public function getTimesheetMenu(): ?MenuItemModel
{
return $this->menu->getChild('timesheet');
return $this->menu->getChild('times');
}
public function getInvoiceMenu(): ?MenuItemModel

View File

@@ -11,7 +11,6 @@ namespace App\EventSubscriber;
use App\Entity\User;
use App\Event\ConfigureMainMenuEvent;
use App\Utils\MenuItemModel;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
@@ -30,38 +29,25 @@ final class MenuFavoritesSubscriber implements EventSubscriberInterface
public function onMainMenuConfigure(ConfigureMainMenuEvent $menuEvent): void
{
/** @var User $user */
/** @var User|null $user */
$user = $this->security->getUser();
if (null === $user) {
return;
}
$favMenu = $menuEvent->getMenu()->findChild('favorites');
if ($favMenu === null) {
return;
}
$userFavorites = $user->getPreferenceValue('favorite_routes');
if (!\is_string($userFavorites) || trim($userFavorites) === '') {
return;
}
$root = new MenuItemModel('', '');
$root->addChild($menuEvent->getMenu());
if ($menuEvent->getAppsMenu()->hasChildren()) {
$root->addChild($menuEvent->getAppsMenu());
}
if ($menuEvent->getAdminMenu()->hasChildren()) {
$root->addChild($menuEvent->getAdminMenu());
}
if ($menuEvent->getSystemMenu()->hasChildren()) {
$root->addChild($menuEvent->getSystemMenu());
$favMenu = $menuEvent->findById('favorites');
if ($favMenu === null) {
return;
}
$userFavorites = explode(',', $userFavorites);
foreach ($userFavorites as $fav) {
$tmp = $root->findChild($fav);
$tmp = $menuEvent->findById($fav);
if ($tmp !== null && !$tmp->hasChildren()) {
$favMenu->addChild(clone $tmp);
}

View File

@@ -50,11 +50,11 @@ final class MenuSubscriber implements EventSubscriberInterface
$menu->addChild(new MenuItemModel('favorites', 'favorite_routes', null, [], 'bookmarked'));
// ------------------- timesheet menu -------------------
$times = new MenuItemModel('timesheet', 'time_tracking', null, [], 'timesheet');
$times = new MenuItemModel('times', 'time_tracking', null, [], 'timesheet');
if ($auth->isGranted('view_own_timesheet')) {
$timesheets = new MenuItemModel('times', 'my_times', 'timesheet', [], 'timesheet');
$timesheets->setChildRoutes(['times', 'timesheet_export', 'timesheet_edit', 'timesheet_create', 'timesheet_multi_update']);
$timesheets = new MenuItemModel('timesheet', 'my_times', 'timesheet', [], 'timesheet');
$timesheets->setChildRoutes(['timesheet_export', 'timesheet_edit', 'timesheet_create', 'timesheet_multi_update']);
$times->addChild($timesheets);
if ($auth->isGranted('quick-entry')) {
@@ -93,14 +93,15 @@ final class MenuSubscriber implements EventSubscriberInterface
$menu->addChild($reporting);
}
$invoice = new MenuItemModel('invoice', 'invoices', null, [], 'invoice');
// ------------------- invoice menu -------------------
$invoice = new MenuItemModel('invoices', 'invoices', null, [], 'invoice');
if ($auth->isGranted('create_invoice')) {
$invoice->addChild(new MenuItemModel('invoice', 'invoice_form.title', 'invoice', [], 'invoice'));
}
if ($auth->isGranted('view_invoice')) {
$tmpMenu = new MenuItemModel('invoice-listing', 'all_invoices', 'admin_invoice_list', [], 'list');
$tmpMenu = new MenuItemModel('invoice_listing', 'all_invoices', 'admin_invoice_list', [], 'list');
$tmpMenu->setChildRoutes(['admin_invoice_edit']);
$invoice->addChild($tmpMenu);
}

View File

@@ -19,9 +19,9 @@ use App\Form\Type\CalendarViewType;
use App\Form\Type\FavoriteMenuType;
use App\Form\Type\FirstWeekDayType;
use App\Form\Type\InitialViewType;
use App\Form\Type\LanguageType;
use App\Form\Type\SkinType;
use App\Form\Type\TimezoneType;
use App\Form\Type\UserLanguageType;
use App\Form\Type\YesNoType;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
@@ -87,7 +87,7 @@ final class UserPreferenceSubscriber implements EventSubscriberInterface
(new UserPreference(UserPreference::LOCALE, $this->systemConfiguration->getUserDefaultLanguage()))
->setOrder(250)
->setSection('locale')
->setType(LanguageType::class),
->setType(UserLanguageType::class),
(new UserPreference(UserPreference::FIRST_WEEKDAY, User::DEFAULT_FIRST_WEEKDAY))
->setOrder(300)
@@ -118,7 +118,7 @@ final class UserPreferenceSubscriber implements EventSubscriberInterface
->setOrder(710)
->setSection('behaviour')
->setOptions(['required' => false])
->addConstraint(new Length(['max' => 75]))
->addConstraint(new Length(['max' => 150]))
->setType(FavoriteMenuType::class),
(new UserPreference('daily_stats', false))

View File

@@ -23,6 +23,7 @@ use App\Repository\Query\TimesheetQuery;
use App\Twig\LocaleFormatExtensions;
use App\Utils\StringHelper;
use DateTime;
use PhpOffice\PhpSpreadsheet\Cell\CellAddress;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
@@ -37,7 +38,7 @@ use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @internal
* @internal means no BC promise whatsoever!
*/
abstract class AbstractSpreadsheetRenderer
{
@@ -50,12 +51,17 @@ abstract class AbstractSpreadsheetRenderer
// Part 1 = positive; Part 2 = negative; Part 3 = zero; Part 4 = Text
public const RATE_FORMAT_DEFAULT = '#.##0,00 [$%1$s];-#.##0,00 [$%1$s]';
public const RATE_FORMAT_LEFT = '_("%1$s"* #,##0.00_);_("%1$s"* -#,##0.00;_("%1$s"* "-"??_);_(@_)';
public const RATE_FORMAT = self::RATE_FORMAT_LEFT;
public const RATE_FORMAT_RIGHT = '_(* "%1$s" #,##0.00_);_(* "%1$s" -#,##0.00;_(* "%1$s" "-"??_);_(@_)';
/**
* @internal used in html to excel exporter
*/
public const RATE_FORMAT_NO_CURRENCY = '#,##0.00;-#,##0.00';
/**
* @see self:RATE_FORMAT_*
*/
protected string $rateFormat = self::RATE_FORMAT_LEFT;
protected string $durationFormat = self::DURATION_FORMAT;
protected int $durationBase = 86400;
/**
@@ -117,10 +123,10 @@ abstract class AbstractSpreadsheetRenderer
return $this->voter->isGranted('view_rate_other_timesheet');
}
protected function setFormattedDateTime(Worksheet $sheet, $column, $row, ?DateTime $date): void
protected function setFormattedDateTime(Worksheet $sheet, int $column, int $row, ?DateTime $date): void
{
if (null === $date) {
$sheet->setCellValueByColumnAndRow($column, $row, '');
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), '');
return;
}
@@ -128,20 +134,20 @@ abstract class AbstractSpreadsheetRenderer
$excelDate = Date::PHPToExcel($date);
if ($excelDate === false) {
$sheet->setCellValueByColumnAndRow($column, $row, $date);
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $date);
return;
}
$sheet->setCellValueByColumnAndRow($column, $row, $excelDate);
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $excelDate);
// TODO why is that format hardcoded and does not depend on the users locale?
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(self::DATETIME_FORMAT);
$sheet->getStyle(CellAddress::fromColumnAndRow($column, $row))->getNumberFormat()->setFormatCode(self::DATETIME_FORMAT);
}
protected function setFormattedTime(Worksheet $sheet, $column, $row, ?DateTime $date): void
protected function setFormattedTime(Worksheet $sheet, int $column, int $row, ?DateTime $date): void
{
if (null === $date) {
$sheet->setCellValueByColumnAndRow($column, $row, '');
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), '');
return;
}
@@ -149,19 +155,19 @@ abstract class AbstractSpreadsheetRenderer
$excelDate = Date::PHPToExcel($date);
if ($excelDate === false) {
$sheet->setCellValueByColumnAndRow($column, $row, $date);
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $date);
return;
}
$sheet->setCellValueByColumnAndRow($column, $row, $excelDate);
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(self::TIME_FORMAT);
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $excelDate);
$sheet->getStyle(CellAddress::fromColumnAndRow($column, $row))->getNumberFormat()->setFormatCode(self::TIME_FORMAT);
}
protected function setFormattedDate(Worksheet $sheet, $column, $row, ?DateTime $date): void
protected function setFormattedDate(Worksheet $sheet, int $column, int $row, ?DateTime $date): void
{
if (null === $date) {
$sheet->setCellValueByColumnAndRow($column, $row, '');
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), '');
return;
}
@@ -169,48 +175,48 @@ abstract class AbstractSpreadsheetRenderer
$excelDate = Date::PHPToExcel($date);
if ($excelDate === false) {
$sheet->setCellValueByColumnAndRow($column, $row, $date);
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $date);
return;
}
$sheet->setCellValueByColumnAndRow($column, $row, $excelDate);
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $excelDate);
// TODO why is that format hardcoded and does not depend on the users locale?
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_DATE_YYYYMMDD2);
$sheet->getStyle(CellAddress::fromColumnAndRow($column, $row))->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_DATE_YYYYMMDD2);
}
protected function setDurationTotal(Worksheet $sheet, $column, $row, $startCoordinate, $endCoordinate): void
protected function setDurationTotal(Worksheet $sheet, int $column, int $row, string $startCoordinate, string $endCoordinate): void
{
$sheet->setCellValueByColumnAndRow($column, $row, sprintf('=SUBTOTAL(9,%s:%s)', $startCoordinate, $endCoordinate));
$style = $sheet->getStyleByColumnAndRow($column, $row);
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), sprintf('=SUBTOTAL(9,%s:%s)', $startCoordinate, $endCoordinate));
$style = $sheet->getStyle(CellAddress::fromColumnAndRow($column, $row));
$style->getNumberFormat()->setFormatCode($this->durationFormat);
}
protected function setDuration(Worksheet $sheet, $column, $row, $duration): void
protected function setDuration(Worksheet $sheet, int $column, int $row, ?int $duration): void
{
if (null === $duration) {
$duration = 0;
}
$sheet->setCellValueByColumnAndRow($column, $row, sprintf('=%s/%s', $duration, $this->durationBase));
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode($this->durationFormat);
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), sprintf('=%s/%s', $duration, $this->durationBase));
$sheet->getStyle(CellAddress::fromColumnAndRow($column, $row))->getNumberFormat()->setFormatCode($this->durationFormat);
}
protected function setRateTotal(Worksheet $sheet, $column, $row, $startCoordinate, $endCoordinate): void
protected function setRateTotal(Worksheet $sheet, int $column, int $row, string $startCoordinate, string $endCoordinate): void
{
$sheet->setCellValueByColumnAndRow($column, $row, sprintf('=SUBTOTAL(9,%s:%s)', $startCoordinate, $endCoordinate));
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), sprintf('=SUBTOTAL(9,%s:%s)', $startCoordinate, $endCoordinate));
}
protected function setRateStyle(Worksheet $sheet, $column, $row, $rate, $currency): void
protected function setRateStyle(Worksheet $sheet, int $column, int $row, ?string $currency): void
{
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(
sprintf(self::RATE_FORMAT_LEFT, $currency)
$sheet->getStyle(CellAddress::fromColumnAndRow($column, $row))->getNumberFormat()->setFormatCode(
sprintf($this->rateFormat, $currency ?? '')
);
}
protected function setRate(Worksheet $sheet, $column, $row, $rate, $currency): void
protected function setRate(Worksheet $sheet, int $column, int $row, ?float $rate, ?string $currency): void
{
$sheet->setCellValueByColumnAndRow($column, $row, $rate);
$this->setRateStyle($sheet, $column, $row, $rate, $currency);
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $rate ?? 0);
$this->setRateStyle($sheet, $column, $row, $currency);
}
/**
@@ -227,8 +233,8 @@ abstract class AbstractSpreadsheetRenderer
/**
* @param ExportableItem[] $exportItems
* @param TimesheetQuery $query
* @param array $columns
* @return array
* @param array<string, array<string, callable|int|float|false|null>> $columns
* @return array<string, array<string, callable|int|float|false|null>>
*/
protected function getColumns(array $exportItems, TimesheetQuery $query, array $columns): array
{
@@ -289,7 +295,7 @@ abstract class AbstractSpreadsheetRenderer
if (null !== $entity->getUser()) {
$user = $entity->getUser()->getDisplayName();
}
$sheet->setCellValueByColumnAndRow($column, $row, $user);
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $user);
};
}
@@ -300,12 +306,12 @@ abstract class AbstractSpreadsheetRenderer
if (null !== $entity->getUser()) {
$username = $entity->getUser()->getUserIdentifier();
}
$sheet->setCellValueByColumnAndRow($column, $row, $username);
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $username);
};
}
if (!isset($columns['username']['header'])) {
$columns['username']['header'] = function (Worksheet $sheet, $row, $column): int {
$sheet->setCellValueByColumnAndRow($column, $row, $this->translator->trans('name'));
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $this->translator->trans('name'));
return 1;
};
@@ -318,7 +324,7 @@ abstract class AbstractSpreadsheetRenderer
if (null !== $entity->getProject()) {
$customer = $entity->getProject()->getCustomer()->getName();
}
$sheet->setCellValueByColumnAndRow($column, $row, $customer);
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $customer);
};
}
@@ -328,7 +334,7 @@ abstract class AbstractSpreadsheetRenderer
if (null !== $entity->getProject()) {
$project = $entity->getProject()->getName();
}
$sheet->setCellValueByColumnAndRow($column, $row, $project);
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $project);
};
}
@@ -338,7 +344,7 @@ abstract class AbstractSpreadsheetRenderer
if (null !== $entity->getActivity()) {
$activity = $entity->getActivity()->getName();
}
$sheet->setCellValueByColumnAndRow($column, $row, $activity);
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $activity);
};
}
@@ -352,7 +358,7 @@ abstract class AbstractSpreadsheetRenderer
$isColumnFormatted = false;
$columns['description']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) use (&$isColumnFormatted, $maxWidth, $wrapText, $sanitizeText) {
$cell = $sheet->getCellByColumnAndRow($column, $row);
$cell = $sheet->getCell(CellAddress::fromColumnAndRow($column, $row));
$desc = $entity->getDescription();
if ($sanitizeText && null !== $desc) {
@@ -379,20 +385,20 @@ abstract class AbstractSpreadsheetRenderer
if (isset($columns['exported']) && !isset($columns['exported']['render'])) {
$columns['exported']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
$exported = $entity->isExported() ? 'yes' : 'no';
$sheet->setCellValueByColumnAndRow($column, $row, $this->translator->trans($exported));
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $this->translator->trans($exported));
};
}
if (isset($columns['billable']) && !isset($columns['billable']['render'])) {
$columns['billable']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
$exported = $entity->isBillable() ? 'yes' : 'no';
$sheet->setCellValueByColumnAndRow($column, $row, $this->translator->trans($exported));
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $this->translator->trans($exported));
};
}
if (isset($columns['tags']) && !isset($columns['tags']['render'])) {
$columns['tags']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
$sheet->setCellValueByColumnAndRow($column, $row, implode(',', $entity->getTagsAsArray()));
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), implode(',', $entity->getTagsAsArray()));
};
}
@@ -422,7 +428,7 @@ abstract class AbstractSpreadsheetRenderer
$columns['timesheet-meta'] = [
'header' => function (Worksheet $sheet, $row, $column) use ($timesheetMetaFields): int {
foreach ($timesheetMetaFields as $metaField) {
$sheet->setCellValueByColumnAndRow($column++, $row, $this->translator->trans($metaField->getLabel()));
$sheet->setCellValue(CellAddress::fromColumnAndRow($column++, $row), $this->translator->trans($metaField->getLabel()));
}
return \count($timesheetMetaFields);
@@ -434,7 +440,7 @@ abstract class AbstractSpreadsheetRenderer
if (null !== $metaField) {
$metaFieldValue = $metaField->getValue();
}
$sheet->setCellValueByColumnAndRow($column++, $row, $metaFieldValue);
$sheet->setCellValue(CellAddress::fromColumnAndRow($column++, $row), $metaFieldValue);
}
return \count($timesheetMetaFields);
@@ -450,7 +456,7 @@ abstract class AbstractSpreadsheetRenderer
$columns['customer-meta'] = [
'header' => function (Worksheet $sheet, $row, $column) use ($customerMetaFields): int {
foreach ($customerMetaFields as $metaField) {
$sheet->setCellValueByColumnAndRow($column++, $row, $this->translator->trans($metaField->getLabel()));
$sheet->setCellValue(CellAddress::fromColumnAndRow($column++, $row), $this->translator->trans($metaField->getLabel()));
}
return \count($customerMetaFields);
@@ -464,7 +470,7 @@ abstract class AbstractSpreadsheetRenderer
$metaFieldValue = $metaField->getValue();
}
}
$sheet->setCellValueByColumnAndRow($column++, $row, $metaFieldValue);
$sheet->setCellValue(CellAddress::fromColumnAndRow($column++, $row), $metaFieldValue);
}
return \count($customerMetaFields);
@@ -477,7 +483,7 @@ abstract class AbstractSpreadsheetRenderer
$columns['project-meta'] = [
'header' => function (Worksheet $sheet, $row, $column) use ($projectMetaFields): int {
foreach ($projectMetaFields as $metaField) {
$sheet->setCellValueByColumnAndRow($column++, $row, $this->translator->trans($metaField->getLabel()));
$sheet->setCellValue(CellAddress::fromColumnAndRow($column++, $row), $this->translator->trans($metaField->getLabel()));
}
return \count($projectMetaFields);
@@ -491,7 +497,7 @@ abstract class AbstractSpreadsheetRenderer
$metaFieldValue = $metaField->getValue();
}
}
$sheet->setCellValueByColumnAndRow($column++, $row, $metaFieldValue);
$sheet->setCellValue(CellAddress::fromColumnAndRow($column++, $row), $metaFieldValue);
}
return \count($projectMetaFields);
@@ -504,7 +510,7 @@ abstract class AbstractSpreadsheetRenderer
$columns['activity-meta'] = [
'header' => function (Worksheet $sheet, $row, $column) use ($activityMetaFields): int {
foreach ($activityMetaFields as $metaField) {
$sheet->setCellValueByColumnAndRow($column++, $row, $this->translator->trans($metaField->getLabel()));
$sheet->setCellValue(CellAddress::fromColumnAndRow($column++, $row), $this->translator->trans($metaField->getLabel()));
}
return \count($activityMetaFields);
@@ -518,7 +524,7 @@ abstract class AbstractSpreadsheetRenderer
$metaFieldValue = $metaField->getValue();
}
}
$sheet->setCellValueByColumnAndRow($column++, $row, $metaFieldValue);
$sheet->setCellValue(CellAddress::fromColumnAndRow($column++, $row), $metaFieldValue);
}
return \count($activityMetaFields);
@@ -533,7 +539,7 @@ abstract class AbstractSpreadsheetRenderer
$columns['user-meta'] = [
'header' => function (Worksheet $sheet, $row, $column) use ($userPreferences): int {
foreach ($userPreferences as $metaField) {
$sheet->setCellValueByColumnAndRow($column++, $row, $this->translator->trans($metaField->getLabel()));
$sheet->setCellValue(CellAddress::fromColumnAndRow($column++, $row), $this->translator->trans($metaField->getLabel()));
}
return \count($userPreferences);
@@ -547,7 +553,7 @@ abstract class AbstractSpreadsheetRenderer
$metaFieldValue = $metaField->getValue();
}
}
$sheet->setCellValueByColumnAndRow($column++, $row, $metaFieldValue);
$sheet->setCellValue(CellAddress::fromColumnAndRow($column++, $row), $metaFieldValue);
}
return \count($userPreferences);
@@ -557,20 +563,20 @@ abstract class AbstractSpreadsheetRenderer
if (isset($columns['type']) && !isset($columns['type']['render'])) {
$columns['type']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
$sheet->setCellValueByColumnAndRow($column, $row, $entity->getType());
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $entity->getType());
};
}
if (isset($columns['category']) && !isset($columns['category']['render'])) {
$columns['category']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
$sheet->setCellValueByColumnAndRow($column, $row, $entity->getCategory());
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $entity->getCategory());
};
}
if (isset($columns['customer_number'])) {
if (!isset($columns['customer_number']['header'])) {
$columns['customer_number']['header'] = function (Worksheet $sheet, $row, $column): int {
$sheet->setCellValueByColumnAndRow($column, $row, $this->translator->trans('number'));
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $this->translator->trans('number'));
return 1;
};
@@ -582,7 +588,7 @@ abstract class AbstractSpreadsheetRenderer
if (null !== $entity->getProject()) {
$customerId = $entity->getProject()->getCustomer()->getNumber();
}
$sheet->setCellValueByColumnAndRow($column, $row, $customerId);
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $customerId);
};
}
}
@@ -590,7 +596,7 @@ abstract class AbstractSpreadsheetRenderer
if (isset($columns['customer_vat']) && !isset($columns['customer_vat']['render'])) {
if (!isset($columns['customer_vat']['header'])) {
$columns['customer_vat']['header'] = function (Worksheet $sheet, $row, $column): int {
$sheet->setCellValueByColumnAndRow($column, $row, $this->translator->trans('vat_id'));
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $this->translator->trans('vat_id'));
return 1;
};
@@ -602,7 +608,7 @@ abstract class AbstractSpreadsheetRenderer
if (null !== $entity->getProject()) {
$customerVat = $entity->getProject()->getCustomer()->getVatId();
}
$sheet->setCellValueByColumnAndRow($column, $row, $customerVat);
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $customerVat);
};
}
}
@@ -610,7 +616,7 @@ abstract class AbstractSpreadsheetRenderer
if (isset($columns['order_number']) && !isset($columns['order_number']['render'])) {
if (!isset($columns['order_number']['header'])) {
$columns['order_number']['header'] = function (Worksheet $sheet, $row, $column): int {
$sheet->setCellValueByColumnAndRow($column, $row, $this->translator->trans('orderNumber'));
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $this->translator->trans('orderNumber'));
return 1;
};
@@ -622,7 +628,7 @@ abstract class AbstractSpreadsheetRenderer
if (null !== $entity->getProject()) {
$orderNumber = $entity->getProject()->getOrderNumber();
}
$sheet->setCellValueByColumnAndRow($column, $row, $orderNumber);
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $orderNumber);
};
}
}
@@ -663,10 +669,13 @@ abstract class AbstractSpreadsheetRenderer
foreach ($columns as $label => $settings) {
if (isset($settings['header'])) {
if (!\is_callable($settings['header'])) {
throw new \RuntimeException('Invalid header renderer given for: ' . $label);
}
$amount = $settings['header']($sheet, $recordsHeaderRow, $recordsHeaderColumn);
$recordsHeaderColumn += $amount;
} else {
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans($label));
$sheet->setCellValue(CellAddress::fromColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow), $this->translator->trans($label));
}
}
@@ -689,11 +698,11 @@ abstract class AbstractSpreadsheetRenderer
}
if (!\array_key_exists('render', $settings) || !\is_callable($settings['render'])) {
throw new \RuntimeException(sprintf('Missing renderer for export column %s', $label));
throw new \RuntimeException(sprintf('Missing or invalid renderer for export column %s', $label));
}
$amount = $settings['render']($sheet, $entryHeaderRow, $entryHeaderColumn, $exportItem);
$entryHeaderColumn += (null === $amount) ? 1 : $amount;
$entryHeaderColumn += (null === $amount) ? 1 : (int) $amount;
}
$entryHeaderRow++;
@@ -701,28 +710,28 @@ abstract class AbstractSpreadsheetRenderer
if ($this->isTotalRowSupported()) {
if (null !== $durationColumn) {
$startCoordinate = $sheet->getCellByColumnAndRow($durationColumn, 2)->getCoordinate();
$endCoordinate = $sheet->getCellByColumnAndRow($durationColumn, $entryHeaderRow - 1)->getCoordinate();
$startCoordinate = $sheet->getCell(CellAddress::fromColumnAndRow($durationColumn, 2))->getCoordinate();
$endCoordinate = $sheet->getCell(CellAddress::fromColumnAndRow($durationColumn, $entryHeaderRow - 1))->getCoordinate();
$this->setDurationTotal($sheet, $durationColumn, $entryHeaderRow, $startCoordinate, $endCoordinate);
$style = $sheet->getStyleByColumnAndRow($durationColumn, $entryHeaderRow);
$style = $sheet->getStyle(CellAddress::fromColumnAndRow($durationColumn, $entryHeaderRow));
$style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN);
$style->getFont()->setBold(true);
}
if (null !== $rateColumn) {
$startCoordinate = $sheet->getCellByColumnAndRow($rateColumn, 2)->getCoordinate();
$endCoordinate = $sheet->getCellByColumnAndRow($rateColumn, $entryHeaderRow - 1)->getCoordinate();
$startCoordinate = $sheet->getCell(CellAddress::fromColumnAndRow($rateColumn, 2))->getCoordinate();
$endCoordinate = $sheet->getCell(CellAddress::fromColumnAndRow($rateColumn, $entryHeaderRow - 1))->getCoordinate();
$this->setRateTotal($sheet, $rateColumn, $entryHeaderRow, $startCoordinate, $endCoordinate);
$style = $sheet->getStyleByColumnAndRow($rateColumn, $entryHeaderRow);
$style = $sheet->getStyle(CellAddress::fromColumnAndRow($rateColumn, $entryHeaderRow));
$style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN);
$style->getFont()->setBold(true);
}
if (null !== $internalRateColumn) {
$startCoordinate = $sheet->getCellByColumnAndRow($internalRateColumn, 2)->getCoordinate();
$endCoordinate = $sheet->getCellByColumnAndRow($internalRateColumn, $entryHeaderRow - 1)->getCoordinate();
$startCoordinate = $sheet->getCell(CellAddress::fromColumnAndRow($internalRateColumn, 2))->getCoordinate();
$endCoordinate = $sheet->getCell(CellAddress::fromColumnAndRow($internalRateColumn, $entryHeaderRow - 1))->getCoordinate();
$this->setRateTotal($sheet, $internalRateColumn, $entryHeaderRow, $startCoordinate, $endCoordinate);
$style = $sheet->getStyleByColumnAndRow($internalRateColumn, $entryHeaderRow);
$style = $sheet->getStyle(CellAddress::fromColumnAndRow($internalRateColumn, $entryHeaderRow));
$style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN);
$style->getFont()->setBold(true);
}

View File

@@ -9,6 +9,7 @@
namespace App\Export\Base;
use PhpOffice\PhpSpreadsheet\Cell\CellAddress;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
@@ -54,16 +55,17 @@ class CsvRenderer extends AbstractSpreadsheetRenderer
return 'csv';
}
protected function setDuration(Worksheet $sheet, $column, $row, $duration): void
protected function setDuration(Worksheet $sheet, int $column, int $row, ?int $duration): void
{
$sheet->setCellValueByColumnAndRow($column, $row, sprintf('=%s', $duration));
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), sprintf('=%s', $duration ?? 0));
}
protected function setRateStyle(Worksheet $sheet, $column, $row, $rate, $currency): void
protected function setRate(Worksheet $sheet, int $column, int $row, ?float $rate, ?string $currency): void
{
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $rate);
if ($rate === 0.00) {
return;
}
parent::setRateStyle($sheet, $column, $row, $rate, $currency);
$this->setRateStyle($sheet, $column, $row, $currency);
}
}

View File

@@ -45,6 +45,16 @@ class XlsxRenderer extends AbstractSpreadsheetRenderer
throw new \Exception('Could not open temporary file');
}
$this->applyStyles($spreadsheet);
$writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
$writer->save($filename);
return $filename;
}
protected function applyStyles(Spreadsheet $spreadsheet): void
{
// Store expensive calculations for later
$sheet = $spreadsheet->getActiveSheet();
$highestRow = $sheet->getHighestRow();
@@ -75,11 +85,6 @@ class XlsxRenderer extends AbstractSpreadsheetRenderer
->getAlignment()
->setVertical(Alignment::VERTICAL_TOP)
->setHorizontal(Alignment::HORIZONTAL_LEFT);
$writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
$writer->save($filename);
return $filename;
}
public function getId(): string

View File

@@ -9,6 +9,7 @@
namespace App\Export\Spreadsheet\CellFormatter;
use PhpOffice\PhpSpreadsheet\Cell\CellAddress;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
final class ArrayFormatter implements CellFormatterInterface
@@ -16,7 +17,7 @@ final class ArrayFormatter implements CellFormatterInterface
public function setFormattedValue(Worksheet $sheet, int $column, int $row, $value): void
{
if (null === $value) {
$sheet->setCellValueByColumnAndRow($column, $row, '');
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), '');
return;
}
@@ -25,6 +26,6 @@ final class ArrayFormatter implements CellFormatterInterface
throw new \InvalidArgumentException('Unsupported value given, only array is supported');
}
$sheet->setCellValueByColumnAndRow($column, $row, implode(';', $value));
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), implode(';', $value));
}
}

View File

@@ -9,6 +9,7 @@
namespace App\Export\Spreadsheet\CellFormatter;
use PhpOffice\PhpSpreadsheet\Cell\CellAddress;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
final class BooleanFormatter implements CellFormatterInterface
@@ -16,7 +17,7 @@ final class BooleanFormatter implements CellFormatterInterface
public function setFormattedValue(Worksheet $sheet, int $column, int $row, $value): void
{
if (null === $value) {
$sheet->setCellValueByColumnAndRow($column, $row, '');
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), '');
return;
}
@@ -25,6 +26,6 @@ final class BooleanFormatter implements CellFormatterInterface
throw new \InvalidArgumentException('Unsupported value given, only boolean is supported');
}
$sheet->setCellValueByColumnAndRow($column, $row, $value);
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $value);
}
}

View File

@@ -9,6 +9,7 @@
namespace App\Export\Spreadsheet\CellFormatter;
use PhpOffice\PhpSpreadsheet\Cell\CellAddress;
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
@@ -18,7 +19,7 @@ final class DateFormatter implements CellFormatterInterface
public function setFormattedValue(Worksheet $sheet, int $column, int $row, $value): void
{
if (null === $value) {
$sheet->setCellValueByColumnAndRow($column, $row, '');
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), '');
return;
}
@@ -27,7 +28,7 @@ final class DateFormatter implements CellFormatterInterface
throw new \InvalidArgumentException('Unsupported value given, only DateTime is supported');
}
$sheet->setCellValueByColumnAndRow($column, $row, Date::PHPToExcel($value));
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), Date::PHPToExcel($value));
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_DATE_YYYYMMDD2);
}
}

View File

@@ -9,6 +9,7 @@
namespace App\Export\Spreadsheet\CellFormatter;
use PhpOffice\PhpSpreadsheet\Cell\CellAddress;
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
@@ -19,7 +20,7 @@ final class DateTimeFormatter implements CellFormatterInterface
public function setFormattedValue(Worksheet $sheet, int $column, int $row, $value): void
{
if (null === $value) {
$sheet->setCellValueByColumnAndRow($column, $row, '');
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), '');
return;
}
@@ -28,7 +29,7 @@ final class DateTimeFormatter implements CellFormatterInterface
throw new \InvalidArgumentException('Unsupported value given, only DateTime is supported');
}
$sheet->setCellValueByColumnAndRow($column, $row, Date::PHPToExcel($value));
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), Date::PHPToExcel($value));
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(self::DATETIME_FORMAT);
}
}

View File

@@ -9,6 +9,7 @@
namespace App\Export\Spreadsheet\CellFormatter;
use PhpOffice\PhpSpreadsheet\Cell\CellAddress;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
final class DurationFormatter implements CellFormatterInterface
@@ -25,7 +26,7 @@ final class DurationFormatter implements CellFormatterInterface
throw new \InvalidArgumentException('Unsupported value given, only int is supported');
}
$sheet->setCellValueByColumnAndRow($column, $row, sprintf('=%s/86400', $value));
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(self::DURATION_FORMAT);
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), sprintf('=%s/86400', $value));
$sheet->getStyle(CellAddress::fromColumnAndRow($column, $row))->getNumberFormat()->setFormatCode(self::DURATION_FORMAT);
}
}

View File

@@ -9,6 +9,7 @@
namespace App\Export\Spreadsheet\CellFormatter;
use PhpOffice\PhpSpreadsheet\Cell\CellAddress;
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
@@ -19,7 +20,7 @@ final class TimeFormatter implements CellFormatterInterface
public function setFormattedValue(Worksheet $sheet, int $column, int $row, $value): void
{
if (null === $value) {
$sheet->setCellValueByColumnAndRow($column, $row, '');
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), '');
return;
}
@@ -28,7 +29,7 @@ final class TimeFormatter implements CellFormatterInterface
throw new \InvalidArgumentException('Unsupported value given, only DateTime is supported');
}
$sheet->setCellValueByColumnAndRow($column, $row, Date::PHPToExcel($value));
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(self::TIME_FORMAT);
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), Date::PHPToExcel($value));
$sheet->getStyle(CellAddress::fromColumnAndRow($column, $row))->getNumberFormat()->setFormatCode(self::TIME_FORMAT);
}
}

View File

@@ -16,6 +16,7 @@ use App\Export\Spreadsheet\CellFormatter\DateFormatter;
use App\Export\Spreadsheet\CellFormatter\DateTimeFormatter;
use App\Export\Spreadsheet\CellFormatter\DurationFormatter;
use App\Export\Spreadsheet\CellFormatter\TimeFormatter;
use PhpOffice\PhpSpreadsheet\Cell\CellAddress;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use Symfony\Contracts\Translation\TranslatorInterface;
@@ -65,7 +66,7 @@ class SpreadsheetExporter
$recordsHeaderRow = 1;
foreach ($columns as $settings) {
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans($settings->getLabel()));
$sheet->setCellValue(CellAddress::fromColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow), $this->translator->trans($settings->getLabel()));
}
$entryHeaderRow = $recordsHeaderRow + 1;
@@ -77,7 +78,7 @@ class SpreadsheetExporter
$value = \call_user_func($settings->getAccessor(), $entry);
if (!\array_key_exists($settings->getType(), $this->formatter)) {
$sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $value);
$sheet->setCellValue(CellAddress::fromColumnAndRow($entryHeaderColumn, $entryHeaderRow), $value);
} else {
$formatter = $this->formatter[$settings->getType()];
$formatter->setFormattedValue($sheet, $entryHeaderColumn, $entryHeaderRow, $value);

View File

@@ -9,9 +9,10 @@
namespace App\Form\Model;
use App\Utils\EquatableInterface;
use DateTime;
final class DateRange
final class DateRange implements EquatableInterface
{
private ?DateTime $begin = null;
private ?DateTime $end = null;
@@ -49,4 +50,29 @@ final class DateRange
return $this;
}
public function isEqualTo(object $compare): bool
{
if (!$compare instanceof DateRange) {
return false;
}
if (($this->getBegin() === null && $compare->getBegin() !== null) || ($this->getBegin() !== null && $compare->getBegin() === null)) {
return false;
}
if (($this->getEnd() === null && $compare->getEnd() !== null) || ($this->getEnd() !== null && $compare->getEnd() === null)) {
return false;
}
if ($this->getBegin() !== null && $compare->getBegin() !== null && $this->getBegin()->getTimestamp() !== $compare->getBegin()->getTimestamp()) {
return false;
}
if ($this->getEnd() !== null && $compare->getEnd() !== null && $this->getEnd()->getTimestamp() !== $compare->getEnd()->getTimestamp()) {
return false;
}
return true;
}
}

View File

@@ -29,7 +29,6 @@ final class FavoriteMenuType extends AbstractType
{
$resolver->setDefaults([
'multiple' => true,
'include_reports' => false,
'filter_menus' => ['favorites', 'dashboard'],
]);
}

View File

@@ -9,20 +9,17 @@
namespace App\Form\Type;
use App\Entity\User;
use App\Event\ConfigureMainMenuEvent;
use App\Reporting\ReportingService;
use App\Utils\MenuItemModel;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\Translation\TranslatorInterface;
final class MenuChoiceType extends AbstractType
{
public function __construct(private EventDispatcherInterface $eventDispatcher, private ReportingService $reportingService, private TranslatorInterface $translator)
public function __construct(private EventDispatcherInterface $eventDispatcher)
{
}
@@ -30,25 +27,19 @@ final class MenuChoiceType extends AbstractType
{
$resolver->setDefaults([
'required' => true,
'include_reports' => true,
'filter_menus' => [],
]);
$resolver->setDefault('choices', function (Options $options): array {
/** @var User $user */
$user = $options['user'];
return $this->getChoices($user, $options['include_reports'], $options['filter_menus']);
return $this->getChoices($options['filter_menus']);
});
}
/**
* @param User $user
* @param bool $withReports
* @param array<string> $filter
* @return array<string, string>
*/
private function getChoices(User $user, bool $withReports, array $filter): array
private function getChoices(array $filter): array
{
$event = new ConfigureMainMenuEvent();
$this->eventDispatcher->dispatch($event);
@@ -58,13 +49,6 @@ final class MenuChoiceType extends AbstractType
$choices += $this->getChoicesFromMenu($event->getAdminMenu(), $filter);
$choices += $this->getChoicesFromMenu($event->getSystemMenu(), $filter);
if ($withReports) {
foreach ($this->reportingService->getAvailableReports($user) as $report) {
$label = $this->translator->trans('menu.reporting') . ': ' . $this->translator->trans($report->getLabel(), [], 'reporting');
$choices[$label] = $report->getId();
}
}
return $choices;
}
@@ -82,13 +66,13 @@ final class MenuChoiceType extends AbstractType
continue;
}
if (!$child->hasChildren()) {
if (\count($child->getRouteArgs()) === 0) {
if (\count($child->getRouteArgs()) === 0 && $child->getRoute() !== null) {
$choices[$child->getLabel()] = $child->getIdentifier();
}
continue;
}
foreach ($child->getChildren() as $subChild) {
if (\count($subChild->getRouteArgs()) === 0) {
if (\count($subChild->getRouteArgs()) === 0 && $subChild->getRoute() !== null) {
$choices[$subChild->getLabel()] = $subChild->getIdentifier();
}
}

View File

@@ -0,0 +1,41 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Custom form field type to select the user language.
*/
final class UserLanguageType extends AbstractType
{
public function __construct(private UrlGeneratorInterface $router, private TranslatorInterface $translator)
{
}
public function configureOptions(OptionsResolver $resolver): void
{
$route = $this->router->generate('help_locales');
$message = $this->translator->trans('user.language.help');
$resolver->setDefaults([
'help_html' => true,
'help' => sprintf('<a href="%s" target="help_locales">%s</a>', $route, $message)
]);
}
public function getParent(): string
{
return LanguageType::class;
}
}

View File

@@ -53,6 +53,14 @@ final class ConfigurableNumberGenerator implements NumberGeneratorInterface
throw new \InvalidArgumentException('Missing invoice model, cannot calculate invoice number');
}
if ($format === 'cname' && $this->model->getCustomer()?->getName() === null) {
throw new \InvalidArgumentException('Customer has no name, replacer {cname} failed evaluation');
}
if ($format === 'cnumber' && $this->model->getCustomer()?->getNumber() === null) {
throw new \InvalidArgumentException('Customer has no number, replacer {cnumber} failed evaluation');
}
return match ($format) {
'Y' => $invoiceDate->format('Y'),
'y' => $invoiceDate->format('y'),

View File

@@ -13,6 +13,7 @@ use App\Entity\Bookmark;
use App\Entity\Team;
use App\Entity\User;
use App\Form\Model\DateRange;
use App\Utils\EquatableInterface;
use App\Utils\SearchTerm;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormErrorIterator;
@@ -330,7 +331,13 @@ class BaseQuery
$currentValue = $this->get($filter);
if (\is_object($currentValue)) {
if ($currentValue !== $expectedValue) {
if ($currentValue instanceof EquatableInterface) {
return $currentValue->isEqualTo($expectedValue);
}
// this is a loose comparison by choice, as == compares object type and content
// instead of === which only compares if both sides are the same instance
if ($currentValue != $expectedValue) { // @phpstan-ignore-line
return false;
}
} else {

View File

@@ -113,8 +113,23 @@ final class DataTable implements \Countable, \IteratorAggregate
$this->columns = $columns;
}
public function addColumn(string $name, array $column): void
/**
* Supported $column options:
* - class
* - title
* - translation_domain
* - orderBy (string|false)
* - order (desc, asc)
*
* @param string $name
* @param array $column
* @return void
*/
public function addColumn(string $name, array $column = []): void
{
if (!\array_key_exists('class', $column)) {
$column['class'] = '';
}
$this->columns[$name] = $column;
}

View File

@@ -0,0 +1,15 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Utils;
interface EquatableInterface
{
public function isEqualTo(object $compare): bool;
}

View File

@@ -0,0 +1,24 @@
{% extends 'datatable.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% block datatable_column_value %}
{% if column == 'name' %}
{{ entry.name }}
{% elseif column == 'description' %}
{{ entry.description }}
{% elseif column == 'language' %}
{{ entry.language }}
{% elseif column == 'date' %}
{{ entry.date }}
{% elseif column == 'time' %}
{{ entry.time }}
{% elseif column == 'duration' %}
{{ entry.duration }}
{% elseif column == 'money' %}
{{ entry.money }}
{% elseif column == 'hour_24' %}
{{ widgets.label_boolean(entry.hour_24) }}
{% elseif column == 'rtl' %}
{{ widgets.label_boolean(entry.rtl) }}
{% endif %}
{% endblock %}

View File

@@ -6,13 +6,18 @@
{% if tabler_bundle.isDarkMode() or tabler_bundle.isNavbarOverlapping() %}
{% set class = "btn-dark" %}
{% endif %}
<div class="nav-item dropdown d-none d-md-flex me-3 notifications-menu" data-reload="{{ path('favorites_timesheets') }}">
<div class="nav-item dropdown d-flex me-sm-3 me-1 notifications-menu" data-reload="{{ path('favorites_timesheets') }}">
<a href="#" class="btn btn-icon {{ class}} px-0" data-bs-toggle="dropdown" tabindex="-1" aria-label="{{ 'recent.activities'|trans }}">
{{ icon('repeat') }}
</a>
<div class="dropdown-menu dropdown-menu-end dropdown-menu-card">
<div class="card">
<div class="card-header">{{ 'recent.activities'|trans }}</div>
<div class="card-header">
{{ 'recent.activities'|trans }}
<div class="card-actions">
<button type="button" class="btn-close{% if tabler_bundle.isDarkMode() %} btn-close-white{% endif %}" data-bs-dismiss="modal" aria-label="{{ 'action.close'|trans }}"></button>
</div>
</div>
<div class="list-group list-group-flush list-group-hoverable menu">
{% for favorite in favorites %}
{% set entry = favorite.getTimesheet() %}

View File

@@ -4,23 +4,23 @@
{# fake entry, because at least one html template node is needed #}
{% set active_timesheets = [{'id': '000', 'begin': null, 'activity': {'name': ''}, 'project': {'name': '', 'customer': {'name': ''}}}] %}
{% endif %}
<div class="nav-item d-flex me-3">
<div class="nav-item d-flex me-sm-3 me-1">
<div class="btn-list">
{% set class = "btn-outline-white" %}
{% if tabler_bundle.isDarkMode() or tabler_bundle.isNavbarOverlapping() %}
{% set class = "btn-dark" %}
{% endif %}
{% set entry = active_timesheets[0] %}
<div class="messages-menu" data-api="{{ path('active_timesheet') }}" data-href="{{ path('stop_timesheet', {'id' : '000'}) }}" style="{% if not hasActiveRecords %}display:none{% endif %}">
<a data-replacer="url" class="api-link ticktac-running ticktac-stop btn {{ class }}" href="{{ path('stop_timesheet', {'id' : entry.id}) }}" data-event="kimai.timesheetStop kimai.timesheetUpdate" data-method="PATCH" data-msg-error="timesheet.stop.error" data-msg-success="timesheet.stop.success"{% if hasActiveRecords %} accesskey="s"{% endif %}>
<i class="icon text-red {{ 'stop-small'|icon(true) }}"></i>
<span data-replacer="duration" data-title="true" data-since="{{ entry.begin is null ? '' : entry.begin|date_format(constant('DATE_ISO8601')) }}">{{ entry is iterable ? 0|duration : entry|duration }}</span>
<div class="ticktac-menu" data-api="{{ path('active_timesheet') }}" data-href="{{ path('stop_timesheet', {'id' : '000'}) }}" style="{% if not hasActiveRecords %}display:none{% endif %}">
<a data-replacer="url" class="api-link ticktac-running ticktac-stop btn {{ class }} btn-icon px-sm-2" href="{{ path('stop_timesheet', {'id' : entry.id}) }}" data-event="kimai.timesheetStop kimai.timesheetUpdate" data-method="PATCH" data-msg-error="timesheet.stop.error" data-msg-success="timesheet.stop.success"{% if hasActiveRecords %} accesskey="s"{% endif %}>
<i class="text-red {{ 'stop-small'|icon(false) }} me-0 me-sm-1"></i>
<span class="d-none d-sm-block" data-replacer="duration" data-title="true" data-since="{{ entry.begin is null ? '' : entry.begin|date_format(constant('DATE_ISO8601')) }}">{{ entry is iterable ? 0|duration : entry|duration }}</span>
</a>
</div>
<div class="messages-menu-empty" style="{% if hasActiveRecords %}display:none{% endif %}">
<a href="{{ path('timesheet_create') }}" class="modal-ajax-form ticktac-start btn {{ class }}" accesskey="n">
<i class="icon text-green {{ 'start'|icon(true) }}"></i>
<span>{{ 0|duration }}</span>
<div class="ticktac-menu-empty" style="{% if hasActiveRecords %}display:none{% endif %}">
<a href="{{ path('timesheet_create') }}" class="modal-ajax-form ticktac-start btn {{ class }} btn-icon px-sm-2" accesskey="n">
<i class="text-green {{ 'start'|icon(false) }} me-0 me-sm-1"></i>
<span class="d-none d-sm-block">{{ 0|duration }}</span>
</a>
</div>
</div>

View File

@@ -11,11 +11,11 @@
{% block profile_intro %}
<div class="card mb-3">
<div class="card-body box-user-profile">
<div class="row g-2 align-items-center">
<div class="row row-sm-2 align-items-center">
<div class="col-auto" data-toggle="tooltip" title="{{ 'id'|trans }}: {{ user.id }}">
{{ widgets.user_avatar(user, false, 'avatar-lg') }}
</div>
<div class="col">
<div class="col" style="min-width: 100px">
<h4 class="card-title m-0">{{ widgets.username(user) }}</h4>
{% if user.title is not empty %}
<div class="text-muted">
@@ -23,7 +23,7 @@
</div>
{% endif %}
</div>
<div class="col-auto" id="profile-buttons">
<div class="col-auto text-end mt-2 mt-sm-1" id="profile-buttons">
{% from '@theme/components/buttons.html.twig' import link_button %}
{% if app.user == user %}
{{ link_button('notifications'|trans, 'javascript:notificationStatus()', 'fas fa-bell', 'default') }}

View File

@@ -17,12 +17,12 @@ use App\Tests\DataFixtures\TimesheetFixtures;
*/
class FavoriteControllerTest extends ControllerBaseTest
{
public function testIsSecure()
public function testIsSecure(): void
{
$this->assertUrlIsSecured('/favorite/timesheet/');
}
public function testIndexAction()
public function testIndexAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$start = new \DateTime('first day of this month');
@@ -38,7 +38,9 @@ class FavoriteControllerTest extends ControllerBaseTest
$this->assertTrue($client->getResponse()->isSuccessful());
$content = $client->getResponse()->getContent();
self::assertStringContainsString('<div class="nav-item dropdown d-none d-md-flex me-3 notifications-menu" data-reload="/en/favorite/timesheet/">', $content);
self::assertStringContainsString('<div class="card-header">Restart one of your last activities</div>', $content);
self::assertNotFalse($content);
self::assertStringContainsString('<div class="nav-item dropdown d-flex me-sm-3 me-1 notifications-menu" data-reload="/en/favorite/timesheet/">', $content);
self::assertStringContainsString('<div class="card-header">', $content);
self::assertStringContainsString('Restart one of your last activities', $content);
}
}

View 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\Tests\Controller;
use App\Entity\User;
/**
* @group integration
*/
class HelpControllerTest extends ControllerBaseTest
{
public function testHelpLocalesAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->assertAccessIsGranted($client, '/help/locales');
$this->assertDataTableRowCount($client, 'datatable_help_locales', 25); // @see services_test.yaml
}
}

View File

@@ -42,9 +42,7 @@ class HomepageControllerTest extends ControllerBaseTest
$em->persist($pref);
$user->addPreference($pref);
$user->setLanguage('ar');
$em->flush();
$this->request($client, '/homepage');

View File

@@ -31,7 +31,7 @@ class ConfigureMainMenuEventTest extends TestCase
self::assertNull($sut->getInvoiceMenu());
self::assertNull($sut->getReportingMenu());
$timesheet = new MenuItemModel('timesheet', 'timesheet');
$timesheet = new MenuItemModel('times', 'timesheet');
$sut->getMenu()->addChild($timesheet);
self::assertNotNull($sut->getTimesheetMenu());
self::assertSame($timesheet, $sut->getTimesheetMenu());
@@ -41,9 +41,19 @@ class ConfigureMainMenuEventTest extends TestCase
self::assertNotNull($sut->getInvoiceMenu());
self::assertSame($invoice, $sut->getInvoiceMenu());
self::assertNull($sut->findById('reporting'));
self::assertNull($sut->findById('foo'));
self::assertNull($sut->findById('bar'));
$reporting = new MenuItemModel('reporting', 'reporting');
$sut->getMenu()->addChild($reporting);
$reporting->addChild(new MenuItemModel('foo', 'foo'));
$reporting->addChild(new MenuItemModel('bar', 'bar'));
self::assertNotNull($sut->getReportingMenu());
self::assertSame($reporting, $sut->getReportingMenu());
self::assertSame($reporting, $sut->findById('reporting'));
self::assertNotNull($sut->findById('foo'));
self::assertNotNull($sut->findById('bar'));
}
}

View File

@@ -17,14 +17,14 @@ use PHPUnit\Framework\TestCase;
*/
class DateRangeTest extends TestCase
{
public function testDefaultValues()
public function testDefaultValues(): void
{
$sut = new DateRange();
self::assertNull($sut->getBegin());
self::assertNull($sut->getEnd());
}
public function testSetterAndGetter()
public function testSetterAndGetter(): void
{
$begin = new \DateTime('now');
$end = new \DateTime('2018-11-25 18:45:32');
@@ -34,7 +34,73 @@ class DateRangeTest extends TestCase
self::assertInstanceOf(DateRange::class, $sut->setBegin($begin));
self::assertInstanceOf(DateRange::class, $sut->setEnd($end));
self::assertInstanceOf(\DateTime::class, $sut->getBegin());
self::assertInstanceOf(\DateTime::class, $sut->getEnd());
self::assertEquals($begin->format('Y-m-d') . ' 00:00:00', $sut->getBegin()->format('Y-m-d H:i:s'));
self::assertEquals('2018-11-25 23:59:59', $sut->getEnd()->format('Y-m-d H:i:s'));
}
public function testEquatableInterface(): void
{
self::assertTrue((new DateRange())->isEqualTo(new DateRange()));
$sut = new DateRange();
$sut->setBegin(new \DateTime('now'));
$sut->setEnd(new \DateTime('+2 minutes'));
$sut1 = new DateRange();
$sut1->setBegin(new \DateTime('+2 minutes'));
$sut1->setEnd(new \DateTime('now'));
self::assertTrue($sut->isEqualTo($sut1));
self::assertTrue($sut1->isEqualTo($sut));
$sut = new DateRange(false);
$sut->setBegin(new \DateTime('now'));
$sut->setEnd(new \DateTime('+2 minutes'));
$sut1 = new DateRange();
$sut1->setBegin(new \DateTime('+2 minutes'));
$sut1->setEnd(new \DateTime('now'));
self::assertFalse($sut->isEqualTo($sut1));
self::assertFalse($sut1->isEqualTo($sut));
$sut = new DateRange();
$sut->setBegin(new \DateTime('now'));
$sut1 = new DateRange();
$sut1->setBegin(new \DateTime('+1 day'));
self::assertFalse($sut->isEqualTo($sut1));
self::assertFalse($sut1->isEqualTo($sut));
$sut = new DateRange();
$sut->setBegin(new \DateTime('now'));
$sut1 = new DateRange();
$sut1->setEnd(new \DateTime('now'));
self::assertFalse($sut->isEqualTo($sut1));
self::assertFalse($sut1->isEqualTo($sut));
$sut = new DateRange();
$sut->setBegin(new \DateTime('now'));
$sut1 = new DateRange();
$sut1->setBegin(new \DateTime('now'));
self::assertTrue($sut->isEqualTo($sut1));
self::assertTrue($sut1->isEqualTo($sut));
$sut = new DateRange();
$sut->setEnd(new \DateTime('now'));
$sut1 = new DateRange();
$sut1->setEnd(new \DateTime('now'));
self::assertTrue($sut->isEqualTo($sut1));
self::assertTrue($sut1->isEqualTo($sut));
}
}

View File

@@ -24,7 +24,7 @@ use PHPUnit\Framework\TestCase;
*/
class ConfigurableNumberGeneratorTest extends TestCase
{
private function getSut(string $format, int $counter = 1)
private function getSut(string $format, int $counter = 1): ConfigurableNumberGenerator
{
$config = SystemConfigurationFactory::createStub(['invoice' => ['number_format' => $format]]);
@@ -53,7 +53,10 @@ class ConfigurableNumberGeneratorTest extends TestCase
return new ConfigurableNumberGenerator($repository, $config);
}
public function getTestData()
/**
* @return array<int, array<int, string|\DateTime|int>>
*/
public function getTestData(): array
{
$invoiceDate = new \DateTime();
@@ -143,7 +146,7 @@ class ConfigurableNumberGeneratorTest extends TestCase
/**
* @dataProvider getTestData
*/
public function testGetInvoiceNumber(string $format, string $expectedInvoiceNumber, \DateTime $invoiceDate, int $counter = 1)
public function testGetInvoiceNumber(string $format, string $expectedInvoiceNumber, \DateTime $invoiceDate, int $counter = 1): void
{
$customer = new Customer('Acme company');
$customer->setNumber('0815');
@@ -163,7 +166,10 @@ class ConfigurableNumberGeneratorTest extends TestCase
$this->assertEquals('default', $sut->getId());
}
public function getInvalidTestData()
/**
* @return array<int, array<int, string|\DateTime>>
*/
public function getInvalidTestData(): array
{
$invoiceDate = new \DateTime();
@@ -188,7 +194,7 @@ class ConfigurableNumberGeneratorTest extends TestCase
/**
* @dataProvider getInvalidTestData
*/
public function testInvalidGetInvoiceNumber(string $format, \DateTime $invoiceDate, string $brokenPart)
public function testInvalidGetInvoiceNumber(string $format, \DateTime $invoiceDate, string $brokenPart): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage(sprintf('Unknown %s found', $brokenPart));
@@ -201,4 +207,40 @@ class ConfigurableNumberGeneratorTest extends TestCase
$sut->getInvoiceNumber();
}
/**
* @return array<int, array<int, string>>
*/
public function getMissingFieldTestData(): array
{
return [
['{Y}/{cnumber}_{ccy,3}', 'Customer has no number, replacer {cnumber} failed evaluation'],
['{Y}/{cname}_{ccy,3}', 'Customer has no name, replacer {cname} failed evaluation'],
];
}
/**
* @dataProvider getMissingFieldTestData
*/
public function testCustomerHasMissingField(string $format, string $message): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage($message);
$user = $this->createMock(User::class);
$user->method('getId')->willReturn(13);
$user->method('getAccountNumber')->willReturn('0815');
$customer = new Customer('');
$customer->setName(null);
$sut = $this->getSut($format);
$model = (new InvoiceModelFactoryFactory($this))->create()->createModel(new DebugFormatter());
$model->setInvoiceDate(new \DateTime());
$model->setCustomer($customer);
$model->setUser($user);
$sut->setModel($model);
$sut->getInvoiceNumber();
}
}

View File

@@ -3342,21 +3342,6 @@ parameters:
count: 2
path: Controller/ExportControllerTest.php
-
message: "#^Method App\\\\Tests\\\\Controller\\\\FavoriteControllerTest\\:\\:testIndexAction\\(\\) has no return type specified\\.$#"
count: 1
path: Controller/FavoriteControllerTest.php
-
message: "#^Method App\\\\Tests\\\\Controller\\\\FavoriteControllerTest\\:\\:testIsSecure\\(\\) has no return type specified\\.$#"
count: 1
path: Controller/FavoriteControllerTest.php
-
message: "#^Parameter \\#2 \\$haystack of static method PHPUnit\\\\Framework\\\\Assert\\:\\:assertStringContainsString\\(\\) expects string, string\\|false given\\.$#"
count: 2
path: Controller/FavoriteControllerTest.php
-
message: "#^Method App\\\\Tests\\\\Controller\\\\HomepageControllerTest\\:\\:testIndexAction\\(\\) has no return type specified\\.$#"
count: 1
@@ -6957,21 +6942,6 @@ parameters:
count: 1
path: Form/Extension/IconExtensionTest.php
-
message: "#^Cannot call method format\\(\\) on DateTime\\|null\\.$#"
count: 2
path: Form/Model/DateRangeTest.php
-
message: "#^Method App\\\\Tests\\\\Form\\\\Model\\\\DateRangeTest\\:\\:testDefaultValues\\(\\) has no return type specified\\.$#"
count: 1
path: Form/Model/DateRangeTest.php
-
message: "#^Method App\\\\Tests\\\\Form\\\\Model\\\\DateRangeTest\\:\\:testSetterAndGetter\\(\\) has no return type specified\\.$#"
count: 1
path: Form/Model/DateRangeTest.php
-
message: "#^Method App\\\\Tests\\\\Form\\\\Model\\\\SystemConfigurationTest\\:\\:testDefaultValues\\(\\) has no return type specified\\.$#"
count: 1
@@ -7507,31 +7477,6 @@ parameters:
count: 1
path: Invoice/InvoiceModelTest.php
-
message: "#^Method App\\\\Tests\\\\Invoice\\\\NumberGenerator\\\\ConfigurableNumberGeneratorTest\\:\\:getInvalidTestData\\(\\) has no return type specified\\.$#"
count: 1
path: Invoice/NumberGenerator/ConfigurableNumberGeneratorTest.php
-
message: "#^Method App\\\\Tests\\\\Invoice\\\\NumberGenerator\\\\ConfigurableNumberGeneratorTest\\:\\:getSut\\(\\) has no return type specified\\.$#"
count: 1
path: Invoice/NumberGenerator/ConfigurableNumberGeneratorTest.php
-
message: "#^Method App\\\\Tests\\\\Invoice\\\\NumberGenerator\\\\ConfigurableNumberGeneratorTest\\:\\:getTestData\\(\\) has no return type specified\\.$#"
count: 1
path: Invoice/NumberGenerator/ConfigurableNumberGeneratorTest.php
-
message: "#^Method App\\\\Tests\\\\Invoice\\\\NumberGenerator\\\\ConfigurableNumberGeneratorTest\\:\\:testGetInvoiceNumber\\(\\) has no return type specified\\.$#"
count: 1
path: Invoice/NumberGenerator/ConfigurableNumberGeneratorTest.php
-
message: "#^Method App\\\\Tests\\\\Invoice\\\\NumberGenerator\\\\ConfigurableNumberGeneratorTest\\:\\:testInvalidGetInvoiceNumber\\(\\) has no return type specified\\.$#"
count: 1
path: Invoice/NumberGenerator/ConfigurableNumberGeneratorTest.php
-
message: "#^Method App\\\\Tests\\\\Invoice\\\\NumberGenerator\\\\DateNumberGeneratorTest\\:\\:getSut\\(\\) has no return type specified\\.$#"
count: 1

View File

@@ -1409,6 +1409,22 @@
<source>about.title</source>
<target>Über Kimai</target>
</trans-unit>
<trans-unit id="i_XOmN4" resname="help_locales">
<source>help_locales</source>
<target>Unterstützte Sprachen mit Formatierungsregeln</target>
</trans-unit>
<trans-unit id="JmkKf48" resname="user.language.help">
<source>user.language.help</source>
<target>Diese Einstellung ist neben der Übersetzung der Oberfläche ebenso zuständig für die Zeit-, Datums-, und Währungsformate</target>
</trans-unit>
<trans-unit id="ulgdXdi" resname="hour_24">
<source>hour_24</source>
<target>24-Stunden</target>
</trans-unit>
<trans-unit id="M2B0gF." resname="time">
<source>time</source>
<target>Uhrzeit</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -1409,6 +1409,22 @@
<source>about.title</source>
<target>About Kimai</target>
</trans-unit>
<trans-unit id="i_XOmN4" resname="help_locales">
<source>help_locales</source>
<target>Supported languages with formatting rules</target>
</trans-unit>
<trans-unit id="JmkKf48" resname="user.language.help">
<source>user.language.help</source>
<target>In addition to translating the interface, this setting is also responsible for the time, date and currency formats.</target>
</trans-unit>
<trans-unit id="ulgdXdi" resname="hour_24">
<source>hour_24</source>
<target>24-hour</target>
</trans-unit>
<trans-unit id="M2B0gF." resname="time">
<source>time</source>
<target>Time</target>
</trans-unit>
</body>
</file>
</xliff>