From ecded0a0656951588f49401eb50103e3a26bb92b Mon Sep 17 00:00:00 2001 From: Kevin Papst Date: Sun, 19 Nov 2023 16:10:32 +0100 Subject: [PATCH] added activity overview report --- config/packages/kimai.yaml | 2 +- phpstan.neon | 6 +- src/Activity/ActivityStatisticService.php | 207 +++++++++++++++++- .../Reporting/ActivityViewController.php | 57 +++++ .../ActivityView/ActivityViewForm.php | 55 +++++ .../ActivityView/ActivityViewModel.php | 181 +++++++++++++++ .../ActivityView/ActivityViewQuery.php | 77 +++++++ src/Reporting/ReportingService.php | 6 + src/Repository/ActivityRepository.php | 2 +- src/Voter/ReportingVoter.php | 5 + .../reporting/activity_list_data.html.twig | 96 ++++++++ templates/reporting/activity_view.html.twig | 21 ++ translations/reporting.de.xlf | 4 + translations/reporting.en.xlf | 4 + 14 files changed, 716 insertions(+), 7 deletions(-) create mode 100644 src/Controller/Reporting/ActivityViewController.php create mode 100644 src/Reporting/ActivityView/ActivityViewForm.php create mode 100644 src/Reporting/ActivityView/ActivityViewModel.php create mode 100644 src/Reporting/ActivityView/ActivityViewQuery.php create mode 100644 templates/reporting/activity_list_data.html.twig create mode 100644 templates/reporting/activity_view.html.twig diff --git a/config/packages/kimai.yaml b/config/packages/kimai.yaml index b50736ccb..e38df1cf8 100644 --- a/config/packages/kimai.yaml +++ b/config/packages/kimai.yaml @@ -104,7 +104,7 @@ kimai: BILLABLE: ['edit_billable_own_timesheet','edit_billable_other_timesheet'] TEAMS: ['view_team','create_team','edit_team','delete_team'] LOCKDOWN: ['lockdown_grace_timesheet','lockdown_override_timesheet'] - REPORTING: ['view_reporting','view_other_reporting','project_reporting','customer_reporting'] + REPORTING: ['view_reporting','view_other_reporting','activity_reporting','project_reporting','customer_reporting'] # permissions which are deactivated, as these features are hidden for now # brave users can try to activate them and be surprised what happens REGISTER_BETA: [] diff --git a/phpstan.neon b/phpstan.neon index 3962b3e53..4642b0a0f 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -173,7 +173,7 @@ parameters: - message: "#^Cannot access offset 'duration' on mixed\\.$#" - count: 4 + count: 13 path: src/Activity/ActivityStatisticService.php - @@ -183,7 +183,7 @@ parameters: - message: "#^Cannot access offset 'id' on mixed\\.$#" - count: 1 + count: 9 path: src/Activity/ActivityStatisticService.php - @@ -193,7 +193,7 @@ parameters: - message: "#^Cannot access offset 'rate' on mixed\\.$#" - count: 4 + count: 10 path: src/Activity/ActivityStatisticService.php - diff --git a/src/Activity/ActivityStatisticService.php b/src/Activity/ActivityStatisticService.php index d64793850..766146fae 100644 --- a/src/Activity/ActivityStatisticService.php +++ b/src/Activity/ActivityStatisticService.php @@ -10,23 +10,33 @@ namespace App\Activity; use App\Entity\Activity; +use App\Entity\Timesheet; +use App\Entity\User; use App\Event\ActivityBudgetStatisticEvent; use App\Event\ActivityStatisticEvent; use App\Model\ActivityBudgetStatisticModel; use App\Model\ActivityStatistic; +use App\Reporting\ActivityView\ActivityViewModel; +use App\Reporting\ActivityView\ActivityViewQuery; +use App\Repository\ActivityRepository; +use App\Repository\Loader\ActivityLoader; use App\Repository\TimesheetRepository; use App\Timesheet\DateTimeFactory; use DateTime; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\QueryBuilder; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\EventDispatcherInterface; /** * @final */ class ActivityStatisticService { - public function __construct(private TimesheetRepository $timesheetRepository, private EventDispatcherInterface $dispatcher) + public function __construct( + private TimesheetRepository $timesheetRepository, + private ActivityRepository $activityRepository, + private EventDispatcherInterface $dispatcher + ) { } @@ -200,4 +210,197 @@ class ActivityStatisticService return $qb; } + + /** + * @return Activity[] + */ + public function findActivitiesForView(ActivityViewQuery $query): array + { + $user = $query->getUser(); + $today = clone $query->getToday(); + + $qb = $this->activityRepository->createQueryBuilder('a'); + $qb + ->select('a') + ->leftJoin('a.project', 'p') + ->leftJoin('p.customer', 'c') + ->andWhere($qb->expr()->eq('a.visible', true)) + ->andWhere( + $qb->expr()->orX( + $qb->expr()->isNull('a.project'), + $qb->expr()->andX( + $qb->expr()->eq('p.visible', true), + $qb->expr()->eq('c.visible', true), + $qb->expr()->orX( + $qb->expr()->isNull('p.end'), + $qb->expr()->gte('p.end', ':project_end') + ) + ) + ) + ) + ->addGroupBy('a') + ->setParameter('project_end', $today, Types::DATETIME_MUTABLE) + ; + + if ($query->getProject() !== null) { + $qb->andWhere($qb->expr()->eq('p', ':project')); + $qb->setParameter('project', $query->getProject()->getId()); + } + + if (!$query->isIncludeNoWork()) { + $qb + ->leftJoin(Timesheet::class, 't', 'WITH', 'a.id = t.activity') + ->andHaving($qb->expr()->gt('SUM(t.duration)', 0)) + ; + } + + if ($query->isIncludeWithBudget()) { + $qb->andWhere( + $qb->expr()->orX( + $qb->expr()->gt('a.timeBudget', 0), + $qb->expr()->gt('a.budget', 0) + ) + ); + } elseif ($query->isIncludeWithoutBudget()) { + $qb->andWhere( + $qb->expr()->andX( + $qb->expr()->eq('a.timeBudget', 0), + $qb->expr()->eq('a.budget', 0) + ) + ); + } + + $this->activityRepository->addPermissionCriteria($qb, $user); + + /** @var Activity[] $activities */ + $activities = $qb->getQuery()->getResult(); + + // pre-cache project objects instead of joining them + $loader = new ActivityLoader($this->activityRepository->createQueryBuilder('a')->getEntityManager(), false, false, false); + $loader->loadResults($activities); + + return $activities; + } + + /** + * @param User $user + * @param Activity[] $activities + * @param DateTime $today + * @return ActivityViewModel[] + */ + public function getActivityView(User $user, array $activities, DateTime $today): array + { + $factory = DateTimeFactory::createByUser($user); + $today = clone $today; + + $startOfWeek = $factory->getStartOfWeek($today); + $endOfWeek = $factory->getEndOfWeek($today); + $startMonth = (clone $startOfWeek)->modify('first day of this month'); + $endMonth = (clone $startOfWeek)->modify('last day of this month'); + + $activityView = []; + foreach ($activities as $activity) { + $activityView[$activity->getId()] = new ActivityViewModel($activity); + } + + $budgetStats = $this->getBudgetStatisticModelForActivities($activities, $today); + foreach ($budgetStats as $model) { + $activityView[$model->getActivity()->getId()]->setBudgetStatisticModel($model); + } + + $activityIds = array_keys($activityView); + + $tplQb = $this->timesheetRepository->createQueryBuilder('t'); + $tplQb + ->select('IDENTITY(t.activity) AS id') + ->addSelect('COUNT(t.id) as amount') + ->addSelect('COALESCE(SUM(t.duration), 0) AS duration') + ->addSelect('COALESCE(SUM(t.rate), 0) AS rate') + ->andWhere($tplQb->expr()->in('t.activity', ':activity')) + ->groupBy('t.activity') + ->setParameter('activity', array_values($activityIds)) + ; + + $qb = clone $tplQb; + $qb->addSelect('MAX(t.date) as lastRecord'); + + $result = $qb->getQuery()->getScalarResult(); + foreach ($result as $row) { + $activityView[$row['id']]->setDurationTotal($row['duration']); + $activityView[$row['id']]->setRateTotal($row['rate']); + $activityView[$row['id']]->setTimesheetCounter($row['amount']); + if ($row['lastRecord'] !== null) { + // might be the wrong timezone + $activityView[$row['id']]->setLastRecord($factory->createDateTime($row['lastRecord'])); + } + } + + // values for today + $qb = clone $tplQb; + $qb + ->andWhere('DATE(t.date) = :start_date') + ->setParameter('start_date', $today, Types::DATETIME_MUTABLE) + ; + + $result = $qb->getQuery()->getScalarResult(); + foreach ($result as $row) { + $activityView[$row['id']]->setDurationDay($row['duration'] ?? 0); + } + + // values for the current week + $qb = clone $tplQb; + $qb + ->andWhere('DATE(t.date) BETWEEN :start_date AND :end_date') + ->setParameter('start_date', $startOfWeek, Types::DATETIME_MUTABLE) + ->setParameter('end_date', $endOfWeek, Types::DATETIME_MUTABLE) + ; + + $result = $qb->getQuery()->getScalarResult(); + foreach ($result as $row) { + $activityView[$row['id']]->setDurationWeek($row['duration']); + } + + // values for the current month + $qb = clone $tplQb; + $qb + ->andWhere('DATE(t.date) BETWEEN :start_date AND :end_date') + ->setParameter('start_date', $startMonth, Types::DATETIME_MUTABLE) + ->setParameter('end_date', $endMonth, Types::DATETIME_MUTABLE) + ; + + $result = $qb->getQuery()->getScalarResult(); + foreach ($result as $row) { + $activityView[$row['id']]->setDurationMonth($row['duration']); + } + + $qb = clone $tplQb; + $qb + ->addSelect('t.exported') + ->addSelect('t.billable') + ->addGroupBy('t.exported') + ->addGroupBy('t.billable') + ; + $result = $qb->getQuery()->getScalarResult(); + foreach ($result as $row) { + /** @var ActivityViewModel $view */ + $view = $activityView[$row['id']]; + if ($row['billable'] === 1 && $row['exported'] === 1) { + $view->setBillableDuration($view->getBillableDuration() + $row['duration']); + $view->setBillableRate($view->getBillableRate() + $row['rate']); + } elseif ($row['billable'] === 1 && $row['exported'] === 0) { + $view->setBillableDuration($view->getBillableDuration() + $row['duration']); + $view->setBillableRate($view->getBillableRate() + $row['rate']); + $view->setNotExportedDuration($view->getNotExportedDuration() + $row['duration']); + $view->setNotExportedRate($view->getNotExportedRate() + $row['rate']); + $view->setNotBilledDuration($view->getNotBilledDuration() + $row['duration']); + $view->setNotBilledRate($view->getNotBilledRate() + $row['rate']); + } elseif ($row['billable'] === 0 && $row['exported'] === 0) { + $view->setNotExportedDuration($view->getNotExportedDuration() + $row['duration']); + $view->setNotExportedRate($view->getNotExportedRate() + $row['rate']); + } + // the last possible case $row['billable'] === 0 && $row['exported'] === 1 is extremely unlikely and not used + } + + return array_values($activityView); + } } diff --git a/src/Controller/Reporting/ActivityViewController.php b/src/Controller/Reporting/ActivityViewController.php new file mode 100644 index 000000000..9ea98b896 --- /dev/null +++ b/src/Controller/Reporting/ActivityViewController.php @@ -0,0 +1,57 @@ +getDateTimeFactory(); + $user = $this->getUser(); + + $query = new ActivityViewQuery($dateFactory->createDateTime(), $user); + $form = $this->createFormForGetRequest(ActivityViewForm::class, $query); + $form->submit($request->query->all(), false); + + $activities = $service->findActivitiesForView($query); + $entries = $service->getActivityView($user, $activities, $query->getToday()); + + $byCustomer = []; + foreach ($entries as $entry) { + $project = $entry->getActivity()->getProject(); + $key = ($project === null || $project->getId() === null ? '__EMPTY__' : $project->getId()); + if (!\array_key_exists($key, $byCustomer)) { + $byCustomer[$key] = ['project' => $project, 'activities' => [], 'name' => $project !== null ? $project->getName() : '']; + } + $byCustomer[$key]['activities'][] = $entry; + } + + return $this->render('reporting/activity_view.html.twig', [ + 'entries' => $byCustomer, + 'form' => $form->createView(), + 'report_title' => 'report_activity_view', + 'tableName' => 'activity_view_reporting', + 'now' => $dateFactory->createDateTime(), + ]); + } +} diff --git a/src/Reporting/ActivityView/ActivityViewForm.php b/src/Reporting/ActivityView/ActivityViewForm.php new file mode 100644 index 000000000..e1ac4b343 --- /dev/null +++ b/src/Reporting/ActivityView/ActivityViewForm.php @@ -0,0 +1,55 @@ + + */ +final class ActivityViewForm extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('project', ProjectType::class, [ + 'required' => false, + 'width' => false, + ]); + $builder->add('budgetType', ChoiceType::class, [ + 'label' => false, + 'required' => false, + 'placeholder' => null, + 'expanded' => true, + 'choices' => [ + 'all' => null, + 'includeWithBudget' => true, + 'includeNoBudget' => false + ] + ]); + $builder->add('includeNoWork', CheckboxType::class, [ + 'required' => false, + 'label' => 'includeNoWork', + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => ActivityViewQuery::class, + 'csrf_protection' => false, + 'method' => 'GET', + ]); + } +} diff --git a/src/Reporting/ActivityView/ActivityViewModel.php b/src/Reporting/ActivityView/ActivityViewModel.php new file mode 100644 index 000000000..4230acd6e --- /dev/null +++ b/src/Reporting/ActivityView/ActivityViewModel.php @@ -0,0 +1,181 @@ +activity; + } + + public function getTimesheetCounter(): int + { + return $this->timesheetCounter; + } + + public function setTimesheetCounter(int $timesheetCounter): void + { + $this->timesheetCounter = $timesheetCounter; + } + + public function getDurationDay(): int + { + return $this->durationDay; + } + + public function setDurationDay(int $durationDay): void + { + $this->durationDay = $durationDay; + } + + public function getDurationWeek(): int + { + return $this->durationWeek; + } + + public function setDurationWeek(int $durationWeek): void + { + $this->durationWeek = $durationWeek; + } + + public function getDurationMonth(): int + { + return $this->durationMonth; + } + + public function setDurationMonth(int $durationMonth): void + { + $this->durationMonth = $durationMonth; + } + + public function getDurationTotal(): int + { + return $this->durationTotal; + } + + public function setDurationTotal(int $durationTotal): void + { + $this->durationTotal = $durationTotal; + } + + public function getNotExportedDuration(): int + { + return $this->notExportedDuration; + } + + public function setNotExportedDuration(int $notExportedDuration): void + { + $this->notExportedDuration = $notExportedDuration; + } + + public function getNotExportedRate(): float + { + return $this->notExportedRate; + } + + public function setNotExportedRate(float $notExportedRate): void + { + $this->notExportedRate = $notExportedRate; + } + + public function getNotBilledDuration(): int + { + return $this->notBilledDuration; + } + + public function setNotBilledDuration(int $notBilledDuration): void + { + $this->notBilledDuration = $notBilledDuration; + } + + public function getNotBilledRate(): float + { + return $this->notBilledRate; + } + + public function setNotBilledRate(float $notBilledRate): void + { + $this->notBilledRate = $notBilledRate; + } + + public function getBillableDuration(): int + { + return $this->billableDuration; + } + + public function setBillableDuration(int $billableDuration): void + { + $this->billableDuration = $billableDuration; + } + + public function getBillableRate(): float + { + return $this->billableRate; + } + + public function setBillableRate(float $billableRate): void + { + $this->billableRate = $billableRate; + } + + public function getRateTotal(): float + { + return $this->rateTotal; + } + + public function setRateTotal(float $rateTotal): void + { + $this->rateTotal = $rateTotal; + } + + public function getLastRecord(): ?DateTime + { + return $this->lastRecord; + } + + public function setLastRecord(DateTime $lastRecord): void + { + $this->lastRecord = $lastRecord; + } + + public function getBudgetStatisticModel(): ?BudgetStatisticModelInterface + { + return $this->budgetStatisticModel; + } + + public function setBudgetStatisticModel(BudgetStatisticModelInterface $budgetStatisticModel): void + { + $this->budgetStatisticModel = $budgetStatisticModel; + } +} diff --git a/src/Reporting/ActivityView/ActivityViewQuery.php b/src/Reporting/ActivityView/ActivityViewQuery.php new file mode 100644 index 000000000..ec1f8e61e --- /dev/null +++ b/src/Reporting/ActivityView/ActivityViewQuery.php @@ -0,0 +1,77 @@ +user; + } + + public function getBudgetType(): ?bool + { + return $this->budgetType; + } + + /** + * @internal + */ + public function setBudgetType(?bool $budgetType): void + { + $this->budgetType = $budgetType; + } + + public function isIncludeWithoutBudget(): bool + { + return $this->budgetType === false; + } + + public function isIncludeWithBudget(): bool + { + return $this->budgetType === true; + } + + public function isIncludeNoWork(): bool + { + return $this->includeNoWork; + } + + public function setIncludeNoWork(bool $includeNoWork): void + { + $this->includeNoWork = $includeNoWork; + } + + public function getProject(): ?Project + { + return $this->project; + } + + public function setProject(Project $project): void + { + $this->project = $project; + } + + public function getToday(): \DateTimeInterface + { + return $this->today; + } +} diff --git a/src/Reporting/ReportingService.php b/src/Reporting/ReportingService.php index 81cb03f87..1c3afc8b5 100644 --- a/src/Reporting/ReportingService.php +++ b/src/Reporting/ReportingService.php @@ -41,6 +41,12 @@ final class ReportingService $event->addReport(new Report('yearly_users_list', 'report_yearly_users', 'report_yearly_users', 'users')); } + if ($this->security->isGranted('report:activity')) { + if ($viewOther) { + $event->addReport(new Report('activity_view', 'report_activity_view', 'report_activity_view', 'activity')); + } + } + if ($this->security->isGranted('report:project')) { if ($this->security->isGranted('details', 'project')) { $event->addReport(new Report('project_details', 'report_project_details', 'report_project_details', 'project')); diff --git a/src/Repository/ActivityRepository.php b/src/Repository/ActivityRepository.php index fb7f02535..4d27c5755 100644 --- a/src/Repository/ActivityRepository.php +++ b/src/Repository/ActivityRepository.php @@ -89,7 +89,7 @@ class ActivityRepository extends EntityRepository return $this->count([]); } - private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [], bool $globalsOnly = false): void + public function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [], bool $globalsOnly = false): void { $permissions = $this->getPermissionCriteria($qb, $user, $teams, $globalsOnly); if ($permissions->count() > 0) { diff --git a/src/Voter/ReportingVoter.php b/src/Voter/ReportingVoter.php index 8146a0106..ec95fbca5 100644 --- a/src/Voter/ReportingVoter.php +++ b/src/Voter/ReportingVoter.php @@ -22,6 +22,7 @@ final class ReportingVoter extends Voter private const ALLOWED_ATTRIBUTES = [ 'report:customer', 'report:other', + 'report:activity', 'report:project', 'report:user', ]; @@ -55,6 +56,10 @@ final class ReportingVoter extends Voter $permissions[] = 'view_other_timesheet'; break; + case 'report:activity': + $permissions[] = 'activity_reporting'; + break; + case 'report:project': $permissions[] = 'project_reporting'; break; diff --git a/templates/reporting/activity_list_data.html.twig b/templates/reporting/activity_list_data.html.twig new file mode 100644 index 000000000..f64ab4892 --- /dev/null +++ b/templates/reporting/activity_list_data.html.twig @@ -0,0 +1,96 @@ +{% extends 'reporting/layout.html.twig' %} +{% import "macros/datatables.html.twig" as tables %} + +{% set availableColumns = { + 'name': {'class': 'alwaysVisible'}, +} %} +{% if is_granted('budget_time', 'activity') %} + {% set availableColumns = availableColumns|merge({ + 'timeBudget': {'class': 'd-none d-md-table-cell', 'title': 'timeBudget'|trans}, + }) %} +{% endif %} +{% if is_granted('budget_money', 'activity') %} + {% set availableColumns = availableColumns|merge({ + 'budget': {'class': 'd-none d-md-table-cell', 'title': 'budget'|trans}, + }) %} +{% endif %} +{% set availableColumns = availableColumns|merge({ + 'durationTotal': {'class': 'text-end hw-min w-min', 'title': 'stats.durationTotal'|trans, 'columnClass': 'w-min'}, + 'actions': {'class': 'actions alwaysVisible'}, +}) %} +{% set tableName = tableName|default('activity_view_reporting') %} +{% set skipColumns = skipColumns is defined ? skipColumns : {} %} +{% set columns = {} %} +{% for name, config in availableColumns %} + {% if name not in skipColumns %} + {% set columns = columns|merge({(name): config}) %} + {% endif %} +{% endfor %} + +{% block main_before %} + {{ tables.data_table_column_modal(tableName, columns) }} +{% endblock %} + +{% block report %} + + {% set hasData = entries|length > 0 %} + + {% embed '@theme/embeds/card.html.twig' %} + {% import "macros/progressbar.html.twig" as progress %} + {% import "macros/widgets.html.twig" as widgets %} + {% import "macros/datatables.html.twig" as tables %} + {% import "activity/actions.html.twig" as activityActions %} + {% block box_body_class %}{{ tableName }}-box {% if hasData %}p-0{% endif %}{% endblock %} + {% block box_body %} + {% if not hasData %} + {{ widgets.nothing_found() }} + {% else %} + {{ tables.datatable_header(tableName, columns, null, {boxClass: ''}) }} + + {% for id, mapping in entries|sort((a, b) => a.name <=> b.name) %} + {% if mapping.project is not null %} + + + {{ widgets.label_customer(mapping.project) }} + + + {% endif %} + {% for entry in mapping.activities|sort((a, b) => a.activity.name <=> b.activity.name) %} + {% set activity = entry.activity %} + {% set project = activity.project %} + {% set budgetStats = entry.getBudgetStatisticModel() %} + {% set currency = null %} + {% if project is not null %} + {% set currency = project.customer.currency %} + {% endif %} + + {% for name, column_config in columns %} + + {% if name == 'name' %} + {{ widgets.label_activity(activity) }} + {% elseif name == 'durationTotal' %} + {{ entry.durationTotal|duration }} + {% elseif name == 'timeBudget' %} + {% if budgetStats.hasTimeBudget() and is_granted('time', activity) %} + {{ progress.progressbar_timebudget(budgetStats) }} + {% endif %} + {% elseif name == 'budget' %} + {% if activity.hasBudget() and is_granted('budget', activity) %} + {{ progress.progressbar_budget(budgetStats, currency) }} + {% endif %} + {% elseif name == 'comment' %} + {{ activity.comment }} + {% elseif name == 'actions' %} + {{ activityActions.activity(activity, 'custom') }} + {% endif %} + + {% endfor %} + + {% endfor %} + {% endfor %} + {{ tables.data_table_footer(entries) }} + {% endif %} + {% endblock %} + {% endembed %} + +{% endblock %} diff --git a/templates/reporting/activity_view.html.twig b/templates/reporting/activity_view.html.twig new file mode 100644 index 000000000..88393e023 --- /dev/null +++ b/templates/reporting/activity_view.html.twig @@ -0,0 +1,21 @@ +{% extends 'reporting/activity_list_data.html.twig' %} + +{% block report_form_layout %} + {{ form_widget(form.project, {'label': false, 'placeholder': 'please_choose'}) }} + +{% endblock %} diff --git a/translations/reporting.de.xlf b/translations/reporting.de.xlf index cea736905..a1525b20d 100644 --- a/translations/reporting.de.xlf +++ b/translations/reporting.de.xlf @@ -26,6 +26,10 @@ report_yearly_users Jahresansicht für alle Benutzer + + report_activity_view + Tätigkeitsübersicht + report_project_view Projektübersicht diff --git a/translations/reporting.en.xlf b/translations/reporting.en.xlf index a0b2020d9..ac61b6cbd 100644 --- a/translations/reporting.en.xlf +++ b/translations/reporting.en.xlf @@ -26,6 +26,10 @@ report_yearly_users Yearly view for all users + + report_activity_view + Activity overview + report_project_view Project overview