Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
724eb9c658 | ||
|
|
936ab05882 | ||
|
|
817a43b178 | ||
|
|
0f16f42887 | ||
|
|
641c199c8c | ||
|
|
04dd6b900f | ||
|
|
536a05210e | ||
|
|
60d16016e8 | ||
|
|
9222075c7a | ||
|
|
641dcdff85 | ||
|
|
668649be7a | ||
|
|
0de936d474 | ||
|
|
47bd8b0ce7 | ||
|
|
2287c9951c | ||
|
|
94c28ebbc5 | ||
|
|
b4739f8f03 | ||
|
|
c32cd36861 | ||
|
|
945562b34d | ||
|
|
af0f89774e | ||
|
|
c6c4098759 | ||
|
|
fa1c79e15c | ||
|
|
0705c26513 | ||
|
|
140fa4b7c0 | ||
|
|
cd88901868 | ||
|
|
8eec97deb5 | ||
|
|
4331a7c9f4 | ||
|
|
f4fcfeba9d | ||
|
|
48aefb1862 | ||
|
|
bda9584346 | ||
|
|
ed1154af67 | ||
|
|
c368eacd87 | ||
|
|
949c59230f | ||
|
|
1114538f41 | ||
|
|
b0f83291ee | ||
|
|
e91e4ff430 | ||
|
|
4ad290d4bd | ||
|
|
37c6e2c0af | ||
|
|
b4a45afce9 | ||
|
|
330ff6eb47 | ||
|
|
c681eb1bdb | ||
|
|
de533e7031 | ||
|
|
ae6ed3f23f | ||
|
|
32aa209b75 | ||
|
|
e8c25d8ac5 | ||
|
|
718ad4b398 | ||
|
|
8d81a394ee | ||
|
|
d661c8b54e | ||
|
|
e45e782e7d | ||
|
|
5aa422dde1 | ||
|
|
24cbe3d281 | ||
|
|
13f5e76015 | ||
|
|
35c65ba890 | ||
|
|
8f2b82b832 | ||
|
|
7f1a206a52 | ||
|
|
8c503a226e | ||
|
|
cdcf9eaf67 | ||
|
|
a651e55dc9 | ||
|
|
d041a3f4f9 | ||
|
|
3ea05cb705 | ||
|
|
3f27ab1e44 | ||
|
|
e99b170d0a | ||
|
|
49e1a1c410 | ||
|
|
93ab3d5666 |
@@ -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
6
.gitignore
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
20
CHANGELOG.md
20
CHANGELOG.md
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
53
UPGRADING.md
53
UPGRADING.md
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
70
assets/js/plugins/KimaiConfirmationLink.js
Normal file
70
assets/js/plugins/KimaiConfirmationLink.js
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
35
assets/js/plugins/KimaiForm.js
Normal file
35
assets/js/plugins/KimaiForm.js
Normal 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);
|
||||
}
|
||||
}
|
||||
168
assets/js/plugins/KimaiFormSelect.js
Normal file
168
assets/js/plugins/KimaiFormSelect.js
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
73
assets/js/plugins/KimaiMultiUpdateTable.js
Normal file
73
assets/js/plugins/KimaiMultiUpdateTable.js
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
10
assets/sass/admin-lte.scss
Normal file
10
assets/sass/admin-lte.scss
Normal 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';
|
||||
@@ -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';
|
||||
|
||||
52
assets/sass/content-header.scss
Normal file
52
assets/sass/content-header.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
23
assets/sass/selectpicker.scss
Normal file
23
assets/sass/selectpicker.scss
Normal 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;
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
2946
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,6 @@
|
||||
framework:
|
||||
assets:
|
||||
json_manifest_path: '%kernel.project_dir%/public/build/manifest.json'
|
||||
packages:
|
||||
avatars:
|
||||
base_path: 'avatars'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
# --------------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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%']
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
0
public/avatars/.gitignore
vendored
Normal file
1
public/build/0.1a6ffb43.js
Normal file
1
public/build/0.1a6ffb43.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
public/build/1.16767f92.js
Normal file
1
public/build/1.16767f92.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
public/build/2.dad56560.js
Normal file
1
public/build/2.dad56560.js
Normal file
File diff suppressed because one or more lines are too long
1
public/build/app.926c5dcb.js
Normal file
1
public/build/app.926c5dcb.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
public/build/app.fe1cac78.css
Normal file
1
public/build/app.fe1cac78.css
Normal file
File diff suppressed because one or more lines are too long
1
public/build/calendar.bd4fe3ae.js
Normal file
1
public/build/calendar.bd4fe3ae.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -2,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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
338
src/Command/ImportProjectCommand.php
Normal file
338
src/Command/ImportProjectCommand.php
Normal 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);
|
||||
}
|
||||
}
|
||||
515
src/Command/ImportTimesheetCommand.php
Normal file
515
src/Command/ImportTimesheetCommand.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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']);
|
||||
|
||||
149
src/Command/UpdateCommand.php
Normal file
149
src/Command/UpdateCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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()');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
239
src/Controller/DoctorController.php
Normal file
239
src/Controller/DoctorController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user