diff --git a/UPGRADING.md b/UPGRADING.md index 2268a7833..d76ddb73e 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -8,7 +8,7 @@ you can upgrade your Kimai installation to the latest stable release. Check below if there are more version specific steps required, which need to be executed after the normal update process. Perform EACH version specific task between your version and the new one, otherwise you risk data inconsistency or a broken installation. -## [1.14](https://github.com/kevinpapst/kimai2/releases/tag/1.13) +## [1.14](https://github.com/kevinpapst/kimai2/releases/tag/1.14) **CRITICAL BC break**: SQLite support was removed. If you are using SQLite, you have to [read this blog post](https://www.kimai.org/blog/2021/sqlite-and-ftp-support-removed/) and migrate to MySQL/MariaDB first! diff --git a/config/packages/kimai.yaml b/config/packages/kimai.yaml index af6755664..9691dc451 100644 --- a/config/packages/kimai.yaml +++ b/config/packages/kimai.yaml @@ -118,7 +118,7 @@ kimai: EXPORT: ['create_export','edit_export_own_timesheet','edit_export_other_timesheet'] TEAMS: ['view_team','create_team','edit_team','delete_team'] LOCKDOWN: ['lockdown_grace_timesheet','lockdown_override_timesheet'] - REPORTING: ['view_reporting'] + REPORTING: ['view_reporting','view_other_reporting'] # some single default definitions for roles SINGLE_USER: ['view_team_member','budget_team_project'] SINGLE_TEAMLEAD: ['view_rate_own_timesheet','view_rate_other_timesheet','hourly-rate_own_profile','view_team_member'] diff --git a/src/Configuration/SystemConfiguration.php b/src/Configuration/SystemConfiguration.php index 3511cc3b4..07e58e941 100644 --- a/src/Configuration/SystemConfiguration.php +++ b/src/Configuration/SystemConfiguration.php @@ -241,6 +241,19 @@ class SystemConfiguration implements SystemBundleConfiguration return $this->getIncrement('timesheet.time_increment', $this->getTimesheetDefaultRoundingEnd(), 0); } + // ========== Company configurations ========== + + public function getFinancialYearStart(): ?string + { + $start = $this->find('company.financial_year'); + + if (empty($start)) { + return null; + } + + return (string) $start; + } + // ========== Theme configurations ========== public function isThemeColorsLimited(): bool diff --git a/src/Controller/ProfileController.php b/src/Controller/ProfileController.php index 41adc86c7..d75b57464 100644 --- a/src/Controller/ProfileController.php +++ b/src/Controller/ProfileController.php @@ -70,7 +70,11 @@ final class ProfileController extends AbstractController public function indexAction(User $profile, TimesheetRepository $repository, LocaleSettings $localeSettings) { $userStats = $repository->getUserStatistics($profile); - $monthlyStats = $repository->getMonthlyStats($profile); + + $begin = $userStats->getFirstEntry() ?? $this->getDateTimeFactory()->getStartOfMonth(); + $end = $this->getDateTimeFactory()->getEndOfMonth(); + $monthlyStats = $repository->getMonthlyStats($begin, $end, $profile); + arsort($monthlyStats); $viewVars = [ 'tab' => 'charts', diff --git a/src/Controller/Reporting/ReportByUserController.php b/src/Controller/Reporting/ReportByUserController.php index dd545969a..8a8c2b59e 100644 --- a/src/Controller/Reporting/ReportByUserController.php +++ b/src/Controller/Reporting/ReportByUserController.php @@ -184,6 +184,10 @@ final class ReportByUserController extends AbstractController ]); } + /** + * @param Day[] $data + * @return array + */ private function prepareReportData(array $data): array { $days = []; @@ -194,7 +198,6 @@ final class ReportByUserController extends AbstractController $rows = []; - /** @var Day $day */ foreach ($data as $day) { $dayId = $day->getDay()->format('Ymd'); foreach ($day->getDetails() as $id => $detail) { diff --git a/src/Controller/Reporting/ReportUsersListController.php b/src/Controller/Reporting/ReportUsersListController.php index 9acb92d97..1b6c46ecc 100644 --- a/src/Controller/Reporting/ReportUsersListController.php +++ b/src/Controller/Reporting/ReportUsersListController.php @@ -9,12 +9,16 @@ namespace App\Controller\Reporting; +use App\Configuration\SystemConfiguration; use App\Controller\AbstractController; use App\Model\Statistic\Day; +use App\Model\Statistic\Year; use App\Reporting\MonthlyUserList; use App\Reporting\MonthlyUserListForm; use App\Reporting\WeeklyUserList; use App\Reporting\WeeklyUserListForm; +use App\Reporting\YearlyUserList; +use App\Reporting\YearlyUserListForm; use App\Repository\Query\UserQuery; use App\Repository\TimesheetRepository; use App\Repository\UserRepository; @@ -26,7 +30,7 @@ use Symfony\Component\Routing\Annotation\Route; /** * @Route(path="/reporting") - * @Security("is_granted('view_reporting') and is_granted('view_other_timesheet')") + * @Security("is_granted('view_reporting') and is_granted('view_other_reporting') and is_granted('view_other_timesheet')") */ final class ReportUsersListController extends AbstractController { @@ -45,6 +49,141 @@ final class ReportUsersListController extends AbstractController $this->userRepository = $userRepository; } + /** + * @Route(path="/yearly_users_list", name="report_yearly_users", methods={"GET","POST"}) + * + * @param Request $request + * @return Response + * @throws Exception + */ + public function yearlyUsersList(Request $request, SystemConfiguration $systemConfiguration): Response + { + $currentUser = $this->getUser(); + $dateTimeFactory = $this->getDateTimeFactory(); + $localeFormats = $this->getLocaleFormats($request->getLocale()); + + $query = new UserQuery(); + $query->setCurrentUser($currentUser); + $allUsers = $this->userRepository->getUsersForQuery($query); + $defaultDate = $dateTimeFactory->createDateTime('01 january this year 00:00:00'); + + if (null !== ($financialYear = $systemConfiguration->getFinancialYearStart())) { + $defaultDate = $this->getDateTimeFactory()->createStartOfFinancialYear($financialYear); + } + + $rows = []; + + $values = new YearlyUserList(); + $values->setDate(clone $defaultDate); + + $form = $this->createForm(YearlyUserListForm::class, $values, [ + 'timezone' => $dateTimeFactory->getTimezone()->getName(), + 'start_date' => $values->getDate(), + 'format' => $localeFormats->getDateTypeFormat(), + ]); + + $form->submit($request->query->all(), false); + + if ($form->isSubmitted() && !$form->isValid()) { + $values->setDate(clone $defaultDate); + } + + if ($values->getDate() === null) { + $values->setDate(clone $defaultDate); + } + + $start = $values->getDate(); + $end = $this->getDateTimeFactory()->createEndOfFinancialYear($start); + + $months = []; + $totals = []; + foreach ($allUsers as $user) { + $rows[] = [ + 'years' => $this->timesheetRepository->getMonthlyStats($start, $end, $user), + 'user' => $user + ]; + } + + if (isset($rows[0])) { + /** @var Year $year */ + foreach ($rows[0]['years'] as $year) { + foreach ($year->getMonths() as $month) { + $date = new \DateTime(); + $date->setDate((int) $year->getYear(), $month->getMonthNumber(), 1); + $date->setTime(0, 0, 0); + $months[$date->format('Ym')] = $date; + } + } + foreach ($rows as $row) { + foreach ($row['years'] as $year) { + foreach ($year->getMonths() as $month) { + $date = new \DateTime(); + $date->setDate((int) $year->getYear(), $month->getMonthNumber(), 1); + $totalsId = $date->format('Ym'); + if (!isset($totals[$totalsId])) { + $totals[$totalsId] = 0; + } + $totals[$totalsId] += $month->getTotalDuration(); + } + } + } + } + /* + foreach ($allUsers as $user) { + $rows[] = [ + 'days' => $this->timesheetRepository->getDailyStats($user, $start, $end), + 'user' => $user + ]; + } + + $userYears = []; + + if (isset($rows[0])) { + foreach ($rows[0]['days'] as $day) { + $months[$day->getDay()->format('Ym')] = $day->getDay(); + } + foreach ($rows as $row) { + $userYear = ['user' => $row['user']]; + foreach ($row['days'] as $day) { + $yearId = $day->getDay()->format('Y'); + $monthId = $day->getDay()->format('m'); + $totalsId = $yearId.$monthId; + + if (!array_key_exists('years', $userYear)) { + $userYear['years'] = []; + } + if (!array_key_exists($yearId, $userYear['years'])) { + $userYear['years'][$yearId] = ['year' => $yearId]; + } + if (!array_key_exists('months', $userYear['years'][$yearId])) { + $userYear['years'][$yearId]['months'] = []; + } + if (!array_key_exists($monthId, $userYear['years'][$yearId]['months'])) { + $userYear['years'][$yearId]['months'][$monthId] = ['month' => $monthId, 'totalDuration' => 0]; + } + if (!array_key_exists($totalsId, $totals)) { + $totals[$totalsId] = 0; + } + + $totals[$totalsId] += $day->getTotalDuration(); + $userYear['years'][$yearId]['months'][$monthId]['totalDuration'] += $day->getTotalDuration();; + } + $userYears[] = $userYear; + } + } + $rows = $userYears; + */ + + return $this->render('reporting/report_user_list_monthly.html.twig', [ + 'report_title' => 'report_yearly_users', + 'box_id' => 'yearly-user-list-reporting-box', + 'form' => $form->createView(), + 'rows' => $rows, + 'months' => $months, + 'totals' => $totals, + ]); + } + /** * @Route(path="/monthly_users_list", name="report_monthly_users", methods={"GET","POST"}) * @@ -89,11 +228,11 @@ final class ReportUsersListController extends AbstractController $end = clone $start; $end->modify('last day of 23:59:59'); - $previousMonth = clone $start; - $previousMonth->modify('-1 month'); + $previous = clone $start; + $previous->modify('-1 month'); - $nextMonth = clone $start; - $nextMonth->modify('+1 month'); + $next = clone $start; + $next->modify('+1 month'); foreach ($allUsers as $user) { $rows[] = [ @@ -103,12 +242,22 @@ final class ReportUsersListController extends AbstractController } $days = []; + $totals = []; if (isset($rows[0])) { /** @var Day $day */ foreach ($rows[0]['days'] as $day) { $days[$day->getDay()->format('Ymd')] = $day->getDay(); } + foreach ($rows as $row) { + foreach ($row['days'] as $day) { + $totalsId = $day->getDay()->format('Ymd'); + if (!isset($totals[$totalsId])) { + $totals[$totalsId] = 0; + } + $totals[$totalsId] += $day->getTotalDuration(); + } + } } return $this->render('reporting/report_user_list.html.twig', [ @@ -117,9 +266,10 @@ final class ReportUsersListController extends AbstractController 'form' => $form->createView(), 'rows' => $rows, 'days' => $days, + 'totals' => $totals, 'current' => $start, - 'next' => $nextMonth, - 'previous' => $previousMonth, + 'next' => $next, + 'previous' => $previous, ]); } @@ -164,11 +314,11 @@ final class ReportUsersListController extends AbstractController $start = $dateTimeFactory->getStartOfWeek($values->getDate()); $end = $dateTimeFactory->getEndOfWeek($values->getDate()); - $previousWeek = clone $start; - $previousWeek->modify('-1 week'); + $previous = clone $start; + $previous->modify('-1 week'); - $nextWeek = clone $start; - $nextWeek->modify('+1 week'); + $next = clone $start; + $next->modify('+1 week'); foreach ($allUsers as $user) { $rows[] = [ @@ -178,12 +328,22 @@ final class ReportUsersListController extends AbstractController } $days = []; + $totals = []; if (isset($rows[0])) { /** @var Day $day */ foreach ($rows[0]['days'] as $day) { $days[$day->getDay()->format('Ymd')] = $day->getDay(); } + foreach ($rows as $row) { + foreach ($row['days'] as $day) { + $totalsId = $day->getDay()->format('Ymd'); + if (!isset($totals[$totalsId])) { + $totals[$totalsId] = 0; + } + $totals[$totalsId] += $day->getTotalDuration(); + } + } } return $this->render('reporting/report_user_list.html.twig', [ @@ -192,9 +352,10 @@ final class ReportUsersListController extends AbstractController 'form' => $form->createView(), 'rows' => $rows, 'days' => $days, + 'totals' => $totals, 'current' => $start, - 'next' => $nextWeek, - 'previous' => $previousWeek, + 'next' => $next, + 'previous' => $previous, ]); } } diff --git a/src/Controller/SystemConfigurationController.php b/src/Controller/SystemConfigurationController.php index c16be9913..226ed81ee 100644 --- a/src/Controller/SystemConfigurationController.php +++ b/src/Controller/SystemConfigurationController.php @@ -15,6 +15,7 @@ use App\Form\Model\Configuration; use App\Form\Model\SystemConfiguration as SystemConfigurationModel; use App\Form\SystemConfigurationForm; use App\Form\Type\ArrayToCommaStringType; +use App\Form\Type\DatePickerType; use App\Form\Type\DateTimeTextType; use App\Form\Type\DayTimeType; use App\Form\Type\LanguageType; @@ -507,6 +508,12 @@ final class SystemConfigurationController extends AbstractController ->setTranslationDomain('system-configuration') ->setRequired(false) ->setType(TextType::class), + (new Configuration()) + ->setName('company.financial_year') + ->setTranslationDomain('system-configuration') + ->setRequired(false) + ->setType(DatePickerType::class) + ->setOptions(['input' => 'string']), ]), ]; } diff --git a/src/DataFixtures/TimesheetFixtures.php b/src/DataFixtures/TimesheetFixtures.php index 369badc1c..45cf74506 100644 --- a/src/DataFixtures/TimesheetFixtures.php +++ b/src/DataFixtures/TimesheetFixtures.php @@ -17,6 +17,7 @@ use App\Entity\User; use App\Entity\UserPreference; use App\Timesheet\Util; use Doctrine\Bundle\FixturesBundle\Fixture; +use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface; use Doctrine\Common\DataFixtures\DependentFixtureInterface; use Doctrine\Persistence\ObjectManager; use Faker\Factory; @@ -30,19 +31,20 @@ use Faker\Factory; * * @codeCoverageIgnore */ -class TimesheetFixtures extends Fixture implements DependentFixtureInterface +class TimesheetFixtures extends Fixture implements DependentFixtureInterface, FixtureGroupInterface { public const MIN_TIMESHEETS_PER_USER = 50; public const MAX_TIMESHEETS_PER_USER = 500; public const MAX_TIMESHEETS_TOTAL = 5000; public const MAX_RUNNING_TIMESHEETS_PER_USER = 1; - public const TIMERANGE_DAYS = 1095; // 3 years + public const TIMERANGE_BEGIN_DAYS = 1; // yesterday + public const TIMERANGE_END_DAYS = 1095; // 3 years public const TIMERANGE_RUNNING = 1047; // in minutes = 17:45 hours public const MIN_MINUTES_PER_ENTRY = 15; public const MAX_MINUTES_PER_ENTRY = 840; // 14h public const MAX_DESCRIPTION_LENGTH = 200; - public const ADD_TAGS_MAX_ENTRIES = 10000; + public const ADD_TAGS_MAX_ENTRIES = 1000; public const MAX_TAG_PER_ENTRY = 3; public const BATCH_SIZE = 100; @@ -59,6 +61,11 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface ]; } + public static function getGroups(): array + { + return ['timesheet']; + } + /** * {@inheritdoc} */ @@ -103,7 +110,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface $manager->persist($entry); - if ($i % self::BATCH_SIZE === 0) { + if ($all % self::BATCH_SIZE === 0) { $manager->flush(); $manager->clear(Timesheet::class); } @@ -129,21 +136,18 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface } $manager->flush(); - // TODO this breaks if too many records need to be loaded: find a better way of adding tags - if ($all < self::ADD_TAGS_MAX_ENTRIES) { - $entries = $manager->getRepository(Timesheet::class)->findAll(); - foreach ($entries as $temp) { - $tagAmount = rand(0, self::MAX_TAG_PER_ENTRY); - for ($iTag = 0; $iTag < $tagAmount; $iTag++) { - $tagId = rand(1, TagFixtures::MAX_TAGS); - if (isset($allTags[$tagId])) { - $temp->addTag($allTags[$tagId]); - } + $entries = $manager->getRepository(Timesheet::class)->findBy([], [], min($all, self::ADD_TAGS_MAX_ENTRIES)); + foreach ($entries as $temp) { + $tagAmount = rand(0, self::MAX_TAG_PER_ENTRY); + for ($iTag = 0; $iTag < $tagAmount; $iTag++) { + $tagId = rand(1, TagFixtures::MAX_TAGS); + if (isset($allTags[$tagId])) { + $temp->addTag($allTags[$tagId]); } } } - $manager->flush(); + $manager->clear(Timesheet::class); $manager->clear(Tag::class); } @@ -212,10 +216,10 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface return $all; } - private function createTimesheetEntry(User $user, Activity $activity, Project $project, $description, $setEndDate = true) + private function createTimesheetEntry(User $user, Activity $activity, Project $project, ?string $description, bool $setEndDate) { $start = new \DateTime(); - $start = $start->modify('- ' . (rand(1, self::TIMERANGE_DAYS)) . ' days'); + $start = $start->modify('- ' . (rand(self::TIMERANGE_BEGIN_DAYS, self::TIMERANGE_END_DAYS)) . ' days'); $start = $start->modify('- ' . (rand(1, 86400)) . ' seconds'); $start->setTimezone(new \DateTimeZone($user->getPreferenceValue(UserPreference::TIMEZONE, date_default_timezone_get()))); diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 7248be0ba..67ece1b04 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -61,6 +61,7 @@ class Configuration implements ConfigurationInterface ->append($this->getLanguagesNode()) ->append($this->getCalendarNode()) ->append($this->getThemeNode()) + ->append($this->getCompanyNode()) ->append($this->getIndustryNode()) ->append($this->getDashboardNode()) ->append($this->getWidgetsNode()) @@ -483,6 +484,22 @@ class Configuration implements ConfigurationInterface return $node; } + protected function getCompanyNode() + { + $builder = new TreeBuilder('company'); + /** @var ArrayNodeDefinition $node */ + $node = $builder->getRootNode(); + + $node + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('financial_year')->defaultNull()->end() + ->end() + ; + + return $node; + } + protected function getUserNode() { $builder = new TreeBuilder('user'); diff --git a/src/EventSubscriber/MenuSubscriber.php b/src/EventSubscriber/MenuSubscriber.php index 63d0c25f1..0974ab8c7 100644 --- a/src/EventSubscriber/MenuSubscriber.php +++ b/src/EventSubscriber/MenuSubscriber.php @@ -129,7 +129,7 @@ final class MenuSubscriber implements EventSubscriberInterface $auth = $this->security; if ($auth->isGranted('view_user')) { - $users = new MenuItemModel('user_admin', 'menu.admin_user', 'admin_user', [], $this->getIcon('user')); + $users = new MenuItemModel('user_admin', 'menu.admin_user', 'admin_user', [], $this->getIcon('users')); $users->setChildRoutes(['admin_user_create', 'admin_user_delete', 'user_profile', 'user_profile_edit', 'user_profile_password', 'user_profile_api_token', 'user_profile_roles', 'user_profile_teams', 'user_profile_preferences']); $menu->addChild($users); } diff --git a/src/Form/Type/YearPickerType.php b/src/Form/Type/YearPickerType.php new file mode 100644 index 000000000..e7909cd54 --- /dev/null +++ b/src/Form/Type/YearPickerType.php @@ -0,0 +1,69 @@ +setDefaults([ + 'widget' => 'single_text', + 'html5' => false, + 'format' => DateType::HTML5_FORMAT, + 'start_date' => new \DateTime(), + 'show_range' => false, + ]); + } + + public function buildView(FormView $view, FormInterface $form, array $options) + { + /** @var \DateTime|null $date */ + $date = $form->getData(); + + if (null === $date) { + $date = $options['start_date']; + } + + $view->vars['year'] = $date; + $view->vars['show_range'] = $options['show_range']; + $view->vars['previousYear'] = (clone $date)->modify('-1 year'); + $view->vars['nextYear'] = (clone $date)->modify('+1 year'); + } + + /** + * {@inheritdoc} + */ + public function getParent() + { + return DateType::class; + } + + /** + * {@inheritdoc} + */ + public function getBlockPrefix() + { + return 'yearpicker'; + } +} diff --git a/src/Model/Statistic/Month.php b/src/Model/Statistic/Month.php index 0eb07fb36..ac7bc9273 100644 --- a/src/Model/Statistic/Month.php +++ b/src/Model/Statistic/Month.php @@ -27,7 +27,7 @@ final class Month sprintf('Invalid month given. Expected 1-12, received "%s".', $monthNumber) ); } - $this->month = $month; + $this->month = str_pad($month, 2, '0', STR_PAD_LEFT); } public function getMonth(): string diff --git a/src/Reporting/ReportingService.php b/src/Reporting/ReportingService.php index 8eaa3c4f6..6570d885a 100644 --- a/src/Reporting/ReportingService.php +++ b/src/Reporting/ReportingService.php @@ -44,9 +44,10 @@ final class ReportingService if ($this->security->isGranted('view_reporting')) { $event->addReport(new Report('week_by_user', 'report_user_week', 'report_user_week', 'user')); $event->addReport(new Report('month_by_user', 'report_user_month', 'report_user_month', 'user')); - if ($this->security->isGranted('view_other_timesheet')) { - $event->addReport(new Report('weekly_users_list', 'report_weekly_users', 'report_weekly_users', 'user')); - $event->addReport(new Report('monthly_users_list', 'report_monthly_users', 'report_monthly_users', 'user')); + if ($this->security->isGranted('view_other_reporting') && $this->security->isGranted('view_other_timesheet')) { + $event->addReport(new Report('weekly_users_list', 'report_weekly_users', 'report_weekly_users', 'users')); + $event->addReport(new Report('monthly_users_list', 'report_monthly_users', 'report_monthly_users', 'users')); + $event->addReport(new Report('yearly_users_list', 'report_yearly_users', 'report_yearly_users', 'users')); } if ($this->security->isGranted('budget_project')) { $event->addReport(new Report('project_view', 'report_project_view', 'report_project_view', 'project')); diff --git a/src/Reporting/YearlyUserList.php b/src/Reporting/YearlyUserList.php new file mode 100644 index 000000000..5115e6fa8 --- /dev/null +++ b/src/Reporting/YearlyUserList.php @@ -0,0 +1,14 @@ +add('date', YearPickerType::class, [ + 'model_timezone' => $options['timezone'], + 'view_timezone' => $options['timezone'], + 'start_date' => $options['start_date'], + 'format' => $options['format'], + 'show_range' => true, + ]); + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => YearlyUserList::class, + 'timezone' => date_default_timezone_get(), + 'start_date' => new \DateTime(), + 'format' => DateType::HTML5_FORMAT, + 'csrf_protection' => false, + 'method' => 'GET', + ]); + } +} diff --git a/src/Repository/TimesheetRepository.php b/src/Repository/TimesheetRepository.php index c56f77592..d207b2ff0 100644 --- a/src/Repository/TimesheetRepository.php +++ b/src/Repository/TimesheetRepository.php @@ -239,7 +239,7 @@ class TimesheetRepository extends EntityRepository return \count($this->getActiveEntries($user)); case self::STATS_QUERY_MONTHLY: - return $this->getMonthlyStats($user, $begin, $end); + return $this->getMonthlyStats($begin, $end, $user); case 'daily': return $this->getDailyStats($user, $begin, $end); @@ -337,7 +337,7 @@ class TimesheetRepository extends EntityRepository $stats->setDurationTotal($durationTotal); $stats->setAmountThisMonth($amountMonth); $stats->setDurationThisMonth($durationMonth); - $stats->setFirstEntry(new DateTime($firstEntry)); + $stats->setFirstEntry(new DateTime($firstEntry, new \DateTimeZone($user->getTimezone()))); $stats->setRecordsTotal($recordsTotal); return $stats; @@ -346,50 +346,57 @@ class TimesheetRepository extends EntityRepository /** * Returns an array of Year statistics. * + * @param DateTime $begin + * @param DateTime $end * @param User|null $user - * @param DateTime|null $begin - * @param DateTime|null $end * @return Year[] */ - public function getMonthlyStats(User $user = null, ?DateTime $begin = null, ?DateTime $end = null): array + public function getMonthlyStats(DateTime $begin, DateTime $end, ?User $user = null): array { /** @var Year[] $years */ $years = []; - $qb = $this->getMonthlyStatsQuery($user, $begin, $end, null); - foreach ($qb->getQuery()->execute() as $statRow) { - $curYear = $statRow['year']; - $curMonth = (int) $statRow['month']; - + $tmp = clone $begin; + while ($tmp < $end) { + $curYear = $tmp->format('Y'); if (!isset($years[$curYear])) { $year = new Year($curYear); for ($i = 1; $i < 13; $i++) { - $month = $i < 10 ? '0' . $i : (string) $i; - $year->setMonth(new Month($month)); + $date = clone $begin; + $date->setDate((int) $curYear, $i, (int) $begin->format('d')); + $date->setTime(0, 0, 0); + if ($date < $begin || $date > $end) { + continue; + } + $year->setMonth(new Month((string) $i)); } $years[$curYear] = $year; } + $tmp->modify('+1 month'); + } - $month = $years[$curYear]->getMonth($curMonth); + $qb = $this->getMonthlyStatsQuery($user, $begin, $end, null); + foreach ($qb->getQuery()->execute() as $statRow) { + if (!isset($years[$statRow['year']])) { + continue; + } + $month = $years[$statRow['year']]->getMonth((int) $statRow['month']); + if (null === $month) { + continue; + } $month->setTotalDuration((int) $statRow['duration']); $month->setTotalRate((float) $statRow['rate']); } $qb = $this->getMonthlyStatsQuery($user, $begin, $end, true); foreach ($qb->getQuery()->execute() as $statRow) { - $curYear = $statRow['year']; - $curMonth = (int) $statRow['month']; - - if (!isset($years[$curYear])) { - $year = new Year($curYear); - for ($i = 1; $i < 13; $i++) { - $month = $i < 10 ? '0' . $i : (string) $i; - $year->setMonth(new Month($month)); - } - $years[$curYear] = $year; + if (!isset($years[$statRow['year']])) { + continue; + } + $month = $years[$statRow['year']]->getMonth((int) $statRow['month']); + if (null === $month) { + continue; } - - $month = $years[$curYear]->getMonth($curMonth); $month->setBillableDuration((int) $statRow['duration']); $month->setBillableRate((float) $statRow['rate']); } @@ -597,7 +604,7 @@ class TimesheetRepository extends EntityRepository $results = $this->getDailyData($begin, $end, $user); foreach ($results as $statRow) { - $dateTime = new DateTime(); + $dateTime = clone $begin; $dateTime->setDate($statRow['year'], $statRow['month'], $statRow['day']); $dateTime->setTime(0, 0, 0); $day = new Day($dateTime, (int) $statRow['duration'], (float) $statRow['rate']); diff --git a/src/Repository/WidgetRepository.php b/src/Repository/WidgetRepository.php index f48003fb9..0e7beec69 100644 --- a/src/Repository/WidgetRepository.php +++ b/src/Repository/WidgetRepository.php @@ -174,16 +174,6 @@ class WidgetRepository 'color' => 'purple', 'type' => Counter::class, ], - 'userDurationYear' => [ - 'title' => 'stats.durationYear', - 'query' => TimesheetRepository::STATS_QUERY_DURATION, - 'user' => true, - 'begin' => '01 january this year 00:00:00', - 'end' => '31 december this year 23:59:59', - 'icon' => 'duration', - 'color' => 'yellow', - 'type' => Counter::class, - ], 'userDurationTotal' => [ 'title' => 'stats.durationTotal', 'query' => TimesheetRepository::STATS_QUERY_DURATION, @@ -222,16 +212,6 @@ class WidgetRepository 'color' => 'purple', 'type' => Counter::class, ], - 'userAmountYear' => [ - 'title' => 'stats.amountYear', - 'query' => TimesheetRepository::STATS_QUERY_RATE, - 'user' => true, - 'begin' => '01 january this year 00:00:00', - 'end' => '31 december this year 23:59:59', - 'icon' => 'money', - 'color' => 'yellow', - 'type' => Counter::class, - ], 'userAmountTotal' => [ 'title' => 'stats.amountTotal', 'query' => TimesheetRepository::STATS_QUERY_RATE, @@ -270,16 +250,6 @@ class WidgetRepository 'user' => false, 'type' => Counter::class, ], - 'durationYear' => [ - 'title' => 'stats.durationYear', - 'query' => TimesheetRepository::STATS_QUERY_DURATION, - 'begin' => '01 january this year 00:00:00', - 'end' => '31 december this year 23:59:59', - 'icon' => 'duration', - 'color' => 'yellow', - 'user' => false, - 'type' => Counter::class, - ], 'durationTotal' => [ 'title' => 'stats.durationTotal', 'query' => TimesheetRepository::STATS_QUERY_DURATION, @@ -318,16 +288,6 @@ class WidgetRepository 'user' => false, 'type' => Counter::class, ], - 'amountYear' => [ - 'title' => 'stats.amountYear', - 'query' => TimesheetRepository::STATS_QUERY_RATE, - 'begin' => '01 january this year 00:00:00', - 'end' => '31 december this year 23:59:59', - 'icon' => 'money', - 'color' => 'yellow', - 'user' => false, - 'type' => Counter::class, - ], 'amountTotal' => [ 'title' => 'stats.amountTotal', 'query' => TimesheetRepository::STATS_QUERY_RATE, @@ -366,16 +326,6 @@ class WidgetRepository 'user' => false, 'type' => Counter::class, ], - 'activeUsersYear' => [ - 'title' => 'stats.userActiveYear', - 'query' => TimesheetRepository::STATS_QUERY_USER, - 'begin' => '01 january this year 00:00:00', - 'end' => '31 december this year 23:59:59', - 'icon' => 'user', - 'color' => 'yellow', - 'user' => false, - 'type' => Counter::class, - ], 'activeUsersTotal' => [ 'title' => 'stats.userActiveTotal', 'query' => TimesheetRepository::STATS_QUERY_USER, diff --git a/src/Timesheet/DateTimeFactory.php b/src/Timesheet/DateTimeFactory.php index ac0b546be..7c25c222c 100644 --- a/src/Timesheet/DateTimeFactory.php +++ b/src/Timesheet/DateTimeFactory.php @@ -124,4 +124,32 @@ class DateTimeFactory return $date; } + + public function createStartOfFinancialYear(?string $financialYear = null): DateTime + { + $defaultDate = $this->createDateTime('01 january this year 00:00:00'); + + if (null === $financialYear) { + return $defaultDate; + } + + $financialYear = $this->createDateTime($financialYear); + $financialYear->setDate((int) $defaultDate->format('Y'), (int) $financialYear->format('m'), (int) $financialYear->format('d')); + + $now = $this->createDateTime('00:00:00'); + + if ($financialYear >= $now) { + $financialYear->modify('-1 year'); + } + + return $financialYear; + } + + public function createEndOfFinancialYear(DateTime $financialYear): DateTime + { + $yearEnd = clone $financialYear; + $yearEnd->modify('+1 year')->modify('-1 day')->setTime(23, 59, 59); + + return $yearEnd; + } } diff --git a/src/Twig/IconExtension.php b/src/Twig/IconExtension.php index 79dfee0e7..89e767f77 100644 --- a/src/Twig/IconExtension.php +++ b/src/Twig/IconExtension.php @@ -95,7 +95,8 @@ final class IconExtension extends AbstractExtension 'trash' => 'far fa-trash-alt', 'unlocked' => 'fas fa-unlock-alt', 'upload' => 'fas fa-upload', - 'user' => 'fas fa-user-friends', + 'user' => 'fas fa-user', + 'users' => 'fas fa-user-friends', 'visibility' => 'far fa-eye', 'warning' => 'fas fa-exclamation-triangle', 'xlsx' => 'fas fa-file-excel', diff --git a/src/Widget/Type/AbstractWidgetType.php b/src/Widget/Type/AbstractWidgetType.php index fd16c4a62..b1290460b 100644 --- a/src/Widget/Type/AbstractWidgetType.php +++ b/src/Widget/Type/AbstractWidgetType.php @@ -16,7 +16,7 @@ abstract class AbstractWidgetType implements WidgetInterface /** * @var string */ - protected $id = ''; + protected $id; /** * @var string */ @@ -39,7 +39,11 @@ abstract class AbstractWidgetType implements WidgetInterface public function getId(): string { - return $this->id; + if (!empty($this->id)) { + return $this->id; + } + + return (new \ReflectionClass($this))->getShortName(); } public function setData($data): self diff --git a/src/Widget/Type/ActiveUsersYear.php b/src/Widget/Type/ActiveUsersYear.php new file mode 100644 index 000000000..d85db9d16 --- /dev/null +++ b/src/Widget/Type/ActiveUsersYear.php @@ -0,0 +1,34 @@ +setId('activeUsersYear'); + $this->setOption('icon', 'user'); + $this->setOption('color', 'yellow'); + $this->setTitle('stats.userActiveYear'); + } + + public function getData(array $options = []) + { + $this->titleYear = 'stats.userActiveFinancialYear'; + $this->setQuery(TimesheetRepository::STATS_QUERY_USER); + $this->setQueryWithUser(false); + + return parent::getData($options); + } +} diff --git a/src/Widget/Type/AmountYear.php b/src/Widget/Type/AmountYear.php new file mode 100644 index 000000000..21511afc8 --- /dev/null +++ b/src/Widget/Type/AmountYear.php @@ -0,0 +1,35 @@ +setId('amountYear'); + $this->setOption('dataType', 'money'); + $this->setOption('icon', 'money'); + $this->setOption('color', 'yellow'); + $this->setTitle('stats.amountYear'); + } + + public function getData(array $options = []) + { + $this->titleYear = 'stats.amountFinancialYear'; + $this->setQuery(TimesheetRepository::STATS_QUERY_RATE); + $this->setQueryWithUser(false); + + return parent::getData($options); + } +} diff --git a/src/Widget/Type/CounterYear.php b/src/Widget/Type/CounterYear.php new file mode 100644 index 000000000..149226224 --- /dev/null +++ b/src/Widget/Type/CounterYear.php @@ -0,0 +1,52 @@ +systemConfiguration = $systemConfiguration; + $this->setOption('dataType', 'int'); + } + + public function getData(array $options = []) + { + $this->begin = '01 january this year 00:00:00'; + $this->end = '31 december this year 23:59:59'; + + if (null !== ($financialYear = $this->systemConfiguration->getFinancialYearStart())) { + $factory = new DateTimeFactory($this->getTimezone()); + $this->begin = $factory->createStartOfFinancialYear($financialYear); + $this->end = $factory->createEndOfFinancialYear($this->begin); + if (!empty($this->titleYear)) { + $this->setTitle($this->titleYear); + } + } + + return parent::getData($options); + } + + public function getTemplateName(): string + { + return 'widget/widget-counter.html.twig'; + } +} diff --git a/src/Widget/Type/DurationYear.php b/src/Widget/Type/DurationYear.php new file mode 100644 index 000000000..ef9ac3e48 --- /dev/null +++ b/src/Widget/Type/DurationYear.php @@ -0,0 +1,35 @@ +setId('durationYear'); + $this->setOption('dataType', 'duration'); + $this->setOption('icon', 'duration'); + $this->setOption('color', 'yellow'); + $this->setTitle('stats.durationYear'); + } + + public function getData(array $options = []) + { + $this->titleYear = 'stats.durationFinancialYear'; + $this->setQuery(TimesheetRepository::STATS_QUERY_DURATION); + $this->setQueryWithUser(false); + + return parent::getData($options); + } +} diff --git a/src/Widget/Type/PaginatedWorkingTimeChart.php b/src/Widget/Type/PaginatedWorkingTimeChart.php index 6ab86b2f0..9461efd88 100644 --- a/src/Widget/Type/PaginatedWorkingTimeChart.php +++ b/src/Widget/Type/PaginatedWorkingTimeChart.php @@ -9,6 +9,7 @@ namespace App\Widget\Type; +use App\Configuration\SystemConfiguration; use App\Entity\User; use App\Repository\TimesheetRepository; use App\Timesheet\DateTimeFactory; @@ -16,22 +17,15 @@ use DateTime; final class PaginatedWorkingTimeChart extends SimpleWidget implements UserWidget { - /** - * @var TimesheetRepository - */ private $repository; + private $systemConfiguration; - public function __construct(TimesheetRepository $repository) + public function __construct(TimesheetRepository $repository, SystemConfiguration $systemConfiguration) { $this->repository = $repository; + $this->systemConfiguration = $systemConfiguration; $this->setId('PaginatedWorkingTimeChart'); $this->setTitle('stats.yourWorkingHours'); - - $this->setOptions([ - 'year' => (new DateTime('now'))->format('o'), - 'week' => (new DateTime('now'))->format('W'), - 'type' => 'bar', - ]); } public function setUser(User $user): void @@ -48,10 +42,15 @@ final class PaginatedWorkingTimeChart extends SimpleWidget implements UserWidget { $options = parent::getOptions($options); - if (!\in_array($options['type'], ['bar', 'line'])) { + if (!\array_key_exists('type', $options) || !\in_array($options['type'], ['bar', 'line'])) { $options['type'] = 'bar'; } + if (!\array_key_exists('year', $options)) { + $options['year'] = (new DateTime('now'))->format('o'); + $options['week'] = (new DateTime('now'))->format('W'); + } + return $options; } @@ -91,6 +90,25 @@ final class PaginatedWorkingTimeChart extends SimpleWidget implements UserWidget $thisMonth = ($dateTimeFactory->createDateTime())->setISODate($year, $week, 1)->setTime(0, 0, 0); } + $dayBegin = $dateTimeFactory->createDateTime('00:00:00'); + $dayEnd = $dateTimeFactory->createDateTime('23:59:59'); + + $monthBegin = (clone $weekBegin)->setDate((int) $weekBegin->format('Y'), (int) $weekBegin->format('n'), 1)->setTime(0, 0, 0); + $monthEnd = (clone $weekBegin)->setDate((int) $weekBegin->format('Y'), (int) $weekBegin->format('n'), (int) $weekBegin->format('t'))->setTime(23, 59, 59); + + $yearBegin = $dateTimeFactory->createDateTime(sprintf('01 january %s 00:00:00', $year)); + $yearEnd = $dateTimeFactory->createDateTime(sprintf('31 december %s 23:59:59', $year)); + $yearData = $this->repository->getStatistic('duration', $yearBegin, $yearEnd, $user); + + $financialYearData = null; + $financialYearBegin = null; + + if (null !== ($financialYear = $this->systemConfiguration->getFinancialYearStart())) { + $financialYearBegin = $dateTimeFactory->createStartOfFinancialYear($financialYear); + $financialYearEnd = $dateTimeFactory->createEndOfFinancialYear($financialYearBegin); + $financialYearData = $this->repository->getStatistic('duration', $financialYearBegin, $financialYearEnd, $user); + } + return [ 'begin' => clone $weekBegin, 'end' => clone $weekEnd, @@ -98,30 +116,12 @@ final class PaginatedWorkingTimeChart extends SimpleWidget implements UserWidget 'thisMonth' => $thisMonth, 'lastWeekInYear' => $lastWeekInYear, 'lastWeekInLastYear' => $lastWeekInLastYear, - 'day' => $this->repository->getStatistic( - 'duration', - $dateTimeFactory->createDateTime('00:00:00'), - $dateTimeFactory->createDateTime('23:59:59'), - $user - ), - 'week' => $this->repository->getStatistic( - 'duration', - $weekBegin, - $weekEnd, - $user - ), - 'month' => $this->repository->getStatistic( - 'duration', - (clone $weekBegin)->setDate((int) $weekBegin->format('Y'), (int) $weekBegin->format('n'), 1)->setTime(0, 0, 0), - (clone $weekBegin)->setDate((int) $weekBegin->format('Y'), (int) $weekBegin->format('n'), (int) $weekBegin->format('t'))->setTime(23, 59, 59), - $user - ), - 'year' => $this->repository->getStatistic( - 'duration', - $dateTimeFactory->createDateTime(sprintf('01 january %s 00:00:00', $year)), - $dateTimeFactory->createDateTime(sprintf('31 december %s 23:59:59', $year)), - $user - ), + 'day' => $this->repository->getStatistic('duration', $dayBegin, $dayEnd, $user), + 'week' => $this->repository->getStatistic('duration', $weekBegin, $weekEnd, $user), + 'month' => $this->repository->getStatistic('duration', $monthBegin, $monthEnd, $user), + 'year' => $yearData, + 'financial' => $financialYearData, + 'financialBegin' => $financialYearBegin, ]; } } diff --git a/src/Widget/Type/SimpleStatisticChart.php b/src/Widget/Type/SimpleStatisticChart.php index 80390db6a..176a56e71 100644 --- a/src/Widget/Type/SimpleStatisticChart.php +++ b/src/Widget/Type/SimpleStatisticChart.php @@ -24,13 +24,13 @@ class SimpleStatisticChart extends SimpleWidget implements UserWidget */ private $query; /** - * @var string + * @var string|\DateTime */ - private $begin; + protected $begin; /** - * @var string + * @var string|\DateTime */ - private $end; + protected $end; /** * @var User|null */ @@ -83,6 +83,16 @@ class SimpleStatisticChart extends SimpleWidget implements UserWidget return $this; } + public function getTimezone(): \DateTimeZone + { + $timezone = date_default_timezone_get(); + if (null !== $this->user) { + $timezone = $this->user->getTimezone(); + } + + return new \DateTimeZone($timezone); + } + /** * @param array $options * @return mixed|null @@ -90,14 +100,18 @@ class SimpleStatisticChart extends SimpleWidget implements UserWidget */ public function getData(array $options = []) { - $timezone = date_default_timezone_get(); - if (null !== $this->user) { - $timezone = $this->user->getTimezone(); - } - $timezone = new \DateTimeZone($timezone); + $timezone = $this->getTimezone(); - $begin = !empty($this->begin) ? new \DateTime($this->begin, $timezone) : null; - $end = !empty($this->end) ? new \DateTime($this->end, $timezone) : null; + $begin = $this->begin; + $end = $this->end; + + if (!empty($begin) && \is_string($begin)) { + $begin = new \DateTime($begin, $timezone); + } + + if (!empty($end) && \is_string($end)) { + $end = new \DateTime($end, $timezone); + } try { if (true === $this->queryWithUser) { diff --git a/src/Widget/Type/UserAmountYear.php b/src/Widget/Type/UserAmountYear.php new file mode 100644 index 000000000..3068d0aa8 --- /dev/null +++ b/src/Widget/Type/UserAmountYear.php @@ -0,0 +1,35 @@ +setId('userAmountYear'); + $this->setOption('dataType', 'money'); + $this->setOption('icon', 'money'); + $this->setOption('color', 'yellow'); + $this->setTitle('stats.amountYear'); + } + + public function getData(array $options = []) + { + $this->titleYear = 'stats.amountFinancialYear'; + $this->setQuery(TimesheetRepository::STATS_QUERY_RATE); + $this->setQueryWithUser(true); + + return parent::getData($options); + } +} diff --git a/src/Widget/Type/UserDurationYear.php b/src/Widget/Type/UserDurationYear.php new file mode 100644 index 000000000..7f8ec1eaa --- /dev/null +++ b/src/Widget/Type/UserDurationYear.php @@ -0,0 +1,35 @@ +setId('userDurationYear'); + $this->setOption('dataType', 'duration'); + $this->setOption('icon', 'duration'); + $this->setOption('color', 'yellow'); + } + + public function getData(array $options = []) + { + $this->setTitle('stats.durationYear'); + $this->titleYear = 'stats.durationFinancialYear'; + $this->setQuery(TimesheetRepository::STATS_QUERY_DURATION); + $this->setQueryWithUser(true); + + return parent::getData($options); + } +} diff --git a/templates/base.html.twig b/templates/base.html.twig index daee470a1..d765f54bb 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -200,7 +200,7 @@
  • - + {{ 'my.profile'|trans }}

    diff --git a/templates/form/kimai-theme.html.twig b/templates/form/kimai-theme.html.twig index ab31acab7..d4948735d 100644 --- a/templates/form/kimai-theme.html.twig +++ b/templates/form/kimai-theme.html.twig @@ -71,6 +71,27 @@ {% endif %} {%- endblock text_widget %} +{% block yearpicker_widget -%} +
    + + + + + + {% if show_range %} + {{ year|date_short }} – {{ nextYear|date_short }} + {% else %} + {{ year|date_format('Y') }} + {% endif %} + + + + + +
    + {{ block('hidden_widget') }} +{%- endblock yearpicker_widget %} + {% block monthpicker_widget -%}
    diff --git a/templates/reporting/report_by_user.html.twig b/templates/reporting/report_by_user.html.twig index f631c4898..092a6e256 100644 --- a/templates/reporting/report_by_user.html.twig +++ b/templates/reporting/report_by_user.html.twig @@ -82,9 +82,7 @@ {{ total|duration }} {% for day in days %} - {% if day.totalDuration > 0 %} - {{ day.totalDuration|duration }} - {% endif %} + {{ day.totalDuration|duration }} {% endfor %} diff --git a/templates/reporting/report_user_list.html.twig b/templates/reporting/report_user_list.html.twig index 5f28440e8..dfd4abaf2 100644 --- a/templates/reporting/report_user_list.html.twig +++ b/templates/reporting/report_user_list.html.twig @@ -17,6 +17,7 @@ {% endblock %} {% block box_body_class %}{{ box_id }} table-responsive no-padding{% endblock %} {% block box_body %} + {% set absoluteTotals = 0 %} @@ -37,15 +38,14 @@ {% for day in userDay.days %} {% if day.totalDuration > 0 %} {% set usersTotalDuration = usersTotalDuration + day.totalDuration %} + {% set absoluteTotals = absoluteTotals + day.totalDuration %} {% endif %} {% endfor %} {% for day in userDay.days %} - {% endfor %} + + + + {% for id, duration in totals %} + + {% endfor %} +
      - {% if usersTotalDuration > 0 %} - {{ usersTotalDuration|duration }} - {% endif %} + {{ usersTotalDuration|duration }} + {% if day.totalDuration > 0 %} {{ day.totalDuration|duration }} {% endif %} @@ -53,6 +53,17 @@ {% endfor %}
      + {{ absoluteTotals|duration }} + + {{ duration|duration }} +
    {% endblock %} {% endembed %} diff --git a/templates/reporting/report_user_list_monthly.html.twig b/templates/reporting/report_user_list_monthly.html.twig new file mode 100644 index 000000000..55ce4eb8c --- /dev/null +++ b/templates/reporting/report_user_list_monthly.html.twig @@ -0,0 +1,86 @@ +{% extends 'reporting/layout.html.twig' %} + +{% block report_title %}{{ report_title|trans({}, 'reporting') }}{% endblock %} + +{% block report %} + + {% embed '@AdminLTE/Widgets/box-widget.html.twig' %} + {% import "macros/widgets.html.twig" as widgets %} + {% block box_before %} + {{ form_start(form, {'attr': {'class': 'form-inline'}}) }} + {% endblock %} + {% block box_after %} + {{ form_end(form) }} + {% endblock %} + {% block box_title %} + {{ form_widget(form.date) }} + {% endblock %} + {% block box_body_class %}{{ box_id }} table-responsive no-padding{% endblock %} + {% block box_body %} + + + + + {% for id, month in months %} + + {% endfor %} + + {% for userYear in rows %} + {% set usersTotalDuration = 0 %} + + + {% for yid, year in userYear.years %} + {% for mid, month in year.months %} + {% if month.totalDuration > 0 %} + {% set usersTotalDuration = usersTotalDuration + month.totalDuration %} + {% endif %} + {% endfor %} + {% endfor %} + + {% for yid, year in userYear.years %} + {% for mid, month in year.months %} + + {% endfor %} + {% endfor %} + + {% endfor %} + + + + {% for id, duration in totals %} + + {% endfor %} + +
       + + {{ month|month_name }}
    + {{ month|date_format('Y') }} +
    +
    + {{ widgets.username(userYear.user) }} + + {{ usersTotalDuration|duration }} + + {% if month.totalDuration > 0 %} + + {{ month.totalDuration|duration }} + + {% endif %} +
       + {{ duration|duration }} +
    + {% endblock %} + {% endembed %} + +{% endblock %} + +{% block javascripts %} + {{ parent() }} + +{% endblock %} diff --git a/templates/user/index.html.twig b/templates/user/index.html.twig index 44db4ce46..0a182eb1a 100644 --- a/templates/user/index.html.twig +++ b/templates/user/index.html.twig @@ -28,7 +28,6 @@ {% block page_title %}{{ 'admin_user.title'|trans }}{% endblock %} {% block page_search %}{{ search.searchModal(toolbarForm) }}{% endblock %} {% block page_actions %}{{ actions.users('index') }}{% endblock %} -{% block page_icon %}{{ 'user'|icon }}{% endblock %} {% block main_before %} {{ tables.data_table_column_modal(tableName, columns, 'kimai.userUpdate') }} diff --git a/templates/user/layout.html.twig b/templates/user/layout.html.twig index d139239b7..2d05d8ccf 100644 --- a/templates/user/layout.html.twig +++ b/templates/user/layout.html.twig @@ -4,7 +4,6 @@ {% block page_title %}{{ 'admin_user.title'|trans }}{% endblock %} {% block page_actions %}{{ actions.user(user, tab) }}{% endblock %} -{% block page_icon %}{{ 'user'|icon }}{% endblock %} {% block main %}
    diff --git a/templates/user/stats.html.twig b/templates/user/stats.html.twig index 0aac265c6..b0d49f8f1 100644 --- a/templates/user/stats.html.twig +++ b/templates/user/stats.html.twig @@ -82,7 +82,7 @@ } - {% for year,yearStat in years %} + {% for year, yearStat in years %} {% embed '@AdminLTE/Widgets/box-widget.html.twig' %} {% block box_title %}{{ year }}{% endblock %} @@ -100,21 +100,33 @@ backgroundColor: '{{ kimai_context.chart.background_color }}', borderColor: '{{ kimai_context.chart.border_color }}', data: [ - {% for month in yearStat.months %} - {{ (month.totalDuration / 3600)|number_format(2, '.', '') }} - {% if not loop.last %},{% endif %} + {% for monthNumber in 1..12 %} + {% set duration = 0 %} + {% set month = yearStat.month(monthNumber) %} + {% if month is not null %} + {% set duration = month.totalDuration %} + {% endif %} + {{ (duration / 3600)|number_format(2, '.', '') }} + {% if not loop.last %},{% endif %} {% endfor %} ], realData: [ - {% for month in yearStat.months %} - {% set realDayData = {duration: month.totalDuration|duration, billable: month.billableDuration|duration} %} + {% for monthNumber in 1..12 %} + {% set duration = 0 %} + {% set billable = 0 %} + {% set month = yearStat.month(monthNumber) %} + {% if month is not null %} + {% set duration = month.totalDuration %} + {% set billable = month.billableDuration %} + {% endif %} + {% set realDayData = {'duration': duration|duration, 'billable': billable|duration} %} {{ realDayData|json_encode|raw }} {% if not loop.last %},{% endif %} {% endfor %} ], monthData: [ - {% for month in yearStat.months %} - '{{ yearStat.year }}-{{ month.month|length == 1 ? '0'~ month.month : month.month }}-01' + {% for monthNumber in 1..12 %} + '{{ yearStat.year }}-{{ monthNumber < 10 ? '0' ~ monthNumber : monthNumber }}-01' {% if not loop.last %},{% endif %} {% endfor %} ], diff --git a/templates/widget/widget-paginatedworkingtimechart.html.twig b/templates/widget/widget-paginatedworkingtimechart.html.twig index 79d772da4..f8b5636b2 100644 --- a/templates/widget/widget-paginatedworkingtimechart.html.twig +++ b/templates/widget/widget-paginatedworkingtimechart.html.twig @@ -56,12 +56,21 @@ {{ 'stats.workingTimeMonth'|trans({'%month%': data.thisMonth|month_name, '%year%': data.thisMonth|date_format('Y')}) }}
    + {% if data.financial is not null %} +
    +
    +
    {{ data.financial|duration }}
    + {{ 'stats.workingTimeFinancialYear'|trans }} +
    +
    + {% else %}
    {{ data.year|duration }}
    {{ 'stats.workingTimeYear'|trans({'%year%': data.thisMonth|date_format('Y')}) }}
    + {% endif %} {% endblock %} {% endembed %} diff --git a/tests/Configuration/SystemConfigurationTest.php b/tests/Configuration/SystemConfigurationTest.php index d10e5bffc..f9abb04bc 100644 --- a/tests/Configuration/SystemConfigurationTest.php +++ b/tests/Configuration/SystemConfigurationTest.php @@ -157,10 +157,12 @@ class SystemConfigurationTest extends TestCase (new Configuration())->setName('timesheet.rules.allow_future_times')->setValue(''), (new Configuration())->setName('saml.activate')->setValue(true), (new Configuration())->setName('theme.color_choices')->setValue(''), + (new Configuration())->setName('company.financial_year')->setValue('2020-03-27'), ]); $this->assertFalse($sut->find('timesheet.rules.allow_future_times')); $this->assertTrue($sut->isSamlActive()); $this->assertNull($sut->getThemeColorChoices()); + $this->assertEquals('2020-03-27', $sut->getFinancialYearStart()); } public function testUnknownConfigs() @@ -209,6 +211,7 @@ class SystemConfigurationTest extends TestCase $this->assertEquals('blue', $sut->getUserDefaultTheme()); $this->assertEquals('IT', $sut->getUserDefaultLanguage()); $this->assertEquals('USD', $sut->getUserDefaultCurrency()); + $this->assertNull($sut->getFinancialYearStart()); } public function testFormDefaultWithLoader() diff --git a/tests/Controller/PermissionControllerTest.php b/tests/Controller/PermissionControllerTest.php index f8b71654d..a73a6ccf0 100644 --- a/tests/Controller/PermissionControllerTest.php +++ b/tests/Controller/PermissionControllerTest.php @@ -35,7 +35,7 @@ class PermissionControllerTest extends ControllerBaseTest $client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN); $this->assertAccessIsGranted($client, '/admin/permissions'); $this->assertHasDataTable($client); - $this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 118); + $this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 119); $this->assertPageActions($client, [ //'back' => $this->createUrl('/admin/user/'), 'create modal-ajax-form' => $this->createUrl('/admin/permissions/roles/create'), diff --git a/tests/Controller/ProfileControllerTest.php b/tests/Controller/ProfileControllerTest.php index 25bf04807..039bec0f2 100644 --- a/tests/Controller/ProfileControllerTest.php +++ b/tests/Controller/ProfileControllerTest.php @@ -34,9 +34,13 @@ class ProfileControllerTest extends ControllerBaseTest $this->request($client, '/profile/' . UserFixtures::USERNAME_USER); $this->assertTrue($client->getResponse()->isSuccessful()); - $this->assertHasNoEntriesWithFilter($client); $this->assertHasProfileBox($client, 'John Doe'); $this->assertHasAboutMeBox($client, UserFixtures::USERNAME_USER); + + $content = $client->getResponse()->getContent(); + $year = (new \DateTime())->format('Y'); + $this->assertStringContainsString('

    ' . $year . '

    ', $content); + $this->assertStringContainsString('var userProfileChart' . $year . ' = new Chart(', $content); } public function testIndexAction() diff --git a/tests/Controller/Reporting/ReportUsersListControllerTest.php b/tests/Controller/Reporting/ReportUsersListControllerTest.php index bd607973a..8da8a0df0 100644 --- a/tests/Controller/Reporting/ReportUsersListControllerTest.php +++ b/tests/Controller/Reporting/ReportUsersListControllerTest.php @@ -28,6 +28,11 @@ class ReportUsersListControllerTest extends ControllerBaseTest $this->importFixture($fixture); } + public function testYearlyListIsSecure() + { + $this->assertUrlIsSecured('/reporting/yearly_users_list'); + } + public function testWeeklyListIsSecure() { $this->assertUrlIsSecured('/reporting/weekly_users_list'); @@ -38,6 +43,11 @@ class ReportUsersListControllerTest extends ControllerBaseTest $this->assertUrlIsSecured('/reporting/monthly_users_list'); } + public function testYearlyUsersListIsSecureForUserRole() + { + $this->assertUrlIsSecuredForRole(User::ROLE_USER, '/reporting/yearly_users_list'); + } + public function testWeeklyUsersListIsSecureForUserRole() { $this->assertUrlIsSecuredForRole(User::ROLE_USER, '/reporting/weekly_users_list'); @@ -48,6 +58,16 @@ class ReportUsersListControllerTest extends ControllerBaseTest $this->assertUrlIsSecuredForRole(User::ROLE_USER, '/reporting/monthly_users_list'); } + public function testYearlyUsersReport() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD); + $this->importReportingFixture(User::ROLE_TEAMLEAD); + $this->assertAccessIsGranted($client, '/reporting/yearly_users_list'); + self::assertStringContainsString('
    count()); + } + public function testWeeklyUsersReport() { $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD); diff --git a/tests/DependencyInjection/ConfigurationTest.php b/tests/DependencyInjection/ConfigurationTest.php index 44fb20777..f55f96de1 100644 --- a/tests/DependencyInjection/ConfigurationTest.php +++ b/tests/DependencyInjection/ConfigurationTest.php @@ -428,6 +428,9 @@ class ConfigurationTest extends TestCase 'connection' => [ 'organization' => [] ], + ], + 'company' => [ + 'financial_year' => null, ] ]; diff --git a/tests/Model/Statistic/MonthTest.php b/tests/Model/Statistic/MonthTest.php index 1f7ae2030..f795a1c58 100644 --- a/tests/Model/Statistic/MonthTest.php +++ b/tests/Model/Statistic/MonthTest.php @@ -10,7 +10,6 @@ namespace App\Tests\Model\Statistic; use App\Model\Statistic\Month; -use Exception; use InvalidArgumentException; use PHPUnit\Framework\TestCase; @@ -29,32 +28,61 @@ class MonthTest extends TestCase self::assertSame(0.0, $sut->getBillableRate()); } - public function testAllowedMonths() + public function getTestData() { - for ($i = 1; $i < 10; $i++) { - new Month('0' . $i); - } - for ($i = 10; $i < 13; $i++) { - new Month((string) $i); - } - self::assertTrue(true); + yield ['01', '01', 1]; + yield ['02', '02', 2]; + yield ['03', '03', 3]; + yield ['04', '04', 4]; + yield ['05', '05', 5]; + yield ['06', '06', 6]; + yield ['07', '07', 7]; + yield ['08', '08', 8]; + yield ['09', '09', 9]; + yield ['10', '10', 10]; + yield ['11', '11', 11]; + yield ['12', '12', 12]; + yield [1, '01', 1]; + yield [2, '02', 2]; + yield [3, '03', 3]; + yield [4, '04', 4]; + yield [5, '05', 5]; + yield [6, '06', 6]; + yield [7, '07', 7]; + yield [8, '08', 8]; + yield [9, '09', 9]; + yield [10, '10', 10]; + yield [11, '11', 11]; + yield [12, '12', 12]; } - public function testInvalidMonths() + /** + * @dataProvider getTestData + */ + public function testAllowedMonths($init, $month, $number) { - foreach (['00', '13', '99', '0.9'] as $month) { - $ex = null; - try { - new Month($month); - } catch (Exception $e) { - $ex = $e; - } - self::assertInstanceOf(InvalidArgumentException::class, $ex); - self::assertEquals( - 'Invalid month given. Expected 1-12, received "' . ((int) $month) . '".', - $ex->getMessage() - ); - } + $sut = new Month($init); + self::assertEquals($month, $sut->getMonth()); + self::assertEquals($number, $sut->getMonthNumber()); + } + + public function getInvalidTestData() + { + yield ['00']; + yield ['13']; + yield ['99']; + yield ['0.9']; + yield [19]; + } + + /** + * @dataProvider getInvalidTestData + */ + public function testInvalidMonths($month) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid month given. Expected 1-12, received "' . ((int) $month) . '".'); + new Month($month); } public function testSetter() diff --git a/tests/Reporting/ReportingServiceTest.php b/tests/Reporting/ReportingServiceTest.php index 5faa46fbd..227f88a6f 100644 --- a/tests/Reporting/ReportingServiceTest.php +++ b/tests/Reporting/ReportingServiceTest.php @@ -47,6 +47,6 @@ class ReportingServiceTest extends TestCase $sut = $this->getSut(true); $reports = $sut->getAvailableReports(new User()); self::assertIsArray($reports); - self::assertCount(6, $reports); + self::assertCount(7, $reports); } } diff --git a/tests/Timesheet/DateTimeFactoryTest.php b/tests/Timesheet/DateTimeFactoryTest.php index 661e1cd45..74e81cde0 100644 --- a/tests/Timesheet/DateTimeFactoryTest.php +++ b/tests/Timesheet/DateTimeFactoryTest.php @@ -169,4 +169,44 @@ class DateTimeFactoryTest extends TestCase // poor test, but there shouldn't be more than 2 seconds between the creation of two DateTime objects $this->assertTrue(2 >= $difference); } + + public function testCreateStartOfFinancialYearWithoutConfig() + { + $sut = $this->createDateTimeFactory(self::TEST_TIMEZONE); + $dateTime = $sut->createStartOfFinancialYear(); + $expected = $sut->createDateTime('01 january this year 00:00:00'); + self::assertInstanceOf(DateTime::class, $dateTime); + self::assertEquals($expected, $dateTime); + } + + public function testCreateStartOfFinancialYearWithConfig() + { + $sut = $this->createDateTimeFactory(self::TEST_TIMEZONE); + + $future = $sut->createDateTime('+10 days'); + $past = $sut->createDateTime('-10 days'); + + $financial = $sut->createStartOfFinancialYear($future->format('Y-m-d')); + + $future->modify('-1 year'); + $future->setTime(0, 0, 0); + + self::assertEquals($future, $financial); + + $financial = $sut->createStartOfFinancialYear($past->format('Y-m-d')); + + $past->setTime(0, 0, 0); + self::assertEquals($past, $financial); + } + + public function testCreateEndOfFinancialYearWithConfig() + { + $sut = $this->createDateTimeFactory(self::TEST_TIMEZONE); + + $expected = $sut->createDateTime('2021-07-22 23:59:59 '); + $financial = $sut->createStartOfFinancialYear('2020-07-23 15:30:00'); + $end = $sut->createEndOfFinancialYear($financial); + + self::assertEquals($expected, $end); + } } diff --git a/tests/Widget/Type/AbstractWidgetTypeTest.php b/tests/Widget/Type/AbstractWidgetTypeTest.php index c3fe7a22a..290df29b4 100644 --- a/tests/Widget/Type/AbstractWidgetTypeTest.php +++ b/tests/Widget/Type/AbstractWidgetTypeTest.php @@ -25,8 +25,6 @@ abstract class AbstractWidgetTypeTest extends TestCase { $sut = $this->createSut(); self::assertInstanceOf(AbstractWidgetType::class, $sut); - self::assertEquals('', $sut->getId()); - self::assertEquals('', $sut->getTitle()); self::assertEquals($this->getDefaultOptions(), $sut->getOptions()); self::assertNull($sut->getData()); self::assertEquals('bar', $sut->getOption('foo', 'bar')); @@ -51,7 +49,10 @@ abstract class AbstractWidgetTypeTest extends TestCase self::assertEquals(array_merge($this->getDefaultOptions(), ['föööö' => 'trääääää']), $sut->getOptions()); $sut->setOptions(['blub' => 'blab', 'dataType' => 'money']); - self::assertEquals(['blub' => 'blab', 'dataType' => 'money', 'föööö' => 'trääääää'], $sut->getOptions()); + $options = $sut->getOptions(); + self::assertEquals('blab', $options['blub']); + self::assertEquals('money', $options['dataType']); + self::assertEquals('trääääää', $options['föööö']); // id $sut->setId('cvbnmyx'); diff --git a/tests/Widget/Type/ActiveUsersYearTest.php b/tests/Widget/Type/ActiveUsersYearTest.php new file mode 100644 index 000000000..9091e9451 --- /dev/null +++ b/tests/Widget/Type/ActiveUsersYearTest.php @@ -0,0 +1,62 @@ +createMock(TimesheetRepository::class); + $configuration = $this->createMock(SystemConfiguration::class); + + return new ActiveUsersYear($repository, $configuration); + } + + public function getDefaultOptions(): array + { + return [ + 'dataType' => 'int', + 'icon' => 'user', + 'color' => 'yellow', + ]; + } + + public function testData() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot set data on instances of SimpleStatisticChart'); + + $sut = $this->createSut(); + self::assertInstanceOf(SimpleStatisticChart::class, $sut); + $sut->setData(10); + } + + public function testSettings() + { + $sut = $this->createSut(); + + self::assertEquals('widget/widget-counter.html.twig', $sut->getTemplateName()); + self::assertEquals('activeUsersYear', $sut->getId()); + } +} diff --git a/tests/Widget/Type/AmountYearTest.php b/tests/Widget/Type/AmountYearTest.php new file mode 100644 index 000000000..edf2422f4 --- /dev/null +++ b/tests/Widget/Type/AmountYearTest.php @@ -0,0 +1,62 @@ +createMock(TimesheetRepository::class); + $configuration = $this->createMock(SystemConfiguration::class); + + return new AmountYear($repository, $configuration); + } + + public function getDefaultOptions(): array + { + return [ + 'dataType' => 'money', + 'icon' => 'money', + 'color' => 'yellow', + ]; + } + + public function testData() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot set data on instances of SimpleStatisticChart'); + + $sut = $this->createSut(); + self::assertInstanceOf(SimpleStatisticChart::class, $sut); + $sut->setData(10); + } + + public function testSettings() + { + $sut = $this->createSut(); + + self::assertEquals('widget/widget-counter.html.twig', $sut->getTemplateName()); + self::assertEquals('amountYear', $sut->getId()); + } +} diff --git a/tests/Widget/Type/CounterYearTest.php b/tests/Widget/Type/CounterYearTest.php new file mode 100644 index 000000000..3f0ae440b --- /dev/null +++ b/tests/Widget/Type/CounterYearTest.php @@ -0,0 +1,101 @@ +createMock(SystemConfiguration::class); + + if (null !== $financialYear) { + $configuration->method('getFinancialYearStart')->willReturn($financialYear); + } + + $sut = new CounterYear($this->createMock(TimesheetRepository::class), $configuration); + $sut->setQuery(TimesheetRepository::STATS_QUERY_ACTIVE); + + return $sut; + } + + public function testQueryWithUser() + { + $user = new User(); + $user->setAlias('foo'); + + $repository = $this->createMock(TimesheetRepository::class); + $repository->expects($this->once())->method('getStatistic')->willReturnCallback(function (string $type, ?DateTime $begin, ?DateTime $end, ?User $user) { + self::assertEquals($type, 'active'); + self::assertNull($begin); + self::assertNull($end); + self::assertNull($user); + }); + $sut = new Counter($repository); + $sut->setQuery(TimesheetRepository::STATS_QUERY_ACTIVE); + $sut->setUser($user); + $sut->getData([]); + + $user = new User(); + $user->setAlias('bar'); + + $repository = $this->createMock(TimesheetRepository::class); + $repository->expects($this->once())->method('getStatistic')->willReturnCallback(function (string $type, ?DateTime $begin, ?DateTime $end, ?User $user) { + self::assertEquals($type, 'active'); + self::assertNull($begin); + self::assertNull($end); + self::assertNotNull($user); + self::assertEquals('bar', $user->getAlias()); + }); + $sut = new Counter($repository); + $sut->setQuery(TimesheetRepository::STATS_QUERY_ACTIVE); + $sut->setUser($user); + $sut->setQueryWithUser(true); + $sut->getData([]); + } + + public function getDefaultOptions(): array + { + return ['dataType' => 'int']; + } + + public function testExtendsSimpleWidget() + { + $sut = $this->createSut(); + self::assertInstanceOf(SimpleWidget::class, $sut); + } + + public function testTemplateName() + { + /** @var Counter $sut */ + $sut = $this->createSut(); + self::assertEquals('widget/widget-counter.html.twig', $sut->getTemplateName()); + } + + public function testTemplateNameWithFinancialYear() + { + /** @var Counter $sut */ + $sut = $this->createSut('2020-01-01'); + self::assertEquals('widget/widget-counter.html.twig', $sut->getTemplateName()); + } +} diff --git a/tests/Widget/Type/DurationYearTest.php b/tests/Widget/Type/DurationYearTest.php new file mode 100644 index 000000000..90f25d5ac --- /dev/null +++ b/tests/Widget/Type/DurationYearTest.php @@ -0,0 +1,62 @@ +createMock(TimesheetRepository::class); + $configuration = $this->createMock(SystemConfiguration::class); + + return new DurationYear($repository, $configuration); + } + + public function getDefaultOptions(): array + { + return [ + 'dataType' => 'duration', + 'icon' => 'duration', + 'color' => 'yellow', + ]; + } + + public function testData() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot set data on instances of SimpleStatisticChart'); + + $sut = $this->createSut(); + self::assertInstanceOf(SimpleStatisticChart::class, $sut); + $sut->setData(10); + } + + public function testSettings() + { + $sut = $this->createSut(); + + self::assertEquals('widget/widget-counter.html.twig', $sut->getTemplateName()); + self::assertEquals('durationYear', $sut->getId()); + } +} diff --git a/tests/Widget/Type/PaginatedWorkingTimeChartTest.php b/tests/Widget/Type/PaginatedWorkingTimeChartTest.php new file mode 100644 index 000000000..d1d136cbc --- /dev/null +++ b/tests/Widget/Type/PaginatedWorkingTimeChartTest.php @@ -0,0 +1,199 @@ +createMock(TimesheetRepository::class); + $configuration = $this->createMock(SystemConfiguration::class); + + $sut = new PaginatedWorkingTimeChart($repository, $configuration); + $sut->setUser(new User()); + + return $sut; + } + + public function testExtendsSimpleWidget() + { + $sut = $this->createSut(); + self::assertInstanceOf(SimpleWidget::class, $sut); + } + + public function testDefaultValues() + { + $sut = $this->createSut(); + self::assertInstanceOf(AbstractWidgetType::class, $sut); + self::assertEquals('PaginatedWorkingTimeChart', $sut->getId()); + self::assertEquals('stats.yourWorkingHours', $sut->getTitle()); + //self::assertNull($sut->getOption('begin', 'xxx')); +// self::assertNull($sut->getOption('end', 'xxx')); +// self::assertEquals('', $sut->getOption('color', 'xxx')); + self::assertInstanceOf(User::class, $sut->getOption('user', 'xxx')); +// self::assertEquals('bar', $sut->getOption('type', 'xxx')); + } + + public function testFluentInterface() + { + $sut = $this->createSut(); + self::assertInstanceOf(AbstractWidgetType::class, $sut->setOptions([])); + self::assertInstanceOf(AbstractWidgetType::class, $sut->setId('')); + self::assertInstanceOf(AbstractWidgetType::class, $sut->setTitle('')); + self::assertInstanceOf(AbstractWidgetType::class, $sut->setData('')); + } + + public function testSetter() + { + $sut = $this->createSut(); + + // options + $sut->setOption('föööö', 'trääääää'); + self::assertEquals('trääääää', $sut->getOption('föööö', 'tröööö')); + + // check default values + self::assertEquals('xxxxx', $sut->getOption('blub', 'xxxxx')); + self::assertEquals('xxxxx', $sut->getOption('dataType', 'xxxxx')); + + $sut->setOptions(['blub' => 'blab', 'dataType' => 'money']); + // check option still exists + self::assertEquals('trääääää', $sut->getOption('föööö', 'tröööö')); + // check options are now existing + self::assertEquals('blab', $sut->getOption('blub', 'xxxxx')); + self::assertEquals('money', $sut->getOption('dataType', 'xxxxx')); + + // id + $sut->setId('cvbnmyx'); + self::assertEquals('cvbnmyx', $sut->getId()); + } + + public function testGetOptions() + { + $sut = $this->createSut(); + + $options = $sut->getOptions(['type' => 'xxx']); + self::assertEquals('bar', $options['type']); + } + + public function testGetData() + { + $activity = $this->createMock(Activity::class); + $activity->method('getId')->willReturn(42); + + $project = $this->createMock(Project::class); + $project->method('getId')->willReturn(4711); + + $repository = $this->createMock(TimesheetRepository::class); + $repository->expects($this->once())->method('getDailyStats')->willReturnCallback(function ($user, $begin, $end) use ($activity, $project) { + return [ + [ + 'year' => $begin->format('Y'), + 'month' => $begin->format('n'), + 'day' => $begin->format('j'), + 'rate' => 13.75, + 'duration' => 1234, + 'billable' => 1234, + 'details' => [ + [ + 'activity' => $activity, + 'project' => $project, + 'billable' => 1234, + ] + ] + ] + ]; + }); + + $expectedKeys = [ + 'begin', 'end', 'stats', 'thisMonth', 'lastWeekInYear', 'lastWeekInLastYear', 'day', 'week', 'month', 'year', 'financial', 'financialBegin' + ]; + + $configuration = $this->createMock(SystemConfiguration::class); + $configuration->expects($this->once())->method('getFinancialYearStart')->willReturn(null); + + $sut = new PaginatedWorkingTimeChart($repository, $configuration); + + $sut->setUser(new User()); + $data = $sut->getData([]); + + self::assertCount(\count($expectedKeys), $data); + foreach ($expectedKeys as $key) { + self::assertArrayHasKey($key, $data); + } + self::assertNull($data['financialBegin']); + } + + public function testGetDataWithFinancialYear() + { + $activity = $this->createMock(Activity::class); + $activity->method('getId')->willReturn(42); + + $project = $this->createMock(Project::class); + $project->method('getId')->willReturn(4711); + + $repository = $this->createMock(TimesheetRepository::class); + $repository->expects($this->once())->method('getDailyStats')->willReturnCallback(function ($user, $begin, $end) use ($activity, $project) { + return [ + [ + 'year' => $begin->format('Y'), + 'month' => $begin->format('n'), + 'day' => $begin->format('j'), + 'rate' => 13.75, + 'duration' => 1234, + 'billable' => 1234, + 'details' => [ + [ + 'activity' => $activity, + 'project' => $project, + 'billable' => 1234, + ] + ] + ] + ]; + }); + + $expectedKeys = [ + 'begin', 'end', 'stats', 'thisMonth', 'lastWeekInYear', 'lastWeekInLastYear', 'day', 'week', 'month', 'year', 'financial', 'financialBegin' + ]; + + $configuration = $this->createMock(SystemConfiguration::class); + $configuration->expects($this->once())->method('getFinancialYearStart')->willReturn('2020-01-01'); + + $sut = new PaginatedWorkingTimeChart($repository, $configuration); + + $sut->setUser(new User()); + $data = $sut->getData([]); + + self::assertCount(\count($expectedKeys), $data); + foreach ($expectedKeys as $key) { + self::assertArrayHasKey($key, $data); + } + self::assertInstanceOf(\DateTime::class, $data['financialBegin']); + } +} diff --git a/tests/Widget/Type/UserAmountYearTest.php b/tests/Widget/Type/UserAmountYearTest.php new file mode 100644 index 000000000..f35f30f72 --- /dev/null +++ b/tests/Widget/Type/UserAmountYearTest.php @@ -0,0 +1,62 @@ +createMock(TimesheetRepository::class); + $configuration = $this->createMock(SystemConfiguration::class); + + return new UserAmountYear($repository, $configuration); + } + + public function getDefaultOptions(): array + { + return [ + 'dataType' => 'money', + 'icon' => 'money', + 'color' => 'yellow', + ]; + } + + public function testData() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot set data on instances of SimpleStatisticChart'); + + $sut = $this->createSut(); + self::assertInstanceOf(SimpleStatisticChart::class, $sut); + $sut->setData(10); + } + + public function testSettings() + { + $sut = $this->createSut(); + + self::assertEquals('widget/widget-counter.html.twig', $sut->getTemplateName()); + self::assertEquals('userAmountYear', $sut->getId()); + } +} diff --git a/tests/Widget/Type/UserDurationYearTest.php b/tests/Widget/Type/UserDurationYearTest.php new file mode 100644 index 000000000..a219ab038 --- /dev/null +++ b/tests/Widget/Type/UserDurationYearTest.php @@ -0,0 +1,62 @@ +createMock(TimesheetRepository::class); + $configuration = $this->createMock(SystemConfiguration::class); + + return new UserDurationYear($repository, $configuration); + } + + public function getDefaultOptions(): array + { + return [ + 'dataType' => 'duration', + 'icon' => 'duration', + 'color' => 'yellow', + ]; + } + + public function testData() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot set data on instances of SimpleStatisticChart'); + + $sut = $this->createSut(); + self::assertInstanceOf(SimpleStatisticChart::class, $sut); + $sut->setData(10); + } + + public function testSettings() + { + $sut = $this->createSut(); + + self::assertEquals('widget/widget-counter.html.twig', $sut->getTemplateName()); + self::assertEquals('userDurationYear', $sut->getId()); + } +} diff --git a/translations/messages.de.xlf b/translations/messages.de.xlf index 1cdb01e93..3a6243610 100644 --- a/translations/messages.de.xlf +++ b/translations/messages.de.xlf @@ -806,6 +806,10 @@ stats.workingTimeYear Gesamtes Jahr %year% + + stats.workingTimeFinancialYear + Geschäftsjahr + stats.durationToday Arbeitszeit heute @@ -822,6 +826,10 @@ stats.durationYear Arbeitszeit dieses Jahr + + stats.durationFinancialYear + Arbeitszeit dieses Geschäftsjahr + stats.durationTotal Arbeitszeit total @@ -846,6 +854,10 @@ stats.amountYear Umsatz dieses Jahr + + stats.amountFinancialYear + Umsatz dieses Geschäftsjahr + stats.amountTotal Umsatz total @@ -866,6 +878,10 @@ stats.userActiveYear Aktive Benutzer dieses Jahr + + stats.userActiveFinancialYear + Aktive Benutzer dieses Geschäftsjahr + stats.userActiveTotal Aktive Benutzer jemals diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 739b4f81f..2b8b4856b 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -820,6 +820,10 @@ stats.workingTimeYear Full year %year% + + stats.workingTimeFinancialYear + Financial year + stats.durationToday Working hours today @@ -836,6 +840,10 @@ stats.durationYear Working hours this year + + stats.durationFinancialYear + Working hours this financial year + stats.durationTotal Working hours total @@ -860,6 +868,10 @@ stats.amountYear Revenue this year + + stats.amountFinancialYear + Revenue this financial year + stats.amountTotal Total revenue @@ -880,6 +892,10 @@ stats.userActiveYear Active users this year + + stats.userActiveFinancialYear + Active users this financial year + stats.userActiveTotal Active users ever diff --git a/translations/reporting.de.xlf b/translations/reporting.de.xlf index c7e361a21..ac89ea80d 100644 --- a/translations/reporting.de.xlf +++ b/translations/reporting.de.xlf @@ -18,6 +18,10 @@ report_monthly_users Monatsansicht für alle Benutzer + + report_yearly_users + Jahresansicht für alle Benutzer + report_project_view Projektübersicht diff --git a/translations/reporting.en.xlf b/translations/reporting.en.xlf index 798901fac..84505f4b3 100644 --- a/translations/reporting.en.xlf +++ b/translations/reporting.en.xlf @@ -18,6 +18,10 @@ report_monthly_users Monthly view for all users + + report_yearly_users + Yearly view for all users + report_project_view Project overview diff --git a/translations/system-configuration.de.xlf b/translations/system-configuration.de.xlf index 3dded342b..be07e9e40 100644 --- a/translations/system-configuration.de.xlf +++ b/translations/system-configuration.de.xlf @@ -128,15 +128,15 @@ branding - Markendarstellung + Mein Unternehmen label.theme.branding.company - Unternehmen + Unternehmensname label.theme.branding.mini @@ -146,6 +146,10 @@ label.theme.branding.title Browser Titel + + label.company.financial_year + Geschäftsjahr + rounding Zeitenrundung diff --git a/translations/system-configuration.en.xlf b/translations/system-configuration.en.xlf index dcd3fae62..513b10f32 100644 --- a/translations/system-configuration.en.xlf +++ b/translations/system-configuration.en.xlf @@ -128,15 +128,15 @@ branding - Branding + My company label.theme.branding.company - Company + Company name label.theme.branding.mini @@ -146,6 +146,10 @@ label.theme.branding.title Browser Title + + label.company.financial_year + Financial year + rounding Time rounding