small bugfixes (#2655)
* improve report visibility * fix timesheet duration, which breaks validation * added contributing section in README * do not allow to change project for existing activities - fixes #2576 * bump theme version to fix FOSUserBundle dependency * use entity color instead of inherited one - fixes #2200
This commit is contained in:
27
README.md
27
README.md
@@ -25,7 +25,7 @@ It is built with modern technologies such as Symfony, Bootstrap, RESTful API, Do
|
||||
|
||||
### Requirements
|
||||
|
||||
- PHP 7.2.9 or higher ([PHP 8 not yet](https://github.com/kevinpapst/kimai2/pull/2158))
|
||||
- PHP 7.2.9 or higher ([PHP 8 support in master](https://github.com/kevinpapst/kimai2/pull/2158))
|
||||
- MariaDB or MySQL
|
||||
- Webserver (nginx, Apache)
|
||||
- Some PHP extensions, see [composer.json](composer.json) or [here](https://www.kimai.org/download/)
|
||||
@@ -39,6 +39,9 @@ JSON API, invoicing, data exports, multi-timer and punch-in punch-out mode, tagg
|
||||
authentication via SAML/LDAP/Database, customizable role and team permissions, responsive and ready for your mobile device,
|
||||
user specific rates, advanced search & filtering, money and time budgets, multiple reports, support for plugins and many more.
|
||||
|
||||
Kimai 2 is based on modern frameworks like [Symfony](https://github.com/symfony/symfony), [Doctrine](https://github.com/doctrine/),
|
||||
[AdminLTE](https://github.com/kevinpapst/AdminLTEBundle/) and [many](composer.json) [more](package.json).
|
||||
|
||||
## Installation
|
||||
|
||||
- [Recommended setup](https://www.kimai.org/documentation/installation.html#recommended-setup) - with Git and Composer
|
||||
@@ -65,14 +68,22 @@ It is open for changes and input from the community, your [ideas and questions](
|
||||
> You can upgrade Kimai at any time, you don't need to wait for the next official release.
|
||||
> The master branch is always deployable, release tags are only snapshots of the current development version.
|
||||
|
||||
Release versions will be created on a regular base (approx. one release every 4-8 weeks).
|
||||
Release versions will be created on a regular base (approx. one release every 2 months).
|
||||
Every code change, whether it's a new feature or a bugfix, will be done on the master branch.
|
||||
Kimai is actively developed in my spare time, I put my effort into the software instead of backporting changes.
|
||||
|
||||
## Credits
|
||||
## Contributing
|
||||
|
||||
Kimai 2 is developed with modern frameworks like
|
||||
[Symfony v4](https://github.com/symfony/symfony),
|
||||
[Doctrine](https://github.com/doctrine/),
|
||||
[AdminLTEBundle](https://github.com/kevinpapst/AdminLTEBundle/) (based on [AdminLTE theme](https://github.com/almasaeed2010/AdminLTE)) and
|
||||
[many](composer.json) [more](package.json).
|
||||
You want to contribute to this repository? This is so great!
|
||||
The best way to start is to [open a new issue](https://github.com/kevinpapst/kimai2/issues) for bugs or feature requests or a [discussion](https://github.com/kevinpapst/kimai2/discussions) for questions, support and such.
|
||||
|
||||
In case you want to contribute, but you wouldn't know how, here are some suggestions:
|
||||
|
||||
- Spread the word: More user means more people testing and contributing to Kimai - which in turn means better stability and more and better features. Please vote for Kimai on platforms lie Slant, Product Hunt, Softpedia or AlternativeTo, you can tweet about it, share it on LinkedIn, reddit or any of your favorite social media platforms. Every little bit helps!
|
||||
- Answer questions: You know the answer to another user's problem? Share your knowledge!
|
||||
- Make a feature request: Something can be done better? Something essential missing? Let us know!
|
||||
- Report bugs
|
||||
- Contribute: You don't have to be programmer to help. The documentation and translation could use some love as well.
|
||||
- Sponsor the project
|
||||
|
||||
There is one simple rule in our "Code of conduct": Don't be an ass!
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"handcraftedinthealps/rest-routing-bundle": "^1.0",
|
||||
"jms/metadata": "^2.0",
|
||||
"jms/serializer-bundle": "^3.9",
|
||||
"kevinpapst/adminlte-bundle": "dev-master",
|
||||
"kevinpapst/adminlte-bundle": "^3.5",
|
||||
"league/csv": "^9.4",
|
||||
"league/html-to-markdown": "^5.0",
|
||||
"mpdf/mpdf": "^8.0",
|
||||
|
||||
11
composer.lock
generated
11
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "d4fcb30ed5f1db78d4f32f09388ea0b3",
|
||||
"content-hash": "c031208fe6bb0d88b062cdd70e1b76c6",
|
||||
"packages": [
|
||||
{
|
||||
"name": "beberlei/doctrineextensions",
|
||||
@@ -2361,7 +2361,7 @@
|
||||
},
|
||||
{
|
||||
"name": "kevinpapst/adminlte-bundle",
|
||||
"version": "dev-master",
|
||||
"version": "3.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/kevinpapst/AdminLTEBundle.git",
|
||||
@@ -2399,7 +2399,6 @@
|
||||
"suggest": {
|
||||
"knplabs/knp-menu-bundle": "Allows easy menu integration"
|
||||
},
|
||||
"default-branch": true,
|
||||
"type": "symfony-bundle",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
@@ -2427,7 +2426,7 @@
|
||||
"description": "Admin theme bundle for Symfony 4+ based on AdminLTE 2.4 with FOSUserBundle support",
|
||||
"support": {
|
||||
"issues": "https://github.com/kevinpapst/AdminLTEBundle/issues",
|
||||
"source": "https://github.com/kevinpapst/AdminLTEBundle/tree/master"
|
||||
"source": "https://github.com/kevinpapst/AdminLTEBundle/tree/3.5"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -13685,9 +13684,7 @@
|
||||
],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": {
|
||||
"kevinpapst/adminlte-bundle": 20
|
||||
},
|
||||
"stability-flags": [],
|
||||
"prefer-stable": false,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
|
||||
@@ -411,6 +411,11 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
|
||||
*/
|
||||
public function getDuration(): ?int
|
||||
{
|
||||
// only auto calculate if manually set duration is null - the result is important for eg. validations
|
||||
if ($this->duration === null && $this->begin !== null && $this->end !== null) {
|
||||
return $this->end->getTimestamp() - $this->begin->getTimestamp();
|
||||
}
|
||||
|
||||
return $this->duration;
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ class ProjectSubscriber extends AbstractActionsSubscriber
|
||||
$event->addDelete($this->path('admin_project_delete', ['id' => $project->getId()]));
|
||||
}
|
||||
|
||||
if ($this->isGranted('view_reporting') && $this->isGranted('details_project')) {
|
||||
if ($project->isVisible() && $this->isGranted('view_reporting') && $this->isGranted('details_project')) {
|
||||
$event->addAction('report_project_details', ['url' => $this->path('report_project_details', ['project' => $project->getId()]), 'icon' => 'reporting', 'translation_domain' => 'reporting']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ class ActivityEditForm extends AbstractType
|
||||
{
|
||||
$project = null;
|
||||
$customer = null;
|
||||
$id = null;
|
||||
$new = true;
|
||||
|
||||
if (isset($options['data'])) {
|
||||
/** @var Activity $entry */
|
||||
@@ -48,7 +48,7 @@ class ActivityEditForm extends AbstractType
|
||||
$options['currency'] = $customer->getCurrency();
|
||||
}
|
||||
|
||||
$id = $entry->getId();
|
||||
$new = $entry->getId() === null;
|
||||
}
|
||||
|
||||
$builder
|
||||
@@ -64,55 +64,57 @@ class ActivityEditForm extends AbstractType
|
||||
])
|
||||
;
|
||||
|
||||
if ($options['customer']) {
|
||||
if ($new) {
|
||||
if ($options['customer']) {
|
||||
$builder
|
||||
->add('customer', CustomerType::class, [
|
||||
'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) {
|
||||
$query = new CustomerFormTypeQuery($customer);
|
||||
$query->setUser($builder->getOption('user'));
|
||||
|
||||
return $repo->getQueryBuilderForFormType($query);
|
||||
},
|
||||
'data' => $customer ? $customer : null,
|
||||
'required' => false,
|
||||
'mapped' => false,
|
||||
'project_enabled' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
$builder
|
||||
->add('customer', CustomerType::class, [
|
||||
'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) {
|
||||
$query = new CustomerFormTypeQuery($customer);
|
||||
$query->setUser($builder->getOption('user'));
|
||||
|
||||
return $repo->getQueryBuilderForFormType($query);
|
||||
},
|
||||
'data' => $customer ? $customer : null,
|
||||
->add('project', ProjectType::class, [
|
||||
'required' => false,
|
||||
'mapped' => false,
|
||||
'project_enabled' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
$builder
|
||||
->add('project', ProjectType::class, [
|
||||
'required' => false,
|
||||
'query_builder' => function (ProjectRepository $repo) use ($builder, $project, $customer) {
|
||||
$query = new ProjectFormTypeQuery($project, $customer);
|
||||
$query->setUser($builder->getOption('user'));
|
||||
$query->setIgnoreDate(true);
|
||||
|
||||
return $repo->getQueryBuilderForFormType($query);
|
||||
},
|
||||
]);
|
||||
|
||||
// replaces the project select after submission, to make sure only projects for the selected customer are displayed
|
||||
$builder->addEventListener(
|
||||
FormEvents::PRE_SUBMIT,
|
||||
function (FormEvent $event) use ($builder, $project) {
|
||||
$data = $event->getData();
|
||||
if (!isset($data['customer']) || empty($data['customer'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$event->getForm()->add('project', ProjectType::class, [
|
||||
'group_by' => null,
|
||||
'query_builder' => function (ProjectRepository $repo) use ($builder, $data, $project) {
|
||||
$query = new ProjectFormTypeQuery($project, $data['customer']);
|
||||
'query_builder' => function (ProjectRepository $repo) use ($builder, $project, $customer) {
|
||||
$query = new ProjectFormTypeQuery($project, $customer);
|
||||
$query->setUser($builder->getOption('user'));
|
||||
$query->setIgnoreDate(true);
|
||||
|
||||
return $repo->getQueryBuilderForFormType($query);
|
||||
},
|
||||
]);
|
||||
}
|
||||
);
|
||||
|
||||
// replaces the project select after submission, to make sure only projects for the selected customer are displayed
|
||||
$builder->addEventListener(
|
||||
FormEvents::PRE_SUBMIT,
|
||||
function (FormEvent $event) use ($builder, $project) {
|
||||
$data = $event->getData();
|
||||
if (!isset($data['customer']) || empty($data['customer'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$event->getForm()->add('project', ProjectType::class, [
|
||||
'group_by' => null,
|
||||
'query_builder' => function (ProjectRepository $repo) use ($builder, $data, $project) {
|
||||
$query = new ProjectFormTypeQuery($project, $data['customer']);
|
||||
$query->setUser($builder->getOption('user'));
|
||||
$query->setIgnoreDate(true);
|
||||
|
||||
return $repo->getQueryBuilderForFormType($query);
|
||||
},
|
||||
]);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
$this->addCommonFields($builder, $options);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ class ProjectDetailsForm extends AbstractType
|
||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||
{
|
||||
$builder->add('project', ProjectType::class, [
|
||||
'ignore_date' => true,
|
||||
'required' => false,
|
||||
'label' => false,
|
||||
'width' => false,
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
{% import "project/actions.html.twig" as projectActions %}
|
||||
{% block box_attributes %}id="activity_details_box"{% endblock %}
|
||||
{% block box_title %}
|
||||
{{ widgets.label_activity(activity) }}
|
||||
{{ widgets.label_name(activity.name, activity.color) }}
|
||||
{% endblock %}
|
||||
{% block box_tools %}
|
||||
{% if can_edit %}
|
||||
|
||||
@@ -22,8 +22,12 @@
|
||||
</div>
|
||||
</div>
|
||||
{{ form_row(form.comment) }}
|
||||
{{ form_row(form.customer) }}
|
||||
{{ form_row(form.project) }}
|
||||
{% if form.customer is defined %}
|
||||
{{ form_row(form.customer) }}
|
||||
{% endif %}
|
||||
{% if form.project is defined %}
|
||||
{{ form_row(form.project) }}
|
||||
{% endif %}
|
||||
{% if form.budget is defined %}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% block box_attributes %}id="{{ id }}"{% endblock %}
|
||||
{% block box_title %}
|
||||
{{ 'rates.title'|trans }}
|
||||
{{ 'label.hourlyRate'|trans }}
|
||||
{% endblock %}
|
||||
{% block box_tools %}
|
||||
<a class="modal-ajax-form open-edit btn btn-default btn-sm" data-href="{{ create_url }}" data-toggle="tooltip" data-placement="top" title="{{ 'create'|trans }}"><i class="{{ 'create'|icon }}"></i></a>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block box_title %}
|
||||
{{ widgets.label_project(project) }}
|
||||
{{ widgets.label_name(project.name, project.color) }}
|
||||
{% endblock %}
|
||||
{% block box_body_class %}no-padding{% endblock %}
|
||||
{% block box_body %}
|
||||
|
||||
@@ -260,7 +260,6 @@
|
||||
|
||||
<div class="nav-tabs-custom">
|
||||
<ul class="nav nav-tabs pull-right">
|
||||
|
||||
<li><a data-chart="{{ chartPrefix }}User" href="#user-chart" data-toggle="tab">{{ 'label.user'|trans }}</a></li>
|
||||
<li><a data-chart="{{ chartPrefix }}Activity" href="#activity-chart" data-toggle="tab">{{ 'label.activity'|trans }}</a></li>
|
||||
{% if view_revenue_tab %}
|
||||
@@ -268,14 +267,14 @@
|
||||
{% endif %}
|
||||
<li><a data-chart="{{ chartPrefix }}Duration" href="#time-chart" data-toggle="tab">{{ 'stats.workingTime'|trans }}</a></li>
|
||||
<li class="active"><a href="#details-chart" data-toggle="tab">{{ 'report_project_details'|trans({}, 'reporting') }}</a></li>
|
||||
<li class="pull-left header">
|
||||
<li class="pull-left header hidden-xs">
|
||||
{{ widgets.label_project(project, {'inherit': false}) }}
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content no-padding">
|
||||
<div class="chart tab-pane active" id="details-chart">
|
||||
<div class="row">
|
||||
<div class="col-xs-6">
|
||||
<div class="col-xs-12 col-md-6">
|
||||
<table class="table table-hover dataTable">
|
||||
<tr {{ widgets.customer_row_attr(entry.project.customer) }}>
|
||||
<th class="w-min">
|
||||
@@ -285,33 +284,21 @@
|
||||
{{ widgets.label_customer(entry.project.customer) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="w-min">
|
||||
{{ 'stats.durationToday'|trans }}
|
||||
</th>
|
||||
<td>{{ entry.durationDay|duration }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="w-min">
|
||||
{{ 'stats.durationWeek'|trans }}
|
||||
</th>
|
||||
<td>{{ entry.durationWeek|duration }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="w-min">
|
||||
{{ 'stats.durationMonth'|trans }}
|
||||
</th>
|
||||
<td>{{ entry.durationMonth|duration }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="w-min">
|
||||
{{ 'stats.durationTotal'|trans }}
|
||||
</th>
|
||||
<td>{{ entry.durationTotal|duration }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="w-min">
|
||||
{{ 'stats.amountTotal'|trans }}
|
||||
</th>
|
||||
<td>{{ entry.rateTotal|money(currency) }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-xs-6">
|
||||
<div class="col-xs-12 col-md-6">
|
||||
<table class="table table-hover dataTable">
|
||||
<tr>
|
||||
<th class="w-min">
|
||||
@@ -353,26 +340,6 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if project.start is not null %}
|
||||
<tr>
|
||||
<th class="w-min">
|
||||
{{ 'label.project_start'|trans }}
|
||||
</th>
|
||||
<td>
|
||||
{{ project.start|date_short }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if project.end is not null %}
|
||||
<tr>
|
||||
<th class="w-min">
|
||||
{{ 'label.project_end'|trans }}
|
||||
</th>
|
||||
<td>
|
||||
{{ project.end|date_short }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -291,7 +291,6 @@ class ActivityControllerTest extends APIControllerBaseTest
|
||||
$data = [
|
||||
'name' => 'foo',
|
||||
'comment' => '',
|
||||
'project' => 1,
|
||||
'visible' => true,
|
||||
'budget' => '999',
|
||||
'timeBudget' => '7200',
|
||||
@@ -332,15 +331,14 @@ class ActivityControllerTest extends APIControllerBaseTest
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$data = [
|
||||
'name' => 'foo',
|
||||
'project' => 255,
|
||||
'visible' => true
|
||||
'name' => 'foofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoo',
|
||||
'visible' => false
|
||||
];
|
||||
$this->request($client, '/api/activities/1', 'PATCH', [], json_encode($data));
|
||||
|
||||
$response = $client->getResponse();
|
||||
$this->assertEquals(400, $response->getStatusCode());
|
||||
$this->assertApiCallValidationError($response, ['project']);
|
||||
$this->assertApiCallValidationError($response, ['name']);
|
||||
}
|
||||
|
||||
public function testMetaActionThrowsNotFound()
|
||||
|
||||
@@ -189,9 +189,6 @@ class ActivityControllerTest extends ControllerBaseTest
|
||||
$this->request($client, '/admin/activity/' . $id . '/edit');
|
||||
$editForm = $client->getCrawler()->filter('form[name=activity_edit_form]')->form();
|
||||
$this->assertEquals('An AcTiVitY Name', $editForm->get('activity_edit_form[name]')->getValue());
|
||||
// make sure customer and project are pre-selected for none global activities
|
||||
$this->assertEquals('1', $editForm->get('activity_edit_form[project]')->getValue());
|
||||
$this->assertEquals('1', $editForm->get('activity_edit_form[customer]')->getValue());
|
||||
}
|
||||
|
||||
public function testCreateActionShowsMetaFields()
|
||||
@@ -213,15 +210,13 @@ class ActivityControllerTest extends ControllerBaseTest
|
||||
$form = $client->getCrawler()->filter('form[name=activity_edit_form]')->form();
|
||||
$this->assertEquals('Test', $form->get('activity_edit_form[name]')->getValue());
|
||||
$client->submit($form, [
|
||||
'activity_edit_form' => ['name' => 'Test 2', 'customer' => 1, 'project' => '1']
|
||||
'activity_edit_form' => ['name' => 'Test 2']
|
||||
]);
|
||||
$this->assertIsRedirect($client, $this->createUrl('/admin/activity/1/details'));
|
||||
$client->followRedirect();
|
||||
$this->request($client, '/admin/activity/1/edit');
|
||||
$editForm = $client->getCrawler()->filter('form[name=activity_edit_form]')->form();
|
||||
$this->assertEquals('Test 2', $editForm->get('activity_edit_form[name]')->getValue());
|
||||
$this->assertEquals('1', $editForm->get('activity_edit_form[customer]')->getValue());
|
||||
$this->assertEquals('1', $editForm->get('activity_edit_form[project]')->getValue());
|
||||
}
|
||||
|
||||
public function testEditActionForGlobalActivity()
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
</trans-unit>
|
||||
<trans-unit id="rates.empty">
|
||||
<source>rates.empty</source>
|
||||
<target>Es wurden noch keine Gebühren hinterlegt.</target>
|
||||
<target>Es wurden noch keine Stundensätze hinterlegt.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="rates.title">
|
||||
<source>rates.title</source>
|
||||
|
||||
@@ -984,7 +984,7 @@
|
||||
</trans-unit>
|
||||
<trans-unit id="rates.empty">
|
||||
<source>rates.empty</source>
|
||||
<target>Es wurden noch keine Gebühren hinterlegt.</target>
|
||||
<target>Es wurden noch keine Stundensätze hinterlegt.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="attachments">
|
||||
<source>attachments</source>
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
</trans-unit>
|
||||
<trans-unit id="rates.empty">
|
||||
<source>rates.empty</source>
|
||||
<target>No fees have been configured yet.</target>
|
||||
<target>No hourly rates have yet been configured.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="rates.title">
|
||||
<source>rates.title</source>
|
||||
|
||||
Reference in New Issue
Block a user