add user preference: default report (#2430)

This commit is contained in:
Kevin Papst
2021-03-14 13:32:47 +01:00
committed by GitHub
parent 0a6ea6be42
commit c0378b4f51
16 changed files with 264 additions and 57 deletions

View File

@@ -14,6 +14,7 @@ use App\Reporting\MonthByUser;
use App\Reporting\MonthByUserForm;
use App\Reporting\MonthlyUserList;
use App\Reporting\MonthlyUserListForm;
use App\Reporting\ReportingService;
use App\Reporting\WeekByUser;
use App\Reporting\WeekByUserForm;
use App\Repository\Query\UserQuery;
@@ -54,9 +55,33 @@ final class ReportingController extends AbstractController
*
* @return Response
*/
public function defaultReport(): Response
public function defaultReport(ReportingService $reportingService): Response
{
return $this->redirectToRoute('report_user_week');
$user = $this->getUser();
$route = null;
$defaultReport = $user->getPreferenceValue('reporting.initial_view', ReportingService::DEFAULT_VIEW);
$allReports = $reportingService->getAvailableReports($user);
foreach ($allReports as $report) {
if ($report->getId() === $defaultReport) {
$route = $report->getRoute();
break;
}
}
// fallback, if the configured report could not be found
// eg. when it was deleted or replaced by an enhanced version with a new id
if ($route === null && \count($allReports) > 0) {
$report = $allReports[array_keys($allReports)[0]];
$route = $report->getRoute();
}
if ($route === null) {
throw $this->createNotFoundException('Unknown default report');
}
return $this->redirectToRoute($route);
}
private function canSelectUser(): bool

View File

@@ -18,8 +18,10 @@ use App\Form\Type\CalendarViewType;
use App\Form\Type\FirstWeekDayType;
use App\Form\Type\InitialViewType;
use App\Form\Type\LanguageType;
use App\Form\Type\ReportType;
use App\Form\Type\SkinType;
use App\Form\Type\ThemeLayoutType;
use App\Reporting\ReportingService;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
@@ -30,24 +32,15 @@ use Symfony\Component\Validator\Constraints\Range;
final class UserPreferenceSubscriber implements EventSubscriberInterface
{
/**
* @var EventDispatcherInterface
*/
private $eventDispatcher;
/**
* @var AuthorizationCheckerInterface
*/
private $voter;
/**
* @var SystemConfiguration
*/
private $configuration;
public function __construct(EventDispatcherInterface $dispatcher, AuthorizationCheckerInterface $voter, SystemConfiguration $formConfig)
public function __construct(EventDispatcherInterface $eventDispatcher, AuthorizationCheckerInterface $voter, SystemConfiguration $systemConfiguration)
{
$this->eventDispatcher = $dispatcher;
$this->eventDispatcher = $eventDispatcher;
$this->voter = $voter;
$this->configuration = $formConfig;
$this->configuration = $systemConfiguration;
}
public static function getSubscribedEvents(): array
@@ -153,6 +146,13 @@ final class UserPreferenceSubscriber implements EventSubscriberInterface
->setSection('behaviour')
->setType(CalendarViewType::class),
(new UserPreference())
->setName('reporting.initial_view')
->setValue(ReportingService::DEFAULT_VIEW)
->setOrder(650)
->setSection('behaviour')
->setType(ReportType::class),
(new UserPreference())
->setName('login.initial_view')
->setValue(InitialViewType::DEFAULT_VIEW)

View File

@@ -0,0 +1,58 @@
<?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\User;
use App\Reporting\ReportingService;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Custom form field type to select a report.
*/
class ReportType extends AbstractType
{
private $reportingService;
public function __construct(ReportingService $reportingService)
{
$this->reportingService = $reportingService;
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefault('required', true);
$resolver->setDefault('translation_domain', 'reporting');
$resolver->setDefault('choices', function (Options $options) {
/** @var User $user */
$user = $options['user'];
$choices = [];
foreach ($this->reportingService->getAvailableReports($user) as $report) {
$choices[$report->getLabel()] = $report->getId();
}
return $choices;
});
}
/**
* {@inheritdoc}
*/
public function getParent()
{
return ChoiceType::class;
}
}

View File

@@ -21,13 +21,28 @@ class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
$total = $model->getCalculator()->getTotal();
$subtotal = $model->getCalculator()->getSubtotal();
$formatter = $model->getFormatter();
$entries = $model->getCalculator()->getEntries();
return [
$begin = null;
if ($model->getQuery()->getBegin() !== null) {
$begin = $model->getQuery()->getBegin();
} elseif (!empty($entries)) {
$begin = $entries[0];
}
$end = null;
if ($model->getQuery()->getEnd() !== null) {
$end = $model->getQuery()->getEnd();
} elseif (!empty($entries)) {
$end = array_keys($entries)[\count($entries) - 1];
}
$values = [
'invoice.due_date' => $formatter->getFormattedDateTime($model->getDueDate()),
'invoice.date' => $formatter->getFormattedDateTime($model->getInvoiceDate()),
'invoice.number' => $model->getInvoiceNumber(),
'invoice.currency' => $currency,
'invoice.language' => $model->getTemplate()->getLanguage(), // since 1.9
'invoice.language' => $model->getTemplate()->getLanguage(), // since 1.9
'invoice.currency_symbol' => $formatter->getCurrencySymbol($currency),
'invoice.vat' => $model->getCalculator()->getVat(),
'invoice.tax' => $formatter->getFormattedMoney($tax, $currency),
@@ -52,20 +67,41 @@ class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
'template.contact' => $model->getTemplate()->getContact(),
'template.payment_details' => $model->getTemplate()->getPaymentDetails(),
'query.begin' => $formatter->getFormattedDateTime($model->getQuery()->getBegin()),
'query.day' => $model->getQuery()->getBegin()->format('d'), // @deprecated
'query.month' => $formatter->getFormattedMonthName($model->getQuery()->getBegin()), // @deprecated
'query.month_number' => $model->getQuery()->getBegin()->format('m'), // @deprecated
'query.year' => $model->getQuery()->getBegin()->format('Y'), // @deprecated
'query.begin_day' => $model->getQuery()->getBegin()->format('d'),
'query.begin_month' => $formatter->getFormattedMonthName($model->getQuery()->getBegin()),
'query.begin_month_number' => $model->getQuery()->getBegin()->format('m'),
'query.begin_year' => $model->getQuery()->getBegin()->format('Y'),
'query.end' => $formatter->getFormattedDateTime($model->getQuery()->getEnd()), // since 1.9
'query.end_day' => $model->getQuery()->getEnd()->format('d'), // since 1.9
'query.end_month' => $formatter->getFormattedMonthName($model->getQuery()->getEnd()), // since 1.9
'query.end_month_number' => $model->getQuery()->getEnd()->format('m'), // since 1.9
'query.end_year' => $model->getQuery()->getEnd()->format('Y'), // since 1.9
'query.begin' => '',
'query.day' => '', // @deprecated
'query.month' => '', // @deprecated
'query.month_number' => '', // @deprecated
'query.year' => '', // @deprecated
'query.begin_day' => '',
'query.begin_month' => '',
'query.begin_month_number' => '',
'query.begin_year' => '',
'query.end' => '', // since 1.9
'query.end_day' => '', // since 1.9
'query.end_month' => '', // since 1.9
'query.end_month_number' => '', // since 1.9
'query.end_year' => '', // since 1.9
];
if ($begin !== null) {
$values = array_merge($values, [
'query.begin' => $formatter->getFormattedDateTime($begin),
'query.day' => $begin->format('d'), // @deprecated
'query.month' => $formatter->getFormattedMonthName($begin), // @deprecated
'query.month_number' => $begin->format('m'), // @deprecated
'query.year' => $begin->format('Y'), // @deprecated
'query.begin_day' => $begin->format('d'),
'query.begin_month' => $formatter->getFormattedMonthName($begin),
'query.begin_month_number' => $begin->format('m'),
'query.begin_year' => $begin->format('Y'),
'query.end' => $formatter->getFormattedDateTime($end), // since 1.9
'query.end_day' => $end->format('d'), // since 1.9
'query.end_month' => $formatter->getFormattedMonthName($end), // since 1.9
'query.end_month_number' => $end->format('m'), // since 1.9
'query.end_year' => $end->format('Y'), // since 1.9
]);
}
return $values;
}
}

View File

@@ -66,7 +66,12 @@ final class ProjectViewService
}
if (!$query->isIncludeNoBudget()) {
$qb->andWhere($qb->expr()->gt('p.timeBudget', 0));
$qb->andWhere(
$qb->expr()->orX(
$qb->expr()->gt('p.budget', 0),
$qb->expr()->gt('p.timeBudget', 0)
)
);
}
$this->repository->addPermissionCriteria($qb, $user);

View File

@@ -16,6 +16,8 @@ use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
final class ReportingService
{
public const DEFAULT_VIEW = 'week_by_user';
/**
* @var EventDispatcherInterface
*/
@@ -40,7 +42,7 @@ final class ReportingService
$event = new ReportingEvent($user);
if ($this->security->isGranted('view_reporting')) {
$event->addReport(new Report('week_by_user', 'report_user_week', 'report_user_week'));
$event->addReport(new Report(self::DEFAULT_VIEW, 'report_user_week', 'report_user_week'));
$event->addReport(new Report('month_by_user', 'report_user_month', 'report_user_month'));
if ($this->security->isGranted('budget_project')) {
$event->addReport(new Report('project_view', 'report_project_view', 'report_project_view'));
@@ -48,9 +50,9 @@ final class ReportingService
if ($this->security->isGranted('view_other_timesheet')) {
$event->addReport(new Report('monthly_users_list', 'report_monthly_users', 'report_monthly_users'));
}
}
$this->dispatcher->dispatch($event);
$this->dispatcher->dispatch($event);
}
return $event->getReports();
}

View File

@@ -15,8 +15,8 @@
}) %}
{% endfor %}
{% set columns = columns|merge({
'budget': {'class': 'hidden-xs hidden-sm hidden', 'title': 'label.budget'|trans},
'timeBudget': {'class': 'hidden-xs hidden-sm hidden', 'title': 'label.timeBudget'|trans},
'budget': {'class': 'hidden-xs hidden-sm hidden text-center w-min', 'title': 'label.budget'|trans},
'timeBudget': {'class': 'hidden-xs hidden-sm hidden text-center w-min', 'title': 'label.timeBudget'|trans},
'team': {'class': 'text-center w-min', 'orderBy': false},
'visible': {'class': 'text-center hidden w-min'},
'actions': {'class': 'actions alwaysVisible'},
@@ -60,8 +60,20 @@
{{ tables.datatable_meta_column(entry, field) }}
</td>
{% endfor %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'budget') }}">{{ entry.budget|money((entry.project is null ? defaultCurrency : entry.project.customer.currency)) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'timeBudget') }}">{{ entry.timeBudget|duration }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'budget') }}">
{% if entry.hasBudget() %}
{{ entry.budget|money((entry.project is null ? defaultCurrency : entry.project.customer.currency)) }}
{% else %}
&ndash;
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'timeBudget') }}">
{% if entry.hasTimeBudget() %}
{{ entry.timeBudget|duration }}
{% else %}
&ndash;
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'team') }}">{{ widgets.badge_team_access(entry.teams) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'visible') }}">{{ widgets.label_visible(entry.visible) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'actions') }}">{{ actions.activity(entry, 'index') }}</td>

View File

@@ -26,8 +26,8 @@
}) %}
{% endfor %}
{% set columns = columns|merge({
'budget': {'class': 'hidden-xs hidden-sm hidden', 'title': 'label.budget'|trans},
'timeBudget': {'class': 'hidden-xs hidden-sm hidden', 'title': 'label.timeBudget'|trans},
'budget': {'class': 'hidden-xs hidden-sm hidden text-center w-min', 'title': 'label.budget'|trans},
'timeBudget': {'class': 'hidden-xs hidden-sm hidden text-center w-min', 'title': 'label.timeBudget'|trans},
'team': {'class': 'text-center w-min', 'orderBy': false},
'visible': {'class': 'text-center hidden w-min'},
'actions': {'class': 'actions alwaysVisible'},
@@ -71,8 +71,20 @@
{{ tables.datatable_meta_column(entry, field) }}
</td>
{% endfor %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'budget') }}">{{ entry.budget|money(entry.currency) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'timeBudget') }}">{{ entry.timeBudget|duration }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'budget') }}">
{% if entry.hasBudget() %}
{{ entry.budget|money(entry.currency) }}
{% else %}
&ndash;
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'timeBudget') }}">
{% if entry.hasBudget() %}
{{ entry.timeBudget|duration }}
{% else %}
&ndash;
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'team') }}">{{ widgets.badge_team_access(entry.teams) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'visible') }}">{{ widgets.label_visible(entry.visible) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'actions') }}">{{ actions.customer(entry, 'index') }}</td>

View File

@@ -19,8 +19,8 @@
}) %}
{% endfor %}
{% set columns = columns|merge({
'budget': {'class': 'hidden-xs hidden-sm hidden', 'title': 'label.budget'|trans},
'timeBudget': {'class': 'hidden-xs hidden-sm hidden', 'title': 'label.timeBudget'|trans},
'budget': {'class': 'hidden-xs hidden-sm hidden text-center w-min', 'title': 'label.budget'|trans},
'timeBudget': {'class': 'hidden-xs hidden-sm hidden text-center w-min', 'title': 'label.timeBudget'|trans},
'team': {'class': 'text-center w-min', 'orderBy': false},
'visible': {'class': 'text-center hidden w-min'},
'actions': {'class': 'actions alwaysVisible'},
@@ -57,8 +57,20 @@
{{ tables.datatable_meta_column(entry, field) }}
</td>
{% endfor %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'budget') }}">{{ entry.budget|money(entry.customer.currency) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'timeBudget') }}">{{ entry.timeBudget|duration }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'budget') }}">
{% if entry.hasBudget() %}
{{ entry.budget|money(entry.customer.currency) }}
{% else %}
&ndash;
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'timeBudget') }}">
{% if entry.hasBudget() %}
{{ entry.timeBudget|duration }}
{% else %}
&ndash;
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'team') }}">{{ widgets.badge_team_access(entry.teams) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'visible') }}">{{ widgets.label_visible(entry.visible) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'actions') }}">{{ actions.project(entry, 'index') }}</td>

View File

@@ -4,16 +4,16 @@
{% block report_title %}{{ 'report_project_view'|trans({}, 'reporting') }}{% endblock %}
{% set columns = {
'name': {'class': 'alwaysVisible'},
'today': {'class': 'text-nowrap text-right', 'title': 'daterangepicker.today'|trans({}, 'daterangepicker')},
'week': {'class': 'text-nowrap text-right', 'title': 'agendaWeek'|trans},
'month': {'class': 'text-nowrap text-right', 'title': 'month'|trans},
'durationTotal': {'class': 'hidden-md hidden-sm hidden-xs text-nowrap text-right', 'title': 'stats.durationTotal'|trans},
'name': {'class': 'alwaysVisible w-min'},
'today': {'class': 'text-nowrap text-center w-min', 'title': 'daterangepicker.today'|trans({}, 'daterangepicker')},
'week': {'class': 'text-nowrap text-center w-min', 'title': 'agendaWeek'|trans},
'month': {'class': 'text-nowrap text-center w-min', 'title': 'month'|trans},
'durationTotal': {'class': 'hidden-md hidden-sm hidden-xs text-nowrap text-center w-min', 'title': 'label.total'|trans},
'timeBudget': {'class': 'text-nowrap', 'title': 'label.timeBudget'|trans},
'budget': {'class': 'text-nowrap', 'title': 'label.budget'|trans},
'stateDuration': {'class': 'hidden-sm hidden-xs text-nowrap text-right', 'title': 'entryState.not_exported'|trans},
'stateMoney': {'class': 'hidden-sm hidden-xs text-nowrap text-right', 'title': 'entryState.not_exported'|trans},
'projectEnd': {'class': 'hidden-md hidden-sm hidden-xs hidden text-nowrap text-center', 'title': 'label.project_end'|trans},
'stateDuration': {'class': 'hidden-sm hidden-xs text-nowrap text-center w-min', 'title': 'label.not_exported'|trans},
'stateMoney': {'class': 'hidden-sm hidden-xs text-nowrap text-center w-min', 'title': 'label.not_invoiced'|trans},
'projectEnd': {'class': 'hidden-md hidden-sm hidden-xs hidden text-nowrap text-center w-min', 'title': 'label.project_end'|trans},
'comment': {'class': 'hidden-md hidden-sm hidden-xs hidden', 'title': 'label.comment'|trans},
} %}
{% set tableName = 'project_view_reporting' %}

View File

@@ -23,6 +23,23 @@ use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
*/
class UserPreferenceSubscriberTest extends TestCase
{
public const EXPECTED_PREFERENCES = [
'hourly_rate',
'internal_rate',
'timezone',
'language',
'first_weekday',
'skin',
'theme.layout',
'theme.collapsed_sidebar',
'theme.update_browser_title',
'calendar.initial_view',
'reporting.initial_view',
'login.initial_view',
'timesheet.daily_stats',
'timesheet.export_decimal',
];
public function testGetSubscribedEvents()
{
$events = UserPreferenceSubscriber::getSubscribedEvents();
@@ -40,7 +57,11 @@ class UserPreferenceSubscriberTest extends TestCase
self::assertSame($user, $event->getUser());
$prefs = $sut->getDefaultPreferences($user);
self::assertCount(13, $prefs);
foreach ($prefs as $pref) {
$this->assertTrue(\in_array($pref->getName(), self::EXPECTED_PREFERENCES), 'Unknown user preference: ' . $pref->getName());
}
self::assertCount(\count(self::EXPECTED_PREFERENCES), $prefs);
foreach ($prefs as $pref) {
switch ($pref->getName()) {
@@ -70,7 +91,7 @@ class UserPreferenceSubscriberTest extends TestCase
// TODO test merging values
$sut->loadUserPreferences($event);
$prefs = $event->getUser()->getPreferences();
self::assertCount(13, $prefs);
self::assertCount(\count(self::EXPECTED_PREFERENCES), $prefs);
foreach ($prefs as $pref) {
switch ($pref->getName()) {

View File

@@ -24,7 +24,7 @@ class ReportingServiceTest extends TestCase
protected function getSut(bool $isGranted = false): ReportingService
{
$dispatcher = $this->createMock(EventDispatcherInterface::class);
$dispatcher->expects($this->once())->method('dispatch')->willReturnCallback(function ($event) {
$dispatcher->expects($this->exactly($isGranted ? 1 : 0))->method('dispatch')->willReturnCallback(function ($event) {
$this->assertInstanceOf(ReportingEvent::class, $event);
});

View File

@@ -1146,6 +1146,14 @@
<source>label.includeNoBudget</source>
<target>Einträge ohne Budget anzeigen</target>
</trans-unit>
<trans-unit id="label.not_exported">
<source>label.not_exported</source>
<target>Nicht exportiert</target>
</trans-unit>
<trans-unit id="label.not_invoiced">
<source>label.not_invoiced</source>
<target>Nicht abgerechnet</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -1166,6 +1166,14 @@
<source>label.includeNoBudget</source>
<target>Show entries without budget</target>
</trans-unit>
<trans-unit id="label.not_exported">
<source>label.not_exported</source>
<target>Not exported</target>
</trans-unit>
<trans-unit id="label.not_invoiced">
<source>label.not_invoiced</source>
<target>Not billed</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -18,6 +18,10 @@
<source>report_project_view</source>
<target>Projektübersicht</target>
</trans-unit>
<trans-unit id="reporting.initial_view">
<source>reporting.initial_view</source>
<target>Initialer Bericht</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -18,6 +18,10 @@
<source>report_project_view</source>
<target>Project overview</target>
</trans-unit>
<trans-unit id="reporting.initial_view">
<source>reporting.initial_view</source>
<target>Initial report</target>
</trans-unit>
</body>
</file>
</xliff>