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 @@
| @@ -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 %} | - {% if usersTotalDuration > 0 %} - {{ usersTotalDuration|duration }} - {% endif %} + {{ usersTotalDuration|duration }} | {% for day in userDay.days %} -+ | {% if day.totalDuration > 0 %} {{ day.totalDuration|duration }} {% endif %} @@ -53,6 +53,17 @@ {% endfor %} |
|---|---|---|---|
| + | + {{ absoluteTotals|duration }} + | + {% for id, duration in totals %} ++ {{ duration|duration }} + | + {% endfor %} +
| + | + {% for id, month in months %} + |
+
+ {{ month|month_name }} + {{ month|date_format('Y') }} + + |
+ {% endfor %}
+
|---|---|---|
| + {{ widgets.username(userYear.user) }} + | + {% for yid, year in userYear.years %} + {% for mid, month in year.months %} + {% if month.totalDuration > 0 %} + {% set usersTotalDuration = usersTotalDuration + month.totalDuration %} + {% endif %} + {% endfor %} + {% endfor %} ++ {{ usersTotalDuration|duration }} + | + {% for yid, year in userYear.years %} + {% for mid, month in year.months %} ++ {% if month.totalDuration > 0 %} + + {{ month.totalDuration|duration }} + + {% endif %} + | + {% endfor %} + {% endfor %} +
| + | + {% for id, duration in totals %} + | + {{ duration|duration }} + | + {% endfor %} +