financial year setting + new users working time per year report (#2547)

This commit is contained in:
Kevin Papst
2021-05-14 18:40:48 +02:00
committed by GitHub
parent a9da5f8476
commit f7aa3c1e13
59 changed files with 1690 additions and 217 deletions

View File

@@ -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!

View File

@@ -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']

View File

@@ -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

View File

@@ -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',

View File

@@ -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) {

View File

@@ -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,
]);
}
}

View File

@@ -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']),
]),
];
}

View File

@@ -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())));

View File

@@ -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');

View File

@@ -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);
}

View File

@@ -0,0 +1,69 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Custom form field type to select a year via picker and select previous and next year.
*
* Always falls back to the current year if none or an invalid date is given.
*/
final class YearPickerType extends AbstractType
{
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->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';
}
}

View File

@@ -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

View File

@@ -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'));

View File

@@ -0,0 +1,14 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Reporting;
final class YearlyUserList extends AbstractUserList
{
}

View File

@@ -0,0 +1,58 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Reporting;
use App\Form\Type\YearPickerType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class YearlyUserListForm extends AbstractType
{
/**
* Simplify cross linking between pages by removing the block prefix.
*
* @return null|string
*/
public function getBlockPrefix()
{
return null;
}
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->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',
]);
}
}

View File

@@ -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']);

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -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',

View File

@@ -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

View File

@@ -0,0 +1,34 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Widget\Type;
use App\Configuration\SystemConfiguration;
use App\Repository\TimesheetRepository;
final class ActiveUsersYear extends CounterYear
{
public function __construct(TimesheetRepository $repository, SystemConfiguration $systemConfiguration)
{
parent::__construct($repository, $systemConfiguration);
$this->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);
}
}

View File

@@ -0,0 +1,35 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Widget\Type;
use App\Configuration\SystemConfiguration;
use App\Repository\TimesheetRepository;
final class AmountYear extends CounterYear
{
public function __construct(TimesheetRepository $repository, SystemConfiguration $systemConfiguration)
{
parent::__construct($repository, $systemConfiguration);
$this->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);
}
}

View File

@@ -0,0 +1,52 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Widget\Type;
use App\Configuration\SystemConfiguration;
use App\Repository\TimesheetRepository;
use App\Timesheet\DateTimeFactory;
class CounterYear extends SimpleStatisticChart
{
private $systemConfiguration;
/**
* @var string|null
*/
protected $titleYear;
public function __construct(TimesheetRepository $repository, SystemConfiguration $systemConfiguration)
{
parent::__construct($repository);
$this->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';
}
}

View File

@@ -0,0 +1,35 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Widget\Type;
use App\Configuration\SystemConfiguration;
use App\Repository\TimesheetRepository;
final class DurationYear extends CounterYear
{
public function __construct(TimesheetRepository $repository, SystemConfiguration $systemConfiguration)
{
parent::__construct($repository, $systemConfiguration);
$this->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);
}
}

View File

@@ -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,
];
}
}

View File

@@ -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) {

View File

@@ -0,0 +1,35 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Widget\Type;
use App\Configuration\SystemConfiguration;
use App\Repository\TimesheetRepository;
final class UserAmountYear extends CounterYear
{
public function __construct(TimesheetRepository $repository, SystemConfiguration $systemConfiguration)
{
parent::__construct($repository, $systemConfiguration);
$this->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);
}
}

View File

@@ -0,0 +1,35 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Widget\Type;
use App\Configuration\SystemConfiguration;
use App\Repository\TimesheetRepository;
final class UserDurationYear extends CounterYear
{
public function __construct(TimesheetRepository $repository, SystemConfiguration $systemConfiguration)
{
parent::__construct($repository, $systemConfiguration);
$this->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);
}
}

View File

@@ -200,7 +200,7 @@
<li>
<a href="{{ path('user_profile', {'username' : app.user.username}) }}">
<h4 class="control-sidebar-subheading">
<i class="{{ 'avatar'|icon }}"></i>
<i class="{{ 'user'|icon }}"></i>
{{ 'my.profile'|trans }}
</h4>
</a>

View File

@@ -71,6 +71,27 @@
{% endif %}
{%- endblock text_widget %}
{% block yearpicker_widget -%}
<div class="btn-group">
<a class="btn btn-default btn-left" href="#" onclick="$('#{{ form.vars.id }}').val('{{ previousYear|date_short }}').change()" data-toggle="tooltip" data-placement="top" title="{{ previousYear|date_short }}">
<i class="{{ 'left'|icon }}"></i>
</a>
<a class="btn btn-default" href="#" onclick="return false;">
<span id="{{ form.vars.id }}_month_name">
{% if show_range %}
{{ year|date_short }} &ndash; {{ nextYear|date_short }}
{% else %}
{{ year|date_format('Y') }}
{% endif %}
</span>
</a>
<a class="btn btn-default btn-right" href="#" onclick="$('#{{ form.vars.id }}').val('{{ nextYear|date_short }}').change()" data-toggle="tooltip" data-placement="top" title="{{ nextYear|date_short }}">
<i class="{{ 'right'|icon }}"></i>
</a>
</div>
{{ block('hidden_widget') }}
{%- endblock yearpicker_widget %}
{% block monthpicker_widget -%}
<div class="btn-group">
<a class="btn btn-default btn-left" href="#" onclick="$('#{{ form.vars.id }}').val('{{ previousMonth|date_short }}').change()" data-toggle="tooltip" data-placement="top" title="{{ previousMonth|month_name(true) }}">

View File

@@ -82,9 +82,7 @@
<th class="text-nowrap text-center total">{{ total|duration }}</th>
{% for day in days %}
<th class="text-nowrap text-center day-total{% if day.day is weekend %} weekend{% endif %}">
{% if day.totalDuration > 0 %}
{{ day.totalDuration|duration }}
{% endif %}
{{ day.totalDuration|duration }}
</th>
{% endfor %}
</tr>

View File

@@ -17,6 +17,7 @@
{% endblock %}
{% block box_body_class %}{{ box_id }} table-responsive no-padding{% endblock %}
{% block box_body %}
{% set absoluteTotals = 0 %}
<table class="table table-bordered table-hover dataTable">
<tr>
<th>&nbsp;</th>
@@ -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 %}
<th class="text-nowrap text-center total">
{% if usersTotalDuration > 0 %}
{{ usersTotalDuration|duration }}
{% endif %}
{{ usersTotalDuration|duration }}
</th>
{% for day in userDay.days %}
<td class="text-nowrap day-total{% if day.day is weekend %} weekend{% endif %}">
<td class="text-nowrap text-center day-total{% if day.day is weekend %} weekend{% endif %}">
{% if day.totalDuration > 0 %}
{{ day.totalDuration|duration }}
{% endif %}
@@ -53,6 +53,17 @@
{% endfor %}
</tr>
{% endfor %}
<tr>
<th>&nbsp;</th>
<th class="text-center text-nowrap">
{{ absoluteTotals|duration }}
</th>
{% for id, duration in totals %}
<th class="text-center text-nowrap">
{{ duration|duration }}
</th>
{% endfor %}
</tr>
</table>
{% endblock %}
{% endembed %}

View File

@@ -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 %}
<table class="table table-bordered table-hover dataTable">
<tr>
<th>&nbsp;</th>
<th>&nbsp;</th>
{% for id, month in months %}
<th class="text-center text-nowrap">
<a href="{{ path('report_monthly_users', {'date': month|date_short}) }}">
{{ month|month_name }}<br>
{{ month|date_format('Y') }}
</a>
</th>
{% endfor %}
</tr>
{% for userYear in rows %}
{% set usersTotalDuration = 0 %}
<tr class="user">
<td class="text-nowrap">
<strong>{{ widgets.username(userYear.user) }}</strong>
</td>
{% for yid, year in userYear.years %}
{% for mid, month in year.months %}
{% if month.totalDuration > 0 %}
{% set usersTotalDuration = usersTotalDuration + month.totalDuration %}
{% endif %}
{% endfor %}
{% endfor %}
<th class="text-nowrap text-center total">
{{ usersTotalDuration|duration }}
</th>
{% for yid, year in userYear.years %}
{% for mid, month in year.months %}
<td class="text-nowrap text-center day-total">
{% if month.totalDuration > 0 %}
<a href="{{ path('report_user_month', {'date': (year.year ~'-'~month.month~'-01')|date_short, 'user': userYear.user.id}) }}" data-toggle="tooltip" title="{{ 'label.billable'|trans }}: {{ month.billableDuration|duration }}">
{{ month.totalDuration|duration }}
</a>
{% endif %}
</td>
{% endfor %}
{% endfor %}
</tr>
{% endfor %}
<tr>
<th>&nbsp;</th>
<th>&nbsp;</th>
{% for id, duration in totals %}
<th class="text-center text-nowrap">
{{ duration|duration }}
</th>
{% endfor %}
</tr>
</table>
{% endblock %}
{% endembed %}
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script type="text/javascript">
document.addEventListener('kimai.initialized', function() {
$('#{{ form.date.vars.id }}').on('change', function(ev) {
$(this).closest('form').submit();
});
});
</script>
{% endblock %}

View File

@@ -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') }}

View File

@@ -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 %}
<div class="row">

View File

@@ -82,7 +82,7 @@
}
</script>
{% 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 %}
],

View File

@@ -56,12 +56,21 @@
<span class="description-text">{{ 'stats.workingTimeMonth'|trans({'%month%': data.thisMonth|month_name, '%year%': data.thisMonth|date_format('Y')}) }}</span>
</div>
</div>
{% if data.financial is not null %}
<div class="col-sm-3 col-xs-6">
<div class="description-block border-right">
<h5 class="description-header">{{ data.financial|duration }}</h5>
<span class="description-text">{{ 'stats.workingTimeFinancialYear'|trans }}</span>
</div>
</div>
{% else %}
<div class="col-sm-3 col-xs-6">
<div class="description-block border-right">
<h5 class="description-header">{{ data.year|duration }}</h5>
<span class="description-text">{{ 'stats.workingTimeYear'|trans({'%year%': data.thisMonth|date_format('Y')}) }}</span>
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% endembed %}

View File

@@ -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()

View File

@@ -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'),

View File

@@ -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('<h3 class="box-title">' . $year . '</h3>', $content);
$this->assertStringContainsString('var userProfileChart' . $year . ' = new Chart(', $content);
}
public function testIndexAction()

View File

@@ -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('<div class="box-body yearly-user-list-reporting-box', $client->getResponse()->getContent());
$select = $client->getCrawler()->filterXPath("//select[@id='user']");
self::assertEquals(0, $select->count());
}
public function testWeeklyUsersReport()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);

View File

@@ -428,6 +428,9 @@ class ConfigurationTest extends TestCase
'connection' => [
'organization' => []
],
],
'company' => [
'financial_year' => null,
]
];

View File

@@ -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()

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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');

View File

@@ -0,0 +1,62 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Widget\Type;
use App\Configuration\SystemConfiguration;
use App\Repository\TimesheetRepository;
use App\Widget\Type\AbstractWidgetType;
use App\Widget\Type\ActiveUsersYear;
use App\Widget\Type\CounterYear;
use App\Widget\Type\SimpleStatisticChart;
/**
* @covers \App\Widget\Type\ActiveUsersYear
* @covers \App\Widget\Type\CounterYear
*/
class ActiveUsersYearTest extends AbstractWidgetTypeTest
{
/**
* @return CounterYear
*/
public function createSut(): AbstractWidgetType
{
$repository = $this->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());
}
}

View File

@@ -0,0 +1,62 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Widget\Type;
use App\Configuration\SystemConfiguration;
use App\Repository\TimesheetRepository;
use App\Widget\Type\AbstractWidgetType;
use App\Widget\Type\AmountYear;
use App\Widget\Type\CounterYear;
use App\Widget\Type\SimpleStatisticChart;
/**
* @covers \App\Widget\Type\AmountYear
* @covers \App\Widget\Type\CounterYear
*/
class AmountYearTest extends AbstractWidgetTypeTest
{
/**
* @return CounterYear
*/
public function createSut(): AbstractWidgetType
{
$repository = $this->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());
}
}

View File

@@ -0,0 +1,101 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Widget\Type;
use App\Configuration\SystemConfiguration;
use App\Entity\User;
use App\Repository\TimesheetRepository;
use App\Widget\Type\AbstractWidgetType;
use App\Widget\Type\Counter;
use App\Widget\Type\CounterYear;
use App\Widget\Type\SimpleWidget;
use DateTime;
/**
* @covers \App\Widget\Type\CounterYear
* @covers \App\Widget\Type\SimpleStatisticChart
* @covers \App\Widget\Type\SimpleWidget
*/
class CounterYearTest extends AbstractSimpleStatisticsWidgetTypeTest
{
public function createSut(?string $financialYear = null): AbstractWidgetType
{
$configuration = $this->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());
}
}

View File

@@ -0,0 +1,62 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Widget\Type;
use App\Configuration\SystemConfiguration;
use App\Repository\TimesheetRepository;
use App\Widget\Type\AbstractWidgetType;
use App\Widget\Type\CounterYear;
use App\Widget\Type\DurationYear;
use App\Widget\Type\SimpleStatisticChart;
/**
* @covers \App\Widget\Type\DurationYear
* @covers \App\Widget\Type\CounterYear
*/
class DurationYearTest extends AbstractWidgetTypeTest
{
/**
* @return CounterYear
*/
public function createSut(): AbstractWidgetType
{
$repository = $this->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());
}
}

View File

@@ -0,0 +1,199 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Widget\Type;
use App\Configuration\SystemConfiguration;
use App\Entity\Activity;
use App\Entity\Project;
use App\Entity\User;
use App\Repository\TimesheetRepository;
use App\Widget\Type\AbstractWidgetType;
use App\Widget\Type\PaginatedWorkingTimeChart;
use App\Widget\Type\SimpleWidget;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Widget\Type\PaginatedWorkingTimeChart
* @covers \App\Widget\Type\SimpleWidget
* @covers \App\Widget\Type\AbstractWidgetType
* @covers \App\Repository\TimesheetRepository
*/
class PaginatedWorkingTimeChartTest extends TestCase
{
/**
* @return PaginatedWorkingTimeChart
*/
public function createSut(): AbstractWidgetType
{
$repository = $this->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']);
}
}

View File

@@ -0,0 +1,62 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Widget\Type;
use App\Configuration\SystemConfiguration;
use App\Repository\TimesheetRepository;
use App\Widget\Type\AbstractWidgetType;
use App\Widget\Type\CounterYear;
use App\Widget\Type\SimpleStatisticChart;
use App\Widget\Type\UserAmountYear;
/**
* @covers \App\Widget\Type\UserAmountYear
* @covers \App\Widget\Type\CounterYear
*/
class UserAmountYearTest extends AbstractWidgetTypeTest
{
/**
* @return CounterYear
*/
public function createSut(): AbstractWidgetType
{
$repository = $this->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());
}
}

View File

@@ -0,0 +1,62 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Widget\Type;
use App\Configuration\SystemConfiguration;
use App\Repository\TimesheetRepository;
use App\Widget\Type\AbstractWidgetType;
use App\Widget\Type\CounterYear;
use App\Widget\Type\SimpleStatisticChart;
use App\Widget\Type\UserDurationYear;
/**
* @covers \App\Widget\Type\UserDurationYear
* @covers \App\Widget\Type\CounterYear
*/
class UserDurationYearTest extends AbstractWidgetTypeTest
{
/**
* @return CounterYear
*/
public function createSut(): AbstractWidgetType
{
$repository = $this->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());
}
}

View File

@@ -806,6 +806,10 @@
<source>stats.workingTimeYear</source>
<target>Gesamtes Jahr %year%</target>
</trans-unit>
<trans-unit id="stats.workingTimeFinancialYear">
<source>stats.workingTimeFinancialYear</source>
<target>Geschäftsjahr</target>
</trans-unit>
<trans-unit id="stats.durationToday">
<source>stats.durationToday</source>
<target>Arbeitszeit heute</target>
@@ -822,6 +826,10 @@
<source>stats.durationYear</source>
<target>Arbeitszeit dieses Jahr</target>
</trans-unit>
<trans-unit id="stats.durationFinancialYear">
<source>stats.durationFinancialYear</source>
<target>Arbeitszeit dieses Geschäftsjahr</target>
</trans-unit>
<trans-unit id="stats.durationTotal">
<source>stats.durationTotal</source>
<target>Arbeitszeit total</target>
@@ -846,6 +854,10 @@
<source>stats.amountYear</source>
<target>Umsatz dieses Jahr</target>
</trans-unit>
<trans-unit id="stats.amountFinancialYear">
<source>stats.amountFinancialYear</source>
<target>Umsatz dieses Geschäftsjahr</target>
</trans-unit>
<trans-unit id="stats.amountTotal">
<source>stats.amountTotal</source>
<target>Umsatz total</target>
@@ -866,6 +878,10 @@
<source>stats.userActiveYear</source>
<target>Aktive Benutzer dieses Jahr</target>
</trans-unit>
<trans-unit id="stats.userActiveFinancialYear">
<source>stats.userActiveFinancialYear</source>
<target>Aktive Benutzer dieses Geschäftsjahr</target>
</trans-unit>
<trans-unit id="stats.userActiveTotal">
<source>stats.userActiveTotal</source>
<target>Aktive Benutzer jemals</target>

View File

@@ -820,6 +820,10 @@
<source>stats.workingTimeYear</source>
<target>Full year %year%</target>
</trans-unit>
<trans-unit id="stats.workingTimeFinancialYear">
<source>stats.workingTimeFinancialYear</source>
<target>Financial year</target>
</trans-unit>
<trans-unit id="stats.durationToday">
<source>stats.durationToday</source>
<target>Working hours today</target>
@@ -836,6 +840,10 @@
<source>stats.durationYear</source>
<target>Working hours this year</target>
</trans-unit>
<trans-unit id="stats.durationFinancialYear">
<source>stats.durationFinancialYear</source>
<target>Working hours this financial year</target>
</trans-unit>
<trans-unit id="stats.durationTotal">
<source>stats.durationTotal</source>
<target>Working hours total</target>
@@ -860,6 +868,10 @@
<source>stats.amountYear</source>
<target>Revenue this year</target>
</trans-unit>
<trans-unit id="stats.amountFinancialYear">
<source>stats.amountFinancialYear</source>
<target>Revenue this financial year</target>
</trans-unit>
<trans-unit id="stats.amountTotal">
<source>stats.amountTotal</source>
<target>Total revenue</target>
@@ -880,6 +892,10 @@
<source>stats.userActiveYear</source>
<target>Active users this year</target>
</trans-unit>
<trans-unit id="stats.userActiveFinancialYear">
<source>stats.userActiveFinancialYear</source>
<target>Active users this financial year</target>
</trans-unit>
<trans-unit id="stats.userActiveTotal">
<source>stats.userActiveTotal</source>
<target>Active users ever</target>

View File

@@ -18,6 +18,10 @@
<source>report_monthly_users</source>
<target>Monatsansicht für alle Benutzer</target>
</trans-unit>
<trans-unit id="report_yearly_users">
<source>report_yearly_users</source>
<target>Jahresansicht für alle Benutzer</target>
</trans-unit>
<trans-unit id="report_project_view">
<source>report_project_view</source>
<target>Projektübersicht</target>

View File

@@ -18,6 +18,10 @@
<source>report_monthly_users</source>
<target>Monthly view for all users</target>
</trans-unit>
<trans-unit id="report_yearly_users">
<source>report_yearly_users</source>
<target>Yearly view for all users</target>
</trans-unit>
<trans-unit id="report_project_view">
<source>report_project_view</source>
<target>Project overview</target>

View File

@@ -128,15 +128,15 @@
</trans-unit>
<trans-unit id="branding">
<source>branding</source>
<target>Markendarstellung</target>
<target>Mein Unternehmen</target>
</trans-unit>
<trans-unit id="label.theme.branding.logo">
<source>label.theme.branding.logo</source>
<target>Logo (Bild URL, ersetzt das Unternehmen im Anmeldebildschirm)</target>
<target>Logo URL (ersetzt den Unternehmensnamen im Anmeldebildschirm)</target>
</trans-unit>
<trans-unit id="label.theme.branding.company">
<source>label.theme.branding.company</source>
<target>Unternehmen</target>
<target>Unternehmensname</target>
</trans-unit>
<trans-unit id="label.theme.branding.mini">
<source>label.theme.branding.mini</source>
@@ -146,6 +146,10 @@
<source>label.theme.branding.title</source>
<target>Browser Titel</target>
</trans-unit>
<trans-unit id="label.company.financial_year">
<source>label.company.financial_year</source>
<target>Geschäftsjahr</target>
</trans-unit>
<trans-unit id="rounding">
<source>rounding</source>
<target>Zeitenrundung</target>

View File

@@ -128,15 +128,15 @@
</trans-unit>
<trans-unit id="branding">
<source>branding</source>
<target>Branding</target>
<target>My company</target>
</trans-unit>
<trans-unit id="label.theme.branding.logo">
<source>label.theme.branding.logo</source>
<target>Logo (Image URL, replaces the company in login screen)</target>
<target>Logo URL (replaces the company name in login screen)</target>
</trans-unit>
<trans-unit id="label.theme.branding.company">
<source>label.theme.branding.company</source>
<target>Company</target>
<target>Company name</target>
</trans-unit>
<trans-unit id="label.theme.branding.mini">
<source>label.theme.branding.mini</source>
@@ -146,6 +146,10 @@
<source>label.theme.branding.title</source>
<target>Browser Title</target>
</trans-unit>
<trans-unit id="label.company.financial_year">
<source>label.company.financial_year</source>
<target>Financial year</target>
</trans-unit>
<trans-unit id="rounding">
<source>rounding</source>
<target>Time rounding</target>