added project detail report (#2651)

- avatars via css only
- random colors for entities
- improves print view
- added colors for teams
- added color to tag
This commit is contained in:
Kevin Papst
2021-07-06 22:01:20 +02:00
committed by GitHub
parent cc6031bfde
commit 9ddb87a6fd
122 changed files with 2736 additions and 1549 deletions

View File

@@ -362,6 +362,16 @@ class SystemConfiguration implements SystemBundleConfiguration
return (bool) $this->find('theme.colors_limited');
}
public function isThemeRandomColors(): bool
{
return (bool) $this->find('theme.random_colors');
}
public function isThemeAllowAvatarUrls(): bool
{
return (bool) $this->find('theme.avatar_url');
}
public function getThemeAutocompleteCharacters(): int
{
return (int) $this->find('theme.autocomplete_chars');
@@ -384,11 +394,6 @@ class SystemConfiguration implements SystemBundleConfiguration
return $this->find('theme.branding.title');
}
public function getBrandingCompany(): ?string
{
return $this->find('theme.branding.company');
}
public function isAllowTagCreation(): bool
{
return (bool) $this->find('theme.tags_create');

View File

@@ -43,7 +43,6 @@ class DoctorController extends AbstractController
'var/data/',
'var/log/',
'var/sessions/',
'public/avatars/',
];
/**

View File

@@ -62,7 +62,6 @@ final class ProfileController extends AbstractController
'user' => $profile,
'stats' => $userStats,
'years' => $monthlyStats,
'stat_date_format' => $localeSettings->getDatePickerFormat(),
];
return $this->render('user/stats.html.twig', $viewVars);
@@ -111,7 +110,7 @@ final class ProfileController extends AbstractController
return $this->redirectToRoute('user_profile_password', ['username' => $profile->getUsername()]);
}
return $this->render('user/profile.html.twig', [
return $this->render('user/form.html.twig', [
'tab' => 'password',
'user' => $profile,
'form' => $form->createView(),
@@ -169,7 +168,7 @@ final class ProfileController extends AbstractController
return $this->redirectToRoute('user_profile_roles', ['username' => $profile->getUsername()]);
}
return $this->render('user/profile.html.twig', [
return $this->render('user/form.html.twig', [
'tab' => 'roles',
'user' => $profile,
'form' => $form->createView(),
@@ -195,7 +194,7 @@ final class ProfileController extends AbstractController
return $this->redirectToRoute('user_profile_teams', ['username' => $profile->getUsername()]);
}
return $this->render('user/profile.html.twig', [
return $this->render('user/form.html.twig', [
'tab' => 'teams',
'user' => $profile,
'form' => $form->createView(),
@@ -276,7 +275,7 @@ final class ProfileController extends AbstractController
}
}
return $this->render('user/form.html.twig', [
return $this->render('user/preferences.html.twig', [
'tab' => 'preferences',
'user' => $profile,
'form' => $form->createView(),

View File

@@ -0,0 +1,53 @@
<?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\Controller\Reporting;
use App\Controller\AbstractController;
use App\Entity\Project;
use App\Project\ProjectStatisticService;
use App\Reporting\ProjectDetails\ProjectDetailsForm;
use App\Reporting\ProjectDetails\ProjectDetailsQuery;
use App\Utils\LocaleSettings;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
final class ProjectDetailsController extends AbstractController
{
/**
* @Route(path="/reporting/project_details", name="report_project_details", methods={"GET"})
* @Security("is_granted('view_reporting') and is_granted('details_project')")
*/
public function __invoke(Request $request, ProjectStatisticService $service, LocaleSettings $localeSettings)
{
$dateFactory = $this->getDateTimeFactory();
$user = $this->getUser();
$query = new ProjectDetailsQuery($dateFactory->createDateTime(), $user);
$form = $this->createForm(ProjectDetailsForm::class, $query);
$form->submit($request->query->all(), false);
$projectView = null;
$projectDetails = null;
if ($query->getProject() !== null && $this->isGranted('details', $query->getProject())) {
$projectViews = $service->getProjectView($user, [$query->getProject()], $query->getToday());
$projectView = $projectViews[0];
$projectDetails = $service->getProjectsDetails($query);
}
return $this->render('reporting/project_details.html.twig', [
'project_view' => $projectView,
'project_details' => $projectDetails,
'form' => $form->createView(),
'now' => $this->getDateTimeFactory()->createDateTime(),
]);
}
}

View File

@@ -531,6 +531,20 @@ final class SystemConfigurationController extends AbstractController
->setOptions(['help' => 'help.theme.color_choices'])
->setConstraints([new ColorChoices()])
->setTranslationDomain('system-configuration'),
// allow avatar URLs
(new Configuration())
->setName('theme.avatar_url')
->setRequired(false)
->setLabel('theme.avatar_url')
->setType(CheckboxType::class)
->setTranslationDomain('system-configuration'),
// random colors as fallback
(new Configuration())
->setName('theme.random_colors')
->setRequired(false)
->setLabel('theme.random_colors')
->setType(CheckboxType::class)
->setTranslationDomain('system-configuration'),
]),
(new SystemConfigurationModel())
->setSection(SystemConfigurationModel::SECTION_CALENDAR)

View File

@@ -9,8 +9,8 @@
namespace App\DependencyInjection\Compiler;
use App\Configuration\SystemConfiguration;
use App\Configuration\ThemeConfiguration;
use App\Twig\Configuration;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
@@ -27,10 +27,11 @@ class TwigContextCompilerPass implements CompilerPassInterface
{
$twig = $container->getDefinition('twig');
// @deprecated since 1.15
$theme = $container->getDefinition(ThemeConfiguration::class);
$twig->addMethodCall('addGlobal', ['kimai_context', $theme]);
$config = $container->getDefinition(SystemConfiguration::class);
$config = $container->getDefinition(Configuration::class);
$twig->addMethodCall('addGlobal', ['kimai_config', $config]);
$definition = $container->getDefinition('twig.loader.native_filesystem');

View File

@@ -475,6 +475,12 @@ class Configuration implements ConfigurationInterface
->integerNode('autocomplete_chars')
->defaultValue(3)
->end()
->booleanNode('random_colors')
->defaultTrue()
->end()
->booleanNode('avatar_url')
->defaultFalse()
->end()
->end()
;

View File

@@ -51,12 +51,9 @@ trait ColorTrait
/**
* @param string $color
* @return self
*/
public function setColor(?string $color = null)
public function setColor(?string $color = null): void
{
$this->color = $color;
return $this;
}
}

View File

@@ -129,6 +129,8 @@ class Team
*/
private $activities;
use ColorTrait;
public function __construct()
{
$this->users = new ArrayCollection();

View File

@@ -280,6 +280,8 @@ class User implements UserInterface, EquatableInterface, \Serializable
*/
private $roles = [];
use ColorTrait;
public function __construct()
{
$this->registeredAt = new DateTime();
@@ -896,4 +898,40 @@ class User implements UserInterface, EquatableInterface, \Serializable
{
return $this->getDisplayName();
}
public function getInitials(): string
{
$length = 2;
$name = $this->getDisplayName();
$initial = '';
if (filter_var($name, FILTER_VALIDATE_EMAIL)) {
// turn my.email@gmail.com into "My Email"
$result = mb_strstr($name, '@', true);
$name = $result === false ? $name : $result;
$name = str_replace('.', ' ', $name);
}
$words = explode(' ', $name);
// if name contains single word, use first N character
if (\count($words) === 1) {
$initial = $words[0];
if (mb_strlen($name) >= $length) {
$initial = mb_substr($name, 0, $length, 'UTF-8');
}
} else {
// otherwise, use initial char from each word
foreach ($words as $word) {
$initial .= mb_substr($word, 0, 1, 'UTF-8');
}
$initial = mb_substr($initial, 0, $length, 'UTF-8');
}
$initial = mb_strtoupper($initial);
return $initial;
}
}

View File

@@ -71,5 +71,9 @@ class ProjectSubscriber extends AbstractActionsSubscriber
if (($event->isIndexView() || $event->isView('customer_details')) && $this->isGranted('delete', $project)) {
$event->addDelete($this->path('admin_project_delete', ['id' => $project->getId()]));
}
if ($this->isGranted('view_reporting') && $this->isGranted('details_project')) {
$event->addAction('report_project_details', ['url' => $this->path('report_project_details', ['project' => $project->getId()]), 'icon' => 'reporting', 'translation_domain' => 'reporting']);
}
}
}

View File

@@ -17,8 +17,6 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class ActivityTeamPermissionForm extends AbstractType
{
use EntityFormTrait;
/**
* {@inheritdoc}
*/

51
src/Form/ColorTrait.php Normal file
View File

@@ -0,0 +1,51 @@
<?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;
use App\Form\Type\ColorChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
trait ColorTrait
{
protected function addColor(FormBuilderInterface $builder): void
{
$builder
->add('color', ColorChoiceType::class, [
'required' => false,
])
;
// this code exists only for backward compatibility
$builder->addEventListener(
FormEvents::PRE_SET_DATA,
function (FormEvent $event) {
if (!$event->getForm()->getConfig()->hasOption('choices')) {
return;
}
$data = $event->getData();
$choices = $event->getForm()->getConfig()->getOption('choices');
if (\is_object($data) && method_exists($data, 'getColor')) {
$color = $data->getColor();
if (!empty($color) && array_search($color, $choices) === false) {
$choices[$color] = $color;
}
}
$event->getForm()->add('color', ColorChoiceType::class, [
'required' => false,
'choices' => $choices,
]);
}
);
}
}

View File

@@ -17,8 +17,6 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class CustomerTeamPermissionForm extends AbstractType
{
use EntityFormTrait;
/**
* {@inheritdoc}
*/

View File

@@ -9,18 +9,17 @@
namespace App\Form;
use App\Form\Type\ColorChoiceType;
use App\Form\Type\DurationType;
use App\Form\Type\MetaFieldsCollectionType;
use App\Form\Type\YesNoType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
trait EntityFormTrait
{
use ColorTrait;
public function addCommonFields(FormBuilderInterface $builder, array $options): void
{
$this->addColor($builder);
@@ -50,39 +49,10 @@ trait EntityFormTrait
]);
}
public function addColor(FormBuilderInterface $builder): void
{
$builder
->add('color', ColorChoiceType::class, [
'required' => false,
])
;
// this code exists only for backward compatibility
$builder->addEventListener(
FormEvents::PRE_SET_DATA,
function (FormEvent $event) use ($builder) {
if (!$builder->get('color')->hasOption('choices')) {
return;
}
$data = $event->getData();
$choices = $builder->get('color')->getOption('choices');
if (\is_object($data) && method_exists($data, 'getColor')) {
$color = $data->getColor();
if (!empty($color) && array_search($color, $choices) === false) {
$choices[$color] = $color;
}
}
$event->getForm()->add('color', ColorChoiceType::class, [
'required' => false,
'choices' => $choices,
]);
}
);
}
/**
* @deprecated since 1.15
* @param FormBuilderInterface $builder
*/
public function addCreateMore(FormBuilderInterface $builder): void
{
$builder->add('create_more', CheckboxType::class, [

View File

@@ -17,8 +17,6 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class ProjectTeamPermissionForm extends AbstractType
{
use EntityFormTrait;
/**
* {@inheritdoc}
*/

View File

@@ -10,7 +10,6 @@
namespace App\Form;
use App\Entity\Tag;
use App\Form\Type\ColorPickerType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
@@ -18,6 +17,8 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class TagEditForm extends AbstractType
{
use ColorTrait;
/**
* {@inheritdoc}
*/
@@ -34,7 +35,8 @@ class TagEditForm extends AbstractType
'description' => 'The tag name (forbidden character: comma)',
],
])
->add('color', ColorPickerType::class);
;
$this->addColor($builder);
}
/**

View File

@@ -18,6 +18,8 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class TeamEditForm extends AbstractType
{
use ColorTrait;
/**
* {@inheritdoc}
*/
@@ -37,7 +39,9 @@ class TeamEditForm extends AbstractType
'type' => 'string',
'description' => 'Name of the team',
],
])
]);
$this->addColor($builder);
$builder
->add('teamlead', UserType::class, [
'label' => 'label.teamlead',
'multiple' => false,

View File

@@ -67,6 +67,7 @@ class ProjectType extends AbstractType
'activity_select' => 'activity',
'activity_visibility' => ActivityQuery::SHOW_VISIBLE,
'ignore_date' => false,
'join_customer' => false,
]);
$resolver->setDefault('query_builder', function (Options $options) {
@@ -78,6 +79,9 @@ class ProjectType extends AbstractType
if (true === $options['ignore_date']) {
$query->setIgnoreDate(true);
}
if (true === $options['join_customer']) {
$query->setWithCustomer(true);
}
return $repo->getQueryBuilderForFormType($query);
};

View File

@@ -9,6 +9,7 @@
namespace App\Form;
use App\Configuration\SystemConfiguration;
use App\Entity\User;
use App\Form\Type\AvatarType;
use App\Form\Type\LanguageType;
@@ -25,6 +26,15 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
*/
class UserEditType extends AbstractType
{
use ColorTrait;
private $configuration;
public function __construct(SystemConfiguration $configuration)
{
$this->configuration = $configuration;
}
/**
* {@inheritdoc}
*/
@@ -39,9 +49,17 @@ class UserEditType extends AbstractType
'label' => 'label.title',
'required' => false,
])
->add('avatar', AvatarType::class, [
;
if ($this->configuration->isThemeAllowAvatarUrls()) {
$builder->add('avatar', AvatarType::class, [
'required' => false,
])
]);
}
$this->addColor($builder);
$builder
->add('email', EmailType::class, [
'label' => 'label.email',
])

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/*
* 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 DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20210704111542 extends AbstractMigration
{
public function getDescription(): string
{
return 'Creates the color columns on: user, team';
}
public function up(Schema $schema): void
{
$users = $schema->getTable('kimai2_users');
$users->addColumn('color', 'string', ['length' => 7, 'notnull' => false, 'default' => null]);
$teams = $schema->getTable('kimai2_teams');
$teams->addColumn('color', 'string', ['length' => 7, 'notnull' => false, 'default' => null]);
}
public function down(Schema $schema): void
{
$users = $schema->getTable('kimai2_users');
$users->dropColumn('color');
$teams = $schema->getTable('kimai2_teams');
$teams->dropColumn('color');
}
}

View File

@@ -9,6 +9,58 @@
namespace App\Model;
class ActivityStatistic extends TimesheetCountedStatistic
use App\Entity\Activity;
class ActivityStatistic extends TimesheetCountedStatistic implements \JsonSerializable
{
/**
* @var Activity
*/
private $activity;
public function getActivity(): ?Activity
{
return $this->activity;
}
public function setActivity(Activity $activity): void
{
$this->activity = $activity;
}
/**
* Added for simpler re-use in frontend (charts).
*
* @return string|null
*/
public function getColor(): ?string
{
if ($this->activity === null) {
return null;
}
return $this->activity->getColor();
}
/**
* Added for simpler re-use in frontend (charts).
*
* @return string|null
*/
public function getName(): ?string
{
if ($this->activity === null) {
return null;
}
return $this->activity->getName();
}
public function jsonSerialize()
{
return array_merge(parent::jsonSerialize(), [
'name' => $this->getName(),
'color' => $this->getColor(),
]);
}
}

View File

@@ -11,20 +11,12 @@ namespace App\Model\Statistic;
use DateTime;
class Day
class Day extends Timesheet
{
/**
* @var int
*/
protected $totalDuration = 0;
/**
* @var int|null
*/
private $totalDurationBillable = 0;
/**
* @var float
*/
protected $totalRate = 0.00;
/**
* @var DateTime
*/
@@ -37,8 +29,8 @@ class Day
public function __construct(DateTime $day, int $duration, float $rate)
{
$this->day = $day;
$this->totalDuration = $duration;
$this->totalRate = $rate;
$this->setTotalDuration($duration);
$this->setTotalRate($rate);
}
public function getDay(): DateTime
@@ -46,18 +38,6 @@ class Day
return $this->day;
}
public function getTotalDuration(): int
{
return $this->totalDuration;
}
public function setTotalDuration(int $seconds): Day
{
$this->totalDuration = $seconds;
return $this;
}
public function getTotalDurationBillable(): int
{
return $this->totalDurationBillable;
@@ -68,18 +48,6 @@ class Day
$this->totalDurationBillable = $seconds;
}
public function getTotalRate(): float
{
return $this->totalRate;
}
public function setTotalRate(float $totalRate): Day
{
$this->totalRate = $totalRate;
return $this;
}
public function setDetails(array $details): Day
{
$this->details = $details;

View File

@@ -11,11 +11,9 @@ namespace App\Model\Statistic;
use InvalidArgumentException;
final class Month
final class Month extends Timesheet
{
private $month;
private $totalDuration = 0;
private $totalRate = 0.00;
private $billableDuration = 0;
private $billableRate = 0.00;
@@ -40,30 +38,6 @@ final class Month
return (int) $this->month;
}
public function getTotalDuration(): int
{
return $this->totalDuration;
}
public function setTotalDuration(int $seconds): Month
{
$this->totalDuration = $seconds;
return $this;
}
public function getTotalRate(): float
{
return $this->totalRate;
}
public function setTotalRate(float $totalRate): Month
{
$this->totalRate = $totalRate;
return $this;
}
public function getBillableDuration(): int
{
return $this->billableDuration;

View File

@@ -0,0 +1,87 @@
<?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\Model\Statistic;
class Timesheet
{
private $totalDuration = 0;
private $totalRate = 0.00;
private $totalInternalRate = 0.00;
/**
* For unified access, used in frontend.
*
* @return int
*/
public function getValue(): int
{
return $this->totalDuration;
}
/**
* For unified access, used in frontend.
*
* @return int
*/
public function getDuration(): int
{
return $this->totalDuration;
}
public function getTotalDuration(): int
{
return $this->totalDuration;
}
public function setTotalDuration(int $totalDuration): void
{
$this->totalDuration = $totalDuration;
}
/**
* For unified access, used in frontend.
*
* @return float
*/
public function getRate(): float
{
return $this->totalRate;
}
public function getTotalRate(): float
{
return $this->totalRate;
}
public function setTotalRate(float $totalRate): void
{
$this->totalRate = $totalRate;
}
/**
* For unified access, used in frontend.
*
* @return float
*/
public function getInternalRate(): float
{
return $this->totalInternalRate;
}
public function getTotalInternalRate(): float
{
return $this->totalInternalRate;
}
public function setTotalInternalRate(float $totalInternalRate): void
{
$this->totalInternalRate = $totalInternalRate;
}
}

View File

@@ -0,0 +1,60 @@
<?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\Model\Statistic;
use App\Entity\User;
final class UserYear
{
/**
* @var Year
*/
private $year;
/**
* @var User
*/
private $user;
public function __construct(User $user, Year $year)
{
$this->user = $user;
$this->year = $year;
}
public function getYear(): Year
{
return $this->year;
}
public function getUser(): User
{
return $this->user;
}
public function getDuration(): int
{
$duration = 0;
foreach ($this->year->getMonths() as $month) {
$duration += $month->getDuration();
}
return $duration;
}
public function getRate(): float
{
$rate = 0;
foreach ($this->year->getMonths() as $month) {
$rate += $month->getRate();
}
return $rate;
}
}

View File

@@ -9,19 +9,13 @@
namespace App\Model\Statistic;
/**
* Yearly statistics
*/
class Year
final class Year extends Timesheet
{
/**
* @var string
*/
protected $year;
private $year;
/**
* @var Month[]
*/
protected $months = [];
private $months = [];
public function __construct(string $year)
{

View File

@@ -9,7 +9,7 @@
namespace App\Model;
class TimesheetCountedStatistic
class TimesheetCountedStatistic implements \JsonSerializable
{
private $recordAmount = 0;
private $recordDuration = 0;
@@ -40,6 +40,26 @@ class TimesheetCountedStatistic
return $this;
}
/**
* For unified access, used in frontend.
*
* @return int
*/
public function getValue(): int
{
return $this->recordDuration;
}
/**
* For unified access, used in frontend.
*
* @return int
*/
public function getDuration(): int
{
return $this->recordDuration;
}
/**
* Returns the total duration of all included timesheet records.
*
@@ -61,6 +81,16 @@ class TimesheetCountedStatistic
return $this;
}
/**
* For unified access, used in frontend.
*
* @return float
*/
public function getRate(): float
{
return $this->recordRate;
}
/**
* Returns the total rate of all included timesheet records.
*
@@ -132,4 +162,17 @@ class TimesheetCountedStatistic
{
$this->recordRateBillable = $recordRate;
}
public function jsonSerialize()
{
return [
'duration' => $this->recordDuration,
'duration_billable' => $this->recordDurationBillable,
'rate' => $this->recordRate,
'rate_billable' => $this->recordRateBillable,
'rate_internal' => $this->recordInternalRate,
'amount' => $this->recordAmount,
'amount_billable' => $this->recordAmountBillable,
];
}
}

View File

@@ -0,0 +1,40 @@
<?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\Model;
use App\Entity\User;
use App\Model\Statistic\Month;
class UserStatistic extends TimesheetCountedStatistic
{
/**
* @var User
*/
private $user;
public function __construct(User $user)
{
$this->user = $user;
}
public function getUser(): User
{
return $this->user;
}
public function addValuesFromMonth(Month $month): void
{
$this->setRecordDuration($this->getRecordDuration() + $month->getTotalDuration());
$this->setDurationBillable($this->getDurationBillable() + $month->getBillableDuration());
$this->setRecordRate($this->getRate() + $month->getTotalRate());
$this->setRateBillable($this->getRateBillable() + $month->getBillableRate());
$this->setRecordInternalRate($this->getRecordInternalRate() + $month->getTotalInternalRate());
}
}

View File

@@ -9,19 +9,28 @@
namespace App\Project;
use App\Entity\Activity;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Event\ProjectStatisticEvent;
use App\Model\ActivityStatistic;
use App\Model\ProjectStatistic;
use App\Model\Statistic\Month;
use App\Model\Statistic\Year;
use App\Model\UserStatistic;
use App\Reporting\ProjectDetails\ProjectDetailsModel;
use App\Reporting\ProjectDetails\ProjectDetailsQuery;
use App\Reporting\ProjectInactive\ProjectInactiveQuery;
use App\Reporting\ProjectView\ProjectViewModel;
use App\Reporting\ProjectView\ProjectViewQuery;
use App\Repository\ProjectRepository;
use App\Repository\TimesheetRepository;
use App\Repository\UserRepository;
use App\Timesheet\DateTimeFactory;
use DateTime;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Query\Expr\Join;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
/**
@@ -32,12 +41,14 @@ class ProjectStatisticService
private $repository;
private $timesheetRepository;
private $dispatcher;
private $userRepository;
public function __construct(ProjectRepository $projectRepository, TimesheetRepository $timesheetRepository, EventDispatcherInterface $dispatcher)
public function __construct(ProjectRepository $projectRepository, TimesheetRepository $timesheetRepository, EventDispatcherInterface $dispatcher, UserRepository $userRepository)
{
$this->repository = $projectRepository;
$this->timesheetRepository = $timesheetRepository;
$this->dispatcher = $dispatcher;
$this->userRepository = $userRepository;
}
public function getProjectStatistics(Project $project, ?DateTime $end = null): ProjectStatistic
@@ -89,6 +100,137 @@ class ProjectStatisticService
return $qb->getQuery()->getResult();
}
/**
* @param ProjectDetailsQuery $query
* @return ProjectDetailsModel
*/
public function getProjectsDetails(ProjectDetailsQuery $query): ProjectDetailsModel
{
$model = new ProjectDetailsModel($query->getProject());
$years = [];
$qb = $this->timesheetRepository->createQueryBuilder('t');
$qb
->select('SUM(t.duration) as duration')
->addSelect('SUM(t.rate) as rate')
->addSelect('SUM(t.internalRate) as internalRate')
->addSelect('COUNT(t.id) as count')
->andWhere('t.project = :project')
->setParameter('project', $query->getProject())
;
// fetch stats grouped by ACTIVITY for all time
$qb1 = clone $qb;
$qb1
->leftJoin(Activity::class, 'a', Join::WITH, 'a.id = t.activity')
->addSelect('a as activity')
->addGroupBy('a')
;
foreach ($qb1->getQuery()->getResult() as $tmp) {
$activity = new ActivityStatistic();
$activity->setActivity($tmp['activity']);
$activity->setRecordRate($tmp['rate']);
$activity->setRecordDuration($tmp['duration']);
$activity->setRecordInternalRate($tmp['internalRate']);
$activity->setRecordAmount($tmp['count']);
$model->addActivity($activity);
}
// ---------------------------------------------------
// fetch stats grouped by YEAR, MONTH and USER
$qb1 = clone $qb;
$qb1
->addSelect('YEAR(t.begin) as year')
->addSelect('MONTH(t.begin) as month')
->addSelect('IDENTITY(t.user) as user')
->addGroupBy('year')
->addGroupBy('month')
->addGroupBy('user')
;
$userMonths = $qb1->getQuery()->getResult();
$userIds = array_unique(array_column($userMonths, 'user'));
$qb2 = $this->userRepository->createQueryBuilder('u');
$qb2->select('u')->where($qb2->expr()->in('u.id', $userIds));
$users = [];
foreach ($qb2->getQuery()->getResult() as $user) {
$users[$user->getId()] = new UserStatistic($user);
}
foreach ($userMonths as $tmp) {
$user = $users[$tmp['user']]->getUser();
$year = $model->getUserYear($tmp['year'], $user);
if ($year === null) {
$year = new Year($tmp['year']);
$model->setUserYear($year, $user);
}
$month = new Month($tmp['month']);
$month->setTotalRate($tmp['rate']);
$month->setTotalDuration($tmp['duration']);
$month->setTotalInternalRate($tmp['internalRate']);
$year->setMonth($month);
$users[$tmp['user']]->addValuesFromMonth($month);
}
// ---------------------------------------------------
// fetch stats grouped by YEARS
$qb1 = clone $qb;
$qb1
->addSelect('YEAR(t.begin) as year')
->addGroupBy('year')
;
foreach ($qb1->getQuery()->getResult() as $year) {
$tmp = new Year($year['year']);
$tmp->setTotalRate($year['rate']);
$tmp->setTotalInternalRate($year['internalRate']);
$tmp->setTotalDuration($year['duration']);
$years[$year['year']] = $tmp;
// fetch yearly stats grouped by ACTIVITY and YEAR
$qb2 = clone $qb;
$qb2
->leftJoin(Activity::class, 'a', Join::WITH, 'a.id = t.activity')
->addSelect('a as activity')
->addSelect('YEAR(t.begin) as year')
->andWhere('YEAR(t.begin) = :year')
->setParameter('year', $year['year'])
->addGroupBy('year')
->addGroupBy('a')
;
foreach ($qb2->getQuery()->getResult() as $tmp) {
$activity = new ActivityStatistic();
$activity->setActivity($tmp['activity']);
$activity->setRecordRate($tmp['rate']);
$activity->setRecordDuration($tmp['duration']);
$activity->setRecordInternalRate($tmp['internalRate']);
$activity->setRecordAmount($tmp['count']);
$model->addYearActivity($tmp['year'], $activity);
}
}
$model->setYears(array_values($years));
// ---------------------------------------------------
// fetch stats grouped by MONTH and YEAR
$qb1 = clone $qb;
$qb1
->addSelect('YEAR(t.begin) as year')
->addSelect('MONTH(t.begin) as month')
->addGroupBy('year')
->addGroupBy('month')
;
foreach ($qb1->getQuery()->getResult() as $month) {
$tmp = new Month($month['month']);
$tmp->setTotalRate($month['rate']);
$tmp->setTotalInternalRate($month['internalRate']);
$tmp->setTotalDuration($month['duration']);
$model->getYear($month['year'])->setMonth($tmp);
}
// ---------------------------------------------------
return $model;
}
/**
* @param ProjectViewQuery $query
* @return Project[]

View File

@@ -0,0 +1,53 @@
<?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\ProjectDetails;
use App\Form\Type\ProjectType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ProjectDetailsForm 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('project', ProjectType::class, [
'required' => false,
'label' => false,
'width' => false,
'join_customer' => true,
]);
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => ProjectDetailsQuery::class,
'csrf_protection' => false,
'method' => 'GET',
]);
}
}

View File

@@ -0,0 +1,158 @@
<?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\ProjectDetails;
use App\Entity\Project;
use App\Entity\User;
use App\Model\ActivityStatistic;
use App\Model\Statistic\UserYear;
use App\Model\Statistic\Year;
use App\Model\UserStatistic;
final class ProjectDetailsModel
{
/**
* @var Project
*/
private $project;
/**
* @var Year[]
*/
private $years = [];
/**
* @var array<string, array<ActivityStatistic>>
*/
private $yearlyActivities = [];
/**
* @var array<string, array<int, UserYear>>
*/
private $usersMonthly = [];
/**
* @var ActivityStatistic[]
*/
private $activities = [];
public function __construct(Project $project)
{
$this->project = $project;
}
public function getProject(): Project
{
return $this->project;
}
public function addActivity(ActivityStatistic $activityStatistic): void
{
$this->activities[$activityStatistic->getActivity()->getId()] = $activityStatistic;
}
/**
* @return ActivityStatistic[]
*/
public function getActivities(): array
{
return array_values($this->activities);
}
public function addYearActivity(string $year, ActivityStatistic $activityStatistic): void
{
$this->yearlyActivities[$year][] = $activityStatistic;
}
/**
* @param string $year
* @return ActivityStatistic[]
*/
public function getYearActivities(string $year): ?array
{
if (!\array_key_exists($year, $this->yearlyActivities)) {
return [];
}
return $this->yearlyActivities[$year];
}
/**
* @return UserStatistic[]
*/
public function getUserStats(): array
{
$users = [];
foreach ($this->usersMonthly as $year) {
foreach ($year as $id => $userYear) {
if (\array_key_exists($id, $users)) {
$userStat = $users[$id];
} else {
$userStat = new UserStatistic($userYear->getUser());
$users[$id] = $userStat;
}
$userStat->setRecordDuration($userStat->getRecordDuration() + $userYear->getDuration());
$userStat->setRecordRate($userStat->getRecordRate() + $userYear->getRate());
}
}
return $users;
}
public function setUserYear(Year $year, User $user): void
{
$this->usersMonthly[$year->getYear()][$user->getId()] = new UserYear($user, $year);
}
public function getUserYear(string $year, User $user): ?Year
{
if (!\array_key_exists($year, $this->usersMonthly) || !\array_key_exists($user->getId(), $this->usersMonthly[$year])) {
return null;
}
return $this->usersMonthly[$year][$user->getId()]->getYear();
}
/**
* @param string $year
* @return UserYear[]
*/
public function getUserYears(string $year): array
{
if (!\array_key_exists($year, $this->usersMonthly)) {
return [];
}
return $this->usersMonthly[$year];
}
/**
* @return Year[]
*/
public function getYears(): array
{
return $this->years;
}
public function getYear(string $year): ?Year
{
foreach ($this->years as $tmp) {
if ($tmp->getYear() === $year) {
return $tmp;
}
}
return null;
}
/**
* @param Year[] $years
*/
public function setYears(array $years): void
{
$this->years = $years;
}
}

View File

@@ -0,0 +1,56 @@
<?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\ProjectDetails;
use App\Entity\Project;
use App\Entity\User;
use DateTime;
final class ProjectDetailsQuery
{
/**
* @var Project|null
*/
private $project;
/**
* @var DateTime
*/
private $today;
/**
* @var User
*/
private $user;
public function __construct(DateTime $today, User $user)
{
$this->today = $today;
$this->user = $user;
}
public function getToday(): DateTime
{
return $this->today;
}
public function getUser(): User
{
return $this->user;
}
public function getProject(): ?Project
{
return $this->project;
}
public function setProject(?Project $project): void
{
$this->project = $project;
}
}

View File

@@ -51,6 +51,9 @@ final class ReportingService
}
if ($this->security->isGranted('budget_project')) {
$event->addReport(new Report('project_view', 'report_project_view', 'report_project_view', 'project'));
if ($this->security->isGranted('details_project')) {
$event->addReport(new Report('project_details', 'report_project_details', 'report_project_details', 'project'));
}
$event->addReport(new Report('inactive_projects', 'report_project_inactive', 'report_inactive_project', 'project'));
}

View File

@@ -224,6 +224,10 @@ class ProjectRepository extends EntityRepository
->addOrderBy('p.name', 'ASC')
;
if ($query->withCustomer()) {
$qb->addSelect('c');
}
$qb->andWhere($qb->expr()->eq('p.visible', ':visible'));
$qb->andWhere($qb->expr()->eq('c.visible', ':customer_visible'));

View File

@@ -18,10 +18,8 @@ final class ProjectFormTypeQuery extends BaseFormTypeQuery
* @var Project|null
*/
private $projectToIgnore;
/**
* @var bool
*/
private $ignoreDate = false;
private $withCustomer = false;
/**
* @param Project|int|null $project
@@ -44,6 +42,16 @@ final class ProjectFormTypeQuery extends BaseFormTypeQuery
}
}
public function withCustomer(): bool
{
return $this->withCustomer;
}
public function setWithCustomer(bool $withCustomer): void
{
$this->withCustomer = $withCustomer;
}
/**
* @return Project|null
*/

View File

@@ -1,59 +0,0 @@
<?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\Twig;
use App\Entity\User;
use App\Utils\AvatarService;
use Symfony\Component\Asset\Packages;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
class AvatarExtension extends AbstractExtension
{
/**
* @var AvatarService
*/
private $avatar;
/**
* @var Packages
*/
private $packages;
public function __construct(AvatarService $avatar, Packages $packages)
{
$this->avatar = $avatar;
$this->packages = $packages;
}
/**
* {@inheritdoc}
*/
public function getFunctions()
{
return [
new TwigFunction('avatar', [$this, 'getAvatarUrl']),
];
}
public function getAvatarUrl(?User $profile, string $default): string
{
if (null === $profile) {
return $this->packages->getUrl($default);
}
$url = $this->avatar->getAvatar($profile);
if (null === $url) {
return $this->packages->getUrl($default);
}
return $this->packages->getUrl($url, 'avatars');
}
}

View File

@@ -0,0 +1,67 @@
<?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\Twig;
use App\Configuration\SystemConfiguration;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
final class Configuration extends AbstractExtension
{
private $configuration;
private $cache = [];
public function __construct(SystemConfiguration $configuration)
{
$this->configuration = $configuration;
}
/**
* {@inheritdoc}
*/
public function getFunctions()
{
return [
new TwigFunction('config', [$this, 'get']),
];
}
public function get(string $name)
{
if (\array_key_exists($name, $this->cache)) {
return $this->cache[$name];
}
$value = $this->configuration->find($name);
$this->cache[$name] = $value;
return $value;
}
public function __call($name, $arguments)
{
if (\array_key_exists($name, $this->cache)) {
return $this->cache[$name];
}
$checks = ['is' . $name, 'get' . $name, 'has' . $name, $name];
foreach ($checks as $methodName) {
if (method_exists($this->configuration, $methodName)) {
$value = \call_user_func([$this->configuration, $methodName], $arguments);
$this->cache[$name] = $value;
return $value;
}
}
return $this->configuration->find($name);
}
}

View File

@@ -31,6 +31,7 @@ class Extensions extends AbstractExtension
new TwigFilter('multiline_indent', [$this, 'multilineIndent']),
new TwigFilter('color', [$this, 'color']),
new TwigFilter('font_contrast', [$this, 'calculateFontContrastColor']),
new TwigFilter('default_color', [$this, 'defaultColor']),
new TwigFilter('nl2str', [$this, 'replaceNewline'], ['pre_escape' => 'html', 'is_safe' => ['html']]),
];
}
@@ -43,6 +44,7 @@ class Extensions extends AbstractExtension
return [
new TwigFunction('class_name', [$this, 'getClassName']),
new TwigFunction('iso_day_by_name', [$this, 'getIsoDayByName']),
new TwigFunction('random_color', [$this, 'randomColor']),
];
}
@@ -71,11 +73,21 @@ class Extensions extends AbstractExtension
return (new Color())->getColor($entity, $defaultColor);
}
public function randomColor(?string $input = null): string
{
return (new Color())->getRandom($input);
}
public function calculateFontContrastColor(string $color): string
{
return (new Color())->getFontContrastColor($color);
}
public function defaultColor(?string $color = null): string
{
return $color ?? Constants::DEFAULT_COLOR;
}
/**
* @param object $object
* @return null|string

View File

@@ -59,6 +59,7 @@ final class LocaleFormatExtensions extends AbstractExtension
new TwigFilter('time', [$this, 'time']),
new TwigFilter('hour24', [$this, 'hour24']),
new TwigFilter('duration', [$this, 'duration']),
new TwigFilter('chart_duration', [$this, 'durationChart']),
new TwigFilter('duration_decimal', [$this, 'durationDecimal']),
new TwigFilter('money', [$this, 'money']),
new TwigFilter('currency', [$this, 'currency']),
@@ -91,6 +92,7 @@ final class LocaleFormatExtensions extends AbstractExtension
new TwigFunction('get_format_duration', [$this, 'getDurationFormat']),
new TwigFunction('create_date', [$this, 'createDate']),
new TwigFunction('locales', [$this, 'getLocales']),
new TwigFunction('month_names', [$this, 'getMonthNames']),
];
}
@@ -187,6 +189,25 @@ final class LocaleFormatExtensions extends AbstractExtension
return $this->getFormatter()->time($date);
}
/**
* @param string|null $year
* @return string[]
*/
public function getMonthNames(?string $year = null): array
{
$withYear = true;
if ($year === null) {
$year = date('Y');
$withYear = false;
}
$months = [];
for ($i = 1; $i < 13; $i++) {
$months[] = $this->getFormatter()->monthName(new DateTime(sprintf('%s-%s-10', $year, ($i < 10 ? '0' . $i : (string) $i))), $withYear);
}
return $months;
}
public function monthName(\DateTime $dateTime, bool $withYear = false): string
{
return $this->getFormatter()->monthName($dateTime, $withYear);
@@ -235,6 +256,11 @@ final class LocaleFormatExtensions extends AbstractExtension
return $this->getFormatter()->durationDecimal($duration);
}
public function durationChart($duration): string
{
return number_format(($duration / 3600), 2, '.', '');
}
/**
* @param string|float $amount
* @return bool|false|string

View File

@@ -15,6 +15,7 @@ use App\Entity\User;
use App\Event\PageActionsEvent;
use App\Event\ThemeEvent;
use App\Event\ThemeJavascriptTranslationsEvent;
use App\Utils\Color;
use Symfony\Bridge\Twig\AppVariable;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
@@ -26,6 +27,10 @@ final class ThemeExtension implements RuntimeExtensionInterface
private $eventDispatcher;
private $translator;
private $configuration;
/**
* @var bool
*/
private $randomColors;
public function __construct(EventDispatcherInterface $dispatcher, TranslatorInterface $translator, SystemConfiguration $configuration)
{
@@ -135,4 +140,25 @@ final class ThemeExtension implements RuntimeExtensionInterface
return $this->configuration->find($name);
}
public function colorize(?string $color, ?string $identifier = null, ?string $fallback = null): string
{
if ($color !== null) {
return $color;
}
if ($this->randomColors === null) {
$this->randomColors = $this->configuration->isThemeRandomColors();
}
if ($this->randomColors) {
return (new Color())->getRandom($identifier);
}
if ($fallback !== null) {
return $fallback;
}
return Constants::DEFAULT_COLOR;
}
}

View File

@@ -48,6 +48,7 @@ class RuntimeExtensions extends AbstractExtension
new TwigFilter('desc2html', [MarkdownExtension::class, 'timesheetContent'], ['pre_escape' => 'html', 'is_safe' => ['html']]),
new TwigFilter('comment2html', [MarkdownExtension::class, 'commentContent'], ['pre_escape' => 'html', 'is_safe' => ['html']]),
new TwigFilter('comment1line', [MarkdownExtension::class, 'commentOneLiner'], ['pre_escape' => 'html', 'is_safe' => ['html']]),
new TwigFilter('colorize', [ThemeExtension::class, 'colorize']),
];
}
}

View File

@@ -1,170 +0,0 @@
<?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\Utils;
use App\Entity\User;
use Laravolt\Avatar\Avatar;
use Laravolt\Avatar\Generator\DefaultGenerator;
class AvatarService
{
/**
* @var string
*/
private $directory;
public const AVATAR_CONFIG = [
'driver' => 'gd',
'generator' => DefaultGenerator::class,
'ascii' => true,
'shape' => 'circle',
'width' => 100,
'height' => 100,
'chars' => 2,
'fontSize' => 44,
'fontFamily' => null,
'uppercase' => true,
//'fonts' => ['path/to/OpenSans-Bold.ttf', 'path/to/rockwell.ttf'],
'foregrounds' => [
'#FFFFFF'
],
'backgrounds' => [
'#f44336',
'#E91E63',
'#9C27B0',
'#673AB7',
'#3F51B5',
'#2196F3',
'#03A9F4',
'#00BCD4',
'#009688',
'#4CAF50',
'#8BC34A',
'#CDDC39',
'#FFC107',
'#FF9800',
'#FF5722',
],
'border' => [
'size' => 1,
'color' => 'background'
],
'theme' => '*',
'themes' => [
/*
'grayscale-light' => [
'backgrounds' => ['#edf2f7', '#e2e8f0', '#cbd5e0'],
'foregrounds' => ['#a0aec0'],
],
'grayscale-dark' => [
'backgrounds' => ['#2d3748', '#4a5568', '#718096'],
'foregrounds' => ['#e2e8f0'],
],
*/
'colorful' => [
'backgrounds' => [
'#a972c9',
'#9C27B0',
'#673AB7',
'#5319e7',
'#041fd1',
'#3F51B5',
'#2196F3',
'#03A9F4',
'#00BCD4',
'#006b75',
'#009688',
'#00bb32',
'#4CAF50',
'#8BC34A',
'#CDDC39',
'#FFC107',
'#FF9800',
'#FF5722',
'#f41a00',
'#E91E63',
'#b60205',
'#cc317c',
'#d82d80',
'#e135f4',
'#2d3748',
'#4a5568',
'#718096',
],
'foregrounds' => ['#FFFFFF'],
],
]
];
public function __construct(string $projectDirectory)
{
$this->setStorageDirectory($projectDirectory . '/public/avatars/');
}
public function getStorageDirectory(): string
{
return $this->directory;
}
/**
* @CloudRequired
*/
public function setStorageDirectory(string $directory)
{
$this->directory = realpath($directory);
}
private function getAvatarUrl(User $profile): string
{
return md5($profile->getId() . '_' . $profile->getDisplayName()) . '.png';
}
private function getImagePath(User $profile): string
{
return $this->getStorageDirectory() . '/' . $this->getAvatarUrl($profile);
}
public function generateAvatar(User $profile, bool $regenerate = false): bool
{
if (!$this->hasDependencies()) {
return false;
}
$filePath = $this->getImagePath($profile);
if ($regenerate || !file_exists($filePath)) {
if (!is_writable(\dirname($filePath))) {
return false;
}
$avatar = new Avatar(self::AVATAR_CONFIG);
$avatar->create($profile->getDisplayName())->save($filePath, 90);
}
return true;
}
public function getAvatar(User $profile): ?string
{
if (!empty(trim($profile->getAvatar()))) {
return $profile->getAvatar();
}
if (!$this->generateAvatar($profile)) {
return null;
}
return $this->getAvatarUrl($profile);
}
public function hasDependencies(): bool
{
return \extension_loaded('gd') && \function_exists('imagettfbbox');
}
}

View File

@@ -18,6 +18,13 @@ use App\Entity\Timesheet;
final class Color
{
// @see https://clrs.cc
public const PALETTE_1 = ['#0074D9', '#7FDBFF', '#39CCCC', '#B10DC9', '#F012BE', '#85144b', '#FF4136', '#FF851B', '#FFDC00', '#3D9970', '#2ECC40', '#01FF70', '#AAAAAA', '#DDDDDD'];
// old avatar color set
public const PALETTE_2 = ['#a972c9', '#9C27B0', '#673AB7', '#5319e7', '#041fd1', '#3F51B5', '#2196F3', '#03A9F4', '#00BCD4', '#006b75', '#009688', '#00bb32', '#4CAF50', '#8BC34A', '#CDDC39', '#FFC107', '#FF9800', '#FF5722', '#f41a00', '#E91E63', '#b60205', '#cc317c', '#d82d80', '#e135f4', '#2d3748', '#4a5568', '#718096'];
// all mixed together
public const PALETTE_3 = ['#AAAAAA', '#DDDDDD', '#a972c9', '#9C27B0', '#673AB7', '#041fd1', '#5319e7', '#3F51B5', '#0074D9', '#2196F3', '#03A9F4', '#7FDBFF', '#39CCCC', '#00BCD4', '#006b75', '#009688', '#00bb32', '#4CAF50', '#3D9970', '#2ECC40', '#01FF70', '#8BC34A', '#CDDC39', '#FFDC00', '#FFC107', '#FF851B', '#FF9800', '#FF5722', '#f41a00', '#E91E63', '#85144b', '#b60205', '#FF4136', '#cc317c', '#F012BE', '#d82d80', '#B10DC9', '#e135f4', '#2d3748', '#4a5568', '#718096'];
public function getTimesheetColor(Timesheet $timesheet): string
{
$activity = $timesheet->getActivity();
@@ -76,6 +83,33 @@ final class Color
return $defaultColor ? Constants::DEFAULT_COLOR : null;
}
public function getRandom(?string $input = null): string
{
if ($input === null) {
return $this->getRandomColor();
}
return $this->getRandomFromPalette($input);
}
public function getRandomColor(): string
{
return sprintf('#%06x', rand(0, 16777215));
}
public function getRandomFromPalette(string $input): string
{
$id = 0;
for ($pos = 0; $pos < \strlen($input); $pos++) {
$id += mb_ord($input[$pos], 'UTF-8');
}
$colors = self::PALETTE_3;
$key = $id % \count($colors);
return $colors[$key];
}
public function getFontContrastColor(string $color): string
{
if (empty($color) || $color[0] !== '#') {