Compare commits

...

63 Commits
1.2 ... 1.6

Author SHA1 Message Date
Kevin Papst
724eb9c658 release 1.6 (#1277) 2019-11-24 15:29:01 +01:00
Kevin Papst
936ab05882 prepare release 1.6 (#1270) 2019-11-24 00:02:43 +01:00
Kevin Papst
817a43b178 added csv importer for timesheet and project data (#1216) 2019-11-23 22:02:55 +01:00
Kevin Papst
0f16f42887 Select2 search enhancement (#1276) 2019-11-23 14:26:35 +01:00
Kevin Papst
641c199c8c API: allow to set user for timesheet (#1259) 2019-11-23 14:21:46 +01:00
Aptevo
04dd6b900f added de_CH language (#1272) 2019-11-23 11:29:29 +01:00
Kevin Papst
536a05210e enable interactive features of Swagger UI (#1273) 2019-11-22 21:59:25 +01:00
Kevin Papst
60d16016e8 allow to configure calendar slot duration (#1269) 2019-11-22 01:30:25 +01:00
Kevin Papst
9222075c7a Added update command (#1268) 2019-11-21 23:15:41 +01:00
1willem
641dcdff85 Dutch translation files (#1226) 2019-11-18 21:48:16 +01:00
Kevin Papst
668649be7a use homepage link to redirect from error page (#1260) 2019-11-18 12:29:21 +01:00
Kevin Papst
0de936d474 fix the password reset page layout (#1261) 2019-11-18 12:28:59 +01:00
Kevin Papst
47bd8b0ce7 added new invoice fields, improved invoice templates (#1258) 2019-11-18 12:01:26 +01:00
Kevin Papst
2287c9951c invoice: money fields without currency, title cleanup (#1254) 2019-11-15 18:54:08 +01:00
Kevin Papst
94c28ebbc5 Update and delete multi timesheets and tags (#1240) 2019-11-15 01:25:31 +01:00
Kevin Papst
b4739f8f03 allow to include bundle configuration in system configuration screen (#1235) 2019-11-14 19:25:38 +01:00
Kevin Papst
c32cd36861 fix reset input for forms (#1251) 2019-11-14 19:09:55 +01:00
Kevin Papst
945562b34d fixes wrong foreign keys on timesheet_tags join table (#1242) 2019-11-14 01:57:58 +01:00
Kevin Papst
af0f89774e User roles and permission management via Admin UI (#1231) 2019-11-10 18:53:56 +01:00
Kevin Papst
c6c4098759 rounding rules via admin screen, round begin when starting record (#1229) 2019-11-10 13:57:05 +01:00
Kevin Papst
fa1c79e15c support different formats in user timesheet exports (#1222) 2019-11-08 16:10:38 +01:00
Kevin Papst
0705c26513 highlight menus on sub-pages (#1220) 2019-11-07 11:19:10 +01:00
Kevin Papst
140fa4b7c0 prepare release 1.5 (#1214) 2019-11-03 17:31:33 +01:00
Kevin Papst
cd88901868 re-style error pages (#1210) 2019-10-31 14:20:53 +01:00
Kevin Papst
8eec97deb5 respect users timezone in statistic widgets (#1207) 2019-10-31 00:17:31 +01:00
Kevin Papst
4331a7c9f4 fix error pages (#1209) 2019-10-31 00:16:25 +01:00
Kevin Papst
f4fcfeba9d dynamic branding options (#1205) 2019-10-30 14:22:34 +01:00
Kevin Papst
48aefb1862 fix avatar path for subdirectory installations (#1201) 2019-10-30 14:00:48 +01:00
Kevin Papst
bda9584346 reformat edit forms (#1192) 2019-10-30 13:59:53 +01:00
Kevin Papst
ed1154af67 fix absolute url (#1200) 2019-10-30 13:45:59 +01:00
Kevin Papst
c368eacd87 fixed renamed filter name, added that disabled users cannot be filtered (#1193) 2019-10-27 13:56:45 +01:00
Kevin Papst
949c59230f improve list views (#1191) 2019-10-25 16:21:30 +02:00
Kevin Papst
1114538f41 duration field in timesheet edit dialog (#1180) 2019-10-24 23:46:22 +02:00
Kevin Papst
b0f83291ee upgraded to phpunit 8 (#1155) 2019-10-24 17:35:05 +02:00
Kevin Papst
e91e4ff430 support search term in API collection requests (#1185) 2019-10-24 17:31:15 +02:00
Ahmed Şeref
4ad290d4bd added turkish translations (#1160) 2019-10-24 17:30:34 +02:00
Kevin Papst
37c6e2c0af added order-date field to project (#1186) 2019-10-24 17:04:21 +02:00
Kevin Papst
b4a45afce9 fix API login in subdirectory installations (#1181) 2019-10-24 11:06:12 +02:00
yayitazale
330ff6eb47 updated spanish translations (#1183) 2019-10-24 10:53:23 +02:00
Adrian Rudnik
c681eb1bdb docker fix to hide details for 500 errors (#1173) 2019-10-21 16:22:29 +02:00
Kevin Papst
de533e7031 Kimai 1.4.2 (#1167) 2019-10-14 12:30:11 +02:00
Kevin Papst
ae6ed3f23f Bugfixes for Kimai 1.4.1 (#1158)
* fix missing translation for preset datatable headers
* better dependency check for avatar service
* show only own teams to teamleads
2019-10-07 16:04:19 +02:00
Kevin Papst
32aa209b75 prepare release 1.4 (#1154) 2019-10-04 07:36:59 +02:00
Kevin Papst
e8c25d8ac5 added avatars, show user teams in list, new team dashboard widgets (#1150) 2019-10-03 13:44:16 +02:00
Kevin Papst
718ad4b398 fix migrations for older database versions (#1148) 2019-09-27 14:52:22 +02:00
Kevin Papst
8d81a394ee updated theme, fix deprecations in events dispatching (#1145) 2019-09-25 19:12:12 +02:00
Kevin Papst
d661c8b54e fix closing search dropdown (#1142)
* using a different library for javascript selects, fixing the closing search dropdown
* fix closing dropdown for daterangepicker
* added missing search button for mobile on customer page
* fix meta fields with same names than existing columns
2019-09-25 18:34:25 +02:00
Kevin Papst
e45e782e7d fix invoice preview (#1129) 2019-09-20 16:00:03 +02:00
Kevin Papst
5aa422dde1 Display meta-fields in datatables (#1116) 2019-09-19 20:28:31 +02:00
Kevin Papst
24cbe3d281 post 1.3 fixes (#1106) 2019-09-17 22:20:36 +02:00
Kevin Papst
13f5e76015 added czech translations (#1075) 2019-09-16 23:19:46 +02:00
Kevin Papst
35c65ba890 composer update (#1104) 2019-09-13 17:16:53 +02:00
Kevin Papst
8f2b82b832 bump version to 1.3 (#1102) 2019-09-12 00:36:48 +02:00
Kevin Papst
7f1a206a52 support installation specific translations (#1099) 2019-09-11 23:18:35 +02:00
Kevin Papst
8c503a226e fix API version string (#1096) 2019-09-11 21:44:14 +02:00
Kevin Papst
cdcf9eaf67 handles session timeouts in modals (#1092) 2019-09-10 17:07:34 +02:00
Kevin Papst
a651e55dc9 added table-column ordering (#1086) 2019-09-09 23:47:42 +02:00
Kevin Papst
d041a3f4f9 improved tag auto-completion and added tag management (#1083) 2019-09-04 23:47:33 +02:00
Kevin Papst
3ea05cb705 support multiple invoice repositories (#1084) 2019-09-04 22:50:53 +02:00
hmr-it-jr
3f27ab1e44 added mail icons (#1077) 2019-09-04 19:49:03 +02:00
Kevin Papst
e99b170d0a refactored search with free search term support (#1064) 2019-09-04 18:54:06 +02:00
Kevin Papst
49e1a1c410 use timesheet description in invoices (#1079) 2019-09-04 10:54:21 +02:00
Kevin Papst
93ab3d5666 post release 1.2 fixes (#1068)
* fix release command
* fix permission name in UPGRADING
* added missing korean calendar translations
* fixed entry.total for invoice with fixed rates
2019-09-03 11:59:34 +02:00
687 changed files with 25968 additions and 7965 deletions

View File

@@ -1,5 +1,5 @@
unreleased=true
future-release=1.2
future-release=1.6
exclude-labels=duplicate,question,invalid,wontfix,release
enhancement_labels=>enhancement,Enhancement,feature request,translation i18n,technical debt
issues-wo-labels=false

6
.gitignore vendored
View File

@@ -5,7 +5,11 @@
/bin/*
!bin/console
/config/packages/local.yaml
/config/packages/*-local.yaml
/config/packages/*/local.yaml
/config/packages/*/*-local.yaml
public/avatars/*.png
templates/invoice/renderer/.~lock*
@@ -18,6 +22,8 @@ templates/invoice/renderer/.~lock*
/var/invoices/*
/var/packages/*
!var/packages/.gitkeep
/var/plugins/*
!var/plugins/.gitkeep

View File

@@ -68,7 +68,7 @@ install:
- composer require zendframework/zend-ldap
script:
- if [[ $CODECOVERAGE == 1 ]]; then vendor/bin/phpunit tests/ --coverage-clover=coverage.xml; fi;
- if [[ $CODECOVERAGE == 1 ]]; then TEST_WITH_BUNDLES=1 vendor/bin/phpunit tests/ --coverage-clover=coverage.xml; fi;
- if [[ $CODECOVERAGE == 0 ]]; then vendor/bin/phpunit tests/; fi;
- if [[ $DB == 'sqlite' ]]; then cp tests/.env.dist.sqlite .env; fi;
- if [[ $DB == "mysql" || $DB == "mariadb" ]]; then cp tests/.env.dist.mysql .env; fi;

View File

@@ -1,5 +1,25 @@
# Change Log
## [1.6](https://github.com/kevinpapst/kimai2/tree/1.6) (2019-11-24)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.5...1.6)
## [1.5](https://github.com/kevinpapst/kimai2/tree/1.5) (2019-11-03)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.4.2...1.5)
## [1.4.2](https://github.com/kevinpapst/kimai2/tree/1.4) (2019-10-14)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.4.1...1.4.2)
## [1.4.1](https://github.com/kevinpapst/kimai2/tree/1.4) (2019-10-07)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.4...1.4.1)
## [1.4](https://github.com/kevinpapst/kimai2/tree/1.4) (2019-10-03)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.3...1.4)
## [1.3](https://github.com/kevinpapst/kimai2/tree/1.3) (2019-09-11)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.2...1.3)
Release notes including the changelog can be found [here](https://github.com/kevinpapst/kimai2/releases/tag/1.3)
## [1.2](https://github.com/kevinpapst/kimai2/tree/1.2) (2019-08-28)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.1...1.2)

View File

@@ -33,7 +33,8 @@ RUN apt update && \
zip && \
apt remove -y wget && \
apt -y autoremove && \
apt clean
apt clean && \
cp /usr/local/etc/php/php.ini-production /usr/local/etc/php/php.ini
FROM tmp_kimai2_base

View File

@@ -61,7 +61,7 @@ It is open for changes and input from the community, your [ideas and questions](
> Kimai 2 uses a rolling release concept for delivering updates.
> You can upgrade Kimai at any time, you don't need to wait for the next official release.
Release versions will be created on a regular base (approx. every second month) and you can use these tags if you are familiar with git.
Release versions will be created on a regular base (approx. one release per month) and you can use these tags if you are familiar with git.
Every code change, whether it's a new feature or a bug fix, will be done on the master branch.
I have to do it this way, as I develop Kimai in my free time and want to put my effort into the software instead of backporting changes for old versions.

View File

@@ -7,8 +7,8 @@ As announced in the [README](README.md) I only support the latest available rele
| Version | Supported |
| ------- | ------------------ |
| master | :white_check_mark: |
| 1.2 | :white_check_mark: |
| < 1.2 | :x: |
| 1.6 | :white_check_mark: |
| < 1.6 | :x: |
## Reporting a Vulnerability

View File

@@ -8,28 +8,67 @@ you can upgrade your Kimai installation to the latest stable release.
Check below if there are more version specific steps required, which need to be executed after the normal update process.
Perform EACH version specific task between your version and the new one, otherwise you risk data inconsistency or a broken installation.
## [1.6](https://github.com/kevinpapst/kimai2/releases/tag/1.6)
**New database tables and fields were created, don't forget to [run the updater](https://www.kimai.org/documentation/updates.html).**
- Invoice improvements:
- Moved CSV, ODS and XSLX invoice templates to [another repository](https://github.com/Keleo/kimai2-invoice-templates). Using them? Install them manually (see [invoice documentation](https://www.kimai.org/documentation/invoices.html)).
- Added new invoice fields (VAT, contact, payment details) and customer field (VAT). Used the twig settings before? Move them to the respective invoice template settings.
- Permissions can be managed via Admin UI. Please move your permission settings from [local.yaml to your database](https://www.kimai.org/documentation/permissions.html).
### Developer
Please add default permissions to your [plugin](https://www.kimai.org/documentation/plugins.html).
## [1.5](https://github.com/kevinpapst/kimai2/releases/tag/1.5)
[Update as usual](https://www.kimai.org/documentation/updates.html)
## [1.4](https://github.com/kevinpapst/kimai2/releases/tag/1.4)
**There is a new directory, which needs to be writable by the webserver: `public/avatars/`.**
New permission (used in new dashboard widget):
- `view_team_member` - display team assignments (names, teamleads and members) for the current user
Activated Javascript select component by default (check mobile devices).
### Developer: BC breaks
- Dashboard widgets and rows need to define their `type` by FQCN
- Switched to Symfony 4.3 event types, this could fail in plugins, but only if they didn't use the official constants for event names
## [1.3](https://github.com/kevinpapst/kimai2/releases/tag/1.3)
Added `manage_tag` permission for new tag features
### Developer: BC breaks
- Refactored toolbars and search, plugins needs to be checked
- Invoices now supports multiple repositories, some method signatures had to be changed (eg. `calculateSumIdentifier()`)
## [1.2](https://github.com/kevinpapst/kimai2/releases/tag/1.2)
### Possible BC breaks
**If you are still using 0.7 or below, you need to upgrade to 1.1 before upgrading to this version.**
- Deleted timezone conversion command. If you are still using 0.7 or below, you need to upgrade to 1.1 before upgrading to this version.
- Deleted timezone conversion command.
- Minimum password length raised from 5 to 8 character (applies only for password changes and new users)
- Maximum customer name length lowered to 150 character
- Maximum project name length lowered to 150 character
- Maximum activity name length lowered to 150 character
- Added new permission: `manage_invoice_template`
- Removed permissions: `view_invoice_template`, `create_invoice_template`, `edit_invoice_template`, `delete_invoice_template`
- Removed permission: `view_invoice` (using `create_export` only)
- Removed permission: `view_export` (using `create_export` only)
### Developer: BC breaks
- Custom export renderer need to check for usage of `Timesheet::getEnd()` as running entries can now be exported as well
## [1.1](https://github.com/kevinpapst/kimai2/releases/tag/1.1)
[Update as usual](https://www.kimai.org/documentation/updates.html), nothing special for this release if you upgrade from 1.0 / 1.0.1.
## [1.0.1](https://github.com/kevinpapst/kimai2/releases/tag/1.0.1)
[Update as usual](https://www.kimai.org/documentation/updates.html), nothing special for this release if you upgrade from 1.0.
## [1.0](https://github.com/kevinpapst/kimai2/releases/tag/1.0)
This release contains several changes, as I still have the goal to stabilize the code base to prevent

View File

@@ -11,27 +11,46 @@ global.$ = global.jQuery = $;
require('bootstrap-sass');
require('jquery-slimscroll');
require('bootstrap-select');
require('select2');
require('select2/dist/js/i18n/ar');
require('select2/dist/js/i18n/cs');
require('select2/dist/js/i18n/de');
require('select2/dist/js/i18n/es');
require('select2/dist/js/i18n/fr');
require('select2/dist/js/i18n/hu');
require('select2/dist/js/i18n/it');
require('select2/dist/js/i18n/ja');
require('select2/dist/js/i18n/ko');
require('select2/dist/js/i18n/nl');
require('select2/dist/js/i18n/pt-BR');
require('select2/dist/js/i18n/ru');
require('select2/dist/js/i18n/sk');
require('select2/dist/js/i18n/sv');
require('select2/dist/js/i18n/tr');
const Moment = require('moment');
global.moment = Moment;
require('moment/locale/ar');
require('moment/locale/cs');
require('moment/locale/de');
require('moment/locale/de-ch');
require('moment/locale/es');
require('moment/locale/fr');
require('moment/locale/hu');
require('moment/locale/it');
require('moment/locale/ja');
require('moment/locale/ko');
require('moment/locale/nl');
require('moment/locale/pt-br');
require('moment/locale/ru');
require('moment/locale/sk');
require('moment/locale/sv');
require('moment/locale/tr');
require('daterangepicker');
// ------ AdminLTE framework ------
require('../vendor/kevinpapst/adminlte-bundle/Resources/assets/admin-lte.scss');
require('./sass/admin-lte.scss');
require('admin-lte/dist/css/AdminLTE.min.css');
require('admin-lte/dist/css/skins/_all-skins.css');
require('../vendor/kevinpapst/adminlte-bundle/Resources/assets/admin-lte-extensions.scss');

View File

@@ -2,16 +2,21 @@
require('fullcalendar');
require('fullcalendar/dist/gcal.min');
require('fullcalendar/dist/locale/de');
require('fullcalendar/dist/locale/it');
require('fullcalendar/dist/locale/fr');
require('fullcalendar/dist/locale/es');
require('fullcalendar/dist/locale/ru');
require('fullcalendar/dist/locale/ar');
require('fullcalendar/dist/locale/cs');
require('fullcalendar/dist/locale/de');
require('fullcalendar/dist/locale/de-ch');
require('fullcalendar/dist/locale/es');
require('fullcalendar/dist/locale/fr');
require('fullcalendar/dist/locale/hu');
require('fullcalendar/dist/locale/pt-br');
require('fullcalendar/dist/locale/sv');
require('fullcalendar/dist/locale/it');
require('fullcalendar/dist/locale/ja');
require('fullcalendar/dist/locale/ko');
require('fullcalendar/dist/locale/nl');
require('fullcalendar/dist/locale/pt-br');
require('fullcalendar/dist/locale/ru');
require('fullcalendar/dist/locale/sk');
require('fullcalendar/dist/locale/sv');
require('fullcalendar/dist/locale/tr');
require('fullcalendar/dist/fullcalendar.min.css');

View File

@@ -16,7 +16,6 @@ import KimaiContainer from "./KimaiContainer";
import KimaiActiveRecordsDuration from './plugins/KimaiActiveRecordsDuration.js';
import KimaiDatatableColumnView from './plugins/KimaiDatatableColumnView.js';
import KimaiThemeInitializer from "./plugins/KimaiThemeInitializer";
import KimaiJqueryPluginInitializer from "./plugins/KimaiJqueryPluginInitializer";
import KimaiDateRangePicker from "./plugins/KimaiDateRangePicker";
import KimaiDatatable from "./plugins/KimaiDatatable";
import KimaiToolbar from "./plugins/KimaiToolbar";
@@ -31,7 +30,11 @@ import KimaiEvent from "./plugins/KimaiEvent";
import KimaiAPILink from "./plugins/KimaiAPILink";
import KimaiAlert from "./plugins/KimaiAlert";
import KimaiAutocomplete from "./plugins/KimaiAutocomplete";
import KimaiToolbarAction from "./plugins/KimaiToolbarAction";
import KimaiFormSelect from "./plugins/KimaiFormSelect";
import KimaiForm from "./plugins/KimaiForm";
import KimaiDatePicker from "./plugins/KimaiDatePicker";
import KimaiConfirmationLink from "./plugins/KimaiConfirmationLink";
import KimaiMultiUpdateTable from "./plugins/KimaiMultiUpdateTable";
export default class KimaiLoader {
@@ -47,14 +50,15 @@ export default class KimaiLoader {
kimai.registerPlugin(new KimaiEvent());
kimai.registerPlugin(new KimaiAPI());
kimai.registerPlugin(new KimaiAlert());
kimai.registerPlugin(new KimaiFormSelect('.selectpicker'));
kimai.registerPlugin(new KimaiConfirmationLink('confirmation-link'));
kimai.registerPlugin(new KimaiActiveRecordsDuration('[data-since]'));
kimai.registerPlugin(new KimaiDatatableColumnView('data-column-visibility'));
kimai.registerPlugin(new KimaiThemeInitializer());
kimai.registerPlugin(new KimaiJqueryPluginInitializer());
kimai.registerPlugin(new KimaiDateRangePicker('.content-wrapper'));
kimai.registerPlugin(new KimaiDateTimePicker('.content-wrapper'));
kimai.registerPlugin(new KimaiDatatable('table.dataTable'));
kimai.registerPlugin(new KimaiToolbar());
kimai.registerPlugin(new KimaiDateRangePicker('input[data-daterangepickerenable="on"]'));
kimai.registerPlugin(new KimaiDateTimePicker('input[data-datetimepicker="on"]'));
kimai.registerPlugin(new KimaiDatePicker('input[data-datepickerenable="on"]'));
kimai.registerPlugin(new KimaiDatatable('section.content', 'table.dataTable'));
kimai.registerPlugin(new KimaiToolbar('form.header-search', 'toolbar-action'));
kimai.registerPlugin(new KimaiSelectDataAPI('select[data-related-select]'));
kimai.registerPlugin(new KimaiAlternativeLinks('.alternative-link'));
kimai.registerPlugin(new KimaiAjaxModalForm('.modal-ajax-form'));
@@ -62,7 +66,9 @@ export default class KimaiLoader {
kimai.registerPlugin(new KimaiActiveRecords('li.messages-menu', 'li.messages-menu-empty'));
kimai.registerPlugin(new KimaiAPILink('api-link'));
kimai.registerPlugin(new KimaiAutocomplete('.js-autocomplete'));
kimai.registerPlugin(new KimaiToolbarAction('toolbar-action'));
kimai.registerPlugin(new KimaiForm());
kimai.registerPlugin(new KimaiThemeInitializer());
kimai.registerPlugin(new KimaiMultiUpdateTable());
//kimai.registerPlugin(new KimaiPauseRecord('li.messages-menu ul.menu li'));
// notify all listeners that Kimai plugins can now be registered

View File

@@ -5,7 +5,7 @@
* file that was distributed with this source code.
*/
import KimaiClickHandlerReducedInTableRow from "./KimaiClickHandlerReducedInTableRow";
import KimaiPlugin from "../KimaiPlugin";
/**
* Needs to be initialized with a class name.
@@ -18,7 +18,7 @@ import KimaiClickHandlerReducedInTableRow from "./KimaiClickHandlerReducedInTabl
*
* @param selector
*/
export default class KimaiAPILink extends KimaiClickHandlerReducedInTableRow {
export default class KimaiAPILink extends KimaiPlugin {
constructor(selector) {
super();

View File

@@ -29,6 +29,24 @@ export default class KimaiAjaxModalForm extends KimaiClickHandlerReducedInTableR
init() {
const self = this;
this.modal = jQuery('#remote_form_modal');
this.modal.on('hide.bs.modal', function () {
self.getContainer().getPlugin('event').trigger('modal-hide');
});
this.modal.on('hidden.bs.modal', function () {
// kill all references, so GC can kick in
self.getContainer().getPlugin('form').destroyForm(self._getFormIdentifier());
jQuery('#remote_form_modal .modal-body').replaceWith('');
});
this.modal.on('show.bs.modal', function () {
self.getContainer().getPlugin('event').trigger('modal-show');
});
this.modal.on('shown.bs.modal', function () {
// workaround for autofocus attribute, as the modal "steals" it
jQuery(self._getFormIdentifier()).find('input[type=text],textarea,select').filter(':not("[data-datetimepicker=on]")').filter(':visible:first').focus().delay(1000).focus();
});
this._addClickHandlerReducedInTableRow(this.selector, function(href) {
self.openUrlInModal(href);
});
@@ -39,12 +57,9 @@ export default class KimaiAjaxModalForm extends KimaiClickHandlerReducedInTableR
if (errorHandler === undefined) {
errorHandler = function(xhr, err) {
if (xhr.status !== undefined && xhr.status === 403) {
const alert = self.getContainer().getPlugin('alert');
alert.error(xhr.statusText);
return;
if (xhr.status === undefined || xhr.status !== 403) {
window.location = url;
}
window.location = url;
};
}
@@ -57,17 +72,26 @@ export default class KimaiAjaxModalForm extends KimaiClickHandlerReducedInTableR
});
}
/**
* Returns the CSS selector for the modal form.
*
* @returns {string}
* @private
*/
_getFormIdentifier() {
return '#remote_form_modal .modal-content form';
}
_openFormInModal(html) {
const self = this;
// the modal that we use to render the form in
let formIdentifier = '#remote_form_modal .modal-content form';
let formIdentifier = this._getFormIdentifier();
// if any of these is found in a response, the form will be re-displayed
let flashErrorIdentifier = 'div.alert-error';
// messages to show above the form
let flashMessageIdentifier = 'div.alert';
let form = jQuery(formIdentifier);
let remoteModal = jQuery('#remote_form_modal');
let remoteModal = this.modal;
// will be (re-)activated later
form.off('submit');
@@ -90,11 +114,7 @@ export default class KimaiAjaxModalForm extends KimaiClickHandlerReducedInTableR
);
// activate new loaded widgets
self.getContainer().getPlugin('date-time-picker').activateDateTimePicker(formIdentifier);
self.getContainer().getPlugin('autocomplete').activateAutocomplete(formIdentifier + " .js-autocomplete");
// activate selectpicker if beta test is active
jQuery('.selectpicker').selectpicker('refresh');
self.getContainer().getPlugin('form').activateForm(formIdentifier);
}
// show error flash messages
@@ -113,11 +133,7 @@ export default class KimaiAjaxModalForm extends KimaiClickHandlerReducedInTableR
});
// -----------------------------------------------------------------------
// workaround for autofocus attribute, as the modal "steals" it
remoteModal.on('shown.bs.modal', function () {
jQuery(this).find('input[type=text],textarea,select').filter(':not("[data-datetimepicker=on]")').filter(':visible:first').focus().delay(1000).focus();
});
this.getContainer().getPlugin('toolbar').hide();
remoteModal.modal('show');
// the new form that was loaded via ajax
@@ -181,6 +197,10 @@ export default class KimaiAjaxModalForm extends KimaiClickHandlerReducedInTableR
err = '[' + xhr.status +'] ' + xhr.statusText;
}
alert.error(message, err);
// this is useful for changing form fields and retrying to save (and in development to test form changes)
setTimeout(function() {
btn.button('reset');
}, 1500);
}
});
});

View File

@@ -27,7 +27,7 @@ export default class KimaiAlert extends KimaiPlugin {
message = translation.get(message);
}
Swal.fire({
type: 'error',
icon: 'error',
title: title.replace('%reason%', ''),
text: message,
});
@@ -42,10 +42,11 @@ export default class KimaiAlert extends KimaiPlugin {
Swal.fire({
timer: 2000,
timerProgressBar: true,
toast: true,
position: 'top-end',
showConfirmButton: false,
type: 'success',
icon: 'success',
title: message,
});
}
@@ -65,7 +66,7 @@ export default class KimaiAlert extends KimaiPlugin {
Swal.fire({
title: message,
type: 'question',
icon: 'question',
showCancelButton: true,
confirmButtonText: translation.get('confirm'),
cancelButtonText: translation.get('cancel')

View File

@@ -20,7 +20,7 @@ export default class KimaiAutocomplete extends KimaiPlugin {
}
init() {
this.activateAutocomplete(this.selector);
this.minChars = this.getContainer().getConfiguration().get('autoComplete');
}
getId() {
@@ -35,60 +35,72 @@ export default class KimaiAutocomplete extends KimaiPlugin {
return this.splitTagList(term).pop();
}
activateAutocomplete(selector)
{
const apiUrl = jQuery(selector).attr('data-autocomplete-url');
activateAutocomplete(selector) {
const self = this;
const API = self.getContainer().getPlugin('api');
jQuery(selector + ' ' + this.selector).each(function(index) {
const currentField = jQuery(this);
const apiUrl = currentField.attr('data-autocomplete-url');
const API = self.getContainer().getPlugin('api');
jQuery(selector)
// don't navigate away from the field on tab when selecting an item
.on("keydown", function (event) {
if (event.keyCode === jQuery.ui.keyCode.TAB &&
jQuery(this).autocomplete("instance").menu.active) {
event.preventDefault();
}
})
.autocomplete({
source: function (request, response) {
const lastEntry = self.extractLastTag(request.term);
API.get(apiUrl, {'name': lastEntry}, function(data){
response(data);
});
},
search: function () {
// custom minLength
var term = self.extractLastTag(this.value);
if (term.length < 2) {
return false;
currentField
// don't navigate away from the field on tab when selecting an item
.on("keydown", function (event) {
if (event.keyCode === jQuery.ui.keyCode.TAB &&
jQuery(this).autocomplete("instance").menu.active) {
event.preventDefault();
}
},
focus: function () {
// prevent value inserted on focus
return false;
},
select: function (event, ui) {
var terms = self.splitTagList(this.value);
})
.autocomplete({
source: function (request, response) {
const lastEntry = self.extractLastTag(request.term);
API.get(apiUrl, {'name': lastEntry}, function(data){
response(data);
});
},
search: function () {
// custom minLength
var term = self.extractLastTag(this.value);
if (term.length < self.minChars) {
return false;
}
},
focus: function () {
// prevent value inserted on focus
return false;
},
select: function (event, ui) {
var terms = self.splitTagList(this.value);
// remove the current input
terms.pop();
// remove the current input
terms.pop();
// check if selected tag is already in list
if (!terms.includes(ui.item.value)) {
// add the selected item
terms.push(ui.item.value);
// check if selected tag is already in list
if (!terms.includes(ui.item.value)) {
// add the selected item
terms.push(ui.item.value);
}
// add placeholder to get the comma-and-space at the end
terms.push("");
this.value = terms.join(", ");
$(this).trigger('change');
return false;
}
}
// add placeholder to get the comma-and-space at the end
terms.push("");
)
;
});
}
this.value = terms.join(", ");
$(this).trigger('change');
return false;
}
}
);
destroyAutocomplete(selector) {
jQuery(selector + ' ' + this.selector).each(function(index) {
const currentField = jQuery(this);
currentField.autocomplete("destroy");
currentField.removeData('autocomplete');
});
}
}

View File

@@ -21,12 +21,13 @@ export default class KimaiClickHandlerReducedInTableRow extends KimaiPlugin {
return;
}
// handles the "click" on table rows to open an entry for editing: when a button within a row is clicked,
// we don't want the table row event to be processed - so we intercept it
// handles the "click" on table rows to open an entry for editing
let target = event.target;
if (event.currentTarget.matches('tr')) {
while (target !== null && !target.matches('body')) {
if (target.matches('a') || target.matches ('button')) {
// when an element within the row is clicked, that can trigger stuff itself, we don't want the event to be processed
// don't act if a link, button or form element was clicked
if (target.matches('a') || target.matches ('button') || target.matches ('input')) {
return;
}
target = target.parentNode;

View File

@@ -0,0 +1,70 @@
/*
* 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.
*/
import KimaiPlugin from "../KimaiPlugin";
/**
* Needs to be initialized with a class name.
*
* Allows to assign the given selector to any element, which then is used as click-handler
* calling an API method and trigger the event from data-event attribute afterwards.
*
* @param selector
*/
export default class KimaiConfirmationLink extends KimaiPlugin {
constructor(selector) {
super();
this.selector = selector;
}
init() {
const self = this;
document.addEventListener('click', function(event) {
let target = event.target;
while (target !== null && !target.matches('body')) {
if (target.classList.contains(self.selector)) {
const attributes = target.dataset;
// is this a link?
let url = attributes['href'];
// or another HTML element with a custom href
if (!url) {
url = target.getAttribute('href');
}
// or is this a button?
let form = null;
if (target.type === 'submit' && target.form !== undefined) {
form = target.form;
}
if (attributes.question !== undefined) {
self.getContainer().getPlugin('alert').question(attributes.question, function(value) {
if (value) {
if (form === null) {
document.location = url;
} else {
if (url !== null) {
form.action = url;
}
form.submit();
}
}
});
}
event.preventDefault();
event.stopPropagation();
}
target = target.parentNode;
}
});
}
}

View File

@@ -14,9 +14,10 @@ import KimaiPlugin from "../KimaiPlugin";
export default class KimaiDatatable extends KimaiPlugin {
constructor(selector) {
constructor(contentAreaSelector, tableSelector) {
super();
this.selector = selector;
this.contentArea = contentAreaSelector;
this.selector = tableSelector;
}
getId() {
@@ -44,16 +45,26 @@ export default class KimaiDatatable extends KimaiPlugin {
for (let eventName of events.split(' ')) {
document.addEventListener(eventName, handle);
}
if (this.getContainer().getConfiguration().get('autoReloadDatatable')) {
document.addEventListener('toolbar-change', handle);
} else {
document.addEventListener('pagination-change', handle);
document.addEventListener('filter-change', handle);
}
}
reloadDatatable() {
const contentArea = this.contentArea;
const durations = this.getContainer().getPlugin('timesheet-duration');
const form = jQuery('.toolbar form');
const toolbarSelector = this.getContainer().getPlugin('toolbar').getSelector();
const form = jQuery(toolbarSelector);
let loading = '<div class="overlay"><i class="fas fa-sync fa-spin"></i></div>';
jQuery('section.content').append(loading);
jQuery(contentArea).append(loading);
// remove the empty fields to prevent errors
let formData = jQuery('.toolbar form :input')
let formData = jQuery(toolbarSelector + ' :input')
.filter(function(index, element) {
return jQuery(element).val() != '';
})
@@ -64,8 +75,8 @@ export default class KimaiDatatable extends KimaiPlugin {
type: form.attr('method'),
data: formData,
success: function(html) {
jQuery('section.content').replaceWith(
jQuery(html).find('section.content')
jQuery(contentArea).replaceWith(
jQuery(html).find(contentArea)
);
durations.updateRecords();
},

View File

@@ -24,13 +24,9 @@ export default class KimaiDatePicker extends KimaiPlugin {
return 'date-picker';
}
init() {
this.activateDatePicker(this.selector);
}
activateDatePicker(selector) {
let translator = this.getContainer().getTranslation();
jQuery(selector + ' input[data-datepickerenable="on"]').each(function(index) {
const TRANSLATE = this.getContainer().getTranslation();
jQuery(selector + ' ' + this.selector).each(function(index) {
let localeFormat = jQuery(this).data('format');
jQuery(this).daterangepicker({
singleDatePicker: true,
@@ -39,9 +35,9 @@ export default class KimaiDatePicker extends KimaiPlugin {
locale: {
format: localeFormat,
firstDay: 1,
applyLabel: translator.get('confirm'),
cancelLabel: translator.get('cancel'),
customRangeLabel: translator.get('customRange'),
applyLabel: TRANSLATE.get('confirm'),
cancelLabel: TRANSLATE.get('cancel'),
customRangeLabel: TRANSLATE.get('customRange'),
daysOfWeek: moment.weekdaysShort(),
monthNames: moment.months(),
}
@@ -54,4 +50,13 @@ export default class KimaiDatePicker extends KimaiPlugin {
});
}
destroyDatePicker(selector) {
jQuery(selector + ' ' + this.selector).each(function(index) {
if (jQuery(this).data('daterangepicker') !== undefined) {
jQuery(this).daterangepicker('destroy');
jQuery(this).data('daterangepicker').remove();
}
});
}
}

View File

@@ -24,13 +24,9 @@ export default class KimaiDateRangePicker extends KimaiPlugin {
return 'date-range-picker';
}
init() {
this.activateDateRangePicker(this.selector);
}
activateDateRangePicker(selector) {
let translator = this.getContainer().getTranslation();
jQuery(selector + ' input[data-daterangepickerenable="on"]').each(function(index) {
jQuery(selector + ' ' + this.selector).each(function(index) {
let localeFormat = jQuery(this).data('format');
let separator = jQuery(this).data('separator');
let rangesList = {};
@@ -70,4 +66,13 @@ export default class KimaiDateRangePicker extends KimaiPlugin {
});
}
destroyDateRangePicker(selector) {
jQuery(selector + ' ' + this.selector).each(function(index) {
if (jQuery(this).data('daterangepicker') !== undefined) {
jQuery(this).daterangepicker('destroy');
jQuery(this).data('daterangepicker').remove();
}
});
}
}

View File

@@ -24,15 +24,11 @@ export default class KimaiDateTimePicker extends KimaiPlugin {
return 'date-time-picker';
}
init() {
this.activateDateTimePicker(this.selector);
}
activateDateTimePicker(selector) {
let translator = this.getContainer().getTranslation();
let configuration = this.getContainer().getConfiguration();
jQuery(selector + ' input[data-datetimepicker="on"]').each(function(index) {
jQuery(selector + ' ' + this.selector).each(function(index) {
let localeFormat = jQuery(this).data('format');
jQuery(this).daterangepicker({
singleDatePicker: true,
@@ -58,4 +54,13 @@ export default class KimaiDateTimePicker extends KimaiPlugin {
});
}
destroyDateTimePicker(selector) {
jQuery(selector + ' ' + this.selector).each(function(index) {
if (jQuery(this).data('daterangepicker') !== undefined) {
jQuery(this).daterangepicker('destroy');
jQuery(this).data('daterangepicker').remove();
}
});
}
}

View File

@@ -0,0 +1,35 @@
/*
* 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.
*/
/*!
* [KIMAI] KimaiForm: basic functions for all forms
*/
import KimaiPlugin from "../KimaiPlugin";
export default class KimaiForm extends KimaiPlugin {
getId() {
return 'form';
}
activateForm(formSelector, container) {
this.getContainer().getPlugin('date-range-picker').activateDateRangePicker(formSelector);
this.getContainer().getPlugin('date-time-picker').activateDateTimePicker(formSelector);
this.getContainer().getPlugin('date-picker').activateDatePicker(formSelector);
this.getContainer().getPlugin('autocomplete').activateAutocomplete(formSelector);
this.getContainer().getPlugin('form-select').activateSelectPicker(formSelector, container);
}
destroyForm(formSelector) {
this.getContainer().getPlugin('form-select').destroySelectPicker(formSelector);
this.getContainer().getPlugin('autocomplete').destroyAutocomplete(formSelector);
this.getContainer().getPlugin('date-picker').destroyDatePicker(formSelector);
this.getContainer().getPlugin('date-time-picker').destroyDateTimePicker(formSelector);
this.getContainer().getPlugin('date-range-picker').destroyDateRangePicker(formSelector);
}
}

View File

@@ -0,0 +1,168 @@
/*
* 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.
*/
/*!
* [KIMAI] KimaiFormSelect: enhanced functionality for HTML select's
*/
import KimaiPlugin from "../KimaiPlugin";
import jQuery from "jquery";
export default class KimaiFormSelect extends KimaiPlugin {
constructor(selector) {
super();
this.selector = selector;
}
getId() {
return 'form-select';
}
activateSelectPicker(selector, container) {
const elementSelector = this.selector;
let options = {};
if (container !== undefined) {
options = {
dropdownParent: $(container),
};
}
// Function to match the name of the parent and not only the names of the children
// Based on the original matcher function of Select2: https://github.com/select2/select2/blob/5765090318c4d382ae56463cfa25ba8ca7bdd495/src/js/select2/defaults.js#L272
// More information: https://select2.org/searching | https://github.com/select2/docs/blob/develop/pages/11.searching/docs.md
function matcher(params, data) {
// Always return the object if there is nothing to compare
if (jQuery.trim(params.term) === '') {
return data;
}
// Check whether options has children
let hasChildren = data.children && data.children.length > 0;
// Split search param by space to search for all terms and convert all to uppercase
let terms = params.term.toUpperCase().split(' ');
let original = data.text.toUpperCase();
// Always return the parent option including its children, when the name matches one of the params
// Check if the text contains all or at least one of the terms
let foundAll = true;
let foundOne = false;
let missingTerms = [];
terms.forEach(function(item, index) {
if (original.indexOf(item) > -1) {
foundOne = true;
} else {
foundAll = false;
missingTerms.push(item);
}
});
// If the option element contains all terms, return it
if(foundAll) {
return data;
}
// Do a recursive check for options with children
if (hasChildren) {
// If the parent already contains one or more search terms, proceed only with the missing ones
// First: Clone the original params object...
let newParams = jQuery.extend(true, {}, params);
if (foundOne) {
newParams.term = missingTerms.join(' ');
} else {
newParams.term = params.term;
}
// Clone the data object if there are children
// This is required as we modify the object to remove any non-matches
let match = jQuery.extend(true, {}, data);
// Check each child of the option
for (let c = data.children.length - 1; c >= 0; c--) {
let child = data.children[c];
let matches = matcher(newParams, child);
// If there wasn't a match, remove the object in the array
if (matches === null) {
match.children.splice(c, 1);
}
}
// If any children matched, return the new object
if (match.children.length > 0) {
return match;
}
}
// If the option or its children do not contain the term, don't return anything
return null;
}
options = {...options, ...{
language: this.getContainer().getConfiguration().get('locale'),
theme: "bootstrap",
matcher: matcher
}};
jQuery(selector + ' ' + elementSelector).select2(options);
jQuery('body').on('reset', 'form', function(event){
setTimeout(function() {
jQuery(event.target).find(elementSelector).trigger('change');
}, 10);
});
}
destroySelectPicker(selector) {
jQuery(selector + ' ' + this.selector).select2('destroy');
}
updateOptions(selectIdentifier, data) {
let select = jQuery(selectIdentifier);
let emptyOption = jQuery(selectIdentifier + ' option[value=""]');
const selectedValue = select.val();
select.find('option').remove().end().find('optgroup').remove().end();
if (emptyOption.length !== 0) {
select.append('<option value="">' + emptyOption.text() + '</option>');
}
let htmlOptions = '';
let emptyOptions = '';
for (const [key, value] of Object.entries(data)) {
if (key === '__empty__') {
for (const entity of value) {
emptyOptions += '<option value="' + entity.id + '">' + entity.name + '</option>';
}
continue;
}
htmlOptions += '<optgroup label="' + key + '">';
for (const entity of value) {
htmlOptions += '<option value="' + entity.id + '">' + entity.name + '</option>';
}
htmlOptions += '</optgroup>';
}
select.append(htmlOptions);
select.append(emptyOptions);
// if available, re-select the previous selected option (mostly usable for global activities)
select.val(selectedValue);
// if we don't trigger the change, the other selects won't be resetted
select.trigger('change');
// if the beta test kimai.theme.select_type is active, this will tell the selects to refresh
if (select.hasClass('selectpicker')) {
select.trigger('change.select2');
}
}
}

View File

@@ -1,24 +0,0 @@
/*
* 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.
*/
/*!
* [KIMAI] KimaiJqueryPluginInitializer: initialize jQuery plugins
*/
import jQuery from 'jquery';
import KimaiPlugin from '../KimaiPlugin';
export default class KimaiJqueryPluginInitializer extends KimaiPlugin {
init() {
// activate the dropdown functionality
jQuery('.dropdown-toggle').dropdown();
// activate the tooltip functionality
jQuery('[data-toggle="tooltip"]').tooltip();
}
}

View File

@@ -0,0 +1,73 @@
/*
* 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.
*/
/*!
* [KIMAI] KimaiMultiUpdateForm: handle the multi update checkbox list and form
*/
import KimaiPlugin from '../KimaiPlugin';
import jQuery from "jquery";
export default class KimaiMultiUpdateTable extends KimaiPlugin {
init() {
const self = this;
jQuery('body').
on('change', '#multi_update_all', function(event) {
jQuery('.multi_update_single').prop('checked', jQuery(event.target).prop('checked'));
self.toggleForm();
})
.on('change', '.multi_update_single', function(event) {
self.toggleForm();
})
.on('change', '#multi_update_table_action', function(event) {
const selectedItem = jQuery('#multi_update_table_action option:selected');
const selectedVal = selectedItem.val();
if (selectedVal === '') {
return;
}
const form = jQuery('#multi_update_form form');
const selectedText = selectedItem.text();
const ids = self.getSelectedIds();
const question = form.attr('data-question').replace(/%action%/, selectedText).replace(/%count%/, ids.length);
self.getContainer().getPlugin('alert').question(question, function(value) {
if (value) {
form.attr('action', selectedVal).submit();
} else {
jQuery('#multi_update_table_action').val('').trigger('change');
}
});
});
}
getSelectedIds()
{
let ids = [];
jQuery('.multi_update_single:checked').each(function(i){
ids[i] = $(this).val();
});
return ids;
}
toggleForm()
{
const ids = this.getSelectedIds();
jQuery('#multi_update_table_entities').val(ids.join(','));
if (ids.length > 0) {
jQuery('#multi_update_form').show();
} else {
jQuery('#multi_update_form').hide();
}
}
}

View File

@@ -58,20 +58,11 @@ export default class KimaiSelectDataAPI extends KimaiPlugin {
}
_updateSelect(selectName, data) {
let select = jQuery(selectName);
let emptyOption = jQuery(selectName + ' option[value=""]');
select.find('option').remove().end().find('optgroup').remove().end();
if (emptyOption.length !== 0) {
select.append('<option value="">' + emptyOption.text() + '</option>');
}
const options = {};
for (const apiData of data) {
let title = apiData.parentTitle;
if (title === null) {
title = '__empty__';
let title = '__empty__';
if (apiData.hasOwnProperty('parentTitle') && apiData.parentTitle !== null) {
title = apiData.parentTitle;
}
if (!options.hasOwnProperty(title)) {
options[title] = [];
@@ -84,32 +75,7 @@ export default class KimaiSelectDataAPI extends KimaiPlugin {
ordered[key] = options[key];
});
let htmlOptions = '';
let emptyOptions = '';
for (const [key, value] of Object.entries(ordered)) {
if (key === '__empty__') {
for (const entity of value) {
emptyOptions += '<option value="' + entity.id + '">' + entity.name + '</option>';
}
continue;
}
htmlOptions += '<optgroup label="' + key + '">';
for (const entity of value) {
htmlOptions += '<option value="' + entity.id + '">' + entity.name + '</option>';
}
htmlOptions += '</optgroup>';
}
select.append(htmlOptions);
select.append(emptyOptions);
// if we don't trigger the change, the other selects won't be resetted
select.trigger('change');
// if the beta test kimai.theme.select_type is active, this will tell the selects to refresh
jQuery('.selectpicker').selectpicker('refresh');
this.getContainer().getPlugin('form-select').updateOptions(selectName, ordered);
}
}

View File

@@ -15,7 +15,35 @@ import KimaiPlugin from '../KimaiPlugin';
export default class KimaiThemeInitializer extends KimaiPlugin {
init() {
this.registerGlobalAjaxErrorHandler();
this.registerAutomaticAlertRemove('div.alert-success', 5000);
// activate the dropdown functionality
jQuery('.dropdown-toggle').dropdown();
// activate the tooltip functionality
jQuery('[data-toggle="tooltip"]').tooltip();
// activate all form plugins
this.getContainer().getPlugin('form').activateForm('.content-wrapper form', 'body');
}
/**
* redirect access denied / session timeouts to login page
*/
registerGlobalAjaxErrorHandler() {
const loginUrl = this.getContainer().getConfiguration().get('login');
const alert = this.getContainer().getPlugin('alert');
const translation = this.getContainer().getTranslation().get('login.required');
jQuery(document).ajaxError(function(event, jqxhr, settings, thrownError) {
if (jqxhr.status !== undefined && jqxhr.status === 403) {
const loginRequired = jqxhr.getResponseHeader('login-required');
if (loginRequired !== null) {
alert.question(translation, function (result) {
if (result === true) {
window.location.replace(loginUrl);
}
});
}
}
});
}
/**

View File

@@ -14,12 +14,183 @@ import KimaiPlugin from "../KimaiPlugin";
export default class KimaiToolbar extends KimaiPlugin {
init() {
const datatable = this.getContainer().getPlugin('datatable');
constructor(formSelector, formSubmitActionClass) {
super();
this.formSelector = formSelector;
this.actionClass = formSubmitActionClass;
}
// This catches all clicks on the pagination and prevents the default action, as we want to relad the page via JS
getId() {
return 'toolbar';
}
init() {
const formSelector = this.getSelector();
const self = this;
const EVENT = self.getContainer().getPlugin('event');
this._registerPagination(formSelector, EVENT);
this._registerSortableTables(formSelector, EVENT);
this._registerAlternativeSubmitActions(formSelector, this.actionClass);
this._registerSearchButtons(formSelector);
jQuery('body')
// prevent that the dropdown closes, when a form input is changed - eg. a select option was clicked
.on('click', formSelector + ' .dropdown-menu', function (event) {
event.stopPropagation();
})
// prevent that a click into the search field will close the dropdown
.on('click', '.select2-search__field', function (event) {
event.stopPropagation();
})
// prevent that the dropdown closes when a optgroup header is clicked
.on('click', '.select2-results__group', function (event) {
event.stopPropagation();
})
// prevent that a click into a daterangepicker will close the dropdown
.on('click', '.daterangepicker', function (event) {
event.stopPropagation();
})
// prevent that clicks in the dropdown elements, but outside of elements will close the dropdown (eg border besides the search field)
.on('click', '.select2-container', function (event) {
event.stopPropagation();
})
;
// Reset the page if filter values are changed, otherwise we might end up with a limited set of data,
// which does not support the given page - and it would be just wrong to stay in the same page
jQuery(formSelector +' input').change(function (event) {
switch (event.target.id) {
case 'order':
case 'orderBy':
case 'page':
break;
default:
jQuery(formSelector + ' input#page').val(1);
break;
}
self.triggerChange();
});
// when user selected a new customer or project, reset the pagination back to 1
// and then find out if the results should be reloaded
jQuery(formSelector + ' select').change(function (event) {
let reload = true;
switch (event.target.id) {
case 'customer':
if (jQuery(formSelector + ' select#project').length > 0) {
reload = false;
}
break;
case 'project':
if (jQuery(formSelector + ' select#activity').length > 0) {
reload = false;
}
break;
}
jQuery(formSelector + ' input#page').val(1);
if (reload) {
self.triggerChange();
}
});
// close all open selectpicker upon choosing any dropdown option
jQuery(formSelector + ' select.selectpicker').on('change', function(event) {
jQuery('.bootstrap-select.open').removeClass('open');
});
}
/**
* The search toggle button is not part of this component, but it is directly connected to it.
* @private
*/
_registerSearchButtons(formSelector) {
jQuery('body')
// only for mobile experience currently: show the search form field
.on('click', '.btn-search.search-toggle', function (event) {
event.preventDefault();
event.stopPropagation();
jQuery(formSelector).parent('section').toggleClass('search-open');
jQuery(formSelector).toggleClass('hidden-xs');
jQuery(formSelector + ' input#searchTerm').dropdown('toggle');
jQuery(formSelector + ' input#searchTerm').focus();
})
// hide the search form field
.on('click', formSelector + ' a.search-cancel', function (event) {
event.preventDefault();
event.stopPropagation();
jQuery(formSelector).parent('section').toggleClass('search-open');
jQuery(formSelector + ' input#searchTerm').dropdown('toggle');
jQuery(formSelector).toggleClass('hidden-xs');
})
;
}
/**
* Some actions utilize the filter from the search form and submit it to another URL.
* @private
*/
_registerAlternativeSubmitActions(toolbarSelector, actionBtnClass) {
document.addEventListener('click', function(event) {
let target = event.target;
while (target !== null && !target.matches('body')) {
if (target.classList.contains(actionBtnClass)) {
const form = document.querySelector(toolbarSelector);
if (form === null) {
return;
}
const prevAction = form.action;
const prevMethod = form.method;
form.target = '_blank';
form.action = target.href;
if (target.dataset.method !== undefined) {
form.method = target.dataset.method;
}
form.submit();
form.target = '';
form.action = prevAction;
form.method = prevMethod;
event.preventDefault();
event.stopPropagation();
}
target = target.parentNode;
}
});
}
/**
* Sortable datatables use hidden fields in the toolbar filter/search form
* @private
*/
_registerSortableTables(formSelector, EVENT) {
jQuery('body').on('click', 'th.sortable', function(event){
var $header = jQuery(event.target);
var order = 'DESC';
var orderBy = $header.data('order');
if ($header.hasClass('sorting_desc')) {
order = 'ASC';
}
jQuery(formSelector + ' input#orderBy').val(orderBy);
jQuery(formSelector + ' input#order').val(order);
// triggers the page reset - see below
jQuery(formSelector + ' input#order').trigger('change');
// triggers the datatable reload - search for the event name
EVENT.trigger('filter-change');
});
}
/**
* This catches all clicks on the pagination and prevents the default action, as we want to reload the page via JS
* @private
*/
_registerPagination(formSelector, EVENT) {
jQuery('body').on('click', 'div.navigation ul.pagination li a', function(event) {
let pager = jQuery(".toolbar form input[name='page']");
let pager = jQuery(formSelector + " input#page");
if (pager.length === 0) {
return;
}
@@ -29,41 +200,33 @@ export default class KimaiToolbar extends KimaiPlugin {
let page = urlParts[urlParts.length-1];
pager.val(page);
pager.trigger('change');
EVENT.trigger('pagination-change');
return false;
});
// Reset the page if any other value is changed, otherwise we might end up with a limited set
// of data which does not support the given page - and it would be just wrong to stay in the same page
jQuery('.toolbar form input').change(function (event) {
switch (event.target.id) {
case 'page':
break;
default:
jQuery('.toolbar form input#page').val(1);
}
datatable.reloadDatatable();
});
}
hide() {
const toggle = jQuery(this.getSelector() + ' #searchTerm.dropdown-toggle');
if (toggle.parent().hasClass('open')) {
toggle.dropdown('toggle');
}
}
jQuery('.toolbar form select').change(function (event) {
let reload = true;
switch (event.target.id) {
case 'customer':
if (jQuery('.toolbar form select#project').length > 0) {
reload = false;
}
break;
/**
* Triggers an event, that everyone can listen for.
*/
triggerChange() {
this.getContainer().getPlugin('event').trigger('toolbar-change');
}
case 'project':
if (jQuery('.toolbar form select#activity').length > 0) {
reload = false;
}
break;
}
jQuery('.toolbar form input#page').val(1);
if (reload) {
datatable.reloadDatatable();
}
});
/**
* Returns the CSS selector to target the toolbar form.
*
* @returns {string}
*/
getSelector() {
return this.formSelector;
}
}

View File

@@ -1,56 +0,0 @@
/*
* 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.
*/
import KimaiPlugin from '../KimaiPlugin';
/**
* Needs to be initialized with a class name.
*
* A link like <a href=# class=remoteLink> can be activated with:
* new KimaiToolbarAction('remoteLink')
*
* @param selector
*/
export default class KimaiToolbarAction extends KimaiPlugin {
constructor(selector) {
super();
this.selector = selector;
}
init() {
const self = this;
document.addEventListener('click', function(event) {
let target = event.target;
while (target !== null && !target.matches('body')) {
if (target.classList.contains(self.selector)) {
const form = document.querySelector('div.toolbar form.navbar-form');
if (form === null) {
return;
}
const prevAction = form.action;
const prevMethod = form.method;
form.target = '_blank';
form.action = target.href;
if (target.dataset.method !== undefined) {
form.method = target.dataset.method;
}
form.submit();
form.target = '';
form.action = prevAction;
form.method = prevMethod;
event.preventDefault();
event.stopPropagation();
}
target = target.parentNode;
}
});
}
}

View File

@@ -0,0 +1,10 @@
$icon-font-path: "~bootstrap-sass/assets/fonts/bootstrap/";
$fa-font-path: "~@fortawesome/fontawesome-free/webfonts/";
@import '~bootstrap-sass/assets/stylesheets/bootstrap';
@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 '~daterangepicker/daterangepicker.css';

View File

@@ -5,11 +5,11 @@
* file that was distributed with this source code.
*/
@import "~bootstrap-sass/assets/stylesheets/bootstrap/variables";
@import 'variables';
@import 'error-page';
@import 'print';
@import 'content';
@import 'content-header';
@import 'toolbar';
@import 'sidebar';
@import 'footer';
@@ -23,3 +23,4 @@
@import 'skins';
@import 'sweetalert';
@import 'autocomplete';
@import 'selectpicker';

View File

@@ -0,0 +1,52 @@
/*
* 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.
*/
.content-header {
height: 50px;
border-top: 1px solid rgba(0, 0, 0, 0.1);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1) !important;
background-color: #fff !important;
padding: 10px 0 0 10px;
h1 {
padding-top: 3px;
float: left;
small {
display: none;
}
}
}
/* Page based action buttons in the upper right corner of the content area */
.content-header>.breadcrumb {
position: absolute;
float: right;
background: transparent;
top: 0;
right: 0;
padding-left: 10px;
}
@media (max-width: $screen-md-min) {
.content-header>.breadcrumb {
margin-top: 0;
}
}
@media (min-width: $screen-sm-min) {
.content-header>.breadcrumb {
right: 10px;
}
.content-header {
h1 {
small {
display: inline-block;
}
}
}
}

View File

@@ -8,43 +8,8 @@
.content {
padding: 15px 0;
}
.content-header {
border-top: 1px solid rgba(0, 0, 0, 0.1);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1) !important;
background-color: #fff !important;
padding: 15px 0 15px 10px;
h1 {
small {
display: none;
}
}
}
.content-header>.breadcrumb {
position: relative;
margin-top: 5px;
top: 0;
right: 0;
float: none;
background: #d2d6de;
padding-left: 10px;
}
@media (min-width: $screen-sm-min) {
.content-header>.breadcrumb {
right: 10px;
}
}
@media (min-width: $screen-sm-min) {
.content-header {
h1 {
small {
display: inline-block;
}
}
}
.content {
padding: 15px;
}
@@ -69,4 +34,18 @@ td {
text-align: center;
line-height: 35px;
margin-right: 15px;
}
/* Profile */
.box-profile {
img.img-circle {
width: 100px;
}
.profile-username {
margin-top: 15px;
}
}
.label-gray {
background-color: $gray-lte;
}

View File

@@ -5,6 +5,8 @@
* file that was distributed with this source code.
*/
span.label-color,
span.label-meta,
span.label-activity,
span.label-project,
span.label-customer {
@@ -31,10 +33,14 @@ table.dataTable {
&.summary td {
font-weight: bold;
border-bottom: 1px solid #ccc;
background-color: #eaeaea;
}
&.open-edit {
cursor: pointer;
}
th.multiCheckbox {
width: 15px;
}
}
td {
/*
@@ -48,6 +54,7 @@ table.dataTable {
font-size: 85%;
font-weight: 500;
padding: .3em .6em .3em;
line-height: 24px;
}
span.label-activity,
span.label-project,

View File

@@ -125,34 +125,35 @@ table.dataTable thead .sorting_desc_disabled {
position: relative;
}
table.dataTable thead .sorting:after,
table.dataTable thead .sorting_asc:after,
table.dataTable thead .sorting_desc:after {
table.dataTable thead .sorting:before,
table.dataTable thead .sorting_asc:before,
table.dataTable thead .sorting_desc:before {
position: absolute;
top: 8px;
right: 8px;
top: 10px;
left: 5px;
display: block;
font-family: 'Glyphicons Halflings';
opacity: 0.5;
font-size: 12px;
}
table.dataTable thead .sorting:after {
table.dataTable thead .sorting:before {
opacity: 0.2;
content: "\e150"; /* sort */
}
table.dataTable thead .sorting_asc:after {
table.dataTable thead .sorting_asc:before {
content: "\e155"; /* sort-by-attributes */
}
table.dataTable thead .sorting_desc:after {
table.dataTable thead .sorting_desc:before {
content: "\e156"; /* sort-by-attributes-alt */
}
div.dataTables_scrollBody table.dataTable thead .sorting:after,
div.dataTables_scrollBody table.dataTable thead .sorting_asc:after,
div.dataTables_scrollBody table.dataTable thead .sorting_desc:after {
div.dataTables_scrollBody table.dataTable thead .sorting:before,
div.dataTables_scrollBody table.dataTable thead .sorting_asc:before,
div.dataTables_scrollBody table.dataTable thead .sorting_desc:before {
display: none;
}
table.dataTable thead .sorting_asc_disabled:after,
table.dataTable thead .sorting_desc_disabled:after {
table.dataTable thead .sorting_asc_disabled:before,
table.dataTable thead .sorting_desc_disabled:before {
color: #eee;
}
@@ -175,9 +176,9 @@ table.dataTable.table-condensed thead > tr > th {
padding-right: 20px;
}
table.dataTable.table-condensed thead .sorting:after,
table.dataTable.table-condensed thead .sorting_asc:after,
table.dataTable.table-condensed thead .sorting_desc:after {
table.dataTable.table-condensed thead .sorting:before,
table.dataTable.table-condensed thead .sorting_asc:before,
table.dataTable.table-condensed thead .sorting_desc:before {
top: 6px;
right: 6px;
}
@@ -264,3 +265,19 @@ div.dataTables_scrollHead table.table-bordered {
.table-striped.dataTable tbody tr.active:nth-child(odd) th {
background-color: #017ebc;
}
/* custom extension to make the column wide enough to show the sortable icons */
table.dataTable th.sortable {
padding-left: 22px;
}
table.dataTable.table > tbody > tr > td {
&.avatars {
line-height: 40px;
}
vertical-align: middle;
.img-circle {
max-width: 35px;
margin-right: 10px;
}
}

View File

@@ -5,301 +5,284 @@
* file that was distributed with this source code.
*/
.invoice {
margin: 0;
border: none;
}
@media (min-width: $screen-sm-min) {
body.invoice_print {
background-color: #eee;
.invoice {
margin: 25px;
margin: 105px auto 30px auto;
padding: 50px 65px;
min-height: 297mm;
position: relative;
max-width: 210mm;
box-shadow: 0 0 20px rgba(80,80,80,0.7);
}
}
.page-header-small{
font-size: 14px;
}
div.invoice-address {
margin-top: 30px;
margin-bottom: 30px;
}
table.invoice-records {}
table.invoice-meta th {
padding-right: 40px;
}
table.invoice-sum th {
width: 70%;
}
table.invoice-sum th, table.invoice-sum td {
text-align: right;
}
/* ================================ FREELANCER INVOICE ================================ */
#freelancer-invoice {
font: 15px/1 $font-family-sans-serif;
box-sizing: border-box;
h1 {
font: bold 100% sans-serif;
letter-spacing: 0.5em;
text-align: center;
text-transform: uppercase;
.page-header h3 {
border-color: #ddd;
}
h2 {
font: bold 1.5em sans-serif;
margin-bottom: 1em;
div.invoice-address {
margin-top: 30px;
margin-bottom: 30px;
}
/* table */
table {
table-layout: fixed;
width: 100%;
table.invoice-meta th {
padding-right: 40px;
}
table {
border-collapse: separate;
border-spacing: 0;
table.invoice-sum th {
width: 70%;
}
/* header */
header {
}
header:after {
clear: both;
content: "";
display: table;
font-size: 1em;
}
header address {
float: left;
font-style: normal;
line-height: 1.25;
margin: 0 1em 1em 0;
font-weight: bold;
font-size: 0.7em;
}
article address {
font-size: 1em;
float: left;
margin: 1em 0;
}
article.address {
margin-bottom: 1.5em;
}
article.footer {
margin-top: 60px;
}
/* article */
article, table.meta, table.inventory {
margin: 1em 0;
}
article:after {
clear: both;
content: "";
display: table;
}
article h1 {
clip: rect(0 0 0 0);
position: absolute;
}
article p {
margin-bottom: 20px;
}
/* table meta & balance */
table.meta:after, table.balance:after {
clear: both;
content: "";
display: table;
}
/* table meta */
table.meta {
float: right;
width: 50%;
font-size: 85%;
th {
text-align: right;
font-weight: normal;
}
td {
width: 120px;
text-align: right;
}
}
/* table balance */
table.balance {
float: right;
th {
text-align: right;
font-weight: normal;
}
td {
width: 140px;
text-align: right;
}
.total {
font-weight: bold;
}
}
/* table items */
table.inventory {
clear: both;
width: 100%;
margin-top: 2em;
border-bottom: 1px solid #000;
}
table.inventory th {
font-weight: bold;
border-width: 0 0 1px 0;
border-bottom: 1px solid #000;
}
table.inventory th,
table.inventory td {
padding: 5px;
}
table.inventory td:nth-child(1),
table.inventory th:nth-child(1) {
text-align: left;
}
table.inventory td:nth-child(2),
table.inventory th:nth-child(2) {
text-align: center;
width: 20%;
}
table.inventory td:nth-child(3),
table.inventory th:nth-child(3) {
text-align: center;
width: 20%;
}
table.inventory td:nth-child(4),
table.inventory th:nth-child(4) {
table.invoice-sum th, table.invoice-sum td {
text-align: right;
width: 20%;
}
.invoice-items {
margin-top: 2em;
margin-bottom: 3em;
.table {
thead th {
font-weight: bold;
border-bottom: 1px solid #ddd;
padding-bottom: 15px
}
tfoot {
border-top: 1px solid #ddd;
tr:first-child th,
tr:first-child td {
padding-top: 15px
}
tr {
th,td {
border:none;
padding: 4px 8px;
}
}
}
}
}
/* footer */
footer {
.footer {
border-top: 1px solid #ccc;
margin-top: 50px;
}
footer table {
border-top: 1px solid #000;
font-size: 0.8em;
padding-top: 5px;
}
footer th {
text-align: left;
font-weight: bold;
margin-bottom: 10px;
}
footer table th:nth-child(1) {
width: 32%;
}
footer table th:nth-child(3) {
width: 28%;
}
footer td {
text-align: left;
vertical-align: bottom;
}
/* aside */
aside {
position: absolute;
top: 10in;
width: 7.5in;
}
aside h1 {
border: none;
border-width: 0 0 1px;
margin: 0 0 1em;
}
aside h1 {
border-color: #999;
border-bottom-style: solid;
}
aside p {
font-size: 85%;
}
aside div {
margin-top: 15px;
}
aside div p {
line-height: 1.4;
}
aside p span {
width: 130px;
font-weight: bold;
display: inline-block;
}
}
@media print {
* {
-webkit-print-color-adjust: exact;
}
body, .wrapper, #freelancer-invoice {
margin: 0;
padding: 0;
padding-top: 10px;
width: 100%;
}
/* ================================ FREELANCER INVOICE ================================ */
#freelancer-invoice {
margin: 1em 2em;
span:empty {
display: none;
box-sizing: border-box;
h1 {
font: bold 100% sans-serif;
letter-spacing: 0.5em;
text-align: center;
text-transform: uppercase;
}
h2 {
font: bold 1.5em sans-serif;
margin-bottom: 1em;
}
/* table */
table {
table-layout: fixed;
width: 100%;
}
table {
border-collapse: separate;
border-spacing: 0;
}
/* header */
header {
&:after {
clear: both;
content: "";
display: table;
font-size: 1em;
}
address {
float: left;
font-style: normal;
line-height: 1.25;
margin: 0 1em 1em 0;
font-size: 0.7em;
}
}
article address {
font-size: 1em;
float: left;
margin: 1em 0;
}
article.address {
margin-bottom: 1.5em;
}
/* article */
article, table.meta, table.inventory {
margin: 1em 0;
}
article:after {
clear: both;
content: "";
display: table;
}
article h1 {
clip: rect(0 0 0 0);
position: absolute;
}
article p {
margin-bottom: 20px;
}
/* table meta & balance */
table.meta:after, table.balance:after {
clear: both;
content: "";
display: table;
}
/* table meta */
table.meta {
float: right;
width: 50%;
font-size: 85%;
th {
text-align: right;
font-weight: normal;
}
td {
width: 120px;
text-align: right;
}
}
/* table balance */
table.balance {
float: right;
th {
text-align: right;
font-weight: normal;
}
td {
width: 140px;
text-align: right;
line-height: 22px;
}
.total {
font-weight: bold;
}
}
/* table items */
table.inventory {
clear: both;
width: 100%;
margin-top: 2em;
border-bottom: 1px solid #000;
}
table.inventory th,
table.inventory td {
padding: 5px;
}
table.inventory thead th {
font-weight: bold;
border-width: 0 0 1px 0;
border-bottom: 1px solid #000;
padding-bottom: 15px
}
table.inventory tbody tr:first-child td {
padding-top: 15px
}
table.inventory tbody tr:last-child td {
padding-bottom: 15px
}
table.inventory td:nth-child(1),
table.inventory th:nth-child(1) {
text-align: left;
}
table.inventory td:nth-child(2),
table.inventory th:nth-child(2) {
text-align: center;
width: 20%;
}
table.inventory td:nth-child(3),
table.inventory th:nth-child(3) {
text-align: center;
width: 20%;
}
table.inventory td:nth-child(4),
table.inventory th:nth-child(4) {
text-align: right;
width: 20%;
}
.add, .cut {
display: none;
.footer {
border-color: #000;
}
}
.invoice {
width: unset;
/* ================================ PRINTING RULES ================================ */
@media print {
* {
-webkit-print-color-adjust: exact;
}
body, .wrapper {
margin: 0;
padding: 0;
background-color: white;
}
#freelancer-invoice {
span:empty {
display: none;
}
.add, .cut {
display: none;
}
}
.invoice {
margin: 1em 2em;
font-size: 90%;
width: unset;
padding: 0;
min-height: unset;
position: unset;
max-width: unset;
box-shadow: unset;
}
}
}

View File

@@ -6,6 +6,10 @@
*/
.navbar-nav {
img.img-circle {
max-height: 26px;
margin-top: -2px;
}
li>a.ddt-large {
padding: 15px 12px 11px 10px;
font-size: 10px;

View File

@@ -0,0 +1,23 @@
@import '~select2/dist/css/select2.min.css';
@import '~select2-bootstrap-theme/dist/select2-bootstrap.min.css';
/* some labels were placed incorrect due to a margin-top: -4px */
.select2-container .select2-selection--single .select2-selection__rendered {
margin-top: 0;
}
/* make sure the border looks like all other form elements */
.select2-container--bootstrap .select2-selection {
border-radius: 0;
}
/* placeholder in multi-selects were cut off */
.select2-search.select2-search--inline .select2-search__field {
width: 100% !important;
}
/* make sure that <optgroup> elements are padded, to allow visual difference to global activities */
.select2-container--bootstrap .select2-results__options.select2-results__options--nested li.select2-results__option {
padding-left: 30px;
}
/* empty <option> were too small to be noticed as clickable element */
.select2-container--bootstrap .select2-results__option {
min-height: 30px;
}

View File

@@ -5,13 +5,6 @@
* file that was distributed with this source code.
*/
/* Page based action buttons in the upper right corner of the content area */
.content-header > .breadcrumb {
position: absolute;
float: right;
background: transparent;
}
.toolbar-pad {
padding: 10px;
}
@@ -19,6 +12,7 @@
/* The filter form is available on most pages above the datatable */
@media (min-width: $screen-sm-min) {
.toolbar {
// @deprecated since 1.2 - using the toolbar is deprecated and will be removed with 2.0
form.navbar-form {
font-size: $font-size-base;
.form-control {
@@ -39,3 +33,58 @@
}
}
/* Search form with filter dropdown */
.content-header {
form {
width: 200px;
float: left;
.container-fluid {
padding-left: 0;
padding-right: 0;
}
.form-group {
margin: 0 0 5px 0;
}
input.search-has-error {
color: $red;
}
.input-group-addon.has-error {
color: $red;
border-color: $red;
}
input.has-error {
border-color: $red;
}
ul.dropdown-menu {
max-height: 100vh;
overflow-y: auto;
padding-top: 10px;
width: 500px;
box-shadow: 0 8px 17px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
max-width: 90vw;
}
a.search-cancel {
margin-top: 8px;
}
}
}
@media (max-width: $screen-xs-min) {
.content-header.search-open {
h1 {
display: none;
}
.breadcrumb {
display: none;
}
form.header-search {
padding: 0 10px 0 0;
float: none;
width: 100%;
ul.dropdown-menu {
width: 100%;
max-width: 100%;
}
}
}
}

View File

@@ -5,6 +5,8 @@
* file that was distributed with this source code.
*/
@import "~bootstrap-sass/assets/stylesheets/bootstrap/variables";
/**
* A simple one to one clone of /node_modules/admin-lte/build/less/variables.less
*/

View File

@@ -24,8 +24,10 @@
"gedmo/doctrine-extensions": "^2.4",
"jms/metadata": "^2.0",
"jms/serializer-bundle": "^3.2",
"kevinpapst/adminlte-bundle": "~2.6",
"kevinpapst/adminlte-bundle": "~3.0",
"kimai/kimai2-composer": "^0.1",
"laravolt/avatar": "^3.0",
"league/csv": "^9.4",
"mpdf/mpdf": "^7.1",
"nelmio/api-doc-bundle": "^3.2",
"nelmio/cors-bundle": "^1.5",
@@ -66,15 +68,21 @@
"phpstan/phpstan-doctrine": "^0.11.4",
"phpstan/phpstan-phpunit": "^0.11.2",
"phpstan/phpstan-symfony": "^0.11.6",
"phpunit/phpunit": "^7.0",
"phpunit/phpunit": "^8.0",
"symfony/browser-kit": "^4.0",
"symfony/css-selector": "^4.0",
"symfony/maker-bundle": "^1.12",
"symfony/phpunit-bridge": "^4.0",
"symfony/profiler-pack": "^1.0",
"symfony/thanks": "^1.0",
"symfony/web-server-bundle": "~4.2.0"
"symfony/web-server-bundle": "^4.0"
},
"repositories": [
{
"type": "artifact",
"url": "var/packages/"
}
],
"config": {
"platform": {
"php": "7.2"
@@ -119,7 +127,14 @@
"kimai:code-check": [
"@kimai:codestyle",
"@kimai:phpstan",
"@kimai:tests"
"@kimai:code-lint",
"@kimai:tests-unit",
"@kimai:tests-integration"
],
"kimai:code-lint": [
"bin/console lint:yaml config --parse-tags",
"bin/console lint:twig templates",
"bin/console doctrine:schema:validate --skip-sync -vvv --no-interaction"
],
"kimai:tests": "vendor/bin/phpunit tests/",
"kimai:tests-unit": "vendor/bin/phpunit --exclude-group integration tests/",

2946
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,6 @@
framework:
assets:
json_manifest_path: '%kernel.project_dir%/public/build/manifest.json'
packages:
avatars:
base_path: 'avatars'

View File

@@ -29,6 +29,3 @@ framework:
#validation:
# possible values: 'loose', 'strict', 'html5'
#email_validation_mode: 'html5'
assets:
json_manifest_path: '%kernel.project_dir%/public/build/manifest.json'

View File

@@ -21,11 +21,13 @@ kimai:
# --------------------------------------------------------------------------------
# TIME-TRACKING
# All configs related to time-tracking, timesheets and record management
# Most settings can be configured in the System configuration screen
# --------------------------------------------------------------------------------
timesheet:
# renders timesheet descriptions with markdown
markdown_content: false
# Allows to render timesheet descriptions with markdown
# This setting can be changed through the Administration screen
# markdown_content: false
# The time-tracking mode that should be used.
# See https://www.kimai.org/documentation/timesheet.html#tracking-modes
@@ -42,36 +44,36 @@ kimai:
# Rounding rules are used to round the begin & end dates and the duration for timesheet records.
# The "default" rule will round "begin" down and "end" up to the full minute, the "duration" will not be rounded.
# Find out more about rounding rules at https://www.kimai.org/documentation/timesheet.html
rounding:
default:
days: ['monday','tuesday','wednesday','thursday','friday','saturday','sunday']
begin: 1
end: 1
duration: 0
mode: default
# rounding:
# default:
# days: ['monday','tuesday','wednesday','thursday','friday','saturday','sunday']
# begin: 1
# end: 1
# duration: 0
# mode: default
# # If you want to apply different hourly rates for specific weekdays, you can uncomment the "rates" configuration.
# # The "weekend" rule will add 50% to each timesheet entry that will be recorded on "saturdays" or "sundays".
# # See documentation at: https://www.kimai.org/documentation/timesheet.html#rate-calculation
# rates:
# weekend:
# days: ['saturday','sunday']
# factor: 1.5
#
# # If you want to limit the max. active entries per user, you can do it here.
# # The soft_limit is used as theme setting and displays a warning color if the user has reached X active recordings
# # The hard_limit is used to detect how many active records are allowed per user:
# # - by default a user can only have one active time-record: it is automatically stopped when a new one is started
# # - when hard_limit is > 1 and the user is trying to start a new entry after reaching the limit, a warning is shown
# # and the user has to stop an active entry first
# active_entries:
# soft_limit: 1
# hard_limit: 3
#
# # Rules that define timesheet validation and behaviour
# rules:
# # whether records in the future can be created
# allow_future_times: true
# If you want to apply different hourly rates for specific weekdays, you can uncomment the "rates" configuration.
# The "weekend" rule will add 50% to each timesheet entry that will be recorded on "saturdays" or "sundays".
# See documentation at: https://www.kimai.org/documentation/timesheet.html#rate-calculation
# rates:
# weekend:
# days: ['saturday','sunday']
# factor: 1.5
# If you want to limit the max. active entries per user, you can do it here.
# The soft_limit is used as theme setting and displays a warning color if the user has reached X active recordings
# The hard_limit is used to detect how many active records are allowed per user:
# - by default a user can only have one active time-record: it is automatically stopped when a new one is started
# - when hard_limit is > 1 and the user is trying to start a new entry after reaching the limit, a warning is shown
# and the user has to stop an active entry first
# active_entries:
# soft_limit: 1
# hard_limit: 3
# Rules that define timesheet validation and behaviour
# allow_future_times: whether records in the future can be created
# rules:
# allow_future_times: true
# --------------------------------------------------------------------------------
@@ -95,19 +97,19 @@ kimai:
TIMESHEET_OTHER: ['view_other_timesheet','start_other_timesheet','stop_other_timesheet','create_other_timesheet','edit_other_timesheet','export_other_timesheet','delete_other_timesheet']
PROFILE: ['view_own_profile','edit_own_profile','password_own_profile','preferences_own_profile','api-token_own_profile']
PROFILE_OTHER: ['view_other_profile','edit_other_profile','delete_other_profile','password_other_profile','roles_other_profile','preferences_other_profile','api-token_other_profile','teams_other_profile']
TAGS: ['view_tag','delete_tag']
TAGS: ['view_tag','manage_tag','delete_tag']
USER: ['view_user','create_user','delete_user','role_permissions']
RATE: ['view_rate_own_timesheet','edit_rate_own_timesheet']
RATE_OTHER: ['view_rate_other_timesheet','edit_rate_other_timesheet']
EXPORT: ['create_export','edit_export_own_timesheet','edit_export_other_timesheet']
TEAMS: ['view_team','create_team','edit_team','delete_team']
# some single default definitions for roles
SINGLE_USER: []
SINGLE_TEAMLEAD: ['view_rate_own_timesheet','view_rate_other_timesheet','hourly-rate_own_profile']
SINGLE_ADMIN: ['hourly-rate_own_profile','edit_exported_timesheet','teams_own_profile']
SINGLE_SUPER_ADMIN: ['hourly-rate_own_profile','hourly-rate_other_profile','delete_own_profile','roles_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet','teams_own_profile']
# link above sets to one set for each user role
ROLE_USER: ['@TIMESHEET','@PROFILE']
SINGLE_USER: ['view_team_member','budget_team_project']
SINGLE_TEAMLEAD: ['view_rate_own_timesheet','view_rate_other_timesheet','hourly-rate_own_profile','view_team_member']
SINGLE_ADMIN: ['hourly-rate_own_profile','edit_exported_timesheet','teams_own_profile','view_team_member']
SINGLE_SUPER_ADMIN: ['hourly-rate_own_profile','hourly-rate_other_profile','delete_own_profile','roles_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet','teams_own_profile','view_team_member']
# link above sets to one complete set for each user role
ROLE_USER: ['@TIMESHEET','@PROFILE','@SINGLE_USER']
ROLE_TEAMLEAD: ['@ACTIVITIES_TEAM','@PROJECTS_TEAM','@CUSTOMERS_TEAM','@TIMESHEET_OTHER','@INVOICE','@TIMESHEET','@PROFILE','@EXPORT','@TAGS','@SINGLE_TEAMLEAD']
ROLE_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_TEMPLATE','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@SINGLE_ADMIN']
ROLE_SUPER_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_TEMPLATE','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@PROFILE_OTHER','@USER','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@SINGLE_SUPER_ADMIN']
@@ -171,16 +173,21 @@ kimai:
# --------------------------------------------------------------------------------
dashboard:
user_duration:
title: dashboard.you
title: stats.yourWorkingHours
order: 10
permission: ROLE_USER
type: 'compoundChart'
permission: view_own_timesheet
type: '\App\Widget\Type\CompoundChart'
widgets: [DailyWorkingTimeChart, userDurationToday, userDurationWeek, userDurationMonth, userDurationYear]
user_rates:
title: ~
order: 20
permission: view_rate_own_timesheet
widgets: [userAmountToday, userAmountWeek, userAmountMonth, userAmountYear]
user_teams:
title: ~
order: 15
permission: ROLE_USER
widgets: [UserTeams, UserTeamProjects]
duration:
title: dashboard.all
order: 30
@@ -210,12 +217,24 @@ kimai:
date: 'Y-m-d'
date_time: 'm-d H:i'
duration: '%%h:%%m h'
cs:
date_time_type: 'dd.MM.yyyy HH:mm'
date_type: 'dd.MM.yyyy'
date: 'd.m.Y'
date_time: 'd.m H:i'
duration: '%%h:%%m h'
de:
date_time_type: 'dd.MM.yyyy HH:mm'
date_type: 'dd.MM.yyyy'
date: 'd.m.Y'
date_time: 'd.m. H:i'
duration: '%%h:%%m h'
de_CH:
date_time_type: 'dd.MM.yyyy HH:mm'
date_type: 'dd.MM.yyyy'
date: 'd.m.Y'
date_time: 'd.m. H:i'
duration: '%%h:%%m h'
en:
date_time_type: 'yyyy-MM-dd HH:mm'
date_type: 'yyyy-MM-dd'
@@ -258,6 +277,12 @@ kimai:
date: 'Y-m-d'
date_time: 'm-d H:i'
duration: '%%h:%%m h'
nl:
date_time_type: 'dd.MM.yyyy HH:mm'
date_type: 'dd.MM.yyyy'
date: 'd.m.Y'
date_time: 'd.m. H:i'
duration: '%%hu%%m'
pt_BR:
date_time_type: 'dd-MM-yyyy HH:mm'
date_type: 'dd-MM-yyyy'
@@ -282,6 +307,12 @@ kimai:
date: 'Y-m-d'
date_time: 'm-d H:i'
duration: '%%h:%%m tim'
tr:
date_time_type: 'yyyy-MM-dd HH:mm'
date_type: 'yyyy-MM-dd'
date: 'Y-m-d'
date_time: 'm-d H:i'
duration: '%%h:%%m h'
# --------------------------------------------------------------------------------

View File

@@ -14,6 +14,8 @@ nelmio_api_doc:
- { alias: ActivityEntity, type: App\Entity\Activity, groups: [Default, Entity, Activity] }
- { alias: ActivityMetaField, type: App\Entity\ActivityMeta, groups: [Default, Activity] }
- { alias: ActivityCollection, type: App\Entity\Activity, groups: [Default, Collection, Activity] }
- { alias: TagEditForm, type: App\Form\API\TagApiEditForm, groups: [Default, Entity, Tag] }
- { alias: TagEntity, type: App\Entity\Tag, groups: [Default, Entity, Tag] }
- { alias: TimesheetEditForm, type: App\Form\API\TimesheetApiEditForm, groups: [Default, Entity, Timesheet] }
- { alias: TimesheetEntity, type: App\Entity\Timesheet, groups: [Default, Entity, Timesheet] }
- { alias: TimesheetMeta, type: App\Entity\TimesheetMeta, groups: [Default, Timesheet] }
@@ -35,9 +37,9 @@ nelmio_api_doc:
info:
title: Kimai 2 - API Docs
description: |
REST API for the Kimai 2 time-tracking software. Read more about its usage in the [API documentation](https://www.kimai.org/documentation/rest-api.html) and then download a [Swagger file](doc.json) for import e.g. in Postman.
JSON API for the Kimai 2 time-tracking software. Read more about its usage in the [API documentation](https://www.kimai.org/documentation/rest-api.html) and then download a [Swagger file](doc.json) for import e.g. in Postman.
Be aware: it is not yet considered stable and BC breaks might happen, but we try to avoid them.
version: 0.3
version: '0.4'
securityDefinitions:
apiUser:
type: apiKey

View File

@@ -48,6 +48,7 @@ security:
allow_if_all_abstain: false
role_hierarchy:
ROLE_USER: ~
ROLE_TEAMLEAD: ROLE_USER
ROLE_ADMIN: ROLE_TEAMLEAD
ROLE_SUPER_ADMIN: ROLE_ADMIN

View File

@@ -7,7 +7,10 @@ home:
homeLocale:
path: /{_locale}
requirements:
_locale: '%app_locales%'
defaults:
_controller: Symfony\Bundle\FrameworkBundle\Controller\RedirectController::redirectAction
_locale: '%locale%'
route: homepage
permanent: true

View File

@@ -1,6 +1,6 @@
app.test.sidebar_settings:
path: /{_locale}/sidebar/settings
controller: App\Controller\SidebarController::settingsAction
app.test.active_entries:
path: /{_locale}/layou/active_entries
controller: App\Controller\LayoutController::activeEntries
requirements:
_locale: '%app_locales%'
defaults:

View File

@@ -1,6 +1,6 @@
App\Entity\Project:
exclusion_policy: All
custom_accessor_order: [id, name, comment, visible, orderNumber, customer, fixedRate, hourlyRate, color, budget, timeBudget, metaFields]
custom_accessor_order: [id, name, comment, visible, orderNumber, orderDate, customer, fixedRate, hourlyRate, color, budget, timeBudget, metaFields]
properties:
id:
include: true
@@ -20,6 +20,9 @@ App\Entity\Project:
orderNumber:
include: true
groups: [Entity]
orderDate:
include: true
groups: [Entity]
fixedRate:
include: true
groups: [Project]

View File

@@ -2,7 +2,7 @@
# https://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration
parameters:
locale: en
app_locales: ar|de|en|es|fr|hu|it|ja|ko|pt_BR|ru|sk|sv
app_locales: ar|cs|de|de_CH|en|es|fr|hu|it|ja|ko|nl|pt_BR|ru|sk|sv|tr
services:
# default configuration for services in *this* file
@@ -74,13 +74,15 @@ services:
App\Plugin\PluginManager:
arguments: [!tagged kimai.plugin]
App\Export\ServiceExport:
arguments: [!tagged export.renderer]
App\Widget\WidgetService:
arguments:
$renderer: !tagged widget.renderer
App\Utils\Translator:
decorates: translator
arguments:
$localDomains: '%kimai.i18n_domains%'
# ================================================================================
# DATABASE
# ================================================================================
@@ -116,18 +118,25 @@ services:
# TIMESHEET RECORD CALCULATOR
# ================================================================================
App\Timesheet\Calculator\DurationCalculator:
arguments: ['%kimai.timesheet.rounding%']
App\Timesheet\RoundingService:
arguments:
$roundingModes: !tagged timesheet.rounding_mode
$rules: '%kimai.timesheet.rounding%'
App\Timesheet\Calculator\RateCalculator:
arguments: ['%kimai.timesheet.rates%']
App\Timesheet\TrackingModeService:
arguments:
$modes: !tagged timesheet.tracking_mode
# ================================================================================
# SECURITY & VOTER
# ================================================================================
App\Security\RoleService:
arguments: ['%security.role_hierarchy.roles%']
arguments:
$roles: '%security.role_hierarchy.roles%'
App\Security\RolePermissionManager:
arguments:
@@ -190,6 +199,16 @@ services:
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\Configuration']
App\Repository\RoleRepository:
class: Doctrine\ORM\EntityRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\Role']
App\Repository\RolePermissionRepository:
class: Doctrine\ORM\EntityRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\RolePermission']
App\Repository\InvoiceDocumentRepository:
class: App\Repository\InvoiceDocumentRepository
arguments: ['%kimai.invoice.documents%']

View File

@@ -25,7 +25,6 @@
"@symfony/webpack-encore": "^0.27.0",
"admin-lte": "~2.4.8",
"bootstrap-sass": "^3.3.7",
"bootstrap-select": "^1.13.1",
"chart.js": "~2.8.0",
"core-js": "^3.1.3",
"daterangepicker": "^3.0.3",
@@ -38,7 +37,9 @@
"moment": "^2.24.0",
"node-sass": "^4.9.0",
"sass-loader": "^7.0.2",
"sweetalert2": "^8.11.7",
"select2": "^4.0.10",
"select2-bootstrap-theme": "^0.1.0-beta.10",
"sweetalert2": "^9.3",
"webpack-notifier": "^1.5.1"
}
}

View File

@@ -17,7 +17,8 @@ parameters:
- '#Access to an undefined property Faker\\Generator::\$stateAbbr.#'
- '#Access to an undefined property Faker\\Generator::\$catchPhrase.#'
- '#Access to an undefined property Faker\\Generator::\$bs.#'
- '#Method Symfony\\Contracts\\EventDispatcher\\EventDispatcherInterface::dispatch\(\) invoked with 2 parameters, 1 required.#'
- '#Method Symfony\\Contracts\\EventDispatcher\\EventDispatcherInterface::dispatch\(\) invoked with 2 parameters, 1 required.#'
- '#Method App\\Controller\\AbstractController::getUser\(\) should return App\\Entity\\User|null but returns object|null. #'
excludes_analyse:
- %rootDir%/../../../src/Command/KimaiImporterCommand.php
- %rootDir%/../../../src/Ldap/LdapDriver.php

0
public/avatars/.gitignore vendored Normal file
View File

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -2,44 +2,44 @@
"entrypoints": {
"app": {
"js": [
"build/runtime.4ee6be68.js",
"build/0.a87622f3.js",
"build/1.c1bee41f.js",
"build/app.98c93d11.js"
"build/runtime.6ca1eea5.js",
"build/0.1a6ffb43.js",
"build/1.16767f92.js",
"build/app.926c5dcb.js"
],
"css": [
"build/app.e8d9cb98.css"
"build/app.fe1cac78.css"
]
},
"chart": {
"js": [
"build/runtime.4ee6be68.js",
"build/0.a87622f3.js",
"build/2.7be60d8d.js",
"build/chart.0af3f813.js"
"build/runtime.6ca1eea5.js",
"build/0.1a6ffb43.js",
"build/2.dad56560.js",
"build/chart.7f4d7f29.js"
]
},
"calendar": {
"js": [
"build/runtime.4ee6be68.js",
"build/0.a87622f3.js",
"build/1.c1bee41f.js",
"build/calendar.e654bdd4.js"
"build/runtime.6ca1eea5.js",
"build/0.1a6ffb43.js",
"build/1.16767f92.js",
"build/calendar.bd4fe3ae.js"
],
"css": [
"build/calendar.b0551848.css"
"build/calendar.ade7bcdf.css"
]
}
},
"integrity": {
"build/runtime.4ee6be68.js": "sha384-xNNrNinl64G3nCUrIskgSjU0mUXXCB9lj6XCSInBTwxSKXk8uTMafnLHtdWdIGtd",
"build/0.a87622f3.js": "sha384-ncT/BKhCsqH6jhxwdsSG95m1ei7ZZjeZtzH1262h+OPUU80TSFFE3dt+abcHHMok",
"build/1.c1bee41f.js": "sha384-7UVWcP6Hefp2k/CrtGSITKXx4dSZqtvpAiU8WX7dClETkzMewrUjoCRtVXQ5j3KI",
"build/app.98c93d11.js": "sha384-z6f94h/RD/8JXo1OWkUp7g6TXXV59JW70WA/SdusZh02FcwRvvBIX21k6O6pUBfw",
"build/app.e8d9cb98.css": "sha384-UuTUHbhvRNk3t+1gL0JNACbAl5xXH8OhD9ah7jaXDbd32z8dNCjK+5oJSQF77rmz",
"build/2.7be60d8d.js": "sha384-txR0QG+838LKYtPQ99Gx4OU7WmgN9J3joZEyGwIskSz74EN1T4/IBVnmNaKiFN1q",
"build/chart.0af3f813.js": "sha384-I57c9DtU3AOG2kzKqIZkIu0hi1aGYHRZ5QG4LKC9+9slzJnAMttPGXoL2cQG3m6y",
"build/calendar.e654bdd4.js": "sha384-TYdXaKv0q95Z4i/TFUaFHzMOZ0N6T4iuV92ejc0BX7Rpbd0p55ZhbgriPSn5pVcK",
"build/calendar.b0551848.css": "sha384-ta519W5+NY1a8FBh7rD4zORYTfUXCYTLci0Z25nf4BfodmuXqyPVNEe7dSe2/fRd"
"build/runtime.6ca1eea5.js": "sha384-xNNrNinl64G3nCUrIskgSjU0mUXXCB9lj6XCSInBTwxSKXk8uTMafnLHtdWdIGtd",
"build/0.1a6ffb43.js": "sha384-vuVCyLVj2b4h3jpKl+JSANRCacmXeLk+ihK5T5NZBO7+SJ5Y+o6d4qju116hTPSD",
"build/1.16767f92.js": "sha384-JPoKdrVtBemSiVBoAnmSxLML7xXM9zYeuwOPYQv/kLzt/P4cmLY5r9gH8oaGRPFG",
"build/app.926c5dcb.js": "sha384-DENClAG7XobiwMAnu1d/3xKCmR/7jYHN7/WIf5xdO6VUmYoHdm3QW2cOvxh97nnd",
"build/app.fe1cac78.css": "sha384-Z5aYp8LzFi0TRFbEFhM2SJzVMmesVfuSaDD66Z7vCkpuCO7+TJB+0xXrYdQqwsSF",
"build/2.dad56560.js": "sha384-oKsefSmRq0GL+Qa6f6jExXvFay3cM6YZFHQRKoAHi2p54UOCtjJfjF2eAB1qykoL",
"build/chart.7f4d7f29.js": "sha384-I57c9DtU3AOG2kzKqIZkIu0hi1aGYHRZ5QG4LKC9+9slzJnAMttPGXoL2cQG3m6y",
"build/calendar.bd4fe3ae.js": "sha384-YRVsW05mn+SMT/9LyfRafnVOGV6c6MN6eKjIX0gcq2BWl97HrieuWZgeR0g8hPtU",
"build/calendar.ade7bcdf.css": "sha384-ta519W5+NY1a8FBh7rD4zORYTfUXCYTLci0Z25nf4BfodmuXqyPVNEe7dSe2/fRd"
}
}

View File

@@ -1,13 +1,13 @@
{
"build/0.a87622f3.js": "build/0.a87622f3.js",
"build/1.c1bee41f.js": "build/1.c1bee41f.js",
"build/2.7be60d8d.js": "build/2.7be60d8d.js",
"build/app.css": "build/app.e8d9cb98.css",
"build/app.js": "build/app.98c93d11.js",
"build/calendar.css": "build/calendar.b0551848.css",
"build/calendar.js": "build/calendar.e654bdd4.js",
"build/chart.js": "build/chart.0af3f813.js",
"build/runtime.js": "build/runtime.4ee6be68.js",
"build/0.1a6ffb43.js": "build/0.1a6ffb43.js",
"build/1.16767f92.js": "build/1.16767f92.js",
"build/2.dad56560.js": "build/2.dad56560.js",
"build/app.css": "build/app.fe1cac78.css",
"build/app.js": "build/app.926c5dcb.js",
"build/calendar.css": "build/calendar.ade7bcdf.css",
"build/calendar.js": "build/calendar.bd4fe3ae.js",
"build/chart.js": "build/chart.7f4d7f29.js",
"build/runtime.js": "build/runtime.6ca1eea5.js",
"build/fonts/fa-brands-400.eot": "build/fonts/fa-brands-400.8e49b728.eot",
"build/fonts/fa-brands-400.woff": "build/fonts/fa-brands-400.9f018d10.woff",
"build/fonts/fa-brands-400.woff2": "build/fonts/fa-brands-400.9f4ce3dc.woff2",

View File

@@ -15,7 +15,7 @@ if (!isset($_SERVER['APP_ENV'])) {
(new Dotenv(true))->load(__DIR__.'/../.env');
}
$env = $_SERVER['APP_ENV'] ?? 'dev';
$env = $_SERVER['APP_ENV'] ?? 'prod';
$debug = $_SERVER['APP_DEBUG'] ?? ('prod' !== $env);
if ($debug) {

View File

@@ -16,11 +16,13 @@ use App\Event\ActivityMetaDefinitionEvent;
use App\Form\API\ActivityApiEditForm;
use App\Repository\ActivityRepository;
use App\Repository\Query\ActivityQuery;
use App\Utils\SearchTerm;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Controller\Annotations\RouteResource;
use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
@@ -31,7 +33,7 @@ use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* @RouteResource("Activity")
*
* @Security("is_granted('ROLE_USER')")
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
*/
class ActivityController extends BaseApiController
{
@@ -72,10 +74,12 @@ class ActivityController extends BaseApiController
* @Rest\QueryParam(name="globalsFirst", requirements="true|false", strict=true, nullable=true, description="Deprecated parameter, value is not used any more")
* @Rest\QueryParam(name="orderBy", requirements="id|name|project", strict=true, nullable=true, description="The field by which results will be ordered. Allowed values: id, name, project (default: name)")
* @Rest\QueryParam(name="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order. Allowed values: ASC, DESC (default: ASC)")
* @Rest\QueryParam(name="term", requirements="[a-zA-Z0-9 \-,:]+", strict=true, nullable=true, description="Free search term")
*
* @return Response
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function cgetAction(ParamFetcherInterface $paramFetcher)
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
{
$query = new ActivityQuery();
@@ -103,6 +107,10 @@ class ActivityController extends BaseApiController
$query->setVisibility($visible);
}
if (!empty($term = $paramFetcher->get('term'))) {
$query->setSearchTerm(new SearchTerm($term));
}
$data = $this->repository->getActivitiesForQuery($query);
$view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Collection', 'Activity']);
@@ -126,12 +134,11 @@ class ActivityController extends BaseApiController
* required=true,
* )
*
* @param int $id
* @return Response
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function getAction($id)
public function getAction(int $id): Response
{
/** @var Activity $data */
$data = $this->repository->find($id);
if (null === $data) {
@@ -162,13 +169,10 @@ class ActivityController extends BaseApiController
* @SWG\Schema(ref="#/definitions/ActivityEditForm")
* )
*
* @param Request $request
* @return Response
* @throws \App\Repository\RepositoryException
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function postAction(Request $request)
public function postAction(Request $request): Response
{
if (!$this->isGranted('create_activity')) {
throw new AccessDeniedHttpException('User cannot create activities');
@@ -177,7 +181,7 @@ class ActivityController extends BaseApiController
$activity = new Activity();
$event = new ActivityMetaDefinitionEvent($activity);
$this->dispatcher->dispatch($event, ActivityMetaDefinitionEvent::class);
$this->dispatcher->dispatch($event);
$form = $this->createForm(ActivityApiEditForm::class, $activity);
@@ -223,13 +227,10 @@ class ActivityController extends BaseApiController
* required=true,
* )
*
* @param Request $request
* @param string $id
* @return Response
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function patchAction(Request $request, string $id)
public function patchAction(Request $request, int $id): Response
{
$activity = $this->repository->find($id);
@@ -242,7 +243,7 @@ class ActivityController extends BaseApiController
}
$event = new ActivityMetaDefinitionEvent($activity);
$this->dispatcher->dispatch($event, ActivityMetaDefinitionEvent::class);
$this->dispatcher->dispatch($event);
$form = $this->createForm(ActivityApiEditForm::class, $activity);
@@ -282,13 +283,10 @@ class ActivityController extends BaseApiController
* @Rest\RequestParam(name="name", strict=true, nullable=false, description="The meta-field name")
* @Rest\RequestParam(name="value", strict=true, nullable=false, description="The meta-field value")
*
* @param int $id
* @param ParamFetcherInterface $paramFetcher
* @return Response
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function metaAction($id, ParamFetcherInterface $paramFetcher)
public function metaAction(int $id, ParamFetcherInterface $paramFetcher): Response
{
$activity = $this->repository->find($id);
@@ -301,7 +299,7 @@ class ActivityController extends BaseApiController
}
$event = new ActivityMetaDefinitionEvent($activity);
$this->dispatcher->dispatch($event, ActivityMetaDefinitionEvent::class);
$this->dispatcher->dispatch($event);
$name = $paramFetcher->get('name');
$value = $paramFetcher->get('value');

View File

@@ -11,6 +11,7 @@ declare(strict_types=1);
namespace App\API;
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
@@ -18,4 +19,12 @@ abstract class BaseApiController extends AbstractController
{
public const DATE_FORMAT = DateTimeType::HTML5_FORMAT;
public const DATE_FORMAT_PHP = 'Y-m-d\TH:m:s';
/**
* @return User|null
*/
protected function getUser()
{
return parent::getUser();
}
}

View File

@@ -17,11 +17,13 @@ use App\Entity\User;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
use Symfony\Component\HttpFoundation\Response;
/**
* @Security("is_granted('ROLE_USER')")
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
*/
class ConfigurationController extends BaseApiController
{
@@ -54,8 +56,11 @@ class ConfigurationController extends BaseApiController
* )
*
* @Rest\Get(path="/config/i18n")
*
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function i18nAction()
public function i18nAction(): Response
{
/** @var User $user */
$user = $this->getUser();

View File

@@ -16,11 +16,13 @@ use App\Event\CustomerMetaDefinitionEvent;
use App\Form\API\CustomerApiEditForm;
use App\Repository\CustomerRepository;
use App\Repository\Query\CustomerQuery;
use App\Utils\SearchTerm;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Controller\Annotations\RouteResource;
use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
@@ -31,7 +33,7 @@ use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* @RouteResource("Customer")
*
* @Security("is_granted('ROLE_USER')")
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
*/
class CustomerController extends BaseApiController
{
@@ -69,10 +71,12 @@ class CustomerController extends BaseApiController
* @Rest\QueryParam(name="visible", requirements="\d+", strict=true, nullable=true, description="Visibility status to filter activities (1=visible, 2=hidden, 3=both)")
* @Rest\QueryParam(name="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order. Allowed values: ASC, DESC (default: ASC)")
* @Rest\QueryParam(name="orderBy", requirements="id|name", strict=true, nullable=true, description="The field by which results will be ordered. Allowed values: id, name (default: name)")
* @Rest\QueryParam(name="term", requirements="[a-zA-Z0-9 \-,:]+", strict=true, nullable=true, description="Free search term")
*
* @return Response
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function cgetAction(ParamFetcherInterface $paramFetcher)
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
{
$query = new CustomerQuery();
$query->setCurrentUser($this->getUser());
@@ -89,6 +93,10 @@ class CustomerController extends BaseApiController
$query->setVisibility($visible);
}
if (!empty($term = $paramFetcher->get('term'))) {
$query->setSearchTerm(new SearchTerm($term));
}
$data = $this->repository->getCustomersForQuery($query);
$view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Collection', 'Customer']);
@@ -105,12 +113,11 @@ class CustomerController extends BaseApiController
* @SWG\Schema(ref="#/definitions/CustomerEntity"),
* )
*
* @param int $id
* @return Response
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function getAction($id)
public function getAction(int $id): Response
{
/** @var Customer $data */
$data = $this->repository->find($id);
if (null === $data) {
@@ -141,13 +148,10 @@ class CustomerController extends BaseApiController
* @SWG\Schema(ref="#/definitions/CustomerEditForm")
* )
*
* @param Request $request
* @return Response
* @throws \App\Repository\RepositoryException
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function postAction(Request $request)
public function postAction(Request $request): Response
{
if (!$this->isGranted('create_customer')) {
throw new AccessDeniedHttpException('User cannot create customers');
@@ -156,7 +160,7 @@ class CustomerController extends BaseApiController
$customer = new Customer();
$event = new CustomerMetaDefinitionEvent($customer);
$this->dispatcher->dispatch($event, CustomerMetaDefinitionEvent::class);
$this->dispatcher->dispatch($event);
$form = $this->createForm(CustomerApiEditForm::class, $customer);
@@ -202,13 +206,10 @@ class CustomerController extends BaseApiController
* required=true,
* )
*
* @param Request $request
* @param string $id
* @return Response
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function patchAction(Request $request, string $id)
public function patchAction(Request $request, int $id): Response
{
$customer = $this->repository->find($id);
@@ -221,7 +222,7 @@ class CustomerController extends BaseApiController
}
$event = new CustomerMetaDefinitionEvent($customer);
$this->dispatcher->dispatch($event, CustomerMetaDefinitionEvent::class);
$this->dispatcher->dispatch($event);
$form = $this->createForm(CustomerApiEditForm::class, $customer);
@@ -261,13 +262,10 @@ class CustomerController extends BaseApiController
* @Rest\RequestParam(name="name", strict=true, nullable=false, description="The meta-field name")
* @Rest\RequestParam(name="value", strict=true, nullable=false, description="The meta-field value")
*
* @param int $id
* @param ParamFetcherInterface $paramFetcher
* @return Response
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function metaAction($id, ParamFetcherInterface $paramFetcher)
public function metaAction(int $id, ParamFetcherInterface $paramFetcher): Response
{
$customer = $this->repository->find($id);
@@ -280,7 +278,7 @@ class CustomerController extends BaseApiController
}
$event = new CustomerMetaDefinitionEvent($customer);
$this->dispatcher->dispatch($event, CustomerMetaDefinitionEvent::class);
$this->dispatcher->dispatch($event);
$name = $paramFetcher->get('name');
$value = $paramFetcher->get('value');

View File

@@ -16,11 +16,13 @@ use App\Event\ProjectMetaDefinitionEvent;
use App\Form\API\ProjectApiEditForm;
use App\Repository\ProjectRepository;
use App\Repository\Query\ProjectQuery;
use App\Utils\SearchTerm;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Controller\Annotations\RouteResource;
use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
@@ -31,7 +33,7 @@ use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* @RouteResource("Project")
*
* @Security("is_granted('ROLE_USER')")
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
*/
class ProjectController extends BaseApiController
{
@@ -70,11 +72,12 @@ class ProjectController extends BaseApiController
* @Rest\QueryParam(name="visible", requirements="\d+", strict=true, nullable=true, description="Visibility status to filter projects (1=visible, 2=hidden, 3=both)")
* @Rest\QueryParam(name="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order. Allowed values: ASC, DESC (default: ASC)")
* @Rest\QueryParam(name="orderBy", requirements="id|name|customer", strict=true, nullable=true, description="The field by which results will be ordered. Allowed values: id, name, customer (default: name)")
* @Rest\QueryParam(name="term", requirements="[a-zA-Z0-9 \-,:]+", strict=true, nullable=true, description="Free search term")
*
* @param ParamFetcherInterface $paramFetcher
* @return Response
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function cgetAction(ParamFetcherInterface $paramFetcher)
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
{
$query = new ProjectQuery();
$query->setCurrentUser($this->getUser());
@@ -95,6 +98,10 @@ class ProjectController extends BaseApiController
$query->setVisibility($visible);
}
if (!empty($term = $paramFetcher->get('term'))) {
$query->setSearchTerm(new SearchTerm($term));
}
$data = $this->repository->getProjectsForQuery($query);
$view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Collection', 'Project']);
@@ -111,12 +118,11 @@ class ProjectController extends BaseApiController
* @SWG\Schema(ref="#/definitions/ProjectEntity"),
* )
*
* @param int $id
* @return Response
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function getAction($id)
public function getAction(int $id): Response
{
/** @var Project $data */
$data = $this->repository->find($id);
if (null === $data) {
@@ -147,13 +153,10 @@ class ProjectController extends BaseApiController
* @SWG\Schema(ref="#/definitions/ProjectEditForm")
* )
*
* @param Request $request
* @return Response
* @throws \App\Repository\RepositoryException
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function postAction(Request $request)
public function postAction(Request $request): Response
{
if (!$this->isGranted('create_project')) {
throw new AccessDeniedHttpException('User cannot create projects');
@@ -162,7 +165,7 @@ class ProjectController extends BaseApiController
$project = new Project();
$event = new ProjectMetaDefinitionEvent($project);
$this->dispatcher->dispatch($event, ProjectMetaDefinitionEvent::class);
$this->dispatcher->dispatch($event);
$form = $this->createForm(ProjectApiEditForm::class, $project);
@@ -208,13 +211,10 @@ class ProjectController extends BaseApiController
* required=true,
* )
*
* @param Request $request
* @param string $id
* @return Response
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function patchAction(Request $request, string $id)
public function patchAction(Request $request, int $id): Response
{
$project = $this->repository->find($id);
@@ -227,7 +227,7 @@ class ProjectController extends BaseApiController
}
$event = new ProjectMetaDefinitionEvent($project);
$this->dispatcher->dispatch($event, ProjectMetaDefinitionEvent::class);
$this->dispatcher->dispatch($event);
$form = $this->createForm(ProjectApiEditForm::class, $project);
@@ -267,13 +267,10 @@ class ProjectController extends BaseApiController
* @Rest\RequestParam(name="name", strict=true, nullable=false, description="The meta-field name")
* @Rest\RequestParam(name="value", strict=true, nullable=false, description="The meta-field value")
*
* @param int $id
* @param ParamFetcherInterface $paramFetcher
* @return Response
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function metaAction($id, ParamFetcherInterface $paramFetcher)
public function metaAction(int $id, ParamFetcherInterface $paramFetcher): Response
{
$project = $this->repository->find($id);
@@ -286,7 +283,7 @@ class ProjectController extends BaseApiController
}
$event = new ProjectMetaDefinitionEvent($project);
$this->dispatcher->dispatch($event, ProjectMetaDefinitionEvent::class);
$this->dispatcher->dispatch($event);
$name = $paramFetcher->get('name');
$value = $paramFetcher->get('value');

View File

@@ -16,7 +16,9 @@ use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Model;
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
use Swagger\Annotations as SWG;
use Symfony\Component\HttpFoundation\Response;
class StatusController extends BaseApiController
{
@@ -43,8 +45,11 @@ class StatusController extends BaseApiController
* )
*
* @Rest\Get(path="/ping")
*
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function pingAction()
public function pingAction(): Response
{
$view = new View(['message' => 'pong'], 200);
@@ -61,8 +66,11 @@ class StatusController extends BaseApiController
* )
*
* @Rest\Get(path="/version")
*
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function versionAction()
public function versionAction(): Response
{
return $this->viewHandler->handle(new View(new Version(), 200));
}

View File

@@ -11,15 +11,20 @@ declare(strict_types=1);
namespace App\API;
use App\Entity\Tag;
use App\Form\API\TagApiEditForm;
use App\Repository\TagRepository;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Controller\Annotations\RouteResource;
use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* @RouteResource("Tag")
@@ -58,18 +63,67 @@ class TagController extends BaseApiController
* )
* )
*
* @Rest\QueryParam(name="name", requirements="[a-zA-Z0-9 -\.]+", strict=true, nullable=true, description="Search term to filter tag list")
* @Rest\QueryParam(name="name", strict=true, nullable=true, description="Search term to filter tag list")
*
* @return Response
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function cgetAction(ParamFetcherInterface $paramFetcher)
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
{
$filter = $paramFetcher->get('name');
$data = $this->repository->findAllTagNames($filter);
$view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Collection']);
$view->getContext()->setGroups(['Default', 'Collection', 'Tag']);
return $this->viewHandler->handle($view);
}
/**
* Creates a new tag
*
* @SWG\Post(
* description="Creates a new tag and returns it afterwards",
* @SWG\Response(
* response=200,
* description="Returns the new created tag",
* @SWG\Schema(ref="#/definitions/TagEntity"),
* )
* )
* @SWG\Parameter(
* name="body",
* in="body",
* required=true,
* @SWG\Schema(ref="#/definitions/TagEditForm")
* )
*
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function postAction(Request $request): Response
{
if (!$this->isGranted('manage_tag')) {
throw new AccessDeniedHttpException('User cannot create tags');
}
$tag = new Tag();
$form = $this->createForm(TagApiEditForm::class, $tag);
$form->submit($request->request->all());
if ($form->isValid()) {
$this->repository->saveTag($tag);
$view = new View($tag, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Tag']);
return $this->viewHandler->handle($view);
}
$view = new View($form);
$view->getContext()->setGroups(['Default', 'Entity', 'Tag']);
return $this->viewHandler->handle($view);
}
@@ -93,10 +147,10 @@ class TagController extends BaseApiController
*
* @Security("is_granted('delete_tag')")
*
* @param int $id
* @return Response
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function deleteAction($id)
public function deleteAction(int $id): Response
{
$tag = $this->repository->find($id);

View File

@@ -17,6 +17,7 @@ use FOS\RestBundle\Controller\Annotations\RouteResource;
use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
use Symfony\Component\HttpFoundation\Response;
@@ -55,9 +56,10 @@ class TeamController extends BaseApiController
*
* @Security("is_granted('view_team')")
*
* @return Response
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function cgetAction(ParamFetcherInterface $paramFetcher)
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
{
$data = $this->repository->findAll();
@@ -76,12 +78,11 @@ class TeamController extends BaseApiController
* @SWG\Schema(ref="#/definitions/TeamEntity"),
* )
*
* @param int $id
* @return Response
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function getAction($id)
public function getAction(int $id): Response
{
/** @var Team $data */
$data = $this->repository->find($id);
if (null === $data) {
@@ -113,10 +114,10 @@ class TeamController extends BaseApiController
*
* @Security("is_granted('delete_team')")
*
* @param int $id
* @return Response
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function deleteAction($id)
public function deleteAction(int $id): Response
{
$team = $this->repository->find($id);

View File

@@ -19,15 +19,18 @@ use App\Form\API\TimesheetApiEditForm;
use App\Repository\Query\TimesheetQuery;
use App\Repository\TagRepository;
use App\Repository\TimesheetRepository;
use App\Timesheet\RoundingService;
use App\Timesheet\TrackingMode\TrackingModeInterface;
use App\Timesheet\TrackingModeService;
use App\Timesheet\UserDateTimeFactory;
use App\Utils\SearchTerm;
use Doctrine\Common\Collections\ArrayCollection;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Controller\Annotations\RouteResource;
use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
use Pagerfanta\Pagerfanta;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
@@ -42,7 +45,7 @@ use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* @RouteResource("Timesheet")
*
* @Security("is_granted('ROLE_USER')")
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
*/
class TimesheetController extends BaseApiController
{
@@ -74,6 +77,10 @@ class TimesheetController extends BaseApiController
* @var EventDispatcherInterface
*/
private $dispatcher;
/**
* @var RoundingService
*/
private $roundingService;
public function __construct(
ViewHandlerInterface $viewHandler,
@@ -82,7 +89,8 @@ class TimesheetController extends BaseApiController
TimesheetConfiguration $configuration,
TagRepository $tagRepository,
TrackingModeService $trackingModeService,
EventDispatcherInterface $dispatcher
EventDispatcherInterface $dispatcher,
RoundingService $roundingService
) {
$this->viewHandler = $viewHandler;
$this->repository = $repository;
@@ -91,6 +99,7 @@ class TimesheetController extends BaseApiController
$this->tagRepository = $tagRepository;
$this->trackingModeService = $trackingModeService;
$this->dispatcher = $dispatcher;
$this->roundingService = $roundingService;
}
protected function getTrackingMode(): TrackingModeInterface
@@ -124,14 +133,14 @@ class TimesheetController extends BaseApiController
* @Rest\QueryParam(name="exported", requirements="0|1", strict=true, nullable=true, description="Use this flag if you want to filter for export state. Allowed values: 0=not exported, 1=exported (default: all)")
* @Rest\QueryParam(name="active", requirements="0|1", strict=true, nullable=true, description="Filter for running/active records. Allowed values: 0=stopped, 1=active (default: all)")
* @Rest\QueryParam(name="full", requirements="true", strict=true, nullable=true, description="Allows to fetch fully serialized objects including subresources (TimesheetSubCollection). Allowed values: true (default: false)")
* @Rest\QueryParam(name="term", requirements="[a-zA-Z0-9 \-,:]+", strict=true, nullable=true, description="Free search term")
*
* @Security("is_granted('view_own_timesheet') or is_granted('view_other_timesheet')")
*
* @param ParamFetcherInterface $paramFetcher
* @return Response
* @throws \Exception
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function cgetAction(ParamFetcherInterface $paramFetcher)
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
{
$query = new TimesheetQuery();
$query->setUser($this->getUser());
@@ -204,6 +213,10 @@ class TimesheetController extends BaseApiController
}
}
if (!empty($term = $paramFetcher->get('term'))) {
$query->setSearchTerm(new SearchTerm($term));
}
/** @var Pagerfanta $data */
$data = $this->repository->getPagerfantaForQuery($query);
$data = (array) $data->getCurrentPageResults();
@@ -236,12 +249,11 @@ class TimesheetController extends BaseApiController
*
* @Security("is_granted('view_own_timesheet') or is_granted('view_other_timesheet')")
*
* @param int $id
* @return Response
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function getAction($id)
public function getAction(int $id): Response
{
/** @var Timesheet $data */
$data = $this->repository->find($id);
if (null === $data) {
@@ -278,32 +290,30 @@ class TimesheetController extends BaseApiController
*
* @Security("is_granted('create_own_timesheet')")
*
* @param Request $request
* @return Response
* @throws \App\Repository\RepositoryException
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function postAction(Request $request)
public function postAction(Request $request): Response
{
$timesheet = new Timesheet();
$timesheet->setUser($this->getUser());
$timesheet->setBegin($this->dateTime->createDateTime());
$event = new TimesheetMetaDefinitionEvent($timesheet);
$this->dispatcher->dispatch($event, TimesheetMetaDefinitionEvent::class);
$this->dispatcher->dispatch($event);
$mode = $this->getTrackingMode();
$mode->create($timesheet, $request);
$form = $this->createForm(TimesheetApiEditForm::class, $timesheet, [
'include_rate' => $this->isGranted('edit_rate', $timesheet),
'include_exported' => $this->isGranted('edit_export', $timesheet),
'include_user' => $this->isGranted('create_other_timesheet'),
'allow_begin_datetime' => $mode->canUpdateTimesWithAPI(),
'allow_end_datetime' => $mode->canUpdateTimesWithAPI(),
'date_format' => self::DATE_FORMAT,
]);
$form->submit($request->request->all());
$form->submit($request->request->all(), false);
if ($form->isValid()) {
if (null === $timesheet->getEnd()) {
@@ -355,11 +365,10 @@ class TimesheetController extends BaseApiController
* required=true,
* )
*
* @param Request $request
* @param int $id the timesheet to update
* @return Response
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function patchAction(Request $request, int $id)
public function patchAction(Request $request, int $id): Response
{
$timesheet = $this->repository->find($id);
@@ -372,13 +381,14 @@ class TimesheetController extends BaseApiController
}
$event = new TimesheetMetaDefinitionEvent($timesheet);
$this->dispatcher->dispatch($event, TimesheetMetaDefinitionEvent::class);
$this->dispatcher->dispatch($event);
$mode = $this->getTrackingMode();
$form = $this->createForm(TimesheetApiEditForm::class, $timesheet, [
'include_rate' => $this->isGranted('edit_rate', $timesheet),
'include_exported' => $this->isGranted('edit_export', $timesheet),
'include_user' => $this->isGranted('edit', $timesheet),
'allow_begin_datetime' => $mode->canUpdateTimesWithAPI(),
'allow_end_datetime' => $mode->canUpdateTimesWithAPI(),
'date_format' => self::DATE_FORMAT,
@@ -419,10 +429,10 @@ class TimesheetController extends BaseApiController
* required=true,
* )
*
* @param int $id
* @return Response
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function deleteAction($id)
public function deleteAction(int $id): Response
{
$timesheet = $this->repository->find($id);
@@ -458,10 +468,11 @@ class TimesheetController extends BaseApiController
* @Rest\QueryParam(name="size", requirements="\d+", strict=true, nullable=true, description="The amount of entries (default: 10)")
*
* @Security("is_granted('view_own_timesheet') or is_granted('view_other_timesheet')")
* @return Response
* @throws \Doctrine\ORM\Query\QueryException
*
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function recentAction(ParamFetcherInterface $paramFetcher)
public function recentAction(ParamFetcherInterface $paramFetcher): Response
{
$user = $this->getUser();
$begin = $this->dateTime->createDateTime('-1 year');
@@ -503,9 +514,11 @@ class TimesheetController extends BaseApiController
* )
*
* @Security("is_granted('view_own_timesheet')")
* @return Response
*
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function activeAction()
public function activeAction(): Response
{
$data = $this->repository->getActiveEntries($this->getUser());
@@ -531,15 +544,11 @@ class TimesheetController extends BaseApiController
* required=true,
* )
*
* @param int $id
* @return Response
* @throws \App\Repository\RepositoryException
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function stopAction($id)
public function stopAction(int $id): Response
{
/** @var Timesheet $timesheet */
$timesheet = $this->repository->find($id);
if (null === $timesheet) {
@@ -576,15 +585,11 @@ class TimesheetController extends BaseApiController
*
* @Rest\RequestParam(name="copy", requirements="all|tags|rates|meta|description", strict=true, nullable=true, description="Whether data should be copied to the new entry. Allowed values: all, tags, rates, description, meta (default: nothing is copied)")
*
* @param int $id
* @return Response
* @throws \App\Repository\RepositoryException
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function restartAction($id, ParamFetcherInterface $paramFetcher, ValidatorInterface $validator)
public function restartAction(int $id, ParamFetcherInterface $paramFetcher, ValidatorInterface $validator): Response
{
/** @var Timesheet $timesheet */
$timesheet = $this->repository->find($id);
if (null === $timesheet) {
@@ -605,6 +610,7 @@ class TimesheetController extends BaseApiController
->setActivity($timesheet->getActivity())
->setProject($timesheet->getProject())
;
$this->roundingService->roundBegin($copyTimesheet);
if (null !== ($copy = $paramFetcher->get('copy'))) {
if (in_array($copy, ['rates', 'all'])) {
@@ -665,14 +671,11 @@ class TimesheetController extends BaseApiController
* required=true,
* )
*
* @param int $id
* @return Response
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function exportAction($id)
public function exportAction(int $id): Response
{
/** @var Timesheet $timesheet */
$timesheet = $this->repository->find($id);
if (null === $timesheet) {
@@ -713,13 +716,10 @@ class TimesheetController extends BaseApiController
* @Rest\RequestParam(name="name", strict=true, nullable=false, description="The meta-field name")
* @Rest\RequestParam(name="value", strict=true, nullable=false, description="The meta-field value")
*
* @param int $id
* @param ParamFetcherInterface $paramFetcher
* @return Response
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function metaAction($id, ParamFetcherInterface $paramFetcher)
public function metaAction(int $id, ParamFetcherInterface $paramFetcher): Response
{
$timesheet = $this->repository->find($id);
@@ -732,7 +732,7 @@ class TimesheetController extends BaseApiController
}
$event = new TimesheetMetaDefinitionEvent($timesheet);
$this->dispatcher->dispatch($event, TimesheetMetaDefinitionEvent::class);
$this->dispatcher->dispatch($event);
$name = $paramFetcher->get('name');
$value = $paramFetcher->get('value');

View File

@@ -13,11 +13,13 @@ namespace App\API;
use App\Repository\Query\UserQuery;
use App\Repository\UserRepository;
use App\Utils\SearchTerm;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Controller\Annotations\RouteResource;
use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
use Symfony\Component\HttpFoundation\Response;
@@ -26,7 +28,7 @@ use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* @RouteResource("User")
*
* @Security("is_granted('ROLE_USER')")
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
*/
class UserController extends BaseApiController
{
@@ -65,18 +67,16 @@ class UserController extends BaseApiController
* @Rest\QueryParam(name="visible", requirements="1|2|3", strict=true, nullable=true, description="Visibility status to filter users. Allowed values: 1=visible, 2=hidden, 3=all (default: 1)")
* @Rest\QueryParam(name="orderBy", requirements="id|username|alias|email", strict=true, nullable=true, description="The field by which results will be ordered. Allowed values: id, username, alias, email (default: username)")
* @Rest\QueryParam(name="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order. Allowed values: ASC, DESC (default: ASC)")
* @Rest\QueryParam(name="term", requirements="[a-zA-Z0-9 \-,:]+", strict=true, nullable=true, description="Free search term")
*
* @Security("is_granted('view_user')")
*
* @return Response
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function cgetAction(ParamFetcherInterface $paramFetcher)
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
{
$query = new UserQuery();
$query
->setResultType(UserQuery::RESULT_TYPE_OBJECTS)
->setOrderBy('username')
;
if (null !== ($visible = $paramFetcher->get('visible'))) {
$query->setVisibility($visible);
@@ -90,7 +90,11 @@ class UserController extends BaseApiController
$query->setOrderBy($orderBy);
}
$data = $this->repository->findByQuery($query);
if (!empty($term = $paramFetcher->get('term'))) {
$query->setSearchTerm(new SearchTerm($term));
}
$data = $this->repository->getUsersForQuery($query);
$view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Collection', 'User']);
@@ -102,7 +106,7 @@ class UserController extends BaseApiController
*
* @SWG\Response(
* response=200,
* description="Return one user entity. Required permission: view_user",
* description="Return one user entity.",
* @SWG\Schema(ref="#/definitions/UserEntity"),
* )
* @SWG\Parameter(
@@ -113,10 +117,10 @@ class UserController extends BaseApiController
* required=true,
* )
*
* @param int $id
* @return Response
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function getAction($id)
public function getAction(int $id): Response
{
$user = $this->repository->find($id);
@@ -133,4 +137,26 @@ class UserController extends BaseApiController
return $this->viewHandler->handle($view);
}
/**
* Return the current user entity
*
* @SWG\Response(
* response=200,
* description="Return the current user entity.",
* @SWG\Schema(ref="#/definitions/UserEntity"),
* )
*
* @Rest\Get(path="/users/me")
*
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
public function meAction(): Response
{
$view = new View($this->getUser(), 200);
$view->getContext()->setGroups(['Default', 'Entity', 'User']);
return $this->viewHandler->handle($view);
}
}

View File

@@ -118,10 +118,8 @@ class CreateReleaseCommand extends Command
$commands = [
'Clone repository' => $gitCmd . ' ' . $tmpDir,
'Install composer dependencies' => 'cd ' . $tmpDir . ' && composer install --no-dev --optimize-autoloader',
'Create database' => 'cd ' . $tmpDir . ' && ' . $prefix . ' bin/console doctrine:database:create -n',
'Create tables' => 'cd ' . $tmpDir . ' && ' . $prefix . ' bin/console doctrine:schema:create -n',
'Add all migrations' => 'cd ' . $tmpDir . ' && ' . $prefix . ' bin/console doctrine:migrations:version --add --all -n',
'Install composer dependencies' => sprintf('cd %s && %s composer install --no-dev --optimize-autoloader', $tmpDir, $prefix),
'Create database' => sprintf('cd %s && %s bin/console kimai:install -n', $tmpDir, $prefix),
];
$filesToDelete = [
@@ -137,7 +135,6 @@ class CreateReleaseCommand extends Command
'phpunit.xml.dist',
'webpack.config.js',
'assets/',
'bin/',
'tests/',
'var/cache/*',
'var/data/kimai_test.sqlite',
@@ -150,25 +147,26 @@ class CreateReleaseCommand extends Command
}
$commands = array_merge($commands, [
'Create tar' => 'cd ' . $tmpDir . ' && tar -czf ' . $directory . '/' . $tar . ' .',
'Create zip' => 'cd ' . $tmpDir . ' && zip -r ' . $directory . '/' . $zip . ' .',
// 'Create tar' => 'cd ' . $tmpDir . ' && tar -czf ' . $directory . '/' . $tar . ' .',
'Create release zip' => 'cd ' . $tmpDir . ' && zip -q -r ' . $directory . '/' . $zip . ' .',
'Remove tmp directory' => 'rm -rf ' . $tmpDir,
]);
$exitCode = 0;
foreach ($commands as $title => $command) {
$io->success($title);
passthru($command, $exitCode);
if ($exitCode !== 0) {
$io->error('Failed with command: ' . $command);
return -1;
} else {
$io->success($title);
}
}
$io->success(
'New release packages available at: ' . PHP_EOL .
$directory . '/' . $tar . PHP_EOL .
'New release package available at: ' . PHP_EOL .
// $directory . '/' . $tar . PHP_EOL .
$directory . '/' . $zip
);

View File

@@ -0,0 +1,338 @@
<?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\Command;
use App\Configuration\FormConfiguration;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Team;
use App\Importer\InvalidFieldsException;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use App\Repository\TeamRepository;
use App\Repository\UserRepository;
use League\Csv\Reader;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* This command can change anytime, don't rely on its API for the future!
*
* @internal
* @codeCoverageIgnore
*/
class ImportProjectCommand extends Command
{
protected static $defaultName = 'kimai:import:customer';
private static $requiredHeader = [
'Name',
'Customer',
];
private static $supportedHeader = [
'Name',
'Customer',
'Comment',
'OrderNumber',
'OrderDate',
];
/**
* @var CustomerRepository
*/
private $customers;
/**
* @var ProjectRepository
*/
private $projects;
/**
* @var TeamRepository
*/
private $teams;
/**
* @var UserRepository
*/
private $users;
/**
* @var FormConfiguration
*/
private $configuration;
/**
* @var Customer[]
*/
private $customerCache = [];
/**
* The datetime of this import as formatted string.
*
* @var string
*/
private $dateTime = '';
/**
* Comment that will be added to new customers, projects and activities.
*
* @var string
*/
private $comment = '';
public function __construct(CustomerRepository $customers, ProjectRepository $projects, TeamRepository $teams, UserRepository $users, FormConfiguration $configuration)
{
parent::__construct();
$this->customers = $customers;
$this->projects = $projects;
$this->teams = $teams;
$this->users = $users;
$this->configuration = $configuration;
}
/**
* {@inheritdoc}
*/
protected function configure()
{
$this
->setName(self::$defaultName)
->setDescription('Import projects from CSV file')
->setHelp(
'This command allows to import projects from a CSV file, creating customers (if not existing) and optional empty teams for each project.' . PHP_EOL .
'Imported customer will be matched by name and optionally created on the fly.' . PHP_EOL .
'Required column names: ' . implode(', ', self::$requiredHeader) . PHP_EOL .
'Supported column names: ' . implode(', ', self::$supportedHeader) . PHP_EOL
)
->addOption('teamlead', null, InputOption::VALUE_REQUIRED, 'If you want to create empty teams for each project, give the username of the teamlead to be assigned')
->addOption('delimiter', null, InputOption::VALUE_OPTIONAL, 'The CSV field delimiter', ',')
->addOption('comment', null, InputOption::VALUE_OPTIONAL, 'A description to be added to created customers and projects. %s will be replaced with the current datetime', 'Imported at %s')
->addArgument('file', InputArgument::REQUIRED, 'The CSV file to be imported')
;
}
/**
* @param InputInterface $input
* @param OutputInterface $output
* @return int|null
*/
protected function execute(InputInterface $input, OutputInterface $output)
{
$io = new SymfonyStyle($input, $output);
$io->title('Kimai importer: Projects');
$csvFile = $input->getArgument('file');
if (!file_exists($csvFile)) {
$io->error('File not existing: ' . $csvFile);
return 1;
}
if (!is_readable($csvFile)) {
$io->error('File cannot be read: ' . $csvFile);
return 2;
}
$this->dateTime = (new \DateTime())->format('Y.m.d H:i');
$this->comment = sprintf($input->getOption('comment'), $this->dateTime);
$csv = Reader::createFromPath($csvFile, 'r');
$csv->setDelimiter($input->getOption('delimiter'));
$csv->setHeaderOffset(0);
$header = $csv->getHeader();
// validate teamlead
$teamlead = $input->getOption('teamlead');
if (null !== $teamlead) {
$tmpUser = $this->users->findOneBy(['username' => $teamlead]);
if (null === $tmpUser) {
$tmpUser = $this->users->findOneBy(['email' => $teamlead]);
if (null === $tmpUser) {
$io->error(
sprintf(
'You requested to create empty teams for each project, but the given teamlead cannot be found.' . PHP_EOL .
'Please create a user with the name (or email) %s first, before continuing.' . PHP_EOL,
$teamlead
)
);
return 3;
}
}
$teamlead = $tmpUser;
}
if (!$this->validateHeader($header)) {
$io->error(
sprintf(
'Found invalid CSV header: %s' . PHP_EOL .
'Required fields: %s' . PHP_EOL .
'All supported fields: %s' . PHP_EOL,
implode(', ', $header),
implode(', ', self::$requiredHeader),
implode(', ', self::$supportedHeader)
)
);
return 4;
}
$records = $csv->getRecords();
$doImport = true;
$row = 1;
$errors = 0;
foreach ($records as $record) {
try {
$this->validateRow($record);
} catch (InvalidFieldsException $ex) {
$io->error(sprintf('Invalid row %s, invalid fields: %s', $row, implode(', ', $ex->getFields())));
$doImport = false;
$errors++;
}
$row++;
}
if (!$doImport) {
$io->caution(sprintf('Not importing, previous %s errors need to be fixed first.', $errors));
return 5;
}
$row = 0;
foreach ($records as $record) {
$row++;
try {
$customer = $this->getCustomer($record['Customer']);
$projectName = $record['Name'];
$project = new Project();
$project->setName($projectName);
$project->setCustomer($customer);
$comment = $this->comment;
if (isset($record['Comment']) && !empty($record['Comment'])) {
$comment = $record['Comment'];
}
$project->setComment($comment);
if (isset($record['OrderNumber']) && !empty($record['OrderNumber'])) {
$project->setOrderNumber($record['OrderNumber']);
}
if (isset($record['OrderDate']) && !empty($record['OrderDate'])) {
$project->setOrderDate($record['OrderDate']);
}
$team = null;
if (null !== $teamlead) {
$team = new Team();
$team->setName($projectName);
$team->setTeamLead($teamlead);
$this->teams->saveTeam($team);
}
$this->projects->saveProject($project);
if (null !== $team) {
$project->addTeam($team);
$team->addProject($project);
$this->teams->saveTeam($team);
$this->projects->saveProject($project);
}
} catch (\Exception $ex) {
$io->error(sprintf('Failed importing project row %s with: %s', $row, $ex->getMessage()));
return 6;
}
}
$io->success(sprintf('Imported %s rows', $row));
return 0;
}
private function getCustomer(string $customerName): Customer
{
if (!array_key_exists($customerName, $this->customerCache)) {
$tmpCustomer = $this->customers->findBy(['name' => $customerName]);
if (count($tmpCustomer) > 1) {
throw new \Exception(sprintf('Found multiple customers with the name: %s', $customerName));
} elseif (count($tmpCustomer) === 1) {
$tmpCustomer = $tmpCustomer[0];
}
if ($tmpCustomer instanceof Customer) {
$this->customerCache[$customerName] = $tmpCustomer;
}
}
if (array_key_exists($customerName, $this->customerCache)) {
return $this->customerCache[$customerName];
}
$customer = new Customer();
$customer->setName(sprintf($customerName, $this->dateTime));
$customer->setComment($this->comment);
$customer->setCountry($this->configuration->getCustomerDefaultCountry());
$timezone = date_default_timezone_get();
if (null !== $this->configuration->getCustomerDefaultTimezone()) {
$timezone = $this->configuration->getCustomerDefaultTimezone();
}
$customer->setTimezone($timezone);
$this->customers->saveCustomer($customer);
$this->customerCache[$customerName] = $customer;
return $customer;
}
/**
* @param array $row
* @return bool
* @throws InvalidFieldsException
*/
private function validateRow(array $row)
{
$fields = [];
foreach (self::$requiredHeader as $headerName) {
if (!isset($row[$headerName]) || empty($row[$headerName])) {
$fields[] = $headerName;
}
}
if (!empty($fields)) {
throw new InvalidFieldsException($fields);
}
return true;
}
private function validateHeader(array $header)
{
$fields = [];
foreach (self::$requiredHeader as $headerName) {
if (!in_array($headerName, $header)) {
$fields[] = $headerName;
}
}
return empty($fields);
}
}

View File

@@ -0,0 +1,515 @@
<?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\Command;
use App\Configuration\FormConfiguration;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Tag;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Importer\InvalidFieldsException;
use App\Importer\UnknownUserException;
use App\Repository\ActivityRepository;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use App\Repository\TimesheetRepository;
use App\Repository\UserRepository;
use App\Utils\Duration;
use League\Csv\Reader;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* This command can change anytime, don't rely on its API for the future!
*
* @internal
* @codeCoverageIgnore
*/
class ImportTimesheetCommand extends Command
{
protected static $defaultName = 'kimai:import:timesheet';
public const DEFAULT_BEGIN = '00:00';
public const DEFAULT_CUSTOMER = 'Imported customer - %s';
private static $supportedHeader = [
'Date',
'From',
'To',
'Duration',
'Rate',
'User',
'Customer',
'Project',
'Activity',
'Description',
'Exported',
'Tags',
'Hourly rate',
'Fixed rate',
];
/**
* @var CustomerRepository
*/
private $customers;
/**
* @var ProjectRepository
*/
private $projects;
/**
* @var ActivityRepository
*/
private $activities;
/**
* @var UserRepository
*/
private $users;
/**
* @var TimesheetRepository
*/
private $timesheets;
/**
* @var FormConfiguration
*/
private $configuration;
/**
* @var Customer
*/
private $customerFallback;
/**
* @var Customer[]
*/
private $customerCache = [];
/**
* @var User[]
*/
private $userCache = [];
/**
* Comment that will be added to new customers, projects and activities.
*
* @var string
*/
private $comment = '';
/**
* The datetime of this import as formatted string.
*
* @var string
*/
private $dateTime = '';
/**
* @var string
*/
private $begin = self::DEFAULT_BEGIN;
public function __construct(CustomerRepository $customers, ProjectRepository $projects, ActivityRepository $activities, UserRepository $users, TimesheetRepository $timesheets, FormConfiguration $configuration)
{
parent::__construct();
$this->customers = $customers;
$this->projects = $projects;
$this->activities = $activities;
$this->users = $users;
$this->timesheets = $timesheets;
$this->configuration = $configuration;
}
/**
* {@inheritdoc}
*/
protected function configure()
{
$this
->setName(self::$defaultName)
->setDescription('Import timesheets from CSV file')
->setHelp(
'This command allows to import timesheets from a CSV file, which are formatted like CSV exports.' . PHP_EOL .
'Imported customer, projects and activities will be matched by name.' . PHP_EOL .
'Supported columns names: ' . implode(', ', self::$supportedHeader) . PHP_EOL
)
->addOption('timezone', null, InputOption::VALUE_OPTIONAL, 'The timezone to be used. Supports: "valid timezone names", the string "user" (using the configured users timezone) and the string "server" (PHP default timezone)', 'user')
->addOption('customer', null, InputOption::VALUE_OPTIONAL, 'A customer ID or name to assign for empty entries. Defaults to creating a new customer which is used for all un-linked projects')
->addOption('activity', null, InputOption::VALUE_OPTIONAL, 'Whether new activities should be "global" or "project" specific. Allowed values are "global" and "project"', 'project')
->addOption('delimiter', null, InputOption::VALUE_OPTIONAL, 'The CSV field delimiter', ',')
->addOption('begin', null, InputOption::VALUE_OPTIONAL, 'Default begin if none was provided in the format HH:MM', self::DEFAULT_BEGIN)
->addOption('comment', null, InputOption::VALUE_OPTIONAL, 'A description to be added to created customers, projects and activities. %s will be replaced with the current datetime', 'Imported at %s')
->addArgument('file', InputArgument::REQUIRED, 'The CSV file to be imported')
;
}
/**
* @param InputInterface $input
* @param OutputInterface $output
* @return int|null
*/
protected function execute(InputInterface $input, OutputInterface $output)
{
$io = new SymfonyStyle($input, $output);
$io->title('Kimai importer: Timesheets');
$csvFile = $input->getArgument('file');
if (!file_exists($csvFile)) {
$io->error('File not existing: ' . $csvFile);
return 1;
}
if (!is_readable($csvFile)) {
$io->error('File cannot be read: ' . $csvFile);
return 2;
}
$this->dateTime = (new \DateTime())->format('Y.m.d H:i');
$this->comment = sprintf($input->getOption('comment'), $this->dateTime);
$this->begin = $input->getOption('begin');
$timezone = $input->getOption('timezone');
switch ($timezone) {
case 'server':
$timezone = new \DateTimeZone(date_default_timezone_get());
break;
case 'user':
// null means fetch from user
$timezone = null;
break;
default:
try {
$timezone = new \DateTimeZone($timezone);
} catch (\Exception $ex) {
$io->error('Invalid timezone given, import canceled.');
return 3;
}
break;
}
$activityType = $input->getOption('activity');
$allowedActivityTypes = ['project', 'global'];
if (!in_array($activityType, $allowedActivityTypes)) {
$io->error(sprintf('Invalid activity type "%s" given, allowed values are: %s', $activityType, implode(', ', $allowedActivityTypes)));
return 4;
}
$csv = Reader::createFromPath($csvFile, 'r');
$csv->setDelimiter($input->getOption('delimiter'));
$csv->setHeaderOffset(0);
$header = $csv->getHeader();
if (!$this->validateHeader($header)) {
$io->error(
sprintf(
'Found invalid CSV. The header: ' . PHP_EOL .
'%s' . PHP_EOL .
'did not match the expected structure: ' . PHP_EOL .
'%s',
implode(', ', $header),
implode(', ', self::$supportedHeader)
)
);
return 5;
}
$records = $csv->getRecords();
$doImport = true;
$row = 1;
$errors = 0;
// ======================= validate rows =======================
foreach ($records as $record) {
try {
$this->validateRow($record);
} catch (InvalidFieldsException $ex) {
$io->error(sprintf('Invalid row %s, invalid fields: %s', $row, implode(', ', $ex->getFields())));
$doImport = false;
$errors++;
}
try {
$user = $this->getUser($record['User']);
} catch (\Exception $ex) {
$io->error(sprintf('Unknown user %s in row %s', $record['User'], $row));
$doImport = false;
$errors++;
}
$row++;
}
if (!$doImport) {
$io->caution(sprintf('Not importing, previous %s errors need to be fixed first.', $errors));
return 5;
}
$durationParser = new Duration();
$row = 0;
foreach ($records as $record) {
$row++;
try {
$project = $this->getProject($record['Project'], $record['Customer'], $input->getOption('customer'));
$activity = $this->getActivity($record['Activity'], $project, $activityType);
$user = $this->getUser($record['User']);
$begin = null;
$end = null;
$duration = 0;
if (!empty($record['Duration'])) {
if (is_int($record['Duration'])) {
$duration = $record['Duration'];
} else {
$duration = $durationParser->parseDurationString($record['Duration']);
}
}
if (null === $timezone) {
$timezone = new \DateTimeZone($user->getTimezone());
}
if (empty($record['From']) && empty($record['To'])) {
$begin = new \DateTime($record['Date'] . ' ' . $this->begin, $timezone);
$end = (new \DateTime())->setTimezone($timezone)->setTimestamp($begin->getTimestamp() + $duration);
} elseif (empty($record['From'])) {
$end = new \DateTime($record['Date'] . ' ' . $record['To'], $timezone);
$begin = (new \DateTime())->setTimezone($timezone)->setTimestamp($end->getTimestamp() - $duration);
} elseif (empty($record['To'])) {
$begin = new \DateTime($record['Date'] . ' ' . $record['From'], $timezone);
$end = (new \DateTime())->setTimezone($timezone)->setTimestamp($begin->getTimestamp() + $duration);
} else {
$begin = new \DateTime($record['Date'] . ' ' . $record['From'], $timezone);
$end = new \DateTime($record['Date'] . ' ' . $record['To'], $timezone);
}
$timesheet = new Timesheet();
$timesheet->setActivity($activity);
$timesheet->setProject($project);
$timesheet->setBegin($begin);
$timesheet->setEnd($end);
$timesheet->setUser($user);
$timesheet->setDescription($record['Description']);
$timesheet->setExported((bool) $record['Exported']);
if (!empty($record['Tags'])) {
foreach (explode(',', $record['Tags']) as $tag) {
if (empty($tag)) {
continue;
}
$timesheet->addTag((new Tag())->setName($tag));
}
}
if (!empty($record['Rate'])) {
$timesheet->setRate($record['Rate']);
}
if (!empty($record['Hourly rate'])) {
$timesheet->setHourlyRate($record['Hourly rate']);
}
if (!empty($record['Fixed rate'])) {
$timesheet->setFixedRate($record['Fixed rate']);
}
$this->timesheets->save($timesheet);
} catch (\Exception $ex) {
$io->error(sprintf('Failed importing timesheet row %s with: %s', $row, $ex->getMessage()));
return 6;
}
}
$io->success(sprintf('Imported %s rows', $row));
return 0;
}
private function getUser($user): User
{
if (!array_key_exists($user, $this->userCache)) {
$tmpUser = $this->users->findOneBy(['username' => $user]);
if (null === $tmpUser) {
$tmpUser = $this->users->findOneBy(['email' => $user]);
if (null === $tmpUser) {
throw new UnknownUserException($user);
}
}
$this->userCache[$user] = $tmpUser;
}
return $this->userCache[$user];
}
private function getActivity($activity, Project $project, $activityType): Activity
{
$tmpActivity = null;
$tmpActivities = $this->activities->findBy(['project' => $project, 'name' => $activity]);
if (count($tmpActivities) === 0) {
$tmpActivity = $this->activities->findOneBy(['project' => null, 'name' => $activity]);
} elseif (count($tmpActivities) === 1) {
$tmpActivity = $tmpActivities[0];
}
if (null === $tmpActivity) {
$tmpActivity = new Activity();
$tmpActivity->setName($activity);
$tmpActivity->setComment($this->comment);
if ($activityType === 'project') {
$tmpActivity->setProject($project);
}
$this->activities->saveActivity($tmpActivity);
}
return $tmpActivity;
}
private function getProject($project, $customer, $fallbackCustomer): Project
{
/** @var Customer $tmpCustomer */
$tmpCustomer = $this->getCustomer($customer, $fallbackCustomer);
/** @var Project $tmpProject */
$tmpProject = null;
/** @var Project[] $tmpProjects */
$tmpProjects = $this->projects->findBy(['name' => $project]);
if (count($tmpProjects) > 1) {
/** @var Project $prj */
foreach ($tmpProjects as $prj) {
if ($prj->getCustomer()->getName() !== $tmpCustomer->getName()) {
continue;
}
$tmpProject = $prj;
break;
}
} elseif (count($tmpProjects) === 1) {
$tmpProject = $tmpProjects[0];
}
if (null !== $tmpProject) {
if ($tmpProject->getCustomer()->getName() !== $tmpCustomer->getName()) {
$tmpProject = null;
}
}
if ($tmpProject === null) {
$tmpProject = new Project();
$tmpProject->setName($project);
$tmpProject->setComment($this->comment);
$tmpProject->setCustomer($tmpCustomer);
$this->projects->saveProject($tmpProject);
}
return $tmpProject;
}
private function getCustomer($customer, $fallback): Customer
{
if (!empty($customer)) {
if (!array_key_exists($customer, $this->customerCache)) {
$tmpCustomer = $this->customers->findBy(['name' => $customer]);
if (count($tmpCustomer) > 1) {
throw new \Exception(sprintf('Found multiple customers with the name: %s', $customer));
} elseif (count($tmpCustomer) === 1) {
$tmpCustomer = $tmpCustomer[0];
}
if ($tmpCustomer instanceof Customer) {
$this->customerCache[$customer] = $tmpCustomer;
}
}
if (array_key_exists($customer, $this->customerCache)) {
return $this->customerCache[$customer];
}
}
if (null === $this->customerFallback) {
$tmpFallback = null;
if (!empty($fallback)) {
if (is_int($customer)) {
$tmpFallback = $this->customers->find($fallback);
} else {
$tmpFallback = $this->customers->findOneBy(['name' => $fallback]);
}
}
if (null === $tmpFallback) {
$newName = self::DEFAULT_CUSTOMER;
if (!empty($fallback) && is_string($fallback)) {
$newName = $fallback;
}
$tmpFallback = new Customer();
$tmpFallback->setName(sprintf($newName, $this->dateTime));
$tmpFallback->setComment($this->comment);
$tmpFallback->setCountry($this->configuration->getCustomerDefaultCountry());
$timezone = date_default_timezone_get();
if (null !== $this->configuration->getCustomerDefaultTimezone()) {
$timezone = $this->configuration->getCustomerDefaultTimezone();
}
$tmpFallback->setTimezone($timezone);
$this->customers->saveCustomer($tmpFallback);
}
$this->customerFallback = $tmpFallback;
}
return $this->customerFallback;
}
/**
* @param array $row
* @return bool
* @throws InvalidFieldsException
*/
private function validateRow(array $row)
{
$fields = [];
if (empty($row['Project'])) {
$fields[] = 'Project';
}
if (empty($row['Activity'])) {
$fields[] = 'Activity';
}
if (empty($row['Date'])) {
$fields[] = 'Date';
}
if ((empty($row['From']) || empty($row['To'])) && empty($row['Duration'])) {
$fields[] = 'Duration';
}
if (!empty($fields)) {
throw new InvalidFieldsException($fields);
}
return true;
}
private function validateHeader(array $header)
{
$result = array_diff(self::$supportedHeader, $header);
return empty($result);
}
}

View File

@@ -10,7 +10,6 @@
namespace App\Command;
use App\Constants;
use App\Utils\File;
use Doctrine\DBAL\Connection;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
@@ -23,36 +22,28 @@ use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Command used to do the basic installation steps for Kimai.
*/
class InstallCommand extends Command
final class InstallCommand extends Command
{
public const ERROR_PERMISSIONS = 1;
public const ERROR_CACHE_CLEAN = 2;
public const ERROR_CACHE_WARMUP = 4;
public const ERROR_DATABASE = 8;
public const ERROR_SCHEMA = 16;
public const ERROR_MIGRATIONS = 32;
public const ERROR_INTERACTIVE = 64;
protected static $defaultName = 'kimai:install';
/**
* @var string
*/
protected $rootDir;
private $rootDir;
/**
* @var Connection
*/
protected $connection;
/**
* @var File
*/
protected $file;
private $connection;
public function __construct(string $projectDirectory, Connection $connection, File $files)
public function __construct(string $projectDirectory, Connection $connection)
{
parent::__construct(self::$defaultName);
parent::__construct();
$this->rootDir = $projectDirectory;
$this->connection = $connection;
$this->file = $files;
}
/**
@@ -61,7 +52,7 @@ class InstallCommand extends Command
protected function configure()
{
$this
->setName(self::$defaultName)
->setName('kimai:install')
->setDescription('Basic installation for Kimai')
->setHelp('This command will perform the basic installation steps to get Kimai up and running.')
;
@@ -76,18 +67,11 @@ class InstallCommand extends Command
{
$io = new SymfonyStyle($input, $output);
$io->title('Kimai installer - v' . Constants::VERSION);
$io->title('Kimai installation running ...');
$result = $this->reviewPermissions($io, $input, $output);
if (true !== $result) {
return $result;
}
// we cannot change the environment here, as it needs to be configured in the .env file before this command is started
// $environment = $io->choice('Which environment should be used ("dev" is only for testing and imports demo data)?', ['dev', 'production'], 'production');
// $io->note(sprintf('You have chosen the "%s" environment', $environment));
$environment = getenv('APP_ENV');
// create the database, in case it is not yet existing
try {
$this->createDatabase($io, $input, $output);
} catch (\Exception $ex) {
@@ -96,15 +80,7 @@ class InstallCommand extends Command
return self::ERROR_DATABASE;
}
try {
$this->createSchema($io, $input, $output);
} catch (\Exception $ex) {
$io->error('Failed to create database schema: ' . $ex->getMessage());
return self::ERROR_SCHEMA;
}
// initialize database with proper migration status
// bootstrap database ONLY via doctrine migrations, so all installation will have the correct and same state
try {
$this->importMigrations($io, $output);
} catch (\Exception $ex) {
@@ -113,10 +89,11 @@ class InstallCommand extends Command
return self::ERROR_MIGRATIONS;
}
// flush the cache, just to make sure ... and ignore result
$this->rebuildCaches($environment, $io, $input, $output);
$io->success(
'Congratulations! ' . Constants::SOFTWARE . ' (' . Constants::VERSION . ' ' . Constants::STATUS . ') was successful installed!'
sprintf('Congratulations! Successfully installed %s version %s (%s)', Constants::SOFTWARE, Constants::VERSION, Constants::STATUS)
);
return 0;
@@ -124,14 +101,7 @@ class InstallCommand extends Command
protected function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output)
{
if ($input->isInteractive()) {
$question = 'Do you want me to rebuild the caches (yes) or skip this step (no)?';
if (!$this->askConfirmation($input, $output, $question, true)) {
return;
}
}
$io->text('Rebuilding your cache now, please be patient ...');
$io->text('Rebuilding your cache, please be patient ...');
$command = $this->getApplication()->find('cache:clear');
try {
@@ -146,104 +116,16 @@ class InstallCommand extends Command
try {
$command->run(new ArrayInput(['--env' => $environment]), $output);
} catch (\Exception $ex) {
$io->error('Failed to clear cache: ' . $ex->getMessage());
$io->error('Failed to warmup cache: ' . $ex->getMessage());
return self::ERROR_CACHE_WARMUP;
}
}
protected function checkPermissions(): array
{
$directories = [
'var/cache/',
'var/data/',
'var/log/',
'var/plugins/',
'var/sessions/',
];
$rows = [];
foreach ($directories as $directory) {
$absDir = rtrim($this->rootDir) . DIRECTORY_SEPARATOR . $directory;
$perms = $this->file->getPermissions($absDir);
$reason = [];
if (!($perms & 0x0100)) {
$reason[] = 'read owner';
}
if (!($perms & 0x0080)) {
$reason[] = 'write owner';
}
if (!($perms & 0x0020)) {
$reason[] = 'read group';
}
if (!($perms & 0x0010)) {
$reason[] = 'write group';
}
if (!empty($reason)) {
$rows[] = [$directory, 'missing: ' . implode(',', $reason)];
} elseif (!is_writable($absDir)) {
$rows[] = [$directory, 'Directory not writable'];
}
}
return $rows;
}
protected function reviewPermissions(SymfonyStyle $io, InputInterface $input, OutputInterface $output)
{
if (!$input->isInteractive()) {
return true;
}
$permissions = $this->checkPermissions();
if (empty($permissions)) {
return true;
}
$question = 'Kimai found file permissions which look incorrect.' .
' More information is available at https://www.kimai.org/documentation/installation.html.' .
' If you are sure that all directories can be written by the webserver, you can continue.' .
' Otherwise it is recommended to abort the installation and check them first.';
$io->caution($question);
$io->table(['Directory', 'Permission'], $permissions);
if (!$this->askConfirmation($input, $output, 'Continue with the installation (yes) or review permissions first (no)?', false)) {
$io->warning('Aborting installation to review the permissions for above mentioned directories');
return self::ERROR_PERMISSIONS;
}
$io->writeln('');
return true;
return 0;
}
protected function importMigrations(SymfonyStyle $io, OutputInterface $output)
{
if (!$this->connection->getSchemaManager()->tablesExist(['migration_versions'])) {
$command = $this->getApplication()->find('doctrine:migrations:version');
$cmdInput = new ArrayInput(['--add' => true, '--all' => true]);
$cmdInput->setInteractive(false);
$command->run($cmdInput, $output);
return;
}
// this case should not happen, but you know ... everything is possible
$amount = $this->connection->executeQuery('SELECT count(*) as counter FROM migration_versions')->fetchColumn(0);
if ($amount === 0) {
$command = $this->getApplication()->find('doctrine:migrations:version');
$cmdInput = new ArrayInput(['--add' => true, '--all' => true]);
$cmdInput->setInteractive(false);
$command->run($cmdInput, $output);
return;
}
$command = $this->getApplication()->find('doctrine:migrations:migrate');
$cmdInput = new ArrayInput(['--allow-no-migration' => true]);
$cmdInput->setInteractive(false);
@@ -254,6 +136,12 @@ class InstallCommand extends Command
protected function createDatabase(SymfonyStyle $io, InputInterface $input, OutputInterface $output)
{
if (!$this->connection->isConnected() && !$this->connection->connect()) {
throw new \Exception(
sprintf('Database connection could not be established: %s', $this->connection->getDatabase())
);
}
if ($this->connection->isConnected()) {
$io->note(sprintf('Database is existing and connection could be established'));
@@ -268,22 +156,6 @@ class InstallCommand extends Command
$command->run(new ArrayInput([]), $output);
}
protected function createSchema(SymfonyStyle $io, InputInterface $input, OutputInterface $output)
{
if (!$this->connection->isConnected() && !$this->connection->connect()) {
throw new \Exception(sprintf('Cannot create tables in database "%s", connection could not be established', $this->connection->getDatabase()));
}
if ($this->connection->getSchemaManager()->tablesExist(['kimai2_users', 'kimai2_timesheet'])) {
$io->note('It seems as if you already have the required tables in your database, skipping schema creation');
return;
}
$command = $this->getApplication()->find('doctrine:schema:create');
$command->run(new ArrayInput([]), $output);
}
/**
* @param InputInterface $input
* @param OutputInterface $output

View File

@@ -1042,7 +1042,7 @@ class KimaiImporterCommand extends Command
}
$user = $this->users[$oldRecord['userID']];
$timezone = $user->getPreferenceValue('timezone', date_default_timezone_get());
$timezone = $user->getTimezone();
$dateTimezone = new \DateTimeZone('UTC');
$begin = new \DateTime('@' . $oldRecord['start']);

View File

@@ -0,0 +1,149 @@
<?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\Command;
use App\Constants;
use Doctrine\DBAL\Connection;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Command used to update a Kimai installation.
*/
final class UpdateCommand extends Command
{
public const ERROR_CACHE_CLEAN = 2;
public const ERROR_CACHE_WARMUP = 4;
public const ERROR_DATABASE = 8;
public const ERROR_MIGRATIONS = 32;
/**
* @var string
*/
private $rootDir;
/**
* @var Connection
*/
private $connection;
public function __construct(string $projectDirectory, Connection $connection)
{
parent::__construct();
$this->rootDir = $projectDirectory;
$this->connection = $connection;
}
/**
* {@inheritdoc}
*/
protected function configure()
{
$this
->setName('kimai:update')
->setDescription('Update your Kimai installation')
->setHelp('This command will execute all required steps to update your Kimai installation.')
;
}
/**
* @param InputInterface $input
* @param OutputInterface $output
* @return int|null
*/
protected function execute(InputInterface $input, OutputInterface $output)
{
$io = new SymfonyStyle($input, $output);
$io->title('Kimai updates running ...');
// we cannot change the environment here, as it needs to be configured in the .env file before this command is started
$environment = getenv('APP_ENV');
// make sure database is available, Kimai running and installed
try {
if (!$this->connection->isConnected() && !$this->connection->connect()) {
throw new \Exception(
sprintf('Database connection could not be established: %s', $this->connection->getDatabase())
);
}
if (!$this->connection->getSchemaManager()->tablesExist(['kimai2_users', 'kimai2_timesheet'])) {
$io->error('Tables missing. Did you run the installer already?');
return self::ERROR_DATABASE;
}
if (!$this->connection->getSchemaManager()->tablesExist(['migration_versions'])) {
$io->error('Unknown migration status, aborting database update');
return self::ERROR_DATABASE;
}
} catch (\Exception $ex) {
$io->error('Failed to validate database: ' . $ex->getMessage());
return self::ERROR_DATABASE;
}
// execute latest doctrine migrations
try {
$command = $this->getApplication()->find('doctrine:migrations:migrate');
$cmdInput = new ArrayInput(['--allow-no-migration' => true]);
$cmdInput->setInteractive(false);
$command->run($cmdInput, $output);
$io->writeln('');
} catch (\Exception $ex) {
$io->error('Failed to set migration status: ' . $ex->getMessage());
return self::ERROR_MIGRATIONS;
}
// flush the cache, in case values from the database are cached
$cacheResult = $this->rebuildCaches($environment, $io, $input, $output);
$io->success(
sprintf('Congratulations! Successfully updated %s to version %s (%s)', Constants::SOFTWARE, Constants::VERSION, Constants::STATUS)
);
if ($cacheResult !== 0) {
$io->warning('Problem resetting cache, please execute cache clean manually');
}
return 0;
}
protected function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output)
{
$io->text('Rebuilding your cache, please be patient ...');
$command = $this->getApplication()->find('cache:clear');
try {
$command->run(new ArrayInput(['--env' => $environment]), $output);
} catch (\Exception $ex) {
$io->error('Failed to clear cache: ' . $ex->getMessage());
return self::ERROR_CACHE_CLEAN;
}
$command = $this->getApplication()->find('cache:warmup');
try {
$command->run(new ArrayInput(['--env' => $environment]), $output);
} catch (\Exception $ex) {
$io->error('Failed to warmup cache: ' . $ex->getMessage());
return self::ERROR_CACHE_WARMUP;
}
return 0;
}
}

View File

@@ -26,9 +26,6 @@ trait StringAccessibleConfigTrait
*/
protected $initialized = false;
/**
* @param array $settings
*/
public function __construct(ConfigLoaderInterface $repository, array $settings)
{
$this->repository = $repository;
@@ -41,7 +38,7 @@ trait StringAccessibleConfigTrait
*/
protected function getConfigurations(ConfigLoaderInterface $repository): array
{
return $repository->getConfiguration($this->getPrefix() . '.');
return $repository->getConfiguration($this->getPrefix());
}
protected function prepare()
@@ -120,4 +117,45 @@ trait StringAccessibleConfigTrait
return $config[$search];
}
/**
* @return bool
*/
public function offsetExists($offset)
{
try {
$this->find($offset);
} catch (\Exception $ex) {
return false;
}
return true;
}
/**
* @return mixed
*/
public function offsetGet($offset)
{
return $this->find($offset);
}
/**
* @param mixed $offset
* @param mixed $value
* @throws \BadMethodCallException
*/
public function offsetSet($offset, $value)
{
throw new \BadMethodCallException('SystemBundleConfiguration does not support offsetSet()');
}
/**
* @param mixed $offset
* @throws \BadMethodCallException
*/
public function offsetUnset($offset)
{
throw new \BadMethodCallException('SystemBundleConfiguration does not support offsetUnset()');
}
}

View File

@@ -9,7 +9,7 @@
namespace App\Configuration;
class ThemeConfiguration implements SystemBundleConfiguration
class ThemeConfiguration implements SystemBundleConfiguration, \ArrayAccess
{
use StringAccessibleConfigTrait;
@@ -18,6 +18,11 @@ class ThemeConfiguration implements SystemBundleConfiguration
return 'theme';
}
public function isAutoReloadDatatable(): bool
{
return (bool) $this->find('auto_reload_datatable');
}
public function getSelectPicker(): string
{
return (string) $this->find('select_type');

View File

@@ -47,4 +47,29 @@ class TimesheetConfiguration implements SystemBundleConfiguration
{
return (int) $this->find('active_entries.soft_limit');
}
public function getDefaultRoundingDays(): string
{
return (string) $this->find('rounding.default.days');
}
public function getDefaultRoundingMode(): string
{
return (string) $this->find('rounding.default.mode');
}
public function getDefaultRoundingBegin(): int
{
return (int) $this->find('rounding.default.begin');
}
public function getDefaultRoundingEnd(): int
{
return (int) $this->find('rounding.default.end');
}
public function getDefaultRoundingDuration(): int
{
return (int) $this->find('rounding.default.duration');
}
}

View File

@@ -10,32 +10,32 @@
namespace App;
/**
* Some very global constants for Kimai.
* Some "very" global constants for Kimai.
*/
class Constants
{
/**
* The software name
*/
public const SOFTWARE = 'Kimai 2';
/**
* The current release version
*/
public const VERSION = '1.2';
/**
* The release name, will only change for new major version
*/
public const NAME = 'Ayumi';
public const VERSION = '1.6';
/**
* The current release status, either "stable" or "dev"
*/
public const STATUS = 'stable';
/**
* The software name
*/
public const SOFTWARE = 'Kimai 2';
/**
* The release name, will only change for new major version
*/
public const NAME = 'Ayumi';
/**
* Used in multiple views
*/
public const GITHUB = 'https://github.com/kevinpapst/kimai2/';
/**
* Used in multiple views
* Homepage, used in multiple views
*/
public const HOMEPAGE = 'https://www.kimai.org';
}

View File

@@ -10,7 +10,7 @@
namespace App\Controller;
use App\Constants;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
@@ -31,65 +31,10 @@ class AboutController extends AbstractController
$this->projectDirectory = $projectDirectory;
}
/**
* @Route(path="/debug", name="about_debug", methods={"GET"})
*
* @Security("is_granted('system_information')")
*
* @return \Symfony\Component\HttpFoundation\Response
*/
public function debugAction()
{
$phpInfo = $this->getPhpInfo();
unset($phpInfo[0]);
unset($phpInfo[1]);
$ini = [
'allow_url_fopen',
'allow_url_include',
'default_charset',
'default_mimetype',
'display_errors',
'error_log',
'error_reporting',
'log_errors',
'max_execution_time',
'memory_limit',
'open_basedir',
'post_max_size',
'sys_temp_dir',
'date.timezone',
];
$settings = [];
foreach ($ini as $name) {
try {
$settings[$name] = ini_get($name);
} catch (\Exception $ex) {
$settings[$name] = "Couldn't load ini setting: " . $ex->getMessage();
}
}
return $this->render('about/system.html.twig', array_merge(
[
'modules' => get_loaded_extensions(),
'dotenv' => [
'APP_ENV' => getenv('APP_ENV'),
'MAILER_FROM' => getenv('MAILER_FROM'),
'CORS_ALLOW_ORIGIN' => getenv('CORS_ALLOW_ORIGIN'),
],
'info' => $phpInfo,
'settings' => $settings,
]
));
}
/**
* @Route(path="", name="about", methods={"GET"})
*
* @return \Symfony\Component\HttpFoundation\Response
*/
public function license()
public function license(): Response
{
$filename = $this->projectDirectory . '/LICENSE';
@@ -108,41 +53,4 @@ class AboutController extends AbstractController
'license' => $license
]);
}
/**
* @author https://php.net/manual/en/function.phpinfo.php#117961
* @return array
*/
private function getPhpInfo()
{
$plainText = function ($input) {
return trim(html_entity_decode(strip_tags($input)));
};
ob_start();
phpinfo(1);
$phpinfo = ['phpinfo' => []];
if (preg_match_all(
'#(?:<h2.*?>(?:<a.*?>)?(.*?)(?:<\/a>)?<\/h2>)|' .
'(?:<tr.*?><t[hd].*?>(.*?)\s*</t[hd]>(?:<t[hd].*?>(.*?)\s*</t[hd]>(?:<t[hd].*?>(.*?)\s*</t[hd]>)?)?</tr>)#s',
ob_get_clean(),
$matches,
PREG_SET_ORDER
)) {
foreach ($matches as $match) {
$fn = $plainText;
if (isset($match[3])) {
$keys1 = array_keys($phpinfo);
$phpinfo[end($keys1)][$fn($match[2])] = isset($match[4]) ? [$fn($match[3]), $fn($match[4])] : $fn($match[3]);
} else {
$keys1 = array_keys($phpinfo);
$phpinfo[end($keys1)][] = $fn($match[2]);
}
}
}
return $phpinfo['phpinfo'];
}
}

View File

@@ -9,6 +9,7 @@
namespace App\Controller;
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as BaseAbstractController;
use Symfony\Component\Translation\DataCollectorTranslator;
use Symfony\Contracts\Service\ServiceSubscriberInterface;
@@ -26,7 +27,10 @@ abstract class AbstractController extends BaseAbstractController implements Serv
public const DOMAIN_FLASH = 'flashmessages';
public const DOMAIN_ERROR = 'exceptions';
public const ROLE_ADMIN = 'ROLE_ADMIN';
/**
* @deprecated since 1.6, will be removed with 2.0
*/
public const ROLE_ADMIN = User::ROLE_ADMIN;
/**
* @return DataCollectorTranslator
@@ -36,6 +40,14 @@ abstract class AbstractController extends BaseAbstractController implements Serv
return $this->container->get('translator');
}
/**
* @return User|null
*/
protected function getUser()
{
return parent::getUser();
}
/**
* Adds a "successful" flash message to the stack.
*

View File

@@ -11,8 +11,10 @@ namespace App\Controller;
use App\Configuration\FormConfiguration;
use App\Entity\Activity;
use App\Entity\MetaTableTypeInterface;
use App\Entity\Project;
use App\Event\ActivityMetaDefinitionEvent;
use App\Event\ActivityMetaDisplayEvent;
use App\Form\ActivityEditForm;
use App\Form\Toolbar\ActivityToolbarForm;
use App\Form\Type\ActivityType;
@@ -81,17 +83,33 @@ class ActivityController extends AbstractController
$form->setData($query);
$form->submit($request->query->all(), false);
if (!$form->isValid()) {
$query->resetByFormError($form->getErrors());
}
/* @var $entries Pagerfanta */
$entries = $this->getRepository()->getPagerfantaForQuery($query);
return $this->render('activity/index.html.twig', [
'entries' => $entries,
'query' => $query,
'showFilter' => $query->isDirty(),
'toolbarForm' => $form->createView(),
'metaColumns' => $this->findMetaColumns($query),
]);
}
/**
* @param ActivityQuery $query
* @return MetaTableTypeInterface[]
*/
protected function findMetaColumns(ActivityQuery $query): array
{
$event = new ActivityMetaDisplayEvent($query, ActivityMetaDisplayEvent::ACTIVITY);
$this->dispatcher->dispatch($event);
return $event->getFields();
}
/**
* @Route(path="/create", name="admin_activity_create", methods={"GET", "POST"})
* @Route(path="/create/{project}", name="admin_activity_create_with_project", methods={"GET", "POST"})
@@ -120,9 +138,13 @@ class ActivityController extends AbstractController
*/
public function budgetAction(Activity $activity)
{
$stats = $this->getRepository()->getActivityStatistics($activity);
// TODO sent event with stats
return $this->render('activity/budget.html.twig', [
'activity' => $activity,
'stats' => $this->getRepository()->getActivityStatistics($activity)
'stats' => $stats,
]);
}
@@ -204,7 +226,7 @@ class ActivityController extends AbstractController
protected function renderActivityForm(Activity $activity, Request $request)
{
$event = new ActivityMetaDefinitionEvent($activity);
$this->dispatcher->dispatch($event, ActivityMetaDefinitionEvent::class);
$this->dispatcher->dispatch($event);
$editForm = $this->createEditForm($activity);
$editForm->handleRequest($request);

View File

@@ -21,7 +21,7 @@ use Symfony\Component\Routing\Annotation\Route;
* Controller used to display calendars.
*
* @Route(path="/calendar")
* @Security("is_granted('ROLE_USER')")
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
*/
class CalendarController extends AbstractController
{

View File

@@ -11,7 +11,9 @@ namespace App\Controller;
use App\Configuration\FormConfiguration;
use App\Entity\Customer;
use App\Entity\MetaTableTypeInterface;
use App\Event\CustomerMetaDefinitionEvent;
use App\Event\CustomerMetaDisplayEvent;
use App\Form\CustomerEditForm;
use App\Form\CustomerTeamPermissionForm;
use App\Form\Toolbar\CustomerToolbarForm;
@@ -49,10 +51,6 @@ class CustomerController extends AbstractController
*/
protected $dispatcher;
/**
* @param CustomerRepository $repository
* @param FormConfiguration $configuration
*/
public function __construct(CustomerRepository $repository, FormConfiguration $configuration, EventDispatcherInterface $dispatcher)
{
$this->repository = $repository;
@@ -83,16 +81,32 @@ class CustomerController extends AbstractController
$form->setData($query);
$form->submit($request->query->all(), false);
if (!$form->isValid()) {
$query->resetByFormError($form->getErrors());
}
$entries = $this->getRepository()->getPagerfantaForQuery($query);
return $this->render('customer/index.html.twig', [
'entries' => $entries,
'query' => $query,
'showFilter' => $query->isDirty(),
'toolbarForm' => $form->createView(),
'metaColumns' => $this->findMetaColumns($query),
]);
}
/**
* @param CustomerQuery $query
* @return MetaTableTypeInterface[]
*/
protected function findMetaColumns(CustomerQuery $query): array
{
$event = new CustomerMetaDisplayEvent($query, CustomerMetaDisplayEvent::CUSTOMER);
$this->dispatcher->dispatch($event);
return $event->getFields();
}
/**
* @Route(path="/create", name="admin_customer_create", methods={"GET", "POST"})
* @Security("is_granted('create_customer')")
@@ -148,9 +162,13 @@ class CustomerController extends AbstractController
*/
public function budgetAction(Customer $customer)
{
$stats = $this->getRepository()->getCustomerStatistics($customer);
// TODO sent event with stats
return $this->render('customer/budget.html.twig', [
'customer' => $customer,
'stats' => $this->getRepository()->getCustomerStatistics($customer)
'stats' => $stats,
]);
}
@@ -221,7 +239,7 @@ class CustomerController extends AbstractController
protected function renderCustomerForm(Customer $customer, Request $request)
{
$event = new CustomerMetaDefinitionEvent($customer);
$this->dispatcher->dispatch($event, CustomerMetaDefinitionEvent::class);
$this->dispatcher->dispatch($event);
$editForm = $this->createEditForm($customer);

View File

@@ -10,9 +10,11 @@
namespace App\Controller;
use App\Event\DashboardEvent;
use App\Widget\Type\CompoundChart;
use App\Widget\Type\AbstractContainer;
use App\Widget\Type\AuthorizedWidget;
use App\Widget\Type\CompoundRow;
use App\Widget\WidgetContainerInterface;
use App\Widget\WidgetException;
use App\Widget\WidgetService;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
@@ -22,7 +24,7 @@ use Symfony\Component\Routing\Annotation\Route;
* Dashboard controller for the admin area.
*
* @Route(path="/dashboard")
* @Security("is_granted('ROLE_USER')")
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
*/
class DashboardController extends AbstractController
{
@@ -67,11 +69,23 @@ class DashboardController extends AbstractController
continue;
}
// TODO this should be dynamic
if ($widgetRow['type'] === 'compoundChart') {
$row = new CompoundChart();
} else {
$row = new CompoundRow();
if (!isset($widgetRow['type'])) {
$widgetRow['type'] = CompoundRow::class;
}
if (!class_exists($widgetRow['type'])) {
throw new WidgetException(sprintf('Unknown widget type "%s"', $widgetRow['type']));
}
$row = new $widgetRow['type']();
if (!($row instanceof AbstractContainer)) {
throw new WidgetException(
sprintf(
'Expected widget type to be an instanceof "%s", but found "%s"',
AbstractContainer::class,
$widgetRow['type']
)
);
}
$row->setTitle($widgetRow['title'] ?? '');
@@ -82,18 +96,41 @@ class DashboardController extends AbstractController
throw new \Exception(sprintf('Unknown widget "%s"', $widgetName));
}
$row->addWidget($this->widgets->getWidget($widgetName));
$widget = $this->widgets->getWidget($widgetName);
$add = true;
if ($widget instanceof AuthorizedWidget) {
$tmp = false;
foreach ($widget->getPermissions() as $perm) {
if ($this->isGranted($perm)) {
$tmp = true;
break;
}
}
$add = $tmp;
}
if ($add) {
$row->addWidget($widget);
}
}
$event->addSection($row);
}
$this->eventDispatcher->dispatch($event, DashboardEvent::DASHBOARD);
$this->eventDispatcher->dispatch($event);
$sections = $event->getSections();
$clearedSections = [];
/** @var WidgetContainerInterface $section */
foreach ($sections as $key => $section) {
if (!empty($section->getWidgets())) {
$clearedSections[] = $section;
}
}
uasort(
$sections,
$clearedSections,
function (WidgetContainerInterface $a, WidgetContainerInterface $b) {
if ($a->getOrder() == $b->getOrder()) {
return 0;
@@ -104,7 +141,7 @@ class DashboardController extends AbstractController
);
return $this->render('dashboard/index.html.twig', [
'widgets' => $sections
'widgets' => $clearedSections
]);
}
}

View File

@@ -0,0 +1,239 @@
<?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 PackageVersions\Versions;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
* @Route(path="/doctor")
* @Security("is_granted('system_information')")
*/
class DoctorController extends AbstractController
{
/**
* PHP extensions which Kimai needs for runtime.
* Some are not a hard requiremenet, but some functions might not work as expected.
*/
public const REQUIRED_EXTENSIONS = [
'intl',
'json',
'mbstring',
'pdo',
'zip',
'gd',
'xml'
];
/**
* Directories which need to be writable by the webserver.
*/
public const DIRECTORIES_WRITABLE = [
'var/cache/',
'var/data/',
'var/log/',
'var/sessions/',
'public/avatars/',
];
/**
* @var string
*/
private $projectDirectory;
public function __construct(string $projectDirectory)
{
$this->projectDirectory = $projectDirectory;
}
/**
* @Route(path="", name="doctor", methods={"GET"})
*/
public function index(): Response
{
$logLines = 100;
return $this->render('doctor/index.html.twig', array_merge(
[
'modules' => get_loaded_extensions(),
'dotenv' => $this->getEnvVars(),
'info' => $this->getPhpInfo(),
'settings' => $this->getIniSettings(),
'extensions' => $this->getLoadedExtensions(),
'directories' => $this->getFilePermissions(),
'logs' => $this->getLog(),
'logLines' => $logLines,
'logSize' => $this->getLogSize(),
'composer' => Versions::VERSIONS,
]
));
}
private function getLoadedExtensions()
{
$results = [];
foreach (self::REQUIRED_EXTENSIONS as $extName) {
$results[$extName] = false;
if (extension_loaded($extName)) {
$results[$extName] = true;
}
}
$results['Freetype Support'] = true;
// @see AvatarService::hasDependencies()
if (!function_exists('imagettfbbox')) {
$results['Freetype Support'] = false;
}
return $results;
}
private function getLogSize()
{
$logfileName = 'var/log/' . getenv('APP_ENV') . '.log';
$logfile = $this->projectDirectory . '/' . $logfileName;
return filesize($logfile);
}
private function getLog(int $lines = 100)
{
if (!in_array(getenv('APP_ENV'), ['test', 'dev', 'prod'])) {
return [
'Unsupported log environment'
];
}
$logfileName = 'var/log/' . getenv('APP_ENV') . '.log';
$logfile = $this->projectDirectory . '/' . $logfileName;
if (!file_exists($logfile)) {
return [
'Could not find logfile: ' . $logfileName
];
}
$file = new \SplFileObject($logfile, 'r');
$file->seek($file->getSize());
$last_line = $file->key();
while ($last_line - $lines < 0) {
$lines--;
}
$lines = new \LimitIterator($file, $last_line - $lines, $last_line);
return iterator_to_array($lines);
}
private function getFilePermissions()
{
$results = [];
foreach (self::DIRECTORIES_WRITABLE as $path) {
$results[$path] = false;
$fullPath = $this->projectDirectory . '/' . $path;
$fullUri = realpath($fullPath);
if ($fullUri === false && !file_exists($fullPath)) {
@mkdir($fullPath);
$fullUri = realpath($fullPath);
}
if ($fullUri !== false && is_readable($fullUri) && is_writable($fullUri)) {
$results[$path] = true;
}
}
return $results;
}
private function getEnvVars()
{
return [
'APP_ENV' => getenv('APP_ENV'),
'MAILER_FROM' => getenv('MAILER_FROM'),
'CORS_ALLOW_ORIGIN' => getenv('CORS_ALLOW_ORIGIN'),
];
}
private function getIniSettings()
{
$ini = [
'allow_url_fopen',
'allow_url_include',
'default_charset',
'default_mimetype',
'display_errors',
'error_log',
'error_reporting',
'log_errors',
'max_execution_time',
'memory_limit',
'open_basedir',
'post_max_size',
'sys_temp_dir',
'date.timezone',
];
$settings = [];
foreach ($ini as $name) {
try {
$settings[$name] = ini_get($name);
} catch (\Exception $ex) {
$settings[$name] = "Couldn't load ini setting: " . $ex->getMessage();
}
}
return $settings;
}
/**
* @author https://php.net/manual/en/function.phpinfo.php#117961
* @return array
*/
private function getPhpInfo()
{
$plainText = function ($input) {
return trim(html_entity_decode(strip_tags($input)));
};
ob_start();
phpinfo(1);
$phpinfo = ['phpinfo' => []];
if (preg_match_all(
'#(?:<h2.*?>(?:<a.*?>)?(.*?)(?:<\/a>)?<\/h2>)|' .
'(?:<tr.*?><t[hd].*?>(.*?)\s*</t[hd]>(?:<t[hd].*?>(.*?)\s*</t[hd]>(?:<t[hd].*?>(.*?)\s*</t[hd]>)?)?</tr>)#s',
ob_get_clean(),
$matches,
PREG_SET_ORDER
)) {
foreach ($matches as $match) {
$fn = $plainText;
if (isset($match[3])) {
$keys1 = array_keys($phpinfo);
$phpinfo[end($keys1)][$fn($match[2])] = isset($match[4]) ? [$fn($match[3]), $fn($match[4])] : $fn($match[3]);
} else {
$keys1 = array_keys($phpinfo);
$phpinfo[end($keys1)][] = $fn($match[2]);
}
}
}
$phpInfo = $phpinfo['phpinfo'];
unset($phpInfo[0]);
unset($phpInfo[1]);
return $phpInfo;
}
}

View File

@@ -146,7 +146,7 @@ class ExportController extends AbstractController
$query->getBegin()->setTime(0, 0, 0);
$query->getEnd()->setTime(23, 59, 59);
return $this->timesheetRepository->getTimesheetsForQuery($query);
return $this->timesheetRepository->getTimesheetsForQuery($query, true);
}
protected function getToolbarForm(ExportQuery $query, string $method): FormInterface

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