Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
417675b3df | ||
|
|
321ac74b6a | ||
|
|
b1c06eed7b | ||
|
|
8cb52e22b1 | ||
|
|
a073064900 | ||
|
|
07cceca67e | ||
|
|
540318fcd0 | ||
|
|
e9cbb872d0 | ||
|
|
f9dc42d1f9 | ||
|
|
1dabeedaa0 | ||
|
|
dc70ff128c | ||
|
|
b012b7170b | ||
|
|
0a1c2d8440 | ||
|
|
d72596f000 | ||
|
|
33029b2a11 | ||
|
|
bae24dd0ec | ||
|
|
db7de3aac1 | ||
|
|
c66dfbc653 | ||
|
|
51c207e025 | ||
|
|
e44e2442af | ||
|
|
86f40083ef | ||
|
|
f45fbbce60 | ||
|
|
c648866cb4 | ||
|
|
f8adafdabe | ||
|
|
ab4a1a0a1d | ||
|
|
34a3f4e275 | ||
|
|
55aedcd4a8 | ||
|
|
6633c550f5 | ||
|
|
0165658659 | ||
|
|
3075276a52 | ||
|
|
b46e752d0a | ||
|
|
ad171e7706 | ||
|
|
92c665436a | ||
|
|
2dbf433474 | ||
|
|
56ac642446 | ||
|
|
2f6c0d2208 | ||
|
|
a32614fff8 |
0
.github/lock.yaml → .github/lock.yml
vendored
0
.github/lock.yaml → .github/lock.yml
vendored
@@ -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
|
||||
77
CHANGELOG.md
77
CHANGELOG.md
@@ -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
41
Dockerfile
Normal 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
10
LICENSE
@@ -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.
|
||||
24
README.md
24
README.md
@@ -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
|
||||
|
||||
@@ -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
165
assets/css/app.scss
Normal 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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -37,3 +37,4 @@ footer.main-footer {
|
||||
@import 'calendar';
|
||||
@import 'dashboard';
|
||||
@import 'navbar';
|
||||
@import 'documentation';
|
||||
|
||||
25
assets/sass/documentation.scss
Normal file
25
assets/sass/documentation.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
615
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
jms_serializer:
|
||||
handlers:
|
||||
datetime:
|
||||
default_format: "Y-m-d H:i"
|
||||
visitors:
|
||||
xml:
|
||||
format_output: '%kernel.debug%'
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
56
config/serializer/App/Entity.Timesheet.yml
Normal file
56
config/serializer/App/Entity.Timesheet.yml
Normal 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]
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
# ================================================================================
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
18
src/API/BaseApiController.php
Normal file
18
src/API/BaseApiController.php
Normal 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
|
||||
{
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
191
src/API/TimesheetController.php
Normal file
191
src/API/TimesheetController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
148
src/Controller/Admin/AboutController.php
Normal file
148
src/Controller/Admin/AboutController.php
Normal 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'];
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ class TimesheetController extends AbstractController
|
||||
'entries' => $entries,
|
||||
'page' => $page,
|
||||
'query' => $query,
|
||||
'showFilter' => $form->isSubmitted(),
|
||||
'toolbarForm' => $form->createView(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/';
|
||||
|
||||
43
src/Controller/HomepageController.php
Normal file
43
src/Controller/HomepageController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ class Activity
|
||||
*
|
||||
* @ORM\ManyToOne(targetEntity="App\Entity\Project", inversedBy="activities")
|
||||
* @ORM\JoinColumn(onDelete="CASCADE")
|
||||
* @Assert\NotNull()
|
||||
*/
|
||||
private $project;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
65
src/Form/Extension/EnhancedChoiceTypeExtension.php
Normal file
65
src/Form/Extension/EnhancedChoiceTypeExtension.php
Normal 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%']
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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%']
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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%']
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
52
src/Form/Type/CalendarViewType.php
Normal file
52
src/Form/Type/CalendarViewType.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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%']
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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%']
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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%']
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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%']
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
51
src/Invoice/Calculator/ActivityInvoiceCalculator.php
Normal file
51
src/Invoice/Calculator/ActivityInvoiceCalculator.php
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
127
src/Migrations/Version20181031220003.php
Normal file
127
src/Migrations/Version20181031220003.php
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
68
src/Utils/ParsedownExtension.php
Normal file
68
src/Utils/ParsedownExtension.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -413,9 +413,6 @@
|
||||
"symfony/intl": {
|
||||
"version": "v4.0.3"
|
||||
},
|
||||
"symfony/lts": {
|
||||
"version": "4-dev"
|
||||
},
|
||||
"symfony/maker-bundle": {
|
||||
"version": "1.0",
|
||||
"recipe": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user