diff --git a/UPGRADING.md b/UPGRADING.md index 2268a783..d76ddb73 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 af675566..9691dc45 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 3511cc3b..07e58e94 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 41adc86c..d75b5746 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 dd545969..8a8c2b59 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 9acb92d9..1b6c46ec 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 c16be991..226ed81e 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 369badc1..45cf7450 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 7248be0b..67ece1b0 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 63d0c25f..0974ab8c 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 00000000..e7909cd5 --- /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 0eb07fb3..ac7bc927 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 8eaa3c4f..6570d885 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 00000000..5115e6fa --- /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 c56f7759..d207b2ff 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 f48003fb..0e7beec6 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 ac0b546b..7c25c222 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 79dfee0e..89e767f7 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 fd16c4a6..b1290460 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 00000000..d85db9d1 --- /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 00000000..21511afc --- /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 00000000..14922622 --- /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 00000000..ef9ac3e4 --- /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 6ab86b2f..9461efd8 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 80390db6..176a56e7 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 00000000..3068d0aa --- /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 00000000..7f8ec1ea --- /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 daee470a..d765f54b 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 ab31acab..d4948735 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 f631c489..092a6e25 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 5f28440e..dfd4abaf 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 00000000..55ce4eb8 --- /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 44db4ce4..0a182eb1 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 d139239b..2d05d8cc 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 0aac265c..b0d49f8f 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 79d772da..f8b5636b 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 d10e5bff..f9abb04b 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 f8b71654..a73a6ccf 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 25bf0480..039bec0f 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 bd607973..8da8a0df 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 44fb2077..f55f96de 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 1f7ae203..f795a1c5 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 5faa46fb..227f88a6 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 661e1cd4..74e81cde 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 c3fe7a22..290df29b 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 00000000..9091e945 --- /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 00000000..edf2422f --- /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 00000000..3f0ae440 --- /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 00000000..90f25d5a --- /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 00000000..d1d136cb --- /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 00000000..f35f30f7 --- /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 00000000..a219ab03 --- /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 1cdb01e9..3a624361 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 739b4f81..2b8b4856 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 c7e361a2..ac89ea80 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 798901fa..84505f4b 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 3dded342..be07e9e4 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 dcd3fae6..513b10f3 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