Compare commits

...

37 Commits
0.5 ... 0.6

Author SHA1 Message Date
Kevin Papst
417675b3df prepare release 0.6 (#412) 2018-11-18 20:45:00 +01:00
Lukas
321ac74b6a added parsedown extension for header ids (#388) (#399) 2018-11-18 20:31:37 +01:00
Kevin Papst
b1c06eed7b API endpoint for Timesheet entries (#332) 2018-11-18 19:43:58 +01:00
Kevin Papst
8cb52e22b1 fix column visibility if unconfigured / no cookie existing (#423) 2018-11-18 02:13:44 +01:00
Kevin Papst
a073064900 redirect to user language after profile update (#421) 2018-11-15 12:06:57 +01:00
Kevin Papst
07cceca67e fix duration and rate for html invoices (#414) 2018-11-12 23:32:05 +01:00
Kevin Papst
540318fcd0 toolbar - always allow to choose global activities (#410) 2018-11-12 15:00:25 +01:00
Kevin Papst
e9cbb872d0 added further timesheet related invoice fields (#411) 2018-11-12 12:19:57 +01:00
Kevin Papst
f9dc42d1f9 option to move entries from one entity to another upon deletion (#409) 2018-11-12 00:01:49 +01:00
Kevin Papst
1dabeedaa0 show toolbar filter after submission (#407) 2018-11-11 15:32:41 +01:00
Kevin Papst
dc70ff128c full configurable data columns in all screen sizes (#404) 2018-11-10 22:22:08 +01:00
Kevin Papst
b012b7170b added global activity support in importer (#402) 2018-11-10 03:05:33 +01:00
Toby Batch
0a1c2d8440 updated docker documentation #397 (#398) 2018-11-09 14:07:49 +01:00
Toby Batch
d72596f000 added links to docker hub and production docker repo (#396) 2018-11-08 23:07:33 +01:00
Lukas
33029b2a11 fixed docu: issues- & milestone links (#395) 2018-11-07 00:45:58 +01:00
Kevin Papst
bae24dd0ec beta test: added quick search box for customers, projects and activities (#392) 2018-11-06 23:35:09 +01:00
Kevin Papst
db7de3aac1 added global activities (#259) 2018-11-06 23:10:34 +01:00
Kevin Papst
c66dfbc653 added links to further docker images by the community (#394) 2018-11-06 22:30:44 +01:00
Kevin Papst
51c207e025 fixed broken toolbar form with empty date (#391) 2018-11-05 22:26:57 +01:00
Lukas
e44e2442af updated invoice documentation (#387) 2018-11-04 15:55:34 +01:00
infeeeee
86f40083ef Added Hungarian translation (#385) 2018-11-02 23:38:12 +01:00
Kevin Papst
f45fbbce60 improved docs and cross-linked docker (#383) 2018-11-02 16:11:40 +01:00
srdco
c648866cb4 Update installation instructions for non-server (#382) 2018-11-01 12:28:39 +01:00
Kevin Papst
f8adafdabe invoice calculator to group entries by activity (#380) 2018-10-31 15:10:06 +01:00
Toby Batch
ab4a1a0a1d added dockerfile for development (#377) 2018-10-31 13:19:48 +01:00
Kevin Papst
34a3f4e275 about screen with debug information for super-admin (#378) 2018-10-29 15:28:52 +01:00
Kevin Papst
55aedcd4a8 bump version number for development (#375) 2018-10-25 22:27:43 +02:00
Kevin Papst
6633c550f5 user-profile - sidebar links and language settings (#369) 2018-10-23 23:59:38 +02:00
Kevin Papst
0165658659 added config for initial calendar view (#363) 2018-10-21 15:43:16 +02:00
Toby Batch
3075276a52 added default settings for new customer form (#358) 2018-10-21 15:27:43 +02:00
Kevin Papst
b46e752d0a decrease minimum username length to 3 character (#362) 2018-10-19 16:50:03 +02:00
Kevin Papst
ad171e7706 removed deprecated symfony/lts and updated all other packages (#354) 2018-10-12 17:51:38 +02:00
Kevin Papst
92c665436a updated admin lte theme bundle (#350) 2018-10-08 20:58:21 +02:00
Kevin Papst
2dbf433474 fixed redirect route after password reset (#345) 2018-10-07 19:12:59 +02:00
Kevin Papst
56ac642446 arabic translations and locale support in datepicker (#337) 2018-10-07 18:29:12 +02:00
Kevin Papst
2f6c0d2208 updated timesheet and email documentation (#349) 2018-10-07 16:50:20 +02:00
Kevin Papst
a32614fff8 updated required dependencies (#346) 2018-10-07 16:43:54 +02:00
214 changed files with 7489 additions and 1253 deletions

View File

@@ -1,3 +1,3 @@
unreleased=true
future-release=0.5
exclude-labels=duplicate,question,invalid,wontfix
future-release=0.6
exclude-labels=duplicate,question,invalid,wontfix,release

View File

@@ -1,5 +1,79 @@
# Change Log
## [0.6](https://github.com/kevinpapst/kimai2/tree/0.6) (2018-11-18)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/0.5...0.6)
**Fixed bugs:**
- Invoicing export - 500: Internal Server Error [\#390](https://github.com/kevinpapst/kimai2/issues/390)
- invalid CSRF-Token [\#344](https://github.com/kevinpapst/kimai2/issues/344)
- fix column visibility if unconfigured \(no cookie existing\) [\#423](https://github.com/kevinpapst/kimai2/pull/423) ([kevinpapst](https://github.com/kevinpapst))
- fix duration and rate for html invoices [\#414](https://github.com/kevinpapst/kimai2/pull/414) ([kevinpapst](https://github.com/kevinpapst))
- fixed broken toolbar form with empty date [\#391](https://github.com/kevinpapst/kimai2/pull/391) ([kevinpapst](https://github.com/kevinpapst))
**Closed issues:**
- Language setting does not work immediately [\#418](https://github.com/kevinpapst/kimai2/issues/418)
- After installation no graphic/background/icons shown [\#417](https://github.com/kevinpapst/kimai2/issues/417)
- Form fields should be always visible [\#408](https://github.com/kevinpapst/kimai2/issues/408)
- Merge projects and/or activities [\#406](https://github.com/kevinpapst/kimai2/issues/406)
- Active filter should stay open [\#405](https://github.com/kevinpapst/kimai2/issues/405)
- Make project field optional [\#401](https://github.com/kevinpapst/kimai2/issues/401)
- Global activities support for Kimai 1 importer [\#400](https://github.com/kevinpapst/kimai2/issues/400)
- Unrecognized Options "renderer, number\_generator" under "kimai.invoice" [\#381](https://github.com/kevinpapst/kimai2/issues/381)
- Invoice Grouped by Activity [\#379](https://github.com/kevinpapst/kimai2/issues/379)
- Set the order of columns \(in my times page\) [\#371](https://github.com/kevinpapst/kimai2/issues/371)
- Columns on small/narrow displays \(mobile\) on „my times“ page [\#370](https://github.com/kevinpapst/kimai2/issues/370)
- Make settings more accessible [\#365](https://github.com/kevinpapst/kimai2/issues/365)
- Data validation for register page username field. [\#360](https://github.com/kevinpapst/kimai2/issues/360)
- Option for setting default calendar view [\#359](https://github.com/kevinpapst/kimai2/issues/359)
- 500: Internal Server Error after login \(new install\) [\#342](https://github.com/kevinpapst/kimai2/issues/342)
- Invoices export error [\#341](https://github.com/kevinpapst/kimai2/issues/341)
- Activities just Duration [\#334](https://github.com/kevinpapst/kimai2/issues/334)
- API: Add filtering/sorting/limits [\#333](https://github.com/kevinpapst/kimai2/issues/333)
- add the same activity for multiple projects [\#325](https://github.com/kevinpapst/kimai2/issues/325)
- API: Endpoint for API docs needs session cookie [\#319](https://github.com/kevinpapst/kimai2/issues/319)
- API: Timesheets [\#315](https://github.com/kevinpapst/kimai2/issues/315)
- Feature request: Quick search box for activities when creating new timesheet [\#307](https://github.com/kevinpapst/kimai2/issues/307)
- docker image for kimai2 [\#284](https://github.com/kevinpapst/kimai2/issues/284)
- Separate Customer / Project / Activity input when logging time [\#250](https://github.com/kevinpapst/kimai2/issues/250)
- Interactive help after fresh installation [\#94](https://github.com/kevinpapst/kimai2/issues/94)
**Merged pull requests:**
- redirect to user language after profile update [\#421](https://github.com/kevinpapst/kimai2/pull/421) ([kevinpapst](https://github.com/kevinpapst))
- added more timesheet related invoice fields [\#411](https://github.com/kevinpapst/kimai2/pull/411) ([kevinpapst](https://github.com/kevinpapst))
- toolbar improvements [\#410](https://github.com/kevinpapst/kimai2/pull/410) ([kevinpapst](https://github.com/kevinpapst))
- move entries from one entity to another upon deletion [\#409](https://github.com/kevinpapst/kimai2/pull/409) ([kevinpapst](https://github.com/kevinpapst))
- show toolbar filter if they were submitted [\#407](https://github.com/kevinpapst/kimai2/pull/407) ([kevinpapst](https://github.com/kevinpapst))
- full configurable data columns in all screen sizes [\#404](https://github.com/kevinpapst/kimai2/pull/404) ([kevinpapst](https://github.com/kevinpapst))
- added global activity support in importer [\#402](https://github.com/kevinpapst/kimai2/pull/402) ([kevinpapst](https://github.com/kevinpapst))
- added parsedown extension for header ids \(\#388\) [\#399](https://github.com/kevinpapst/kimai2/pull/399) ([lduer](https://github.com/lduer))
- Fix for \#397 [\#398](https://github.com/kevinpapst/kimai2/pull/398) ([tobybatch](https://github.com/tobybatch))
- Added links to docker hub and my docker repo [\#396](https://github.com/kevinpapst/kimai2/pull/396) ([tobybatch](https://github.com/tobybatch))
- fixed issues- & milestone links [\#395](https://github.com/kevinpapst/kimai2/pull/395) ([lduer](https://github.com/lduer))
- added links to further docker images by the community [\#394](https://github.com/kevinpapst/kimai2/pull/394) ([kevinpapst](https://github.com/kevinpapst))
- beta test: added quick search box for customers, projects and activities [\#392](https://github.com/kevinpapst/kimai2/pull/392) ([kevinpapst](https://github.com/kevinpapst))
- updated the documentation, reflecting the changes in \#306 [\#387](https://github.com/kevinpapst/kimai2/pull/387) ([lduer](https://github.com/lduer))
- Added Hungarian translation and fixed the path of kimai.yaml in translation documentation [\#385](https://github.com/kevinpapst/kimai2/pull/385) ([infeeeee](https://github.com/infeeeee))
- improved docs and cross-linked docker [\#383](https://github.com/kevinpapst/kimai2/pull/383) ([kevinpapst](https://github.com/kevinpapst))
- Doc update of install/update instructions re: username [\#382](https://github.com/kevinpapst/kimai2/pull/382) ([srdco](https://github.com/srdco))
- invoice calculator to group entries by activity [\#380](https://github.com/kevinpapst/kimai2/pull/380) ([kevinpapst](https://github.com/kevinpapst))
- basic about screen with server and debug infos [\#378](https://github.com/kevinpapst/kimai2/pull/378) ([kevinpapst](https://github.com/kevinpapst))
- Dev/demo dockerfile [\#377](https://github.com/kevinpapst/kimai2/pull/377) ([tobybatch](https://github.com/tobybatch))
- Sidebar - user-profile links and language settings [\#369](https://github.com/kevinpapst/kimai2/pull/369) ([kevinpapst](https://github.com/kevinpapst))
- added configurable initial calendar view [\#363](https://github.com/kevinpapst/kimai2/pull/363) ([kevinpapst](https://github.com/kevinpapst))
- decrease minimum username to 3 character [\#362](https://github.com/kevinpapst/kimai2/pull/362) ([kevinpapst](https://github.com/kevinpapst))
- Default country \(\#2\) [\#358](https://github.com/kevinpapst/kimai2/pull/358) ([tobybatch](https://github.com/tobybatch))
- composer: removed deprecated symfony/lt, updated others [\#354](https://github.com/kevinpapst/kimai2/pull/354) ([kevinpapst](https://github.com/kevinpapst))
- updated admin-lte theme bundle to 2.1.1 [\#350](https://github.com/kevinpapst/kimai2/pull/350) ([kevinpapst](https://github.com/kevinpapst))
- updated timesheet and email documentation [\#349](https://github.com/kevinpapst/kimai2/pull/349) ([kevinpapst](https://github.com/kevinpapst))
- updated required dependencies [\#346](https://github.com/kevinpapst/kimai2/pull/346) ([kevinpapst](https://github.com/kevinpapst))
- Fix password reset [\#345](https://github.com/kevinpapst/kimai2/pull/345) ([kevinpapst](https://github.com/kevinpapst))
- added arabic translations and locale support in datepicker [\#337](https://github.com/kevinpapst/kimai2/pull/337) ([kevinpapst](https://github.com/kevinpapst))
- API endpoint for Timesheet entries [\#332](https://github.com/kevinpapst/kimai2/pull/332) ([kevinpapst](https://github.com/kevinpapst))
- Support global activities [\#259](https://github.com/kevinpapst/kimai2/pull/259) ([kevinpapst](https://github.com/kevinpapst))
## [0.5](https://github.com/kevinpapst/kimai2/tree/0.5) (2018-09-27)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/0.4...0.5)
@@ -62,7 +136,6 @@
**Merged pull requests:**
- prepare release 0.4 [\#292](https://github.com/kevinpapst/kimai2/pull/292) ([kevinpapst](https://github.com/kevinpapst))
- fixed money display in dashboard widgets [\#291](https://github.com/kevinpapst/kimai2/pull/291) ([kevinpapst](https://github.com/kevinpapst))
- Symfony update 4.1.4 [\#290](https://github.com/kevinpapst/kimai2/pull/290) ([kevinpapst](https://github.com/kevinpapst))
- do not allow to stop already stopped timesheets [\#289](https://github.com/kevinpapst/kimai2/pull/289) ([kevinpapst](https://github.com/kevinpapst))
@@ -115,7 +188,6 @@
**Merged pull requests:**
- prepare release 0.3 [\#231](https://github.com/kevinpapst/kimai2/pull/231) ([kevinpapst](https://github.com/kevinpapst))
- Easier creation of Tasks/Projects/User [\#230](https://github.com/kevinpapst/kimai2/pull/230) ([kevinpapst](https://github.com/kevinpapst))
- Added email configuration docs [\#228](https://github.com/kevinpapst/kimai2/pull/228) ([kevinpapst](https://github.com/kevinpapst))
- Show revenue only for ROLE\_ADMIN [\#227](https://github.com/kevinpapst/kimai2/pull/227) ([kevinpapst](https://github.com/kevinpapst))
@@ -189,7 +261,6 @@
**Merged pull requests:**
- \[WIP\] Translations [\#173](https://github.com/kevinpapst/kimai2/pull/173) ([simonschaufi](https://github.com/simonschaufi))
- prepare v0.2 release \#177 [\#178](https://github.com/kevinpapst/kimai2/pull/178) ([kevinpapst](https://github.com/kevinpapst))
- various documentation updates [\#176](https://github.com/kevinpapst/kimai2/pull/176) ([kevinpapst](https://github.com/kevinpapst))
- code styles: header\_comment and ordered\_imports [\#175](https://github.com/kevinpapst/kimai2/pull/175) ([kevinpapst](https://github.com/kevinpapst))
- Code sniffer rules \#158 [\#172](https://github.com/kevinpapst/kimai2/pull/172) ([kevinpapst](https://github.com/kevinpapst))

41
Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
# 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.
FROM php:7.2.9-apache-stretch
WORKDIR /opt/kimai
RUN php -r "readfile('http://getcomposer.org/installer');" | php -- --install-dir=/usr/bin/ --filename=composer && \
apt update && \
apt install -y \
git \
libicu-dev \
libjpeg-dev \
libldap2-dev \
libldb-dev \
libpng-dev \
unzip \
zip \
&& \
docker-php-ext-install \
gd \
intl \
ldap \
pdo_mysql \
zip && \
apt-get -y autoremove && \
apt-get clean && \
git clone https://github.com/kevinpapst/kimai2.git /opt/kimai && \
sed "s/prod/dev/g" .env.dist > .env && \
composer install --dev --optimize-autoloader && \
bin/console doctrine:database:create && \
bin/console doctrine:schema:create && \
bin/console doctrine:migrations:version --add --all && \
bin/console cache:warmup && \
chown -R www-data:www-data var
EXPOSE 8001
USER www-data
CMD /opt/kimai/bin/console server:run 0.0.0.0:8001

10
LICENSE
View File

@@ -1,11 +1,13 @@
MIT License
Copyright (c) 2017-2018 Kevin Papst
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
@@ -15,5 +17,5 @@ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -20,14 +20,16 @@ But we already support to [import your timesheets](var/docs/migration_v1.md) fro
It is developed with modern frameworks like [Symfony v4](https://github.com/symfony/symfony), [Doctrine](https://github.com/doctrine/),
[AdminLTE](https://github.com/kevinpapst/AdminLTEBundle/) and [many](composer.json) [more](package.json).
Kimai is a multi-language application and already translated to English. German, Italian, Frensh, Spanish and Russian.
Kimai is a multi-language application and already translated to: English, German, Italian, French, Spanish, Russian, Arabic and Hungarian.
If you want to support us in translating Kimai, please [read this documentation](var/docs/translations.md).
### Requirements
- PHP 7.1.3 or higher
- One PHP extension of [PDO-SQLite](https://php.net/manual/en/ref.pdo-sqlite.php) or [PDO-MySQL](https://php.net/manual/en/ref.pdo-mysql.php) enabled (it might work with PostgreSQL and Oracle as well, but that wasn't tested and is not officially supported)
- The PHP extension [intl](https://php.net/manual/en/book.intl.php)
- The PHP extensions:
- [PDO](https://php.net/manual/en/book.pdo.php) (with either [pdo_sqlite](https://php.net/manual/en/ref.pdo-sqlite.php) or [pdo_mysql](https://php.net/manual/en/ref.pdo-mysql.php) enabled)
- [intl](https://php.net/manual/en/book.intl.php)
- [zip](https://php.net/manual/en/book.zip.php)
- The [usual Symfony application requirements](http://symfony.com/doc/current/reference/requirements.html)
- If you use MariaDB, make sure its at least v10.2.7 (see [FAQ](var/docs/faq.md))
- Kimai needs to be installed in the root directory of a domain or you need to [recompile the frontend assets](var/docs/developers.md)
@@ -37,13 +39,16 @@ If you want to support us in translating Kimai, please [read this documentation]
Looking for more information about using Kimai? Check out our more detailed [documentation](var/docs/).
You can see our development roadmap for the future in the [Milestones](milestones/) sections.
You can see our development roadmap for the future in the [Milestones](https://github.com/kevinpapst/kimai2/milestones) sections.
Our roadmap is open for changes and input from the community, please [sent us](issues/) your ideas and questions.
Our roadmap is open for changes and input from the community, please [send us](https://github.com/kevinpapst/kimai2/issues) your ideas and questions.
## Installation
There are [further infos about installation](var/docs/installation.md) if you have to use FTP or want to develop with Kimai.
> **NOTE**
>
> There are [further infos about installation](var/docs/installation.md) if you have to use FTP, want to develop with Kimai
or are setting up Kimai on your personal computer. There is also a [docker image](var/docs/docker.md) available.
If you want to install Kimai 2 in your production environment, then SSH into your server and change to your webserver root.
You need to install Git and [Composer](https://getcomposer.org/doc/00-intro.md) if you haven't already.
@@ -63,7 +68,7 @@ chmod -R g+rw var/
cp .env.dist .env
```
It's up to you which database server you want to use, Kimai v2 supports MySQL/MariaDB and SQLite.
It's up to you which database server you want to use, Kimai v2 supports MySQL/MariaDB and SQLite, but SQLite is [not recommended](var/docs/faq.md) for production usage.
Configure the database connection string in your the `.env` file:
```
# adjust all settings in .env to your needs
@@ -116,6 +121,11 @@ Installation complete: enjoy time-tracking :-)
## Updating Kimai
> **NOTE**
>
> Don't forget tweaks that may be necessary to these instructions if you are using FTP, developing or updating on your
personal computer instead of a server. See [further infos about installation](var/docs/installation.md).
**STOP**
1. It's important that you don't execute the Installation steps before or after your upgrade

View File

@@ -18,6 +18,11 @@ bin/console doctrine:migrations:migrate
There might be version specific tasks that need to be executed before or after these steps, please see below
if your updated version is mentioned below.
## [0.6](https://github.com/kevinpapst/kimai2/releases/tag/0.6) (2018-11-18)
The API has some minor BC breaks: some fields were renamed and entities have a larger attribute set than collections.
Be aware that the API is still is development mode and shouldn't be considered stable for now.
## [0.5](https://github.com/kevinpapst/kimai2/releases/tag/0.5) (2018-09-27)
Some configuration nodes were removed, if you have one of them in your `local.yaml` you need to delete them before you start the update:

165
assets/css/app.scss Normal file
View File

@@ -0,0 +1,165 @@
/*
* 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 "~bootstrap-sass/assets/stylesheets/bootstrap/variables";
.error-page {
margin-bottom: 50px;
}
/* ================================ CONTENT ================================ */
.content {
padding: 15px 0;
}
.content-header {
h1 {
small {
display: none;
}
}
}
@media (min-width: $screen-sm-min) {
.content-header {
h1 {
small {
display: inline-block;
}
}
}
.content {
padding: 15px;
}
}
/* ================================ NAVBAR ================================ */
.navbar-nav > li > a.ddt-large {
padding-top: 12px;
padding-bottom: 9px;
}
li.messages-menu ul.menu li .pull-left i {
color: #dd4b39;
}
.navbar-nav>.messages-menu>.dropdown-menu>li .menu>li>a>p {
overflow: hidden;
text-overflow: ellipsis;
}
.navbar-nav .start_record {
text-align: center;
}
/*
.ticktac:hover i.running{
color: #4ff131;
}
li.open .ticktac i.running{
color: #4ff131;
}
.ticktac i.stopped{
color: #fff;
}
.ticktac:hover i.stopped{
color: #4ff131;
}
.user-panel>.info {
left: 40px;
padding-top: 0px;
}
*/
/* ================================ TOOLBAR ================================ */
/* Page based action buttons in the upper right corner of the content area */
.content-header > .breadcrumb {
position: absolute;
float: right;
background: transparent;
}
/* Right sidebar with additional tabs for personal settings and "About Kimai" section */
.control-sidebar {
select {
color: $input-color;
background-color: $input-bg;
}
div.image {
img.img-circle {
max-width: 34px;
}
}
}
/* The filter form is available on most pages above the datatable */
@media (min-width: 768px) {
.toolbar {
form.navbar-form {
font-size: $font-size-base;
.form-control {
display: inline-block;
width: 100%;
vertical-align: middle;
}
}
}
}
/* ================================ SIDEBAR ================================ */
/* Used for the Sidebar UserPanel (which is deactivated right now) */
.user-panel>.info {
left: 80px;
}
/* ================================ FOOTER ================================ */
footer.main-footer {
padding: 5px;
font-size: 80%;
}
/* ================================ PRINT ================================ */
@media print{
.sf-toolbar, .control-sidebar {display: none !important;}
}
/* ================================ INVOICES ================================ */
.invoice {
margin: 0;
}
@media (min-width: $screen-sm-min) {
.invoice {
margin: 10px 25px;
}
}
.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;
}

View File

@@ -22,22 +22,26 @@ if (typeof jQuery === 'undefined') {
$.datatable = {};
$(function() {
"use strict";
"use strict";
$.datatable = {
saveVisibility: function (modalSelector) {
var settings = {};
var cookieName = $(modalSelector).find('form').attr('name');
$(modalSelector).find('form').find('input:checkbox:not(:checked)').each(
function () {
settings[$(this).attr('name')] = $(this).is(':checked');
$(modalSelector).find('form').each(
function() {
var settings = {};
var cookieName = $(this).attr('name');
$(this).find('input:checkbox').each(
function () {
settings[$(this).attr('name')] = $(this).is(':checked');
}
);
if (jQuery.isEmptyObject(settings)) {
Cookies.remove(cookieName);
} else {
Cookies.set(cookieName, JSON.stringify(settings), {expires: 365});
}
}
);
if (jQuery.isEmptyObject(settings)) {
Cookies.remove(cookieName);
} else {
Cookies.set(cookieName, JSON.stringify(settings), {expires: 365});
}
$(modalSelector).modal('toggle');
location.reload();
},
@@ -49,12 +53,12 @@ $(function() {
}
var header = $(tbl.find('th').get(column));
if (header.css("display") === "none") {
header.show();
header.show('ease');
tbl.find('tr').each(function(){
$($(this).find('td').get(column)).show('ease');
});
} else {
header.hide();
header.hide('ease');
tbl.find('tr').each(function(){
$($(this).find('td').get(column)).hide('ease');
});

View File

@@ -29,6 +29,9 @@ $(function() {
$.kimai.settings = $.extend({}, $.kimai.defaults, options);
}
// set the current locale for all javascript components
moment.locale($.kimai.settings['locale']);
// ask before a delete call is executed
$('a.btn-trash').click(function (event) {
return confirm($.kimai.settings['confirmDelete']);
@@ -81,6 +84,38 @@ $(function() {
$(this).trigger("change");
});
$('select[data-related-select]').change(function() {
var apiUrl = $(this).attr('data-api-url').replace('-s-', $(this).val());
var targetSelect = $(this).attr('data-related-select');
$.ajax({
url: apiUrl,
headers: {
'X-AUTH-SESSION': true,
'Content-Type':'application/json'
},
method: 'GET',
dataType: 'json',
success: function(data){
$('#' + targetSelect).find('option').remove().end().find('optgroup').remove().end();
$.each(data, function(i, obj) {
$('#' + targetSelect).append('<option value="' + obj.id + '">' + obj.name + '</option>');
});
$('#' + targetSelect).trigger('change')
}
});
});
$('.markdown-body :header').prepend(function() {
$(this).prepend('<a class="anchor" href="#'+$(this).attr('id')+'"><i class="fas fa-link"></i>');
});
$('.markdown-body :header').hover(function(){
$(this).find('a.anchor').show();
}, function(){
$(this).find('a.anchor').hide();
});
/*
$('input').iCheck({
checkboxClass: 'icheckbox_square-blue',
@@ -106,6 +141,7 @@ $(function() {
// default values
$.kimai.defaults = {
locale: 'en',
confirmDelete: 'Really delete?',
alertSuccessAutoHide: 5000
};

View File

@@ -16,17 +16,13 @@ $(document).ready(function () {
$('.toolbar form select').change(function (event) {
switch (event.target.id) {
case 'customer':
if ($(this).val() === '') {
$('.toolbar form select#project').parent().remove();
} else {
$('.toolbar form select#project').val('');
$('.toolbar form select#project').val('');
if ($('.toolbar form select#activity').find(':selected').attr('data-project')) {
$('.toolbar form select#activity').val('');
}
$('.toolbar form select#activity').parent().remove();
break;
case 'project':
if ($(this).val() === '') {
$('.toolbar form select#activity').parent().remove();
} else {
if ($('.toolbar form select#activity').find(':selected').attr('data-project')) {
$('.toolbar form select#activity').val('');
}
break;

View File

@@ -37,3 +37,4 @@ footer.main-footer {
@import 'calendar';
@import 'dashboard';
@import 'navbar';
@import 'documentation';

View File

@@ -0,0 +1,25 @@
/*
* 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.
*/
.markdown-body {
h1, h2, h3, h4, h5, h6 {
a.anchor {
float: left;
font-size: 0.5em;
margin-left: -1em;
padding-top: 0.5em;
color: $text-color;
display: none;
}
}
}
@media (min-width: $screen-sm-min) {
.markdown-body {
padding-left: 25px;
}
}

View File

@@ -26,7 +26,7 @@
}
/* The filter form is available on most pages above the datatable */
@media (min-width: 768px) {
@media (min-width: $screen-sm-min) {
.toolbar {
form.navbar-form {
font-size: $font-size-base;

View File

@@ -7,13 +7,13 @@
"php": "^7.1.3",
"ext-intl": "*",
"ext-pdo": "*",
"ext-pdo_sqlite": "*",
"ext-zip": "*",
"beberlei/DoctrineExtensions": "^1.0",
"erusev/parsedown": "^1.6",
"friendsofsymfony/rest-bundle": "^2.3",
"friendsofsymfony/user-bundle": "~2.0",
"jms/serializer-bundle": "^2.4",
"kevinpapst/adminlte-bundle": "^2.1",
"kevinpapst/adminlte-bundle": "~2.1",
"nelmio/api-doc-bundle": "^3.2",
"ocramius/proxy-manager": "2.1.1",
"phpoffice/phpspreadsheet": "^1.4",
@@ -26,7 +26,6 @@
"symfony/flex": "^1.0",
"symfony/form": "^4.0",
"symfony/framework-bundle": "^4.0",
"symfony/lts": "^4@dev",
"symfony/monolog-bundle": "^3.1",
"symfony/orm-pack": "^1.0",
"symfony/profiler-pack": "^1.0",
@@ -97,7 +96,8 @@
"extra": {
"symfony": {
"id": "01C3FWRDJJEX9K6Y3A4XDFXPBR",
"allow-contrib": true
"allow-contrib": true,
"require": "~4.1.0"
}
}
}

615
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,7 +23,7 @@ admin_lte:
# https://github.com/kevinpapst/AdminLTEBundle/blob/master/Resources/docs/control_sidebar.md
control_sidebar:
settings:
icon: "fas fa-cogs"
icon: "fas fa-user"
controller: 'App\Controller\SidebarController::settingsAction'
home:
icon: "fas fa-question-circle"
@@ -31,7 +31,7 @@ admin_lte:
routes:
adminlte_profile: user_profile
adminlte_welcome: dashboard
adminlte_welcome: homepage
adminlte_login: fos_user_security_login
adminlte_login_check: fos_user_security_check
adminlte_registration: fos_user_registration_register

View File

@@ -1,16 +1,16 @@
fos_rest:
# disable_csrf_role: ROLE_API
# disable_csrf_role: IS_AUTHENTICATED_ANONYMOUSLY
# access_denied_listener:
# enabled: false
# service: null
# formats:
# name: ~
# unauthorized_challenge: null
# param_fetcher_listener:
# enabled: false
param_fetcher_listener:
enabled: true
# force: false
# service: null
# cache_dir: '%kernel.cache_dir%/fos_rest'
cache_dir: '%kernel.cache_dir%/fos_rest'
# allowed_methods_listener:
# enabled: false
# service: null
@@ -18,8 +18,8 @@ fos_rest:
default_format: json
include_format: true
# prefix_methods: true
# body_converter:
# enabled: false
body_converter:
enabled: true
# validate: false
# validation_errors_argument: validationErrors
# service:
@@ -46,8 +46,8 @@ fos_rest:
# name: ~
# templating_formats:
# name: ~
# view_response_listener:
# enabled: true
view_response_listener:
enabled: true
# force: true
# service: null
failed_validation: 400
@@ -67,16 +67,16 @@ fos_rest:
messages:
'Symfony\Component\Routing\Exception\ResourceNotFoundException': true
'Symfony\Component\HttpKernel\Exception\NotFoundHttpException': true
# body_listener:
# enabled: true
body_listener:
enabled: true
# service: null
# default_format: null
# throw_exception_on_unsupported_content_type: false
# decoders:
# name: ~
# array_normalizer:
# service: null
# forms: false
array_normalizer:
service: null
forms: true
format_listener:
enabled: true
# service: null

View File

@@ -1,4 +1,7 @@
jms_serializer:
handlers:
datetime:
default_format: "Y-m-d H:i"
visitors:
xml:
format_output: '%kernel.debug%'

View File

@@ -66,6 +66,14 @@ kimai:
date_short: 'd.m.Y'
duration: '%%h:%%m Часов'
duration_short: '%%h:%%m h'
ar:
date_short: 'Y-m-d'
duration: '%%h:%%m hours'
duration_short: '%%h:%%m h'
hu:
date_short: 'Y.m.d.'
duration: '%%h:%%m óra'
duration_short: '%%h:%%m h'
# timesheet calendar configuration
calendar:
@@ -88,6 +96,9 @@ kimai:
# theme related settings, will be available as twig globals at "kimai_context.*"
# please see documentation at var/docs/theme.md
theme:
# BETA test: If you set this to 'selectpicker' the customer/project/activity select boxes will be transformed
# into a searchable and javascript enhanced input type
select_type: ~
# display a warning color if the user has at least X active recordings
active_warning: 3
# fallback color for all widgets that don't have a dedicated color
@@ -150,3 +161,10 @@ kimai:
activeUsersYear: { title: stats.userActiveYear, query: users, begin: '01 january this year 00:00:00', end: '31 december this year 23:59:59', icon: user, color: yellow }
activeUsersTotal: { title: stats.userActiveTotal, query: users, icon: user, color: red }
activeRecordings: { title: stats.activeRecordings, query: active, icon: duration, color: red }
# Default settings used to populate forms
#defaults:
# customer:
# timezone: Europe/Berlin
# country: DE
# currency: EUR

View File

@@ -1,16 +1,35 @@
nelmio_api_doc:
models:
use_jms: true
names:
- { alias: CustomerEntity, type: App\Entity\Customer, groups: [Default, Entity, Customer] }
- { alias: CustomerCollection, type: App\Entity\Customer, groups: [Default, Collection, Customer] }
- { alias: ProjectEntity, type: App\Entity\Project, groups: [Default, Entity, Project] }
- { alias: ProjectCollection, type: App\Entity\Project, groups: [Default, Collection, Project] }
- { alias: ActivityEntity, type: App\Entity\Activity, groups: [Default, Entity, Activity] }
- { alias: ActivityCollection, type: App\Entity\Activity, groups: [Default, Collection, Activity] }
- { alias: TimesheetFormEntity, type: App\Form\TimesheetEditForm, groups: [Default, Entity, Timesheet] }
- { alias: TimesheetEntity, type: App\Entity\Timesheet, groups: [Default, Entity, Timesheet] }
- { alias: TimesheetCollection, type: App\Entity\Timesheet, groups: [Default, Collection, Timesheet] }
- { alias: UserEntity, type: App\Entity\User, groups: [Default, Entity, User] }
- { alias: UserCollection, type: App\Entity\User, groups: [Default, Collection, User] }
areas:
path_patterns:
- ^/api(?!/doc)
documentation:
#host: '{{hostname}}'
# host: '{{hostname}}'
schemes: [http, https]
info:
title: Kimai 2 - API Docs
description: REST API description for the Kimai 2 time-tracking software
version: 0.1
description: REST API description for the Kimai 2 time-tracking software. Do not rely on the example values/models, they are currently generated wrong. Please fetch the request and response structure from the API itself.
version: 0.2
# parameters:
# hostname:
# name: hostname
# in: url
# description: the hostname where your api runs
# required: true
# type: string
securityDefinitions:
X-AUTH-USER:
type: apiKey

View File

@@ -4,10 +4,3 @@ home:
_controller: Symfony\Bundle\FrameworkBundle\Controller\RedirectController::redirectAction
route: homepage
permanent: true
homepage:
path: /{_locale}
defaults:
_controller: Symfony\Bundle\FrameworkBundle\Controller\RedirectController::redirectAction
route: timesheet
permanent: true

View File

@@ -1,18 +1,32 @@
App\Entity\Activity:
exclusion_policy: All
custom_accessor_order: [id, name, comment, visible, project_id]
custom_accessor_order: [id, name, comment, visible, project, fixedRate, hourlyRate]
properties:
id:
include: true
groups: [Default]
name:
include: true
groups: [Default]
comment:
include: true
groups: [Entity]
visible:
include: true
groups: [Default]
fixedRate:
include: true
groups: [Default]
hourlyRate:
include: true
groups: [Default]
project:
include: false
exclude: true
groups: [Default]
virtual_properties:
getProjectId:
name: project_id
serialized_name: project_id
getProject:
serialized_name: project
exp: "object.getProject() === null ? null : object.getProject().getId()"
type: integer
groups: [Default]

View File

@@ -1,35 +1,58 @@
App\Entity\Customer:
exclusion_policy: All
custom_accessor_order: [id, name, number, comment, visible, company, contact, address, country, currency, phone, fax, mobile, mail, homepage, timezone, fixedRate, hourlyRate]
properties:
id:
include: true
groups: [Default]
name:
include: true
groups: [Default]
number:
include: true
groups: [Entity]
comment:
include: true
groups: [Entity]
visible:
include: true
groups: [Default]
company:
include: true
groups: [Entity]
contact:
include: true
groups: [Entity]
address:
include: true
groups: [Entity]
country:
include: true
groups: [Entity]
currency:
include: true
groups: [Entity]
phone:
include: true
groups: [Entity]
fax:
include: true
groups: [Entity]
mobile:
include: true
groups: [Entity]
mail:
include: true
groups: [Entity]
homepage:
include: true
groups: [Entity]
timezone:
include: true
groups: [Entity]
fixedRate:
include: true
groups: [Default]
hourlyRate:
include: true
groups: [Default]

View File

@@ -1,22 +1,38 @@
App\Entity\Project:
exclusion_policy: All
custom_accessor_order: [id, name, comment, visible, budget, orderNumber, customer_id]
custom_accessor_order: [id, name, comment, visible, budget, orderNumber, customer, fixedRate, hourlyRate]
properties:
id:
include: true
groups: [Default]
name:
include: true
groups: [Default]
comment:
include: true
groups: [Entity]
visible:
include: true
groups: [Default]
budget:
include: true
groups: [Entity]
orderNumber:
include: true
groups: [Entity]
fixedRate:
include: true
groups: [Entity]
hourlyRate:
include: true
groups: [Entity]
customer:
include: false
exclude: true
groups: [Default]
virtual_properties:
getCustomerId:
name: customer_id
serialized_name: customer_id
getCustomer:
serialized_name: customer
exp: "object.getCustomer() === null ? null : object.getCustomer().getId()"
type: integer
groups: [Default]

View File

@@ -0,0 +1,56 @@
App\Entity\Timesheet:
exclusion_policy: All
custom_accessor_order: [id, begin, end, duration, rate, activity, project, user, description, fixedRate, hourlyRate]
properties:
id:
include: true
groups: [Default]
begin:
include: true
groups: [Default]
end:
include: true
groups: [Default]
duration:
include: true
groups: [Default]
description:
include: true
groups: [Entity]
rate:
include: true
groups: [Default]
fixedRate:
include: true
groups: [Entity]
hourlyRate:
include: true
groups: [Entity]
activity:
include: false
exclude: true
groups: [Default]
project:
include: false
exclude: true
groups: [Default]
user:
include: false
exclude: true
groups: [Default]
virtual_properties:
getActivity:
serialized_name: activity
exp: "object.getActivity() === null ? null : object.getActivity().getId()"
type: integer
groups: [Default]
getProject:
serialized_name: project
exp: "object.getProject() === null ? null : object.getProject().getId()"
type: integer
groups: [Default]
getUser:
serialized_name: user
exp: "object.getUser().getId()"
type: integer
groups: [Default]

View File

@@ -3,9 +3,13 @@ App\Entity\User:
properties:
id:
include: true
groups: [Default]
alias:
include: true
groups: [Default]
title:
include: true
groups: [Entity]
avatar:
include: true
groups: [Entity]

View File

@@ -1,6 +1,15 @@
FOS\UserBundle\Model\User:
exclusion_policy: NONE
properties:
enabled:
include: true
groups: [Default]
groups:
include: true
groups: [Entity]
roles:
include: true
groups: [Entity]
email:
exclude: true
emailCanonical:

View File

@@ -2,7 +2,7 @@
# https://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration
parameters:
locale: en
app_locales: en|de|it|fr|es|ru
app_locales: en|de|it|fr|es|ru|ar|hu
services:
# default configuration for services in *this* file
@@ -33,10 +33,7 @@ services:
# ================================================================================
App\EventSubscriber\RedirectToLocaleSubscriber:
class: App\EventSubscriber\RedirectToLocaleSubscriber
arguments: ['@router', '%app_locales%', '%locale%']
tags:
- { name: kernel.event_listener, event: kernel.request, method: onKernelRequest }
App\Calendar\Service:
arguments:
@@ -54,6 +51,10 @@ services:
arguments:
$timesheetAsMarkdown: "%kimai.timesheet.markdown%"
App\Controller\Admin\CustomerController:
arguments:
$defaults: "%kimai.defaults%"
# ================================================================================
# DATABASE
# ================================================================================
@@ -77,17 +78,21 @@ services:
# FORMS
# ================================================================================
# form type to edit user roles
App\Form\Type\UserRoleType:
class: App\Form\Type\UserRoleType
arguments: ["%security.role_hierarchy.roles%"]
tags:
- { name: form.type }
App\Form\Type\LanguageType:
arguments: ["%app_locales%"]
App\Form\Extension\DocumentationLinkExtension:
tags:
- { name: form.type_extension, extended_type: Symfony\Component\Form\Extension\Core\Type\FormType }
App\Form\Extension\EnhancedChoiceTypeExtension:
arguments: ["%kimai.theme.select_type%"]
tags:
- { name: form.type_extension, extended_type: Symfony\Bridge\Doctrine\Form\Type\EntityType }
# ================================================================================
# THEME
# ================================================================================

View File

@@ -4,7 +4,7 @@ App\Entity\User:
- App\Validator\Constraints\Role: ~
username:
- NotBlank: ~
- Length: { min: 5, max: 60 }
- Length: { min: 3, max: 60 }
email:
- NotBlank: ~
- Email: ~

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,8 +1,8 @@
{
"build/app.js": "/build/app.js?227afb1055d55a1cbd52",
"build/app.css": "/build/app.css?6501689dff217d14b179e3049295343e",
"build/fonts/fa-regular-400.woff": "/build/fonts/fa-regular-400.woff?d9e29124",
"build/app.js": "/build/app.js?d823754f6c3293656052",
"build/app.css": "/build/app.css?979d88ed847886068fcf3eb0bc3de545",
"build/images/blue@2x.png": "/build/images/blue@2x.png?2694acfd",
"build/images/blue.png": "/build/images/blue.png?96f8a905",
"build/fonts/fa-solid-900.woff2": "/build/fonts/fa-solid-900.woff2?e8a92a29",
"build/images/fa-solid-900.svg": "/build/images/fa-solid-900.svg?666a82cb",
"build/images/glyphicons-halflings-regular.svg": "/build/images/glyphicons-halflings-regular.svg?89889688",
@@ -14,7 +14,7 @@
"build/fonts/glyphicons-halflings-regular.woff2": "/build/fonts/glyphicons-halflings-regular.woff2?448c34a5",
"build/fonts/fa-solid-900.eot": "/build/fonts/fa-solid-900.eot?0b93480e",
"build/fonts/fa-solid-900.woff": "/build/fonts/fa-solid-900.woff?dfc040d5",
"build/images/blue.png": "/build/images/blue.png?96f8a905",
"build/fonts/fa-regular-400.woff": "/build/fonts/fa-regular-400.woff?d9e29124",
"build/fonts/fa-regular-400.woff2": "/build/fonts/fa-regular-400.woff2?930c1264",
"build/images/fa-regular-400.svg": "/build/images/fa-regular-400.svg?4e41caaf",
"build/fonts/fa-regular-400.ttf": "/build/fonts/fa-regular-400.ttf?cdd8fa76",

View File

@@ -13,13 +13,14 @@ namespace App\API;
use App\Entity\Activity;
use App\Repository\ActivityRepository;
use App\Repository\Query\ActivityQuery;
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 as API;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Response;
/**
@@ -27,7 +28,7 @@ use Symfony\Component\HttpFoundation\Response;
*
* @Security("is_granted('ROLE_USER')")
*/
class ActivityController extends Controller
class ActivityController extends BaseApiController
{
/**
* @var ActivityRepository
@@ -51,26 +52,46 @@ class ActivityController extends Controller
/**
* @SWG\Response(
* response=200,
* description="Returns the collection of all existing activities",
* @SWG\Schema(ref=@API\Model(type=Activity::class)),
* response=200,
* description="Returns the collection of all existing activities",
* @SWG\Schema(ref="#/definitions/ActivityCollection"),
* )
* @Rest\QueryParam(name="project", requirements="\d+", strict=true, nullable=true, description="Project ID to filter activities. If none is provided, only global activities will be returned.")
* @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="globals", requirements="true", strict=true, nullable=true, description="Pass 'true' as string to fetch only global activities")
*
* @return Response
*/
public function cgetAction()
public function cgetAction(ParamFetcherInterface $paramFetcher)
{
$data = $this->repository->findAll();
$query = new ActivityQuery();
$query->setOrderGlobalsFirst(true)
->setResultType(ActivityQuery::RESULT_TYPE_OBJECTS);
if (null !== ($globals = $paramFetcher->get('globals'))) {
$query->setGlobalsOnly(true);
}
if (null !== ($project = $paramFetcher->get('project'))) {
$query->setProject($project);
}
if (null !== ($visible = $paramFetcher->get('visible'))) {
$query->setVisibility($visible);
}
$data = $this->repository->findByQuery($query);
$view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Collection', 'Activity']);
return $this->viewHandler->handle($view);
}
/**
* @SWG\Response(
* response=200,
* description="Returns one activity entity",
* @SWG\Schema(ref=@API\Model(type=Activity::class)),
* response=200,
* description="Returns one activity entity",
* @SWG\Schema(ref="#/definitions/ActivityEntity"),
* )
*
* @param int $id
@@ -83,6 +104,7 @@ class ActivityController extends Controller
throw new NotFoundException();
}
$view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Activity']);
return $this->viewHandler->handle($view);
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\API;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
abstract class BaseApiController extends Controller
{
}

View File

@@ -13,13 +13,14 @@ namespace App\API;
use App\Entity\Customer;
use App\Repository\CustomerRepository;
use App\Repository\Query\CustomerQuery;
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\Model;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Response;
/**
@@ -27,7 +28,7 @@ use Symfony\Component\HttpFoundation\Response;
*
* @Security("is_granted('ROLE_USER')")
*/
class CustomerController extends Controller
class CustomerController extends BaseApiController
{
/**
* @var CustomerRepository
@@ -51,26 +52,35 @@ class CustomerController extends Controller
/**
* @SWG\Response(
* response=200,
* description="Returns the collection of all existing customer",
* @SWG\Schema(ref=@Model(type=Customer::class)),
* response=200,
* description="Returns the collection of all existing customer",
* @SWG\Schema(ref="#/definitions/CustomerCollection"),
* )
* @Rest\QueryParam(name="visible", requirements="\d+", strict=true, nullable=true, description="Visibility status to filter activities (1=visible, 2=hidden, 3=both)")
*
* @return Response
*/
public function cgetAction()
public function cgetAction(ParamFetcherInterface $paramFetcher)
{
$data = $this->repository->findAll();
$query = new CustomerQuery();
$query->setResultType(CustomerQuery::RESULT_TYPE_OBJECTS);
if (null !== ($visible = $paramFetcher->get('visible'))) {
$query->setVisibility($visible);
}
$data = $this->repository->findByQuery($query);
$view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Collection', 'Customer']);
return $this->viewHandler->handle($view);
}
/**
* @SWG\Response(
* response=200,
* description="Returns one customer entity",
* @SWG\Schema(ref=@Model(type=Customer::class)),
* response=200,
* description="Returns one customer entity",
* @SWG\Schema(ref="#/definitions/CustomerEntity"),
* )
*
* @param int $id
@@ -83,6 +93,7 @@ class CustomerController extends Controller
throw new NotFoundException();
}
$view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Customer']);
return $this->viewHandler->handle($view);
}

View File

@@ -37,6 +37,7 @@ class HealthcheckController extends Controller
* @SWG\Response(
* response=200,
* description="A simple route that returns a 'pong', which you can use for testing the API",
* examples={"{'message': 'pong'}"}
* )
*
* @Rest\Get(path="/ping")

View File

@@ -13,13 +13,14 @@ namespace App\API;
use App\Entity\Project;
use App\Repository\ProjectRepository;
use App\Repository\Query\ProjectQuery;
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\Model;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Response;
/**
@@ -27,7 +28,7 @@ use Symfony\Component\HttpFoundation\Response;
*
* @Security("is_granted('ROLE_USER')")
*/
class ProjectController extends Controller
class ProjectController extends BaseApiController
{
/**
* @var ProjectRepository
@@ -51,26 +52,41 @@ class ProjectController extends Controller
/**
* @SWG\Response(
* response=200,
* description="Returns the collection of all existing projects",
* @SWG\Schema(ref=@Model(type=Project::class)),
* response=200,
* description="Returns the collection of all existing projects",
* @SWG\Schema(ref="#/definitions/ProjectCollection"),
* )
* @Rest\QueryParam(name="customer", requirements="\d+", strict=true, nullable=true, description="Customer ID to filter projects")
* @Rest\QueryParam(name="visible", requirements="\d+", strict=true, nullable=true, description="Visibility status to filter projects (1=visible, 2=hidden, 3=both)")
*
* @param ParamFetcherInterface $paramFetcher
* @return Response
*/
public function cgetAction()
public function cgetAction(ParamFetcherInterface $paramFetcher)
{
$data = $this->repository->findAll();
$query = new ProjectQuery();
$query->setResultType(ProjectQuery::RESULT_TYPE_OBJECTS);
if (null !== ($customer = $paramFetcher->get('customer'))) {
$query->setCustomer($customer);
}
if (null !== ($visible = $paramFetcher->get('visible'))) {
$query->setVisibility($visible);
}
$data = $this->repository->findByQuery($query);
$view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Collection', 'Project']);
return $this->viewHandler->handle($view);
}
/**
* @SWG\Response(
* response=200,
* description="Returns one project entity",
* @SWG\Schema(ref=@Model(type=Project::class)),
* response=200,
* description="Returns one project entity",
* @SWG\Schema(ref="#/definitions/ProjectEntity"),
* )
*
* @param int $id
@@ -83,6 +99,7 @@ class ProjectController extends Controller
throw new NotFoundException();
}
$view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Project']);
return $this->viewHandler->handle($view);
}

View File

@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\API;
use App\Entity\Timesheet;
use App\Form\TimesheetEditForm;
use App\Repository\Query\TimesheetQuery;
use App\Repository\TimesheetRepository;
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 Pagerfanta\Pagerfanta;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* @RouteResource("Timesheet")
*
* @Security("is_granted('ROLE_USER')")
*/
class TimesheetController extends BaseApiController
{
/**
* @var TimesheetRepository
*/
protected $repository;
/**
* @var ViewHandlerInterface
*/
protected $viewHandler;
/**
* @param ViewHandlerInterface $viewHandler
* @param TimesheetRepository $repository
*/
public function __construct(ViewHandlerInterface $viewHandler, TimesheetRepository $repository)
{
$this->viewHandler = $viewHandler;
$this->repository = $repository;
}
/**
* @SWG\Response(
* response=200,
* description="Returns the collection of all existing timesheets for the user",
* @SWG\Schema(ref="#/definitions/TimesheetCollection"),
* )
* @Rest\QueryParam(name="customer", requirements="\d+", strict=true, nullable=true, description="Customer ID to filter timesheets")
* @Rest\QueryParam(name="project", requirements="\d+", strict=true, nullable=true, description="Project ID to filter timesheets")
* @Rest\QueryParam(name="activity", requirements="\d+", strict=true, nullable=true, description="Activity ID to filter timesheets")
* @Rest\QueryParam(name="page", requirements="\d+", strict=true, nullable=true, description="The page to display, renders a 404 if not found (default: 1)")
* @Rest\QueryParam(name="size", requirements="\d+", strict=true, nullable=true, description="The amount of entries for each page (default: 25)")
* @Rest\QueryParam(name="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order (allowed values: 'ASC', 'DESC')")
* @Rest\QueryParam(name="orderBy", requirements="id|begin|end|rate", strict=true, nullable=true, description="The field by which results will be ordered (allowed values: 'id', 'begin', 'end', 'rate')")
*
* @return Response
*/
public function cgetAction(ParamFetcherInterface $paramFetcher)
{
$query = new TimesheetQuery();
$query->setUser($this->getUser());
$query->setResultType(TimesheetQuery::RESULT_TYPE_PAGER);
if (null !== ($customer = $paramFetcher->get('customer'))) {
$query->setCustomer($customer);
}
if (null !== ($project = $paramFetcher->get('project'))) {
$query->setProject($project);
}
if (null !== ($activity = $paramFetcher->get('activity'))) {
$query->setActivity($activity);
}
if (null !== ($page = $paramFetcher->get('page'))) {
$query->setPage($page);
}
if (null !== ($size = $paramFetcher->get('size'))) {
$query->setPageSize($size);
}
if (null !== ($order = $paramFetcher->get('order'))) {
$query->setOrder($order);
}
if (null !== ($orderBy = $paramFetcher->get('orderBy'))) {
$query->setOrderBy($orderBy);
}
/** @var Pagerfanta $data */
$data = $this->repository->findByQuery($query);
$data = (array) $data->getCurrentPageResults();
$view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Collection', 'Timesheet']);
return $this->viewHandler->handle($view);
}
/**
* @SWG\Response(
* response=200,
* description="Returns one timesheet entity",
* @SWG\Schema(ref="#/definitions/TimesheetEntity")
* )
*
* @param int $id
* @return Response
*/
public function getAction($id)
{
$data = $this->repository->find($id);
if (null === $data) {
throw new NotFoundException();
}
$view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']);
return $this->viewHandler->handle($view);
}
/**
* @SWG\Post(
* description="Creates a new timesheet entry and returns it afterwards",
* @SWG\Schema(ref="#/definitions/TimesheetFormEntity"),
* @SWG\Response(
* response=200,
* description="Returns the new created timesheet entry",
* @SWG\Schema(ref="#/definitions/TimesheetEntity"),
* )
* )
*
* @param Request $request
* @return Response
*/
public function postAction(Request $request)
{
$timesheet = new Timesheet();
$timesheet->setUser($this->getUser());
$timesheet->setBegin(new \DateTime());
$form = $this->createForm(TimesheetEditForm::class, $timesheet, [
'csrf_protection' => false,
'method' => 'POST',
'duration_only' => false,
]);
$form->setData($timesheet);
$form->submit($request->request->all());
if ($form->isValid()) {
if (null !== $timesheet->getId()) {
return new Response('This method does not support updates', Response::HTTP_BAD_REQUEST);
}
if (!$this->isGranted('start', $timesheet)) {
return new Response('You are not allowed to start this timesheet record', Response::HTTP_BAD_REQUEST);
}
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($timesheet);
$entityManager->flush();
$view = new View($timesheet, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']);
return $this->viewHandler->handle($view);
}
$view = new View($form);
$view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']);
return $this->viewHandler->handle($view);
}
}

View File

@@ -16,10 +16,8 @@ use App\Repository\UserRepository;
use FOS\RestBundle\Controller\Annotations\RouteResource;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Model;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Response;
/**
@@ -28,7 +26,7 @@ use Symfony\Component\HttpFoundation\Response;
* @Security("is_granted('ROLE_SUPER_ADMIN')")
* @Security("is_granted('IS_AUTHENTICATED_FULLY')")
*/
class UserController extends Controller
class UserController extends BaseApiController
{
/**
* @var UserRepository
@@ -52,9 +50,9 @@ class UserController extends Controller
/**
* @SWG\Response(
* response=200,
* description="Returns the collection of all registered users",
* @SWG\Schema(ref=@Model(type=User::class)),
* response=200,
* description="Returns the collection of all registered users",
* @SWG\Schema(ref="#/definitions/UserCollection"),
* )
*
* @return Response
@@ -63,15 +61,16 @@ class UserController extends Controller
{
$data = $this->repository->findAll();
$view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Collection', 'User']);
return $this->viewHandler->handle($view);
}
/**
* @SWG\Response(
* response=200,
* description="Return one user entity",
* @SWG\Schema(ref=@Model(type=User::class)),
* response=200,
* description="Return one user entity",
* @SWG\Schema(ref="#/definitions/UserEntity"),
* )
*
* @param int $id
@@ -84,6 +83,7 @@ class UserController extends Controller
throw new NotFoundException();
}
$view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'User']);
return $this->viewHandler->handle($view);
}

View File

@@ -63,8 +63,8 @@ class TimesheetEntity
$this->start = $entry->getBegin();
$this->title = $entry->getActivity()->getName();
$this->description = $entry->getDescription();
$this->customer = $entry->getActivity()->getProject()->getCustomer()->getName();
$this->project = $entry->getActivity()->getProject()->getName();
$this->customer = $entry->getProject()->getCustomer()->getName();
$this->project = $entry->getProject()->getName();
$this->activity = $entry->getActivity()->getName();
if (null === $entry->getEnd()) {

View File

@@ -111,7 +111,7 @@ class CreateUserCommand extends Command
$pwd = $this->encoder->encodePassword($user, $user->getPlainPassword());
$user->setPassword($pwd);
$errors = $this->validator->validate($user);
$errors = $this->validator->validate($user, null, ['registration']);
if ($errors->count() > 0) {
/** @var \Symfony\Component\Validator\ConstraintViolation $error */
foreach ($errors as $error) {
@@ -149,13 +149,13 @@ class CreateUserCommand extends Command
/* @var QuestionHelper $helper */
$helper = $this->getHelper('question');
$passwordQuestion = new Question('Please enter the password');
$passwordQuestion = new Question('Please enter the password: ');
$passwordQuestion->setHidden(true);
$passwordQuestion->setHiddenFallback(false);
$passwordQuestion->setValidator(function (?string $value) {
$password = trim($value);
if (empty($password) || strlen($password) < 6) {
throw new \Exception('The password is too short, must be at least 6 character');
if (empty($password)) {
throw new \Exception('The password may not be empty');
}
return $value;

View File

@@ -81,15 +81,14 @@ class KimaiImporterCommand extends Command
* @var Activity[]
*/
protected $activities = [];
/**
* activityId => activity[]
* @var array
*/
protected $unassignedActivities = [];
/**
* @var bool
*/
protected $debug = false;
/**
* @var array
*/
protected $oldActivities = [];
/**
* @param UserPasswordEncoderInterface $encoder
@@ -704,16 +703,32 @@ class KimaiImporterCommand extends Command
{
$counter = 0;
$entityManager = $this->getDoctrine()->getManager();
// remember which activity has at least one assigned project
$oldActivityMapping = [];
foreach ($activityToProject as $mapping) {
$oldActivityMapping[$mapping['activityID']] = $mapping['projectID'];
$oldActivityMapping[$mapping['activityID']][] = $mapping['projectID'];
}
// create global activities
foreach ($activities as $oldActivity) {
$this->oldActivities[$oldActivity['activityID']] = $oldActivity;
if (isset($oldActivityMapping[$oldActivity['activityID']])) {
$projectId = $oldActivityMapping[$oldActivity['activityID']];
$project = null;
continue;
}
$this->createActivity($io, $entityManager, $oldActivity, $fixedRates, $rates, null);
++$counter;
}
$io->success('Created global activities: ' . $counter);
// create project specific activities
foreach ($activities as $oldActivity) {
if (!isset($oldActivityMapping[$oldActivity['activityID']])) {
continue;
}
foreach ($oldActivityMapping[$oldActivity['activityID']] as $projectId) {
if (!isset($this->projects[$projectId])) {
throw new \Exception(
'Invalid project linked to activity ' . $oldActivity['name'] . ': ' . $projectId
@@ -722,11 +737,8 @@ class KimaiImporterCommand extends Command
$project = $this->projects[$projectId];
$this->unassignedActivities[$oldActivity['activityID']] = $oldActivity;
$this->createActivity($io, $entityManager, $project, $oldActivity, $fixedRates, $rates);
$this->createActivity($io, $entityManager, $oldActivity, $fixedRates, $rates, $project);
++$counter;
} else {
$this->unassignedActivities[$oldActivity['activityID']] = $oldActivity;
}
}
@@ -736,24 +748,26 @@ class KimaiImporterCommand extends Command
/**
* @param SymfonyStyle $io
* @param ObjectManager $entityManager
* @param Project $project
* @param array $oldActivity
* @param array $fixedRates
* @param array $rates
* @param Project $project
* @return Activity
* @throws \Exception
*/
protected function createActivity(
SymfonyStyle $io,
ObjectManager $entityManager,
Project $project,
array $oldActivity,
array $fixedRates,
array $rates
array $rates,
?Project $project
) {
$activityId = $oldActivity['activityID'];
if (isset($this->activities[$activityId][$project->getId()])) {
return $this->activities[$activityId][$project->getId()];
$projectId = null !== $project ? $project->getId() : null;
if (isset($this->activities[$activityId][$projectId])) {
return $this->activities[$activityId][$projectId];
}
$isActive = (bool) $oldActivity['visible'] && !(bool) $oldActivity['trash'];
@@ -775,7 +789,7 @@ class KimaiImporterCommand extends Command
if ($fixedRow['activityID'] === null) {
continue;
}
if ($fixedRow['projectID'] !== null && $fixedRow['projectID'] !== $project->getId()) {
if ($fixedRow['projectID'] !== null && $fixedRow['projectID'] !== $projectId) {
continue;
}
@@ -788,7 +802,7 @@ class KimaiImporterCommand extends Command
if ($ratesRow['userID'] !== null || $ratesRow['activityID'] === null) {
continue;
}
if ($ratesRow['projectID'] !== null && $ratesRow['projectID'] !== $project->getId()) {
if ($ratesRow['projectID'] !== null && $ratesRow['projectID'] !== $projectId) {
continue;
}
@@ -815,7 +829,7 @@ class KimaiImporterCommand extends Command
if (!isset($this->activities[$activityId])) {
$this->activities[$activityId] = [];
}
$this->activities[$activityId][$project->getId()] = $activity;
$this->activities[$activityId][$projectId] = $activity;
return $activity;
}
@@ -872,18 +886,22 @@ class KimaiImporterCommand extends Command
continue;
}
$customerId = $project->getCustomer()->getId();
if (isset($this->activities[$activityId][$projectId])) {
$activity = $this->activities[$activityId][$projectId];
} elseif (isset($this->activities[$activityId][null])) {
$activity = $this->activities[$activityId][null];
}
if (null === $activity && isset($this->unassignedActivities[$activityId])) {
$oldActivity = $this->unassignedActivities[$activityId];
$activity = $this->createActivity($io, $entityManager, $project, $oldActivity, $fixedRates, $rates);
if (null === $activity && isset($this->oldActivities[$activityId])) {
$oldActivity = $this->oldActivities[$activityId];
$activity = $this->createActivity($io, $entityManager, $oldActivity, $fixedRates, $rates, $project);
++$activityCounter;
}
if (null === $activity) {
$io->error('Could not create timesheet record, missing activity with ID: ' . $activityId);
$io->error('Could not create timesheet record, missing activity with ID: ' . $activityId . '/' . $projectId . '/' . $customerId);
continue;
}
@@ -904,7 +922,7 @@ class KimaiImporterCommand extends Command
if ($timesheet->getFixedRate() !== null) {
$timesheet->setRate($timesheet->getFixedRate());
} elseif ($timesheet->getHourlyRate() !== null) {
$rate = $timesheet->getHourlyRate();
$hourlyRate = $timesheet->getHourlyRate();
$rate = (float) $hourlyRate * ($duration / 3600);
$timesheet->setRate(round($rate, 2));
}
@@ -916,6 +934,7 @@ class KimaiImporterCommand extends Command
->setEnd(new \DateTime('@' . $oldRecord['end']))
->setDuration($duration)
->setActivity($activity)
->setProject($project)
;
if (!$this->validateImport($io, $timesheet)) {
@@ -947,7 +966,7 @@ class KimaiImporterCommand extends Command
$io->writeln(' (' . $counter . '/' . $total . ')');
if ($activityCounter > 0) {
$io->success('Created new (previously global) activities during timesheet import: ' . $activityCounter);
$io->success('Created new activities during timesheet import: ' . $activityCounter);
}
return $counter;

View File

@@ -17,17 +17,21 @@ class Constants
/**
* The current release version
*/
public const VERSION = 0.5;
public const VERSION = 0.6;
/**
* The release name
* The release name, will only change for new major version
*/
public const NAME = 'Ayumi';
/**
* The current release status
* The current release status, either "stable" or "dev"
*/
public const STATUS = 'stable';
/**
* Used in multiple views
*/
public const GITHUB = 'https://github.com/kevinpapst/kimai2/';
/**
* Used in multiple views
*/
public const HOMEPAGE = 'https://v2.kimai.org';
}

View File

@@ -12,7 +12,6 @@ namespace App\Controller;
use App\Entity\Activity;
use App\Repository\ActivityRepository;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Response;
/**
@@ -20,7 +19,7 @@ use Symfony\Component\HttpFoundation\Response;
*
* @Security("is_granted('ROLE_USER')")
*/
class ActivityController extends Controller
class ActivityController extends AbstractController
{
/**
* @return ActivityRepository
@@ -38,12 +37,11 @@ class ActivityController extends Controller
public function recentActivitiesAction()
{
$user = $this->getUser();
// TODO make days configurable
$activeEntries = $this->getRepository()->getRecentActivities($user, new \DateTime('-30 days'));
$entries = $this->getRepository()->getRecentActivities($user, new \DateTime('-30 days'));
return $this->render(
'navbar/recent-activities.html.twig',
['activities' => $activeEntries]
['entries' => $entries]
);
}
}

View File

@@ -0,0 +1,148 @@
<?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\Admin;
use App\Constants;
use App\Controller\AbstractController;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
/**
* Controller used for executing system relevant tasks.
*
* @Route(path="/admin/about")
* @Security("is_granted('ROLE_SUPER_ADMIN')")
*/
class AboutController extends AbstractController
{
/**
* @var string
*/
protected $projectDirectory;
/**
* @param string $projectDirectory
*/
public function __construct(string $projectDirectory)
{
$this->projectDirectory = $projectDirectory;
}
/**
* @Route(path="", name="about", methods={"GET"})
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
*/
public function indexAction(Request $request)
{
$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',
];
$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('admin/system.html.twig', [
'modules' => get_loaded_extensions(),
'dotenv' => [
'APP_ENV' => getenv('APP_ENV'),
'DATABASE_PREFIX' => getenv('DATABASE_PREFIX'),
'MAILER_FROM' => getenv('MAILER_FROM'),
'MAILER_URL' => getenv('MAILER_URL'),
'CORS_ALLOW_ORIGIN' => getenv('CORS_ALLOW_ORIGIN'),
],
'info' => $phpInfo,
'settings' => $settings,
'license' => $this->getLicense(),
]);
}
/**
* @return string
*/
protected function getLicense()
{
$filename = $this->projectDirectory . '/LICENSE';
try {
$license = file_get_contents($filename);
} catch (\Exception $ex) {
$license = false;
}
if (false === $license) {
$license = 'Failed reading license file: ' . $filename . '. ' .
'Check this instead: ' . Constants::GITHUB . 'blob/master/LICENSE';
}
return $license;
}
/**
* @author https://php.net/manual/en/function.phpinfo.php#117961
* @return array
*/
private function getPhpInfo()
{
$plainText = function ($input) {
return trim(html_entity_decode(strip_tags($input)));
};
ob_start();
phpinfo(1);
$phpinfo = ['phpinfo' => []];
if (preg_match_all(
'#(?:<h2.*?>(?:<a.*?>)?(.*?)(?:<\/a>)?<\/h2>)|' .
'(?:<tr.*?><t[hd].*?>(.*?)\s*</t[hd]>(?:<t[hd].*?>(.*?)\s*</t[hd]>(?:<t[hd].*?>(.*?)\s*</t[hd]>)?)?</tr>)#s',
ob_get_clean(),
$matches,
PREG_SET_ORDER
)) {
foreach ($matches as $match) {
$fn = $plainText;
if (isset($match[3])) {
$keys1 = array_keys($phpinfo);
$phpinfo[end($keys1)][$fn($match[2])] = isset($match[4]) ? [$fn($match[3]), $fn($match[4])] : $fn($match[3]);
} else {
$keys1 = array_keys($phpinfo);
$phpinfo[end($keys1)][] = $fn($match[2]);
}
}
}
return $phpinfo['phpinfo'];
}
}

View File

@@ -13,7 +13,10 @@ use App\Controller\AbstractController;
use App\Entity\Activity;
use App\Form\ActivityEditForm;
use App\Form\Toolbar\ActivityToolbarForm;
use App\Form\Type\ActivityType;
use App\Repository\ActivityRepository;
use App\Repository\Query\ActivityQuery;
use Doctrine\ORM\ORMException;
use Pagerfanta\Pagerfanta;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Cache;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
@@ -61,6 +64,7 @@ class ActivityController extends AbstractController
return $this->render('admin/activity.html.twig', [
'entries' => $entries,
'query' => $query,
'showFilter' => $form->isSubmitted(),
'toolbarForm' => $form->createView(),
]);
}
@@ -104,6 +108,22 @@ class ActivityController extends AbstractController
$stats = $this->getRepository()->getActivityStatistics($activity);
$deleteForm = $this->createFormBuilder()
->add('activity', ActivityType::class, [
'label' => 'label.activity',
'query_builder' => function (ActivityRepository $repo) use ($activity) {
$query = new ActivityQuery();
$query
->setResultType(ActivityQuery::RESULT_TYPE_QUERYBUILDER)
->setProject($activity->getProject())
->setOrderGlobalsFirst(true)
->addIgnoredEntity($activity)
->setGlobalsOnly(null === $activity->getProject())
;
return $repo->findByQuery($query);
},
'required' => false,
])
->setAction($this->generateUrl('admin_activity_delete', ['id' => $activity->getId()]))
->setMethod('POST')
->getForm();
@@ -111,11 +131,12 @@ class ActivityController extends AbstractController
$deleteForm->handleRequest($request);
if (0 == $stats->getRecordAmount() || ($deleteForm->isSubmitted() && $deleteForm->isValid())) {
$entityManager = $this->getDoctrine()->getManager();
$entityManager->remove($activity);
$entityManager->flush();
$this->flashSuccess('action.delete.success');
try {
$this->getRepository()->deleteActivity($activity, $deleteForm->get('activity')->getData());
$this->flashSuccess('action.delete.success');
} catch (ORMException $ex) {
$this->flashError('action.delete.error');
}
return $this->redirectToRoute('admin_activity');
}

View File

@@ -13,7 +13,10 @@ use App\Controller\AbstractController;
use App\Entity\Customer;
use App\Form\CustomerEditForm;
use App\Form\Toolbar\CustomerToolbarForm;
use App\Form\Type\CustomerType;
use App\Repository\CustomerRepository;
use App\Repository\Query\CustomerQuery;
use Doctrine\ORM\ORMException;
use Pagerfanta\Pagerfanta;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
@@ -28,6 +31,19 @@ use Symfony\Component\Routing\Annotation\Route;
*/
class CustomerController extends AbstractController
{
/**
* @var array
*/
private $defaults;
/**
* @param array $defaults
*/
public function __construct(array $defaults)
{
$this->defaults = $defaults;
}
/**
* @return \App\Repository\CustomerRepository
*/
@@ -58,6 +74,7 @@ class CustomerController extends AbstractController
return $this->render('admin/customer.html.twig', [
'entries' => $entries,
'query' => $query,
'showFilter' => $form->isSubmitted(),
'toolbarForm' => $form->createView(),
]);
}
@@ -67,7 +84,12 @@ class CustomerController extends AbstractController
*/
public function createAction(Request $request)
{
return $this->renderCustomerForm(new Customer(), $request);
$customer = new Customer();
$customer->setCountry($this->defaults['customer']['country']);
$customer->setCurrency($this->defaults['customer']['currency']);
$customer->setTimezone($this->defaults['customer']['timezone']);
return $this->renderCustomerForm($customer, $request);
}
/**
@@ -121,6 +143,18 @@ class CustomerController extends AbstractController
$stats = $this->getRepository()->getCustomerStatistics($customer);
$deleteForm = $this->createFormBuilder()
->add('customer', CustomerType::class, [
'label' => 'label.customer',
'query_builder' => function (CustomerRepository $repo) use ($customer) {
$query = new CustomerQuery();
$query
->setResultType(CustomerQuery::RESULT_TYPE_QUERYBUILDER)
->addIgnoredEntity($customer);
return $repo->findByQuery($query);
},
'required' => false,
])
->setAction($this->generateUrl('admin_customer_delete', ['id' => $customer->getId()]))
->setMethod('POST')
->getForm();
@@ -128,11 +162,12 @@ class CustomerController extends AbstractController
$deleteForm->handleRequest($request);
if (0 == $stats->getRecordAmount() || ($deleteForm->isSubmitted() && $deleteForm->isValid())) {
$entityManager = $this->getDoctrine()->getManager();
$entityManager->remove($customer);
$entityManager->flush();
$this->flashSuccess('action.delete.success');
try {
$this->getRepository()->deleteCustomer($customer, $deleteForm->get('customer')->getData());
$this->flashSuccess('action.delete.success');
} catch (ORMException $ex) {
$this->flashError('action.delete.error');
}
return $this->redirectToRoute('admin_customer');
}

View File

@@ -14,7 +14,10 @@ use App\Entity\Customer;
use App\Entity\Project;
use App\Form\ProjectEditForm;
use App\Form\Toolbar\ProjectToolbarForm;
use App\Form\Type\ProjectType;
use App\Repository\ProjectRepository;
use App\Repository\Query\ProjectQuery;
use Doctrine\ORM\ORMException;
use Pagerfanta\Pagerfanta;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Cache;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
@@ -62,6 +65,7 @@ class ProjectController extends AbstractController
return $this->render('admin/project.html.twig', [
'entries' => $entries,
'query' => $query,
'showFilter' => $form->isSubmitted(),
'toolbarForm' => $form->createView(),
]);
}
@@ -98,6 +102,19 @@ class ProjectController extends AbstractController
$stats = $this->getRepository()->getProjectStatistics($project);
$deleteForm = $this->createFormBuilder()
->add('project', ProjectType::class, [
'label' => 'label.project',
'query_builder' => function (ProjectRepository $repo) use ($project) {
$query = new ProjectQuery();
$query
->setResultType(ProjectQuery::RESULT_TYPE_QUERYBUILDER)
->setCustomer($project->getCustomer())
->addIgnoredEntity($project);
return $repo->findByQuery($query);
},
'required' => false,
])
->setAction($this->generateUrl('admin_project_delete', ['id' => $project->getId()]))
->setMethod('POST')
->getForm();
@@ -105,11 +122,12 @@ class ProjectController extends AbstractController
$deleteForm->handleRequest($request);
if (0 == $stats->getRecordAmount() || ($deleteForm->isSubmitted() && $deleteForm->isValid())) {
$entityManager = $this->getDoctrine()->getManager();
$entityManager->remove($project);
$entityManager->flush();
$this->flashSuccess('action.delete.success');
try {
$this->getRepository()->deleteProject($project, $deleteForm->get('project')->getData());
$this->flashSuccess('action.delete.success');
} catch (ORMException $ex) {
$this->flashError('action.delete.error');
}
return $this->redirectToRoute('admin_project');
}

View File

@@ -75,6 +75,7 @@ class TimesheetController extends AbstractController
'entries' => $entries,
'page' => $page,
'query' => $query,
'showFilter' => $form->isSubmitted(),
'toolbarForm' => $form->createView(),
]);
}

View File

@@ -77,6 +77,7 @@ class UserController extends AbstractController
return $this->render('admin/user.html.twig', [
'entries' => $entries,
'query' => $query,
'showFilter' => $form->isSubmitted(),
'toolbarForm' => $form->createView(),
]);
}

View File

@@ -13,7 +13,6 @@ use App\Event\DashboardEvent;
use App\Model\DashboardSection;
use App\Repository\WidgetRepository;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Routing\Annotation\Route;
@@ -23,7 +22,7 @@ use Symfony\Component\Routing\Annotation\Route;
* @Route(path="/dashboard")
* @Security("is_granted('ROLE_USER')")
*/
class DashboardController extends Controller
class DashboardController extends AbstractController
{
/**
* @var EventDispatcherInterface

View File

@@ -11,7 +11,6 @@ namespace App\Controller;
use App\Constants;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\Routing\Annotation\Route;
/**
@@ -20,7 +19,7 @@ use Symfony\Component\Routing\Annotation\Route;
* @Route(path="/help")
* @Security("is_granted('ROLE_USER')")
*/
class HelpController extends Controller
class HelpController extends AbstractController
{
public const README = 'README';
public const DOCS_DIR = 'var/docs/';

View File

@@ -0,0 +1,43 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Controller;
use App\Entity\User;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
/**
* Homepage controller is a redirect controller with user specific logic.
*
* @Route(path="/homepage")
* @Security("is_granted('ROLE_USER')")
*/
class HomepageController extends AbstractController
{
/**
* @Route(path="", defaults={}, name="homepage", methods={"GET"})
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse
*/
public function indexAction(Request $request)
{
// make me configurable via UserPreference
$route = 'timesheet';
/** @var User $user */
$user = $this->getUser();
$locale = $request->getLocale();
$language = $user->getPreferenceValue('language', $locale);
return $this->redirectToRoute($route, ['_locale' => $language]);
}
}

View File

@@ -161,6 +161,7 @@ class InvoiceController extends AbstractController
*/
protected function getEntries(InvoiceQuery $query)
{
// customer needs to be defined, as we need the currency for the invoice
if (null === $query->getCustomer()) {
return [];
}

View File

@@ -45,6 +45,14 @@ class ProfileController extends AbstractController
$this->encoder = $encoder;
}
/**
* @Route(path="/", name="fos_user_profile_show", methods={"GET"})
*/
public function profileAction()
{
return $this->redirectToRoute('user_profile', ['username' => $this->getUser()->getUsername()]);
}
/**
* @Route(path="/{username}", name="user_profile", methods={"GET"})
* @Security("is_granted('view', profile)")
@@ -186,7 +194,13 @@ class ProfileController extends AbstractController
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('user_profile_preferences', ['username' => $profile->getUsername()]);
// switch locale if neccessary
$locale = $profile->getPreferenceValue('language', $request->getLocale());
return $this->redirectToRoute('user_profile_preferences', [
'_locale' => $locale,
'username' => $profile->getUsername()
]);
}
return $this->getProfileView($profile, 'preferences', null, null, null, $form);

View File

@@ -9,7 +9,6 @@
namespace App\Controller;
use App\Entity\Activity;
use App\Entity\Timesheet;
use App\Form\TimesheetEditForm;
use App\Form\Toolbar\TimesheetToolbarForm;
@@ -19,6 +18,7 @@ use Sensio\Bundle\FrameworkExtraBundle\Configuration\Cache;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* Controller used to manage timesheets.
@@ -74,6 +74,7 @@ class TimesheetController extends AbstractController
'entries' => $entries,
'page' => $page,
'query' => $query,
'showFilter' => $form->isSubmitted(),
'toolbarForm' => $form->createView(),
]);
}
@@ -143,20 +144,36 @@ class TimesheetController extends AbstractController
}
/**
* The route to start a running entry.
* The route to re-start a timesheet entry.
*
* @Route(path="/start/{id}", name="timesheet_start", requirements={"id" = "\d+"}, methods={"GET", "POST"})
* @Security("is_granted('start', activity)")
* @Security("is_granted('start', timesheet)")
*
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
*/
public function startAction(Activity $activity)
public function startAction(ValidatorInterface $validator, Timesheet $timesheet)
{
$user = $this->getUser();
try {
$this->getRepository()->startRecording($user, $activity);
$this->flashSuccess('timesheet.start.success');
$entry = new Timesheet();
$entry
->setBegin(new \DateTime())
->setUser($user)
->setActivity($timesheet->getActivity())
->setProject($timesheet->getProject())
;
$errors = $validator->validate($entry);
if (count($errors) > 0) {
$this->flashError('timesheet.start.error', ['%reason%' => $errors[0]->getPropertyPath() . ' = ' . $errors[0]->getMessage()]);
} else {
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($entry);
$entityManager->flush();
$this->flashSuccess('timesheet.start.success');
}
} catch (\Exception $ex) {
$this->flashError('timesheet.start.error', ['%reason%' => $ex->getMessage()]);
}

View File

@@ -30,6 +30,8 @@ class CustomerFixtures extends Fixture
public const MAX_CUSTOMERS = 15;
public const MIN_BUDGET = 0;
public const MAX_BUDGET = 100000;
public const MIN_GLOBAL_ACTIVITIES = 5;
public const MAX_GLOBAL_ACTIVITIES = 50;
public const MIN_PROJECTS_PER_CUSTOMER = 10;
public const MAX_PROJECTS_PER_CUSTOMER = 50;
public const MIN_ACTIVITIES_PER_PROJECT = 0;
@@ -66,6 +68,16 @@ class CustomerFixtures extends Fixture
$manager->flush();
$manager->clear();
}
$amountGlobalActivities = rand(self::MIN_GLOBAL_ACTIVITIES, self::MAX_GLOBAL_ACTIVITIES);
for ($c = 1; $c <= $amountGlobalActivities; $c++) {
$visibleActivity = 0 != $a % 3;
$activity = $this->createActivity($faker, null, $visibleActivity);
$manager->persist($activity);
}
$manager->flush();
$manager->clear();
}
/**
@@ -113,11 +125,11 @@ class CustomerFixtures extends Fixture
/**
* @param Generator $faker
* @param Project $project
* @param Project|null $project
* @param bool $visible
* @return Activity
*/
private function createActivity(Generator $faker, Project $project, $visible)
private function createActivity(Generator $faker, ?Project $project, $visible)
{
$entry = new Activity();
$entry

View File

@@ -10,6 +10,7 @@
namespace App\DataFixtures;
use App\Entity\Activity;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Entity\UserPreference;
@@ -56,6 +57,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
{
$allUser = $this->getAllUsers($manager);
$activities = $this->getAllActivities($manager);
$projects = $this->getAllProjects($manager);
$faker = Factory::create();
@@ -80,6 +82,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
$entry = $this->createTimesheetEntry(
$user,
$activities[array_rand($activities)],
$projects[array_rand($projects)],
$description
);
@@ -98,6 +101,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
$entry = $this->createTimesheetEntry(
$user,
$activities[array_rand($activities)],
$projects[array_rand($projects)],
null,
false
);
@@ -126,6 +130,22 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
return $all;
}
/**
* @param ObjectManager $manager
* @return Project[]
*/
protected function getAllProjects(ObjectManager $manager)
{
$all = [];
/* @var Project[] $entries */
$entries = $manager->getRepository(Project::class)->findAll();
foreach ($entries as $temp) {
$all[$temp->getId()] = $temp;
}
return $all;
}
/**
* @param ObjectManager $manager
* @return Activity[]
@@ -133,7 +153,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
protected function getAllActivities(ObjectManager $manager)
{
$all = [];
/* @var User[] $entries */
/* @var Activity[] $entries */
$entries = $manager->getRepository(Activity::class)->findAll();
foreach ($entries as $temp) {
$all[$temp->getId()] = $temp;
@@ -142,7 +162,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
return $all;
}
private function createTimesheetEntry(User $user, Activity $activity, $description, $setEndDate = true)
private function createTimesheetEntry(User $user, Activity $activity, Project $project, $description, $setEndDate = true)
{
$start = new \DateTime();
$start = $start->modify('- ' . (rand(1, self::TIMERANGE_DAYS)) . ' days');
@@ -151,6 +171,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
$entry = new Timesheet();
$entry
->setActivity($activity)
->setProject($activity->getProject() ?? $project)
->setDescription($description)
->setUser($user)
->setBegin($start);

View File

@@ -35,46 +35,57 @@ class AppExtension extends Extension implements PrependExtensionInterface
$container->setParameter('kimai.languages', $config['languages']);
$container->setParameter('kimai.calendar', $config['calendar']);
$container->setParameter('kimai.theme', $config['theme']);
$container->setParameter('kimai.dashboard', $config['dashboard']);
$container->setParameter('kimai.widgets', $config['widgets']);
$container->setParameter('kimai.invoice.documents', $config['invoice']['documents']);
$container->setParameter('kimai.defaults', $config['defaults']);
$this->createUserParameter($config, $container);
$this->createTimesheetParameter($config, $container);
$this->createThemeParameter($config['theme'], $container);
$this->createUserParameter($config['user'], $container);
$this->createTimesheetParameter($config['timesheet'], $container);
}
/**
* @param array $config
* @param ContainerBuilder $container
*/
public function createUserParameter(array $config, ContainerBuilder $container)
protected function createThemeParameter(array $config, ContainerBuilder $container)
{
if (!$config['user']['registration']) {
$container->setParameter('kimai.theme', $config);
$container->setParameter('kimai.theme.select_type', $config['select_type']);
}
/**
* @param array $config
* @param ContainerBuilder $container
*/
protected function createUserParameter(array $config, ContainerBuilder $container)
{
if (!$config['registration']) {
$routes = $container->getParameter('admin_lte_theme.routes');
$routes['adminlte_registration'] = null;
$container->setParameter('admin_lte_theme.routes', $routes);
}
if (!$config['user']['password_reset']) {
if (!$config['password_reset']) {
$routes = $container->getParameter('admin_lte_theme.routes');
$routes['adminlte_password_reset'] = null;
$container->setParameter('admin_lte_theme.routes', $routes);
}
$container->setParameter('kimai.fosuser', $config['user']);
$container->setParameter('kimai.fosuser', $config);
}
/**
* @param array $config
* @param ContainerBuilder $container
*/
public function createTimesheetParameter(array $config, ContainerBuilder $container)
protected function createTimesheetParameter(array $config, ContainerBuilder $container)
{
$container->setParameter('kimai.timesheet.rates', $config['timesheet']['rates']);
$container->setParameter('kimai.timesheet.rounding', $config['timesheet']['rounding']);
$container->setParameter('kimai.timesheet.duration_only', $config['timesheet']['duration_only']);
$container->setParameter('kimai.timesheet.markdown', $config['timesheet']['markdown_content']);
$container->setParameter('kimai.timesheet.rates', $config['rates']);
$container->setParameter('kimai.timesheet.rounding', $config['rounding']);
$container->setParameter('kimai.timesheet.duration_only', $config['duration_only']);
$container->setParameter('kimai.timesheet.markdown', $config['markdown_content']);
}
/**

View File

@@ -39,6 +39,7 @@ class Configuration implements ConfigurationInterface
->append($this->getThemeNode())
->append($this->getDashboardNode())
->append($this->getWidgetsNode())
->append($this->getDefaultsNode())
->end()
->end();
@@ -214,6 +215,9 @@ class Configuration implements ConfigurationInterface
->scalarNode('box_color')
->defaultValue('green')
->end()
->scalarNode('select_type')
->defaultNull()
->end()
->end()
;
@@ -303,4 +307,26 @@ class Configuration implements ConfigurationInterface
return $node;
}
protected function getDefaultsNode()
{
$builder = new TreeBuilder();
$node = $builder->root('defaults');
$node
->addDefaultsIfNotSet()
->children()
->arrayNode('customer')
->addDefaultsIfNotSet()
->children()
->scalarNode('timezone')->defaultValue('Europe/Berlin')->end()
->scalarNode('country')->defaultValue('DE')->end()
->scalarNode('currency')->defaultValue('EUR')->end()
->end()
->end()
->end()
;
return $node;
}
}

View File

@@ -34,7 +34,6 @@ class Activity
*
* @ORM\ManyToOne(targetEntity="App\Entity\Project", inversedBy="activities")
* @ORM\JoinColumn(onDelete="CASCADE")
* @Assert\NotNull()
*/
private $project;

View File

@@ -33,7 +33,7 @@ class Project
* @var Customer
*
* @ORM\ManyToOne(targetEntity="App\Entity\Customer", inversedBy="projects")
* @ORM\JoinColumn(onDelete="CASCADE")
* @ORM\JoinColumn(onDelete="CASCADE", nullable=false)
* @Assert\NotNull()
*/
private $customer;
@@ -101,6 +101,13 @@ class Project
*/
private $hourlyRate = null;
/**
* @var Timesheet[]
*
* @ORM\OneToMany(targetEntity="App\Entity\Timesheet", mappedBy="project")
*/
private $timesheets;
/**
* @return int
*/
@@ -118,8 +125,8 @@ class Project
}
/**
* @param $customer
* @return $this
* @param Customer $customer
* @return Project
*/
public function setCustomer($customer)
{
@@ -129,8 +136,6 @@ class Project
}
/**
* Set name
*
* @param string $name
* @return Project
*/
@@ -206,6 +211,25 @@ class Project
return $this->budget;
}
/**
* @param Timesheet[] $timesheets
* @return Project
*/
public function setTimesheets($timesheets)
{
$this->timesheets = $timesheets;
return $this;
}
/**
* @return Timesheet[]
*/
public function getTimesheets()
{
return $this->timesheets;
}
/**
* @param Activity[] $activities
* @return Project

View File

@@ -64,7 +64,7 @@ class Timesheet
* @var User
*
* @ORM\ManyToOne(targetEntity="App\Entity\User")
* @ORM\JoinColumn(name="user", referencedColumnName="id", onDelete="CASCADE")
* @ORM\JoinColumn(name="user", referencedColumnName="id", onDelete="CASCADE", nullable=false)
* @Assert\NotNull()
*/
private $user;
@@ -73,11 +73,20 @@ class Timesheet
* @var Activity
*
* @ORM\ManyToOne(targetEntity="App\Entity\Activity", inversedBy="timesheets")
* @ORM\JoinColumn(onDelete="CASCADE")
* @ORM\JoinColumn(onDelete="CASCADE", nullable=false)
* @Assert\NotNull()
*/
private $activity;
/**
* @var Project
*
* @ORM\ManyToOne(targetEntity="App\Entity\Project", inversedBy="timesheets")
* @ORM\JoinColumn(onDelete="CASCADE", nullable=false)
* @Assert\NotNull()
*/
private $project;
/**
* @var string
*
@@ -232,6 +241,25 @@ class Timesheet
return $this->activity;
}
/**
* @return Project
*/
public function getProject()
{
return $this->project;
}
/**
* @param Project $project
* @return Timesheet
*/
public function setProject(Project $project)
{
$this->project = $project;
return $this;
}
/**
* Set description
*
@@ -317,6 +345,8 @@ class Timesheet
}
/**
* These validations are used in places, where we don't use a form yet (like the API).
*
* @param ExecutionContextInterface $context
* @param mixed $payload
*
@@ -324,7 +354,58 @@ class Timesheet
*/
public function validate(ExecutionContextInterface $context, $payload)
{
if (null !== $this->getEnd() && $this->getEnd()->getTimestamp() < $this->getBegin()->getTimestamp()) {
if (null === ($activity = $this->getActivity())) {
$context->buildViolation('A timesheet must have an activity.')
->atPath('activity')
->setTranslationDomain('validators')
->addViolation();
}
if (null === ($project = $this->getProject())) {
$context->buildViolation('A timesheet must have a project.')
->atPath('project')
->setTranslationDomain('validators')
->addViolation();
}
if (null !== $activity && null !== $project) {
if (null !== $activity->getProject() && $activity->getProject() !== $project) {
$context->buildViolation('Project mismatch, project specific activity and timesheet project are different.')
->atPath('project')
->setTranslationDomain('validators')
->addViolation();
}
if ($activity->getVisible() === false) {
$context->buildViolation('Cannot start a disabled activity.')
->atPath('activity')
->setTranslationDomain('validators')
->addViolation();
}
if ($project->getVisible() === false) {
$context->buildViolation('Cannot start a disabled project.')
->atPath('project')
->setTranslationDomain('validators')
->addViolation();
}
if ($project->getCustomer()->getVisible() === false) {
$context->buildViolation('Cannot start a disabled customer.')
->atPath('customer')
->setTranslationDomain('validators')
->addViolation();
}
}
if (null === $this->getBegin() && null !== $this->getEnd()) {
$context->buildViolation('You must submit a begin date before an end date is added.')
->atPath('begin')
->setTranslationDomain('validators')
->addViolation();
}
if (null !== $this->getBegin() && null !== $this->getEnd() && $this->getEnd()->getTimestamp() < $this->getBegin()->getTimestamp()) {
$context->buildViolation('End date must not be earlier then start date.')
->atPath('end')
->setTranslationDomain('validators')

View File

@@ -89,10 +89,6 @@ class MenuBuilderSubscriber implements EventSubscriberInterface
);
}
$event->addItem(
new MenuItemModel('logout', 'menu.logout', 'fos_user_security_logout', [], 'fas fa-sign-out-alt')
);
$this->activateByRoute(
$event->getRequest()->get('_route'),
$event->getItems()

View File

@@ -9,8 +9,10 @@
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
@@ -21,7 +23,7 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
*
* @author Oleg Voronkovich <oleg-voronkovich@yandex.ru>
*/
class RedirectToLocaleSubscriber
class RedirectToLocaleSubscriber implements EventSubscriberInterface
{
/**
* @var UrlGeneratorInterface
@@ -70,6 +72,16 @@ class RedirectToLocaleSubscriber
$this->locales = array_unique($this->locales);
}
/**
* @return array
*/
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onKernelRequest']
];
}
/**
* @param GetResponseEvent $event
*/

View File

@@ -18,7 +18,7 @@ use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
/**
* Class ThemeOptionsSubscriber
* Allows dynamic injection of theme related options.
*/
class ThemeOptionsSubscriber implements EventSubscriberInterface
{
@@ -33,7 +33,6 @@ class ThemeOptionsSubscriber implements EventSubscriberInterface
protected $helper;
/**
* ThemeOptionsSubscriber constructor.
* @param TokenStorageInterface $storage
* @param ContextHelper $helper
*/

View File

@@ -12,11 +12,14 @@ namespace App\EventSubscriber;
use App\Entity\User;
use App\Entity\UserPreference;
use App\Event\UserPreferenceEvent;
use App\Form\Type\CalendarViewType;
use App\Form\Type\LanguageType;
use App\Form\Type\SkinType;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TimezoneType;
use Symfony\Component\HttpKernel\Event\KernelEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
@@ -69,6 +72,16 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
->setValue(0)
->setType(IntegerType::class)
->addConstraint(new Range(['min' => 0])),
/*
(new UserPreference())
->setName('timezone')
->setValue(date_default_timezone_get())
->setType(TimezoneType::class),
*/
(new UserPreference())
->setName('language')
->setValue('en') // TODO fetch from services.yaml
->setType(LanguageType::class),
(new UserPreference())
->setName(UserPreference::SKIN)
@@ -95,12 +108,10 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
->setValue(true)
->setType(CheckboxType::class),
/*
(new UserPreference())
->setName('language')
->setValue('de')
->setType(LanguageType::class),
*/
->setName('calendar.initial_view')
->setValue(CalendarViewType::DEFAULT_VIEW)
->setType(CalendarViewType::class),
];
}

View File

@@ -10,8 +10,10 @@
namespace App\Form;
use App\Entity\Activity;
use App\Form\Type\CustomerType;
use App\Form\Type\ProjectType;
use App\Form\Type\YesNoType;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
@@ -35,8 +37,11 @@ class ActivityEditForm extends AbstractType
$entry = $options['data'];
$project = null;
if ($entry->getId() !== null) {
$customer = null;
if (null !== $entry->getProject()) {
$project = $entry->getProject();
$customer = $project->getCustomer();
}
$builder
@@ -49,9 +54,21 @@ class ActivityEditForm extends AbstractType
'label' => 'label.comment',
'required' => false,
])
// entity type: project
->add('customer', CustomerType::class, [
'label' => 'label.customer',
'query_builder' => function (CustomerRepository $repo) use ($customer) {
return $repo->builderForEntityType($customer);
},
'required' => false,
'mapped' => false,
'attr' => [
'data-related-select' => $this->getBlockPrefix() . '_project',
'data-api-url' => ['get_projects', ['customer' => '-s-']],
],
])
->add('project', ProjectType::class, [
'label' => 'label.project',
'required' => false,
'query_builder' => function (ProjectRepository $repo) use ($project) {
return $repo->builderForEntityType($project);
},

View File

@@ -0,0 +1,65 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form\Extension;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
/**
* Converts normal select boxes into javascript enhanced versions.
*/
class EnhancedChoiceTypeExtension extends AbstractTypeExtension
{
public const TYPE_SELECTPICKER = 'selectpicker';
/**
* @var string|null
*/
protected $type = null;
/**
* @param null|string $type
*/
public function __construct(?string $type)
{
$this->type = $type;
}
/**
* @return string
*/
public function getExtendedType()
{
return EntityType::class;
}
/**
* @param FormView $view
* @param FormInterface $form
* @param array $options
*/
public function buildView(FormView $view, FormInterface $form, array $options)
{
if ($this->type !== self::TYPE_SELECTPICKER) {
return;
}
if (!isset($view->vars['attr'])) {
$view->vars['attr'] = [];
}
$view->vars['attr'] = array_merge(
$view->vars['attr'],
['class' => 'selectpicker', 'data-live-search' => true, 'data-width' => '100%']
);
}
}

View File

@@ -10,15 +10,21 @@
namespace App\Form;
use App\Entity\Timesheet;
use App\Form\Type\ActivityGroupedWithCustomerNameType;
use App\Form\Type\ActivityType;
use App\Form\Type\CustomerType;
use App\Form\Type\DurationType;
use App\Form\Type\ProjectType;
use App\Form\Type\UserType;
use App\Repository\ActivityRepository;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
@@ -31,15 +37,27 @@ class TimesheetEditForm extends AbstractType
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
/** @var Timesheet $entry */
$entry = $options['data'];
$activity = null;
if ($entry->getId() !== null) {
$project = null;
$customer = null;
$end = null;
if (isset($options['data'])) {
/** @var Timesheet $entry */
$entry = $options['data'];
$activity = $entry->getActivity();
$project = $entry->getProject();
$customer = null === $entry->getProject() ? null : $entry->getProject()->getCustomer();
if (null === $project && null !== $activity) {
$project = $activity->getProject();
}
$end = $entry->getEnd();
}
if (null === $entry->getEnd() || !$options['duration_only']) {
if (null === $end || !$options['duration_only']) {
$builder->add('begin', DateTimeType::class, [
'label' => 'label.begin',
'widget' => 'single_text',
@@ -65,7 +83,47 @@ class TimesheetEditForm extends AbstractType
}
$builder
->add('activity', ActivityGroupedWithCustomerNameType::class, [
->add('customer', CustomerType::class, [
// documentation is for NelmioApiDocBundle
'documentation' => [
'type' => 'integer',
'description' => 'Customer ID',
],
'label' => 'label.customer',
'query_builder' => function (CustomerRepository $repo) use ($customer) {
return $repo->builderForEntityType($customer);
},
'data' => $customer ? $customer : '',
'required' => false,
'mapped' => false,
'attr' => [
'data-related-select' => $this->getBlockPrefix() . '_project',
'data-api-url' => ['get_projects', ['customer' => '-s-']],
],
])
->add('project', ProjectType::class, [
// documentation is for NelmioApiDocBundle
'documentation' => [
'type' => 'integer',
'description' => 'Project ID',
],
'required' => true,
'placeholder' => '',
'label' => 'label.project',
'query_builder' => function (ProjectRepository $repo) use ($project) {
return $repo->builderForEntityType($project);
},
'attr' => [
'data-related-select' => $this->getBlockPrefix() . '_activity',
'data-api-url' => ['get_activities', ['project' => '-s-']],
],
])
->add('activity', ActivityType::class, [
// documentation is for NelmioApiDocBundle
'documentation' => [
'type' => 'integer',
'description' => 'Activity ID',
],
'label' => 'label.activity',
'query_builder' => function (ActivityRepository $repo) use ($activity) {
return $repo->builderForEntityType($activity);
@@ -84,6 +142,38 @@ class TimesheetEditForm extends AbstractType
'required' => false,
])
;
/*
$builder->get('customer')->addEventListener(
FormEvents::POST_SUBMIT,
function (FormEvent $event) {
$customer = $event->getForm()->getData();
$event->getForm()->getParent()->add('project', ProjectType::class, [
'required' => true,
'placeholder' => '',
'label' => 'label.project',
'query_builder' => function (ProjectRepository $repo) use ($customer) {
return $repo->builderForEntityType(null, $customer);
},
'attr' => [
'data-related-select' => $this->getBlockPrefix() . '_activity',
'data-api-url' => ['get_activities', ['project' => '-s-']],
],
]);
}
);
*/
$builder->get('project')->addEventListener(
FormEvents::POST_SUBMIT,
function (FormEvent $event) {
$project = $event->getForm()->getData();
$event->getForm()->getParent()->add('activity', ActivityType::class, [
'label' => 'label.activity',
'query_builder' => function (ActivityRepository $repo) use ($project) {
return $repo->builderForEntityType(null, $project);
},
]);
}
);
if ($options['include_user']) {
$builder->add('user', UserType::class);

View File

@@ -19,8 +19,11 @@ use App\Form\Type\VisibilityType;
use App\Repository\ActivityRepository;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\ActivityQuery;
use App\Repository\Query\CustomerQuery;
use App\Repository\Query\ProjectQuery;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
@@ -110,8 +113,9 @@ abstract class AbstractToolbarForm extends AbstractType
'widget' => 'single_text',
'html5' => false,
'required' => false,
'format' => 'yyyy-MM-dd',
'format' => DateType::HTML5_FORMAT,
'attr' => ['autocomplete' => 'off', 'data-datepicker' => 'on'],
'empty_data' => (new \DateTime('first day of this month'))->format('Y-M-d')
]);
}
@@ -125,8 +129,9 @@ abstract class AbstractToolbarForm extends AbstractType
'widget' => 'single_text',
'html5' => false,
'required' => false,
'format' => 'yyyy-MM-dd',
'format' => DateType::HTML5_FORMAT,
'attr' => ['autocomplete' => 'off', 'data-datepicker' => 'on'],
'empty_data' => (new \DateTime('last day of this month'))->format('Y-M-d')
]);
}
@@ -135,6 +140,12 @@ abstract class AbstractToolbarForm extends AbstractType
*/
protected function addProjectChoice(FormBuilderInterface $builder)
{
$builder->add('project', ChoiceType::class, [
'group_by' => null,
'required' => false,
'label' => 'label.project',
]);
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) {
@@ -147,10 +158,12 @@ abstract class AbstractToolbarForm extends AbstractType
'group_by' => null,
'required' => false,
'query_builder' => function (ProjectRepository $repo) use ($data) {
$qb = $repo->builderForEntityType();
$qb->where('p.customer = :customer')->setParameter('customer', $data['customer']);
$query = new ProjectQuery();
$query->setCustomer($data['customer']);
$query->setResultType(ProjectQuery::RESULT_TYPE_QUERYBUILDER);
$query->setVisibility(ProjectQuery::SHOW_BOTH);
return $qb;
return $repo->findByQuery($query);
},
]);
}
@@ -162,6 +175,20 @@ abstract class AbstractToolbarForm extends AbstractType
*/
protected function addActivityChoice(FormBuilderInterface $builder)
{
$builder->add('activity', ActivityType::class, [
'group_by' => null,
'required' => false,
'query_builder' => function (ActivityRepository $repo) {
$query = new ActivityQuery();
$query->setResultType(ActivityQuery::RESULT_TYPE_QUERYBUILDER);
$query->setGlobalsOnly(true);
$query->setOrderGlobalsFirst(true);
$query->setVisibility(ActivityQuery::SHOW_BOTH);
return $repo->findByQuery($query);
},
]);
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) {
@@ -174,10 +201,13 @@ abstract class AbstractToolbarForm extends AbstractType
'group_by' => null,
'required' => false,
'query_builder' => function (ActivityRepository $repo) use ($data) {
$qb = $repo->builderForEntityType();
$qb->where('a.project = :project')->setParameter('project', $data['project']);
$query = new ActivityQuery();
$query->setResultType(ActivityQuery::RESULT_TYPE_QUERYBUILDER);
$query->setProject($data['project']);
$query->setOrderGlobalsFirst(true);
$query->setVisibility(ActivityQuery::SHOW_BOTH);
return $qb;
return $repo->findByQuery($query);
},
]);
}

View File

@@ -45,6 +45,7 @@ class TimesheetToolbarForm extends AbstractToolbarForm
'entryState.running' => TimesheetQuery::STATE_RUNNING,
'entryState.stopped' => TimesheetQuery::STATE_STOPPED
],
//'attr' => ['class' => 'selectpicker', 'data-live-search' => false, 'data-width' => '100%']
]);
}

View File

@@ -1,38 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form\Type;
use App\Entity\Activity;
/**
* Custom form field type to select an activity which are grouped by their Projects, preceeded by their customer names.
*/
class ActivityGroupedWithCustomerNameType extends ActivityType
{
/**
* @param Activity $activity
* @param $key
* @param $index
* @return string
*/
public function groupBy(Activity $activity, $key, $index)
{
return $activity->getProject()->getCustomer()->getName();
}
/**
* @param Activity $activity
* @return string
*/
public function choiceLabel(Activity $activity)
{
return $activity->getProject()->getName() . ': ' . $activity->getName();
}
}

View File

@@ -25,7 +25,11 @@ class ActivityType extends AbstractType
*/
public function groupBy(Activity $activity, $key, $index)
{
return '[' . $activity->getProject()->getId() . '] ' . $activity->getProject()->getName();
if (null === $activity->getProject()) {
return null;
}
return $activity->getProject()->getName();
}
/**
@@ -36,6 +40,27 @@ class ActivityType extends AbstractType
return $activity->getName();
}
/**
* @param Activity $choiceValue
* @param $key
* @param $value
* @return array
*/
public function choiceAttr($choiceValue, $key, $value)
{
$project = null;
if (!($choiceValue instanceof Activity)) {
return [];
}
if (null !== $choiceValue->getProject()) {
$project = $choiceValue->getProject()->getId();
}
return ['data-project' => $project];
}
/**
* {@inheritdoc}
*/
@@ -46,9 +71,11 @@ class ActivityType extends AbstractType
'class' => Activity::class,
'choice_label' => [$this, 'choiceLabel'],
'group_by' => [$this, 'groupBy'],
'choice_attr' => [$this, 'choiceAttr'],
'query_builder' => function (ActivityRepository $repo) {
return $repo->builderForEntityType(null);
return $repo->builderForEntityType();
},
//'attr' => ['class' => 'selectpicker', 'data-size' => 10, 'data-live-search' => true, 'data-width' => '100%']
]);
}

View File

@@ -0,0 +1,52 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Custom form field type to select a calendar view.
*/
class CalendarViewType extends AbstractType
{
public const DEFAULT_VIEW = 'month';
public const ALLOWED_VIEWS = [
'month',
'agendaWeek',
'agendaDay',
];
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$choices = [];
foreach (self::ALLOWED_VIEWS as $name) {
$choices[$name] = $name;
}
$resolver->setDefaults([
'required' => true,
'choices' => $choices,
]);
}
/**
* {@inheritdoc}
*/
public function getParent()
{
return ChoiceType::class;
}
}

View File

@@ -32,6 +32,7 @@ class CustomerType extends AbstractType
'query_builder' => function (CustomerRepository $repo) {
return $repo->builderForEntityType(null);
},
//'attr' => ['class' => 'selectpicker', 'data-size' => 10, 'data-live-search' => true, 'data-width' => '100%']
]);
}

View File

@@ -19,16 +19,36 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
*/
class LanguageType extends AbstractType
{
/**
* @var string[]
*/
private $locales = [];
/**
* @param array|string $locales
*/
public function __construct($locales)
{
if (!is_array($locales)) {
$locales = explode('|', $locales);
}
$this->locales = $locales;
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$choices = [];
foreach ($this->locales as $key) {
$name = ucfirst(Intl::getLocaleBundle()->getLocaleName($key, $key));
$choices[$name] = $key;
}
$resolver->setDefaults([
'choices' => [
Intl::getLocaleBundle()->getLocaleName('de', 'de') => 'de',
Intl::getLocaleBundle()->getLocaleName('en', 'en') => 'en',
]
'choices' => $choices
]);
}

View File

@@ -26,6 +26,7 @@ class PageSizeType extends AbstractType
$resolver->setDefaults([
'label' => 'label.pageSize',
'choices' => [10 => 10, 25 => 25, 50 => 50, 75 => 75, 100 => 100],
//'attr' => ['class' => 'selectpicker', 'data-live-search' => false, 'data-width' => '100%']
]);
}

View File

@@ -20,6 +20,27 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
*/
class ProjectType extends AbstractType
{
/**
* @param Project $choiceValue
* @param $key
* @param $value
* @return array
*/
public function choiceAttr($choiceValue, $key, $value)
{
$customer = null;
if (!($choiceValue instanceof Project)) {
return [];
}
if (null !== $choiceValue->getCustomer()) {
$customer = $choiceValue->getCustomer()->getId();
}
return ['data-customer' => $customer];
}
/**
* {@inheritdoc}
*/
@@ -29,12 +50,14 @@ class ProjectType extends AbstractType
'label' => 'label.project',
'class' => Project::class,
'choice_label' => 'name',
'choice_attr' => [$this, 'choiceAttr'],
'group_by' => function (Project $project, $key, $index) {
return $project->getCustomer()->getName();
},
'query_builder' => function (ProjectRepository $repo) {
return $repo->builderForEntityType(null);
},
//'attr' => ['class' => 'selectpicker', 'data-size' => 10, 'data-live-search' => true, 'data-width' => '100%']
]);
}

View File

@@ -31,6 +31,7 @@ class VisibilityType extends AbstractType
'yes' => VisibilityQuery::SHOW_VISIBLE,
'no' => VisibilityQuery::SHOW_HIDDEN,
],
//'attr' => ['class' => 'selectpicker', 'data-live-search' => false, 'data-width' => '100%']
]);
}

View File

@@ -0,0 +1,51 @@
<?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\Invoice\Calculator;
use App\Entity\Timesheet;
use App\Invoice\CalculatorInterface;
/**
* A calculator that sums up the timesheet records by activity.
*/
class ActivityInvoiceCalculator extends AbstractMergedCalculator implements CalculatorInterface
{
/**
* @return Timesheet[]
*/
public function getEntries()
{
$entries = $this->model->getEntries();
if (empty($entries)) {
return [];
}
/** @var Timesheet[] $timesheets */
$timesheets = [];
foreach ($entries as $entry) {
if (!isset($timesheets[$entry->getActivity()->getId()])) {
$timesheets[$entry->getActivity()->getId()] = new Timesheet();
}
$timesheet = $timesheets[$entry->getActivity()->getId()];
$this->mergeTimesheets($timesheet, $entry);
}
return array_values($timesheets);
}
/**
* @return string
*/
public function getId(): string
{
return 'activity';
}
}

View File

@@ -135,20 +135,26 @@ trait RendererTrait
}
$activity = $timesheet->getActivity();
$project = $activity->getProject();
$project = $timesheet->getProject();
$customer = $project->getCustomer();
$begin = $timesheet->getBegin();
$end = $timesheet->getEnd();
return [
'entry.description' => $description,
'entry.amount' => $amount,
'entry.rate' => $this->getFormattedMoney($hourlyRate),
'entry.total' => $this->getFormattedMoney($rate),
'entry.duration' => $timesheet->getDuration(),
'entry.begin' => $this->getFormattedDateTime($timesheet->getBegin()),
'entry.begin_timestamp' => $timesheet->getBegin()->getTimestamp(),
'entry.end' => $this->getFormattedDateTime($timesheet->getEnd()),
'entry.end_timestamp' => $timesheet->getEnd()->getTimestamp(),
'entry.date' => $this->getFormattedDateTime($timesheet->getBegin()),
'entry.duration_minutes' => number_format($timesheet->getDuration() / 60),
'entry.begin' => $this->getFormattedDateTime($begin),
'entry.begin_time' => date('H:i', $begin->getTimestamp()),
'entry.begin_timestamp' => $begin->getTimestamp(),
'entry.end' => $this->getFormattedDateTime($end),
'entry.end_time' => date('H:i', $end->getTimestamp()),
'entry.end_timestamp' => $end->getTimestamp(),
'entry.date' => $this->getFormattedDateTime($begin),
'entry.user_id' => $user->getId(),
'entry.user_name' => $user->getUsername(),
'entry.user_alias' => $user->getAlias(),

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* Migration that adds the project column to timesheets table, to allow global activities.
* It also fixes foreign-key columns on timesheet and projects table, which are not allowed.
*/
final class Version20181031220003 extends AbstractMigration
{
public function up(Schema $schema): void
{
$platform = $this->getPlatform();
if (!in_array($platform, ['sqlite', 'mysql'])) {
$this->abortIf(true, 'Unsupported database platform: ' . $platform);
}
$timesheet = $this->getTableName('timesheet');
$projects = $this->getTableName('projects');
$activities = $this->getTableName('activities');
$users = $this->getTableName('users');
$customers = $this->getTableName('customers');
if ($platform === 'sqlite') {
// project table
$this->addSql('DROP INDEX IDX_407F12069395C3F3');
$this->addSql('CREATE TEMPORARY TABLE __temp__' . $projects . ' AS SELECT id, customer_id, name, order_number, comment, visible, budget, fixed_rate, hourly_rate FROM ' . $projects);
$this->addSql('DROP TABLE ' . $projects);
$this->addSql('CREATE TABLE ' . $projects . ' (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, customer_id INTEGER NOT NULL, name VARCHAR(255) NOT NULL COLLATE BINARY, order_number CLOB DEFAULT NULL COLLATE BINARY, comment CLOB DEFAULT NULL COLLATE BINARY, visible BOOLEAN NOT NULL, budget NUMERIC(10, 2) NOT NULL, fixed_rate NUMERIC(10, 2) DEFAULT NULL, hourly_rate NUMERIC(10, 2) DEFAULT NULL, CONSTRAINT FK_407F12069395C3F3 FOREIGN KEY (customer_id) REFERENCES ' . $customers . ' (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO ' . $projects . ' (id, customer_id, name, order_number, comment, visible, budget, fixed_rate, hourly_rate) SELECT id, customer_id, name, order_number, comment, visible, budget, fixed_rate, hourly_rate FROM __temp__' . $projects);
$this->addSql('DROP TABLE __temp__' . $projects);
$this->addSql('CREATE INDEX IDX_407F12069395C3F3 ON ' . $projects . ' (customer_id)');
// timesheet table
$this->addSql('DROP INDEX IDX_4F60C6B18D93D649');
$this->addSql('DROP INDEX IDX_4F60C6B181C06096');
$this->addSql('CREATE TEMPORARY TABLE __temp__' . $timesheet . ' AS SELECT id, user, activity_id, start_time, end_time, duration, description, rate, fixed_rate, hourly_rate FROM ' . $timesheet);
$this->addSql('DROP TABLE ' . $timesheet);
$this->addSql('CREATE TABLE ' . $timesheet . ' (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user INTEGER DEFAULT NULL, activity_id INTEGER DEFAULT NULL, project_id INTEGER DEFAULT NULL, start_time DATETIME NOT NULL, end_time DATETIME DEFAULT NULL, duration INTEGER DEFAULT NULL, description CLOB DEFAULT NULL COLLATE BINARY, rate NUMERIC(10, 2) NOT NULL, fixed_rate NUMERIC(10, 2) DEFAULT NULL, hourly_rate NUMERIC(10, 2) DEFAULT NULL, CONSTRAINT FK_4F60C6B18D93D649 FOREIGN KEY (user) REFERENCES ' . $users . ' (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_4F60C6B181C06096 FOREIGN KEY (activity_id) REFERENCES ' . $activities . ' (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_4F60C6B1166D1F9C FOREIGN KEY (project_id) REFERENCES ' . $projects . ' (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO ' . $timesheet . ' (id, user, activity_id, start_time, end_time, duration, description, rate, fixed_rate, hourly_rate) SELECT id, user, activity_id, start_time, end_time, duration, description, rate, fixed_rate, hourly_rate FROM __temp__' . $timesheet);
$this->addSql('DROP TABLE __temp__' . $timesheet);
$this->addSql('CREATE INDEX IDX_4F60C6B18D93D649 ON ' . $timesheet . ' (user)');
$this->addSql('CREATE INDEX IDX_4F60C6B181C06096 ON ' . $timesheet . ' (activity_id)');
$this->addSql('CREATE INDEX IDX_4F60C6B1166D1F9C ON ' . $timesheet . ' (project_id)');
} else {
// project table
$this->addSql('ALTER TABLE ' . $projects . ' CHANGE customer_id customer_id INT NOT NULL');
// timesheet table
$this->addSql('ALTER TABLE ' . $timesheet . ' ADD project_id INT DEFAULT NULL AFTER activity_id');
$this->addSql('ALTER TABLE ' . $timesheet . ' ADD CONSTRAINT FK_4F60C6B1166D1F9C FOREIGN KEY (project_id) REFERENCES ' . $projects . ' (id) ON DELETE CASCADE');
$this->addSql('CREATE INDEX IDX_4F60C6B1166D1F9C ON ' . $timesheet . ' (project_id)');
}
// update timesheet table and insert project_id from activity table
$this->addSql('UPDATE ' . $timesheet . ' SET project_id = (SELECT project_id FROM ' . $activities . ' WHERE id = activity_id)');
// now update the timesheet table and disallow null values for all required columns (that was a bug before)
if ($platform === 'sqlite') {
$this->addSql('DROP INDEX IDX_4F60C6B181C06096');
$this->addSql('DROP INDEX IDX_4F60C6B18D93D649');
$this->addSql('DROP INDEX IDX_4F60C6B1166D1F9C');
$this->addSql('CREATE TEMPORARY TABLE __temp__' . $timesheet . ' AS SELECT id, user, activity_id, project_id, start_time, end_time, duration, description, rate, fixed_rate, hourly_rate FROM ' . $timesheet);
$this->addSql('DROP TABLE ' . $timesheet);
$this->addSql('CREATE TABLE ' . $timesheet . ' (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user INTEGER NOT NULL, activity_id INTEGER NOT NULL, project_id INTEGER NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME DEFAULT NULL, duration INTEGER DEFAULT NULL, description CLOB DEFAULT NULL COLLATE BINARY, rate NUMERIC(10, 2) NOT NULL, fixed_rate NUMERIC(10, 2) DEFAULT NULL, hourly_rate NUMERIC(10, 2) DEFAULT NULL, CONSTRAINT FK_4F60C6B18D93D649 FOREIGN KEY (user) REFERENCES ' . $users . ' (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_4F60C6B181C06096 FOREIGN KEY (activity_id) REFERENCES ' . $activities . ' (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_4F60C6B1166D1F9C FOREIGN KEY (project_id) REFERENCES ' . $projects . ' (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO ' . $timesheet . ' (id, user, activity_id, project_id, start_time, end_time, duration, description, rate, fixed_rate, hourly_rate) SELECT id, user, activity_id, project_id, start_time, end_time, duration, description, rate, fixed_rate, hourly_rate FROM __temp__' . $timesheet);
$this->addSql('DROP TABLE __temp__' . $timesheet);
$this->addSql('CREATE INDEX IDX_4F60C6B181C06096 ON ' . $timesheet . ' (activity_id)');
$this->addSql('CREATE INDEX IDX_4F60C6B18D93D649 ON ' . $timesheet . ' (user)');
$this->addSql('CREATE INDEX IDX_4F60C6B1166D1F9C ON ' . $timesheet . ' (project_id)');
} else {
$this->addSql('ALTER TABLE ' . $timesheet . ' CHANGE project_id project_id INT NOT NULL, CHANGE user user INT NOT NULL, CHANGE activity_id activity_id INT NOT NULL');
}
}
public function down(Schema $schema): void
{
$platform = $this->getPlatform();
if (!in_array($platform, ['sqlite', 'mysql'])) {
$this->abortIf(true, 'Unsupported database platform: ' . $platform);
}
$timesheet = $this->getTableName('timesheet');
$projects = $this->getTableName('projects');
if ($platform === 'sqlite') {
// project table
$this->addSql('DROP INDEX IDX_407F12069395C3F3');
$this->addSql('CREATE TEMPORARY TABLE __temp__' . $projects . ' AS SELECT id, customer_id, name, order_number, comment, visible, budget, fixed_rate, hourly_rate FROM ' . $projects);
$this->addSql('DROP TABLE ' . $projects);
$this->addSql('CREATE TABLE ' . $projects . ' (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL, order_number CLOB DEFAULT NULL, comment CLOB DEFAULT NULL, visible BOOLEAN NOT NULL, budget NUMERIC(10, 2) NOT NULL, fixed_rate NUMERIC(10, 2) DEFAULT NULL, hourly_rate NUMERIC(10, 2) DEFAULT NULL, customer_id INTEGER DEFAULT NULL)');
$this->addSql('INSERT INTO ' . $projects . ' (id, customer_id, name, order_number, comment, visible, budget, fixed_rate, hourly_rate) SELECT id, customer_id, name, order_number, comment, visible, budget, fixed_rate, hourly_rate FROM __temp__' . $projects);
$this->addSql('DROP TABLE __temp__' . $projects);
$this->addSql('CREATE INDEX IDX_407F12069395C3F3 ON ' . $projects . ' (customer_id)');
// timesheet table
$this->addSql('DROP INDEX IDX_4F60C6B1166D1F9C');
$this->addSql('DROP INDEX IDX_4F60C6B18D93D649');
$this->addSql('DROP INDEX IDX_4F60C6B181C06096');
$this->addSql('CREATE TEMPORARY TABLE __temp__' . $timesheet . ' AS SELECT id, user, activity_id, start_time, end_time, duration, description, rate, fixed_rate, hourly_rate FROM ' . $timesheet);
$this->addSql('DROP TABLE ' . $timesheet);
$this->addSql('CREATE TABLE ' . $timesheet . ' (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user INTEGER DEFAULT NULL, activity_id INTEGER DEFAULT NULL, start_time DATETIME NOT NULL, end_time DATETIME DEFAULT NULL, duration INTEGER DEFAULT NULL, description CLOB DEFAULT NULL, rate NUMERIC(10, 2) NOT NULL, fixed_rate NUMERIC(10, 2) DEFAULT NULL, hourly_rate NUMERIC(10, 2) DEFAULT NULL)');
$this->addSql('INSERT INTO ' . $timesheet . ' (id, user, activity_id, start_time, end_time, duration, description, rate, fixed_rate, hourly_rate) SELECT id, user, activity_id, start_time, end_time, duration, description, rate, fixed_rate, hourly_rate FROM __temp__' . $timesheet);
$this->addSql('DROP TABLE __temp__' . $timesheet);
$this->addSql('CREATE INDEX IDX_4F60C6B18D93D649 ON ' . $timesheet . ' (user)');
$this->addSql('CREATE INDEX IDX_4F60C6B181C06096 ON ' . $timesheet . ' (activity_id)');
} else {
// project table
$this->addSql('ALTER TABLE ' . $projects . ' CHANGE customer_id customer_id INT DEFAULT NULL');
// timesheet table
$this->addSql('ALTER TABLE ' . $timesheet . ' DROP FOREIGN KEY FK_4F60C6B1166D1F9C');
$this->addSql('DROP INDEX IDX_4F60C6B1166D1F9C ON ' . $timesheet);
$this->addSql('ALTER TABLE ' . $timesheet . ' DROP project_id, CHANGE user user INT DEFAULT NULL, CHANGE activity_id activity_id INT DEFAULT NULL');
}
}
}

View File

@@ -10,10 +10,12 @@
namespace App\Repository;
use App\Entity\Activity;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Model\ActivityStatistic;
use App\Repository\Query\ActivityQuery;
use Doctrine\ORM\ORMException;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Pagerfanta\Pagerfanta;
@@ -35,7 +37,7 @@ class ActivityRepository extends AbstractRepository
/**
* @param User|null $user
* @param \DateTime|null $startFrom
* @return mixed
* @return Timesheet[]
*/
public function getRecentActivities(User $user = null, \DateTime $startFrom = null)
{
@@ -45,7 +47,7 @@ class ActivityRepository extends AbstractRepository
->distinct()
->from(Timesheet::class, 't')
->join('t.activity', 'a')
->join('a.project', 'p')
->join('t.project', 'p')
->join('p.customer', 'c')
->andWhere($qb->expr()->isNotNull('t.end'))
->andWhere('a.visible = 1')
@@ -66,15 +68,7 @@ class ActivityRepository extends AbstractRepository
->setParameter('begin', $startFrom);
}
$results = $qb->getQuery()->getResult();
$activities = [];
/* @var Timesheet $entry */
foreach ($results as $entry) {
$activities[] = $entry->getActivity();
}
return $activities;
return $qb->getQuery()->getResult();
}
/**
@@ -118,14 +112,21 @@ class ActivityRepository extends AbstractRepository
/**
* Returns a query builder that is used for ActivityType and your own 'query_builder' option.
*
* @param Activity|null $entity
* @param Activity|string|null $activity
* @param Project|string|null $project
* @return \Doctrine\ORM\QueryBuilder
*/
public function builderForEntityType(Activity $entity = null)
public function builderForEntityType($activity = null, $project = null)
{
$query = new ActivityQuery();
$query->setHiddenEntity($entity);
$query->setHiddenEntity($activity);
$query->setResultType(ActivityQuery::RESULT_TYPE_QUERYBUILDER);
$query->setProject($project);
$query->setOrderGlobalsFirst(true);
if (null === $activity && $project === null) {
$query->setGlobalsOnly(true);
}
return $this->findByQuery($query);
}
@@ -140,34 +141,107 @@ class ActivityRepository extends AbstractRepository
$qb->select('a', 'p', 'c')
->from(Activity::class, 'a')
->join('a.project', 'p')
->join('p.customer', 'c')
->leftJoin('a.project', 'p')
->leftJoin('p.customer', 'c')
->orderBy('a.' . $query->getOrderBy(), $query->getOrder());
$where = $qb->expr()->andX();
if (ActivityQuery::SHOW_VISIBLE == $query->getVisibility()) {
$where->add('a.visible = :visible');
if (!$query->isExclusiveVisibility()) {
$qb->andWhere('c.visible = 1');
$qb->andWhere('p.visible = 1');
}
$qb->andWhere('a.visible = 1');
/** @var Activity $entity */
$entity = $query->getHiddenEntity();
if (null !== $entity) {
$qb->orWhere('a.id = :activity')->setParameter('activity', $entity);
$where->add(
$qb->expr()->orX(
$qb->expr()->eq('c.visible', ':visible'),
$qb->expr()->isNull('c.visible')
)
);
$where->add(
$qb->expr()->orX(
$qb->expr()->eq('p.visible', ':visible'),
$qb->expr()->isNull('p.visible')
)
);
}
$qb->setParameter('visible', 1);
} elseif (ActivityQuery::SHOW_HIDDEN == $query->getVisibility()) {
$qb->andWhere('a.visible = 0');
$where->add('a.visible = :visible');
$qb->setParameter('visible', 0);
}
if (null !== $query->getProject()) {
$qb->andWhere('a.project = :project')
->setParameter('project', $query->getProject());
if ($query->isGlobalsOnly()) {
$where->add($qb->expr()->isNull('a.project'));
} elseif (null !== $query->getProject()) {
$where->add(
$qb->expr()->orX(
$qb->expr()->eq('a.project', ':project'),
$qb->expr()->isNull('a.project')
)
);
$qb->setParameter('project', $query->getProject());
} elseif (null !== $query->getCustomer()) {
$qb->andWhere('p.customer = :customer')
->setParameter('customer', $query->getCustomer());
$where->add('p.customer = :customer');
$qb->setParameter('customer', $query->getCustomer());
}
if (!empty($query->getIgnoredEntities())) {
$qb->andWhere('a.id NOT IN(:ignored)');
$qb->setParameter('ignored', $query->getIgnoredEntities());
}
$or = $qb->expr()->orX();
// this must always be the last part before the or
$or->add($where);
// this must always be the last part of the query
/** @var Activity $entity */
$entity = $query->getHiddenEntity();
if (null !== $entity) {
$or->add($qb->expr()->eq('a.id', ':activity'));
$qb->setParameter('activity', $entity);
}
if ($query->isOrderGlobalsFirst()) {
$qb->orderBy('a.project', 'ASC');
}
$qb->andWhere($or);
return $this->getBaseQueryResult($qb, $query);
}
/**
* @param Activity $delete
* @param Activity|null $replace
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function deleteActivity(Activity $delete, ?Activity $replace = null)
{
$em = $this->getEntityManager();
$em->beginTransaction();
try {
if (null !== $replace) {
$qb = $em->createQueryBuilder();
$query = $qb
->update(Timesheet::class, 't')
->set('t.activity', ':replace')
->where('t.activity = :delete')
->setParameter('delete', $delete)
->setParameter('replace', $replace)
->getQuery();
$result = $query->execute();
}
$em->remove($delete);
$em->flush();
$em->commit();
} catch (ORMException $ex) {
$em->rollback();
throw $ex;
}
}
}

View File

@@ -15,6 +15,7 @@ use App\Entity\Project;
use App\Entity\Timesheet;
use App\Model\CustomerStatistic;
use App\Repository\Query\CustomerQuery;
use Doctrine\ORM\ORMException;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Pagerfanta\Pagerfanta;
@@ -60,7 +61,7 @@ class CustomerRepository extends AbstractRepository
->join(Project::class, 'p')
->join(Customer::class, 'c')
->andWhere('t.activity = a.id')
->andWhere('a.project = p.id')
->andWhere('t.project = p.id')
->andWhere('p.customer = c.id')
->andWhere('c.id = :customer')
;
@@ -121,6 +122,43 @@ class CustomerRepository extends AbstractRepository
$qb->andWhere('c.visible = 0');
}
if (!empty($query->getIgnoredEntities())) {
$qb->andWhere('c.id NOT IN(:ignored)');
$qb->setParameter('ignored', $query->getIgnoredEntities());
}
return $this->getBaseQueryResult($qb, $query);
}
/**
* @param Customer $delete
* @param Customer|null $replace
* @throws \Doctrine\ORM\ORMException
*/
public function deleteCustomer(Customer $delete, ?Customer $replace = null)
{
$em = $this->getEntityManager();
$em->beginTransaction();
try {
if (null !== $replace) {
$qb = $em->createQueryBuilder();
$qb
->update(Project::class, 'p')
->set('p.customer', ':replace')
->where('p.customer = :delete')
->setParameter('delete', $delete)
->setParameter('replace', $replace)
->getQuery()
->execute();
}
$em->remove($delete);
$em->flush();
$em->commit();
} catch (ORMException $ex) {
$em->rollback();
throw $ex;
}
}
}

View File

@@ -10,10 +10,12 @@
namespace App\Repository;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Model\ProjectStatistic;
use App\Repository\Query\ProjectQuery;
use Doctrine\ORM\ORMException;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Pagerfanta\Pagerfanta;
@@ -41,7 +43,7 @@ class ProjectRepository extends AbstractRepository
}
/**
* Retrieves statistics for one activity.
* Retrieves statistics for one project.
*
* @param Project $project
* @return ProjectStatistic
@@ -55,7 +57,7 @@ class ProjectRepository extends AbstractRepository
->addSelect('COUNT(DISTINCT(a.id)) as activityAmount')
->from(Activity::class, 'a')
->join(Timesheet::class, 't')
->where('a.project = :project')
->where('t.project = :project')
->andWhere('t.activity = a.id')
;
@@ -79,12 +81,14 @@ class ProjectRepository extends AbstractRepository
* Returns a query builder that is used for ProjectType and your own 'query_builder' option.
*
* @param Project|null $entity
* @return \Doctrine\ORM\QueryBuilder
* @param Customer|null $customer
* @return array|QueryBuilder|Pagerfanta
*/
public function builderForEntityType(Project $entity = null)
public function builderForEntityType(Project $entity = null, Customer $customer = null)
{
$query = new ProjectQuery();
$query->setHiddenEntity($entity);
$query->setCustomer($customer);
$query->setResultType(ProjectQuery::RESULT_TYPE_QUERYBUILDER);
return $this->findByQuery($query);
@@ -128,6 +132,53 @@ class ProjectRepository extends AbstractRepository
->setParameter('customer', $query->getCustomer());
}
if (!empty($query->getIgnoredEntities())) {
$qb->andWhere('p.id NOT IN(:ignored)');
$qb->setParameter('ignored', $query->getIgnoredEntities());
}
return $this->getBaseQueryResult($qb, $query);
}
/**
* @param Project $delete
* @param Project|null $replace
* @throws \Doctrine\ORM\ORMException
*/
public function deleteProject(Project $delete, ?Project $replace = null)
{
$em = $this->getEntityManager();
$em->beginTransaction();
try {
if (null !== $replace) {
$qb = $em->createQueryBuilder();
$qb
->update(Timesheet::class, 't')
->set('t.project', ':replace')
->where('t.project = :delete')
->setParameter('delete', $delete)
->setParameter('replace', $replace)
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
$qb
->update(Activity::class, 'a')
->set('a.project', ':replace')
->where('a.project = :delete')
->setParameter('delete', $delete)
->setParameter('replace', $replace)
->getQuery()
->execute();
}
$em->remove($delete);
$em->flush();
$em->commit();
} catch (ORMException $ex) {
$em->rollback();
throw $ex;
}
}
}

View File

@@ -17,12 +17,58 @@ use App\Entity\Project;
class ActivityQuery extends ProjectQuery
{
/**
* @var Project
* @var Project|int
*/
protected $project;
/**
* @var bool
*/
protected $orderGlobalsFirst = false;
/**
* @var bool
*/
protected $globalsOnly = false;
/**
* @return Project
* @return bool
*/
public function isOrderGlobalsFirst(): bool
{
return $this->orderGlobalsFirst;
}
/**
* @param bool $orderGlobalsFirst
* @return ActivityQuery
*/
public function setOrderGlobalsFirst(bool $orderGlobalsFirst)
{
$this->orderGlobalsFirst = $orderGlobalsFirst;
return $this;
}
/**
* @return bool
*/
public function isGlobalsOnly(): bool
{
return $this->globalsOnly;
}
/**
* @param bool $globalsOnly
* @return ActivityQuery
*/
public function setGlobalsOnly(bool $globalsOnly)
{
$this->globalsOnly = $globalsOnly;
return $this;
}
/**
* @return Project|int
*/
public function getProject()
{
@@ -30,10 +76,10 @@ class ActivityQuery extends ProjectQuery
}
/**
* @param Project $project
* @param Project|int $project
* @return $this
*/
public function setProject(Project $project = null)
public function setProject($project = null)
{
$this->project = $project;

View File

@@ -140,7 +140,7 @@ class BaseQuery
}
/**
* @param string $resultType
* @param $resultType
* @return $this
* @throws \InvalidArgumentException
*/
@@ -166,7 +166,7 @@ class BaseQuery
}
/**
* @param object $hiddenEntity
* @param object|string $hiddenEntity
* @return BaseQuery
*/
public function setHiddenEntity($hiddenEntity)

View File

@@ -9,9 +9,34 @@
namespace App\Repository\Query;
use App\Entity\Customer;
/**
* Can be used for advanced queries with the: CustomerRepository
*/
class CustomerQuery extends VisibilityQuery
{
/**
* @var array
*/
protected $ignored = [];
/**
* @param Customer|int $customer
* @return $this
*/
public function addIgnoredEntity($customer)
{
$this->ignored[] = $customer;
return $this;
}
/**
* @return array
*/
public function getIgnoredEntities()
{
return $this->ignored;
}
}

View File

@@ -17,12 +17,36 @@ use App\Entity\Customer;
class ProjectQuery extends VisibilityQuery
{
/**
* @var Customer
* @var Customer|int
*/
protected $customer;
/**
* @return Customer
* @var array
*/
protected $ignored = [];
/**
* @param mixed $entity
* @return $this
*/
public function addIgnoredEntity($entity)
{
$this->ignored[] = $entity;
return $this;
}
/**
* @return array
*/
public function getIgnoredEntities()
{
return $this->ignored;
}
/**
* @return Customer|int
*/
public function getCustomer()
{
@@ -30,10 +54,10 @@ class ProjectQuery extends VisibilityQuery
}
/**
* @param Customer $customer
* @param Customer|int $customer
* @return $this
*/
public function setCustomer(Customer $customer = null)
public function setCustomer($customer = null)
{
$this->customer = $customer;

View File

@@ -9,7 +9,6 @@
namespace App\Repository;
use App\Entity\Activity;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Model\Statistic\Month;
@@ -54,28 +53,6 @@ class TimesheetRepository extends AbstractRepository
return true;
}
/**
* @param User $user
* @param Activity $activity
* @return Timesheet
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function startRecording(User $user, Activity $activity)
{
$entry = new Timesheet();
$entry
->setBegin(new DateTime())
->setUser($user)
->setActivity($activity);
$entityManager = $this->getEntityManager();
$entityManager->persist($entry);
$entityManager->flush();
return $entry;
}
/**
* @param string $type
* @param DateTime|null $begin
@@ -223,17 +200,17 @@ class TimesheetRepository extends AbstractRepository
$qb->andWhere($qb->expr()->isNotNull('t.end'));
}
if (null !== $user) {
$qb->andWhere('t.user = :user')
->setParameter('user', $user);
}
$qb
->orderBy('year', 'DESC')
->addOrderBy('month', 'ASC')
->groupBy('year')
->addGroupBy('month');
if (null !== $user) {
$qb->andWhere('t.user = :user')
->setParameter('user', $user);
}
$years = [];
foreach ($qb->getQuery()->execute() as $statRow) {
$curYear = $statRow['year'];
@@ -267,7 +244,7 @@ class TimesheetRepository extends AbstractRepository
$qb->select('t', 'a', 'p', 'c')
->from(Timesheet::class, 't')
->join('t.activity', 'a')
->join('a.project', 'p')
->join('t.project', 'p')
->join('p.customer', 'c')
->where($qb->expr()->gt('t.begin', '0'))
->andWhere($qb->expr()->isNull('t.end'))
@@ -293,10 +270,10 @@ class TimesheetRepository extends AbstractRepository
$qb->select('t', 'a', 'p', 'c', 'u')
->from(Timesheet::class, 't')
->join('t.activity', 'a')
->join('t.user', 'u')
->join('a.project', 'p')
->join('p.customer', 'c')
->leftJoin('t.activity', 'a')
->leftJoin('t.user', 'u')
->leftJoin('t.project', 'p')
->leftJoin('p.customer', 'c')
->orderBy('t.' . $query->getOrderBy(), $query->getOrder());
if (null !== $query->getUser()) {
@@ -314,6 +291,7 @@ class TimesheetRepository extends AbstractRepository
$qb->andWhere('t.begin >= :begin')
->setParameter('begin', $query->getBegin());
}
if (null !== $query->getEnd()) {
$qb->andWhere('t.end <= :end')
->setParameter('end', $query->getEnd());
@@ -322,12 +300,16 @@ class TimesheetRepository extends AbstractRepository
if (null !== $query->getActivity()) {
$qb->andWhere('t.activity = :activity')
->setParameter('activity', $query->getActivity());
} elseif (null !== $query->getProject()) {
$qb->andWhere('a.project = :project')
->setParameter('project', $query->getProject());
} elseif (null !== $query->getCustomer()) {
$qb->andWhere('p.customer = :customer')
->setParameter('customer', $query->getCustomer());
}
if (null === $query->getActivity() || null === $query->getActivity()->getProject()) {
if (null !== $query->getProject()) {
$qb->andWhere('t.project = :project')
->setParameter('project', $query->getProject());
} elseif (null !== $query->getCustomer()) {
$qb->andWhere('p.customer = :customer')
->setParameter('customer', $query->getCustomer());
}
}
return $this->getBaseQueryResult($qb, $query);

View File

@@ -74,7 +74,7 @@ class RateCalculator implements CalculatorInterface
return $activity->getHourlyRate();
}
$project = $activity->getProject();
$project = $record->getProject();
if (null !== $project) {
if (null !== $project->getHourlyRate()) {
return $project->getHourlyRate();
@@ -104,7 +104,7 @@ class RateCalculator implements CalculatorInterface
return $activity->getFixedRate();
}
$project = $activity->getProject();
$project = $record->getProject();
if (null !== $project) {
if (null !== $project->getFixedRate()) {
return $project->getFixedRate();

View File

@@ -84,6 +84,7 @@ class Extensions extends \Twig_Extension
'trash' => 'far fa-trash-alt',
'user' => 'fas fa-user',
'visibility' => 'far fa-eye',
'settings' => 'fas fa-wrench',
];
/**
@@ -119,29 +120,53 @@ class Extensions extends \Twig_Extension
return [
new \Twig_SimpleFunction('locales', [$this, 'getLocales']),
new \Twig_SimpleFunction('is_visible_column', [$this, 'isColumnVisible']),
new \Twig_SimpleFunction('is_datatable_configured', [$this, 'isDatatableConfigured']),
];
}
/**
* @param string $dataTable
* @param string $size
* @return bool
*/
public function isDatatableConfigured(string $dataTable, string $size)
{
$cookie = $this->getVisibilityCookieName($dataTable, $size);
return $this->requestStack->getCurrentRequest()->cookies->has($cookie);
}
/**
* @param string $dataTable
* @param string $size
* @return string
*/
public function getVisibilityCookieName(string $dataTable, string $size)
{
return $dataTable . '_visibility' . $size;
}
/**
* This is only for datatables, do not use it outside this context.
*
* @param string $dataTable
* @param string $column
* @param string $size
* @return bool
*/
public function isColumnVisible(string $dataTable, string $column)
public function isColumnVisible(string $dataTable, string $column, string $size)
{
// TODO name handling could be improved, as now this info is spread in datatables.html.twig and here
$dataTable = $dataTable . '_visibility';
// name handling is spread between here and datatables.html.twig (data_table_column_modal)
$cookie = $this->getVisibilityCookieName($dataTable, $size);
if (!isset($this->cookies[$dataTable])) {
if (!isset($this->cookies[$cookie])) {
$visibility = false;
if ($this->requestStack->getCurrentRequest()->cookies->has($dataTable)) {
$visibility = json_decode($this->requestStack->getCurrentRequest()->cookies->get($dataTable), true);
if ($this->requestStack->getCurrentRequest()->cookies->has($cookie)) {
$visibility = json_decode($this->requestStack->getCurrentRequest()->cookies->get($cookie), true);
}
$this->cookies[$dataTable] = $visibility;
$this->cookies[$cookie] = $visibility;
}
$values = $this->cookies[$dataTable];
$values = $this->cookies[$cookie];
if (empty($values) || !is_array($values)) {
return true;

View File

@@ -9,8 +9,6 @@
namespace App\Utils;
use Parsedown as MarkdownParser;
/**
* A simple class to parse markdown syntax and return HTML.
*/
@@ -26,7 +24,7 @@ class Markdown
*/
public function __construct()
{
$this->parser = new MarkdownParser();
$this->parser = new ParsedownExtension();
}
/**

View File

@@ -0,0 +1,68 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Utils;
/**
* This Class extends the default Parsedown Class for custom methods.
*/
class ParsedownExtension extends \Parsedown
{
private $ids = [];
protected function blockHeader($line)
{
$block = parent::blockHeader($line);
$text = $block['element']['text'];
$id = $this->getIDfromText($text);
// add id-attribute
$block['element']['attributes'] = [
'id' => $id
];
return $block;
}
/**
* github-action for creating ids:
*
* - It downcases the header string
* - remove anything that is not a letter, number, space or hyphen
* - changes any space to a hyphen.
* - If that is not unique, add "-1", "-2", "-3",... to make it unique
*
* @param $text
* @return string
*/
private function getIDfromText($text)
{
$text = strtolower($text);
$text = preg_replace('/[^A-Za-z0-9\-\ ]/', '', $text);
$text = strtr($text, [' ' => '-']);
if (isset($this->ids[$text])) {
$i = 0;
$numberedText = $text . '-1';
while (isset($this->ids[$numberedText])) {
$i++;
$numberedText = $text . '-' . $i;
}
$text = $numberedText;
}
$this->ids[$text] = '';
return $text;
}
}

View File

@@ -44,10 +44,6 @@ class TimesheetVoter extends AbstractVoter
return false;
}
if ($subject instanceof Activity && self::START == $attribute) {
return true;
}
if (!$subject instanceof Timesheet) {
return false;
}
@@ -71,38 +67,18 @@ class TimesheetVoter extends AbstractVoter
switch ($attribute) {
case self::STOP:
if (!$subject instanceof Timesheet) {
return false;
}
return $this->canStop($subject, $user, $token);
case self::START:
if (!$subject instanceof Activity) {
return false;
}
return $this->canStart($subject, $user, $token);
case self::VIEW:
if (!$subject instanceof Timesheet) {
return false;
}
return $this->canView($subject, $user, $token);
case self::EDIT:
if (!$subject instanceof Timesheet) {
return false;
}
return $this->canEdit($subject, $user, $token);
case self::DELETE:
if (!$subject instanceof Timesheet) {
return false;
}
return $this->canDelete($subject, $user, $token);
}
@@ -122,23 +98,21 @@ class TimesheetVoter extends AbstractVoter
}
/**
* @param Activity $activity
* @param Timesheet $timesheet
* @param User $user
* @param TokenInterface $token
* @return bool
*/
protected function canStart(Activity $activity, User $user, TokenInterface $token)
protected function canStart(Timesheet $timesheet, User $user, TokenInterface $token)
{
// we could check the amount of active entries
// if a teamlead starts an entry for another user, check that this user is part of his team
if (!$activity->getVisible()) {
if (!$timesheet->getActivity()->getVisible() || !$timesheet->getProject()->getVisible()) {
return false;
}
if (!$activity->getProject()->getVisible()) {
return false;
}
if (!$activity->getProject()->getCustomer()->getVisible()) {
if (!$timesheet->getProject()->getCustomer()->getVisible()) {
return false;
}

View File

@@ -413,9 +413,6 @@
"symfony/intl": {
"version": "v4.0.3"
},
"symfony/lts": {
"version": "4-dev"
},
"symfony/maker-bundle": {
"version": "1.0",
"recipe": {

View File

@@ -8,7 +8,7 @@
{% block page_actions %}{{ widgets.page_actions({'filter': '#collapseActivityAdmin', 'visibility': '#modal_activity_admin', 'create': path('admin_activity_create')}) }}{% endblock %}
{% block main_before %}
{{ toolbar.toolbar(toolbarForm, 'collapseActivityAdmin') }}
{{ toolbar.toolbar(toolbarForm, 'collapseActivityAdmin', showFilter) }}
{% endblock %}
{% block main %}
@@ -33,10 +33,18 @@
<tr>
<td>{{ entry.name }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'customer') }}">
<a href="{{ path('admin_customer_edit', {'id' : entry.project.customer.id}) }}">{{ widgets.label_customer(entry.project.customer) }}</a>
{% if entry.project and entry.project.customer %}
<a href="{{ path('admin_customer_edit', {'id' : entry.project.customer.id}) }}">{{ widgets.label_customer(entry.project.customer) }}</a>
{% else %}
{#{ widgets.label('x', 'success') }#}
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'project') }}">
<a href="{{ path('admin_project_edit', {'id' : entry.project.id}) }}">{{ widgets.label_project(entry.project) }}</a>
{% if entry.project %}
<a href="{{ path('admin_project_edit', {'id' : entry.project.id}) }}">{{ widgets.label_project(entry.project) }}</a>
{% else %}
{#{ widgets.label('x', 'success') }#}
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'comment') }}">{{ entry.comment }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'visible') }}">{{ widgets.label_visible(entry.visible) }}</td>

View File

@@ -9,12 +9,18 @@
{% set params = {
'%activity%': '<strong>' ~ activity.name ~ '</strong>',
'%project%': '<strong>' ~ activity.project.name ~ '</strong>',
'%customer%': '<strong>' ~ activity.project.customer.name ~ '</strong>',
'%project%': '<strong>-</strong>',
'%customer%': '<strong>-</strong>',
'%records%': '<strong>' ~ stats.recordAmount ~ '</strong>',
'%duration%': '<strong>' ~ stats.recordDuration|duration ~ '</strong>'
} %}
{% if activity.project is not null %}
{% set params = params|merge({
'%project%': '<strong>' ~ activity.project.name ~ '</strong>',
'%customer%': '<strong>' ~ activity.project.customer.name ~ '</strong>',
}) %}
{% endif %}
{{ include('default/_form_delete.html.twig', {
'message': "admin_activity.delete_confirm"|trans(params)|raw,
'form': form,

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