weekly quick-entry form (#2793)

This commit is contained in:
Kevin Papst
2021-10-18 11:12:46 +02:00
committed by GitHub
parent 64893e0a95
commit 9ab098af86
105 changed files with 3356 additions and 778 deletions

View File

@@ -115,7 +115,8 @@ final class ReloadCommand extends Command
$io->warning(
[
'Cache could not be rebuilt.',
'Please run the cache commands manually:',
'Please run these commands to rebuild the cache manually:',
'rm -r var/cache/*' . PHP_EOL .
'bin/console cache:clear --env=' . $environment . PHP_EOL .
'bin/console cache:warmup --env=' . $environment
]

View File

@@ -29,7 +29,6 @@ use App\Form\Toolbar\ActivityToolbarForm;
use App\Form\Type\ActivityType;
use App\Repository\ActivityRateRepository;
use App\Repository\ActivityRepository;
use App\Repository\Query\ActivityFormTypeQuery;
use App\Repository\Query\ActivityQuery;
use App\Repository\TeamRepository;
use Exception;
@@ -314,6 +313,13 @@ final class ActivityController extends AbstractController
{
$stats = $statisticService->getActivityStatistics($activity);
$options = [
'projects' => $activity->getProject(),
'query_builder_for_user' => true,
'ignore_activity' => $activity,
'required' => false,
];
$deleteForm = $this->createFormBuilder(null, [
'attr' => [
'data-form-event' => 'kimai.activityDelete',
@@ -321,17 +327,7 @@ final class ActivityController extends AbstractController
'data-msg-error' => 'action.delete.error',
]
])
->add('activity', ActivityType::class, [
'label' => 'label.activity',
'query_builder' => function (ActivityRepository $repo) use ($activity) {
$query = new ActivityFormTypeQuery();
$query->addProject($activity->getProject());
$query->setActivityToIgnore($activity);
return $repo->getQueryBuilderForFormType($query);
},
'required' => false,
])
->add('activity', ActivityType::class, $options)
->setAction($this->generateUrl('admin_activity_delete', ['id' => $activity->getId()]))
->setMethod('POST')
->getForm();

View File

@@ -30,7 +30,6 @@ use App\Form\Type\CustomerType;
use App\Repository\CustomerRateRepository;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\CustomerFormTypeQuery;
use App\Repository\Query\CustomerQuery;
use App\Repository\Query\ProjectQuery;
use App\Repository\TeamRepository;
@@ -384,14 +383,8 @@ final class CustomerController extends AbstractController
]
])
->add('customer', CustomerType::class, [
'label' => 'label.customer',
'query_builder' => function (CustomerRepository $repo) use ($customer) {
$query = new CustomerFormTypeQuery();
$query->setCustomerToIgnore($customer);
$query->setUser($this->getUser());
return $repo->getQueryBuilderForFormType($query);
},
'query_builder_for_user' => true,
'ignore_customer' => $customer,
'required' => false,
])
->setAction($this->generateUrl('admin_customer_delete', ['id' => $customer->getId()]))

View File

@@ -35,7 +35,6 @@ use App\Repository\ActivityRepository;
use App\Repository\ProjectRateRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\ActivityQuery;
use App\Repository\Query\ProjectFormTypeQuery;
use App\Repository\Query\ProjectQuery;
use App\Repository\TeamRepository;
use Pagerfanta\Pagerfanta;
@@ -428,15 +427,9 @@ final class ProjectController extends AbstractController
]
])
->add('project', ProjectType::class, [
'label' => 'label.project',
'query_builder' => function (ProjectRepository $repo) use ($project) {
$query = new ProjectFormTypeQuery();
$query->addCustomer($project->getCustomer());
$query->setProjectToIgnore($project);
$query->setUser($this->getUser());
return $repo->getQueryBuilderForFormType($query);
},
'ignore_project' => $project,
'customers' => $project->getCustomer(),
'query_builder_for_user' => true,
'required' => false,
])
->setAction($this->generateUrl('admin_project_delete', ['id' => $project->getId()]))

View File

@@ -0,0 +1,234 @@
<?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;
use App\Configuration\SystemConfiguration;
use App\Entity\Timesheet;
use App\Form\QuickEntryForm;
use App\Model\QuickEntryModel;
use App\Model\QuickEntryWeek;
use App\Repository\Query\TimesheetQuery;
use App\Repository\TimesheetRepository;
use App\Timesheet\TimesheetService;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
/**
* Controller used to enter times in weekly form.
*
* @Route(path="/quick_entry")
* @Security("is_granted('view_own_timesheet')")
*/
class QuickEntryController extends AbstractController
{
private $configuration;
private $timesheetService;
private $repository;
public function __construct(SystemConfiguration $configuration, TimesheetService $timesheetService, TimesheetRepository $repository)
{
$this->configuration = $configuration;
$this->timesheetService = $timesheetService;
$this->repository = $repository;
}
/**
* @Route(path="/{begin}", name="quick_entry", methods={"GET", "POST"})
* @Security("is_granted('edit_own_timesheet')")
*/
public function quickEntry(Request $request, ?string $begin = null)
{
$mode = $this->timesheetService->getActiveTrackingMode();
if (!$mode->canEditDuration() && !$mode->canEditEnd()) {
$this->flashError('Not allowed');
return $this->redirectToRoute('homepage');
}
$factory = $this->getDateTimeFactory();
if ($begin === null) {
$begin = $factory->createDateTime();
} else {
$begin = $factory->createDateTime($begin);
}
$startWeek = $factory->getStartOfWeek($begin);
$endWeek = $factory->getEndOfWeek($begin);
$user = $this->getUser();
$tmpDay = clone $startWeek;
$week = [];
while ($tmpDay < $endWeek) {
$nextDay = clone $tmpDay;
$week[$nextDay->format('Y-m-d')] = ['day' => $nextDay];
$tmpDay = $tmpDay->modify('+1 day');
}
$query = new TimesheetQuery();
$query->setBegin($startWeek);
$query->setEnd($endWeek);
$query->setName('quickEntryForm');
$query->setUser($this->getUser());
$result = $this->repository->getTimesheetResult($query);
$rows = [];
/** @var Timesheet $timesheet */
foreach ($result->getResults(true) as $timesheet) {
$i = 0;
$id = $timesheet->getProject()->getId() . '_' . $timesheet->getActivity()->getId();
$day = $timesheet->getBegin()->format('Y-m-d');
while (\array_key_exists($id, $rows) && \array_key_exists('entry', $rows[$id]['days'][$day])) {
$i++;
$id = $timesheet->getProject()->getId() . '_' . $timesheet->getActivity()->getId() . '_' . $i;
}
if (!\array_key_exists($id, $rows)) {
$rows[$id] = [
'days' => $week,
'project' => $timesheet->getProject(),
'activity' => $timesheet->getActivity()
];
}
$rows[$id]['days'][$day]['entry'] = $timesheet;
}
ksort($rows);
// attach recent activities
$timesheets = $this->repository->getRecentActivities($this->getUser(), null, 5);
foreach ($timesheets as $timesheet) {
$id = $timesheet->getProject()->getId() . '_' . $timesheet->getActivity()->getId();
if (\array_key_exists($id, $rows)) {
continue;
}
$rows[$id] = [
'days' => $week,
'project' => $timesheet->getProject(),
'activity' => $timesheet->getActivity()
];
}
$beginTime = $this->configuration->getTimesheetDefaultBeginTime();
/** @var QuickEntryModel[] $models */
$models = [];
foreach ($rows as $id => $row) {
$model = new QuickEntryModel($user, $row['project'], $row['activity']);
foreach ($row['days'] as $dayId => $day) {
if (!\array_key_exists('entry', $day)) {
$tmp = new Timesheet();
$tmp->setUser($user);
$tmp->setProject($row['project']);
$tmp->setActivity($row['activity']);
$tmp->setBegin(clone $day['day']);
$tmp->getBegin()->modify($beginTime);
$model->addTimesheet($tmp);
} else {
$model->addTimesheet($day['entry']);
}
}
$models[] = $model;
}
// create prototype model
$empty = new QuickEntryModel($user);
foreach ($week as $dayId => $day) {
$tmp = new Timesheet();
$tmp->setUser($user);
$tmp->setBegin(clone $day['day']);
$tmp->getBegin()->modify($beginTime);
$empty->addTimesheet($tmp);
}
// add empty rows for simpler starting
$minRows = 3;
if (\count($models) < $minRows) {
$newRows = $minRows - \count($models);
for ($a = 0; $a < $newRows; $a++) {
$model = new QuickEntryModel();
foreach ($week as $dayId => $day) {
$tmp = new Timesheet();
$tmp->setUser($user);
$tmp->setBegin(clone $day['day']);
$tmp->getBegin()->modify($beginTime);
$model->addTimesheet($tmp);
}
$models[] = $model;
}
}
$formModel = new QuickEntryWeek($startWeek, $models);
$form = $this->createForm(QuickEntryForm::class, $formModel, [
'timezone' => $this->getDateTimeFactory()->getTimezone()->getName(),
'prototype_data' => $empty,
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var QuickEntryWeek $data */
$data = $form->getData();
$saveTimesheets = [];
$deleteTimesheets = [];
foreach ($data->getRows() as $tmpModel) {
foreach ($tmpModel->getTimesheets() as $timesheet) {
if ($timesheet->getId() !== null) {
if ($timesheet->getDuration(false) === null || $timesheet->getEnd() === null) {
$deleteTimesheets[] = $timesheet;
} else {
$saveTimesheets[] = $timesheet;
}
} else {
if ($timesheet->getDuration() !== null) {
$saveTimesheets[] = $timesheet;
}
}
}
}
if ($this->isGranted('delete_own_timesheet') && \count($deleteTimesheets) > 0) {
try {
$this->timesheetService->deleteMultipleTimesheets($deleteTimesheets);
return $this->redirectToRoute('quick_entry', ['begin' => $begin->format('Y-m-d')]);
} catch (\Exception $ex) {
$this->flashError('action.delete.error');
$this->logException($ex);
}
}
if (\count($saveTimesheets) > 0) {
try {
$this->timesheetService->updateMultipleTimesheets($saveTimesheets);
return $this->redirectToRoute('quick_entry', ['begin' => $begin->format('Y-m-d')]);
} catch (\Exception $ex) {
$this->flashError('action.update.error');
$this->logException($ex);
}
}
}
return $this->render('quick-entry/index.html.twig', [
'days' => $week,
'week' => $rows,
'form' => $form->createView(),
]);
}
}

View File

@@ -371,7 +371,7 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
{
$this->begin = $begin;
$this->timezone = $begin->getTimezone()->getName();
// make sure that the original date is always
// make sure that the original date is always kept in UTC
$this->date = new DateTime($begin->format('Y-m-d 00:00:00'), new DateTimeZone('UTC'));
return $this;
@@ -421,12 +421,13 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
/**
* Do not rely on the results of this method for running records.
*
* @param bool $calculate
* @return int|null
*/
public function getDuration(): ?int
public function getDuration(bool $calculate = true): ?int
{
// only auto calculate if manually set duration is null - the result is important for eg. validations
if ($this->duration === null && $this->begin !== null && $this->end !== null) {
if ($calculate && $this->duration === null && $this->begin !== null && $this->end !== null) {
return $this->end->getTimestamp() - $this->begin->getTimestamp();
}

View File

@@ -0,0 +1,29 @@
<?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\EventSubscriber\Actions;
use App\Event\PageActionsEvent;
class QuickEntrySubscriber extends AbstractActionsSubscriber
{
public static function getActionName(): string
{
return 'weekly_times';
}
public function onActions(PageActionsEvent $event): void
{
if ($this->isGranted('view_own_timesheet')) {
$event->addBack($this->path('timesheet'));
}
$event->addHelp($this->documentationLink('weekly-times.html'));
}
}

View File

@@ -35,6 +35,7 @@ class TimesheetsSubscriber extends AbstractActionsSubscriber
if ($this->isGranted('create_own_timesheet')) {
$event->addCreate($this->path('timesheet_create'));
$event->addAction('quick_entry', ['url' => $this->path('quick_entry'), 'class' => 'create-ts', 'icon' => 'weekly-times']);
}
$event->addHelp($this->documentationLink('timesheet.html'));

View File

@@ -12,8 +12,6 @@ namespace App\EventSubscriber;
use App\Event\ConfigureMainMenuEvent;
use App\Twig\IconExtension;
use App\Utils\MenuItemModel;
use KevinPapst\AdminLTEBundle\Event\SidebarMenuEvent;
use KevinPapst\AdminLTEBundle\Model\MenuItemInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
@@ -22,19 +20,11 @@ use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
*/
final class MenuSubscriber implements EventSubscriberInterface
{
/**
* @var AuthorizationCheckerInterface
*/
private $security;
/**
* @var IconExtension
*/
private $icons;
public function __construct(AuthorizationCheckerInterface $security)
{
$this->security = $security;
$this->icons = new IconExtension();
}
public static function getSubscribedEvents(): array
@@ -52,67 +42,62 @@ final class MenuSubscriber implements EventSubscriberInterface
return;
}
$this->configureMainMenu($event->getMenu());
$this->configureAdminMenu($event->getAdminMenu());
$this->configureSystemMenu($event->getSystemMenu());
}
$icons = new IconExtension();
private function configureMainMenu(SidebarMenuEvent $menu)
{
$auth = $this->security;
// ------------------- main menu -------------------
$menu = $event->getMenu();
if ($auth->isGranted('view_own_timesheet')) {
$timesheets = new MenuItemModel('timesheet', 'menu.timesheet', 'timesheet', [], $this->getIcon('timesheet'));
$timesheets->setChildRoutes(['timesheet_export', 'timesheet_edit', 'timesheet_create', 'timesheet_multi_update']);
$timesheets = new MenuItemModel('timesheet', 'menu.timesheet', 'timesheet', [], $icons->icon('timesheet'));
$timesheets->setChildRoutes(['timesheet_export', 'timesheet_edit', 'timesheet_create', 'timesheet_multi_update', 'quick_entry']);
$menu->addItem($timesheets);
$menu->addItem(
new MenuItemModel('calendar', 'calendar.title', 'calendar', [], $this->getIcon('calendar'))
new MenuItemModel('calendar', 'calendar.title', 'calendar', [], $icons->icon('calendar'))
);
}
if ($auth->isGranted('view_invoice')) {
$invoice = new MenuItemModel('invoice', 'menu.invoice', 'invoice', [], $this->getIcon('invoice'));
$invoice = new MenuItemModel('invoice', 'menu.invoice', 'invoice', [], $icons->icon('invoice'));
$invoice->setChildRoutes(['admin_invoice_template', 'admin_invoice_template_edit', 'admin_invoice_template_create', 'admin_invoice_template_copy', 'admin_invoice_list', 'admin_invoice_document_upload']);
$menu->addItem($invoice);
}
if ($auth->isGranted('create_export')) {
$menu->addItem(
new MenuItemModel('export', 'menu.export', 'export', [], $this->getIcon('export'))
new MenuItemModel('export', 'menu.export', 'export', [], $icons->icon('export'))
);
}
}
private function configureAdminMenu(MenuItemInterface $menu)
{
$auth = $this->security;
// ------------------- admin menu -------------------
$menu = $event->getAdminMenu();
if ($auth->isGranted('view_other_timesheet')) {
$timesheets = new MenuItemModel('timesheet_admin', 'menu.admin_timesheet', 'admin_timesheet', [], $this->getIcon('timesheet-team'));
$timesheets = new MenuItemModel('timesheet_admin', 'menu.admin_timesheet', 'admin_timesheet', [], $icons->icon('timesheet-team'));
$timesheets->setChildRoutes(['admin_timesheet_export', 'admin_timesheet_edit', 'admin_timesheet_create', 'admin_timesheet_multi_update']);
$menu->addChild($timesheets);
}
if ($auth->isGranted('view_reporting')) {
$reporting = new MenuItemModel('reporting', 'menu.reporting', 'reporting', [], $this->getIcon('reporting'));
$reporting = new MenuItemModel('reporting', 'menu.reporting', 'reporting', [], $icons->icon('reporting'));
$reporting->setChildRoutes(['report_user_week', 'report_user_month', 'report_weekly_users', 'report_monthly_users', 'report_project_view']);
$menu->addChild($reporting);
}
if ($auth->isGranted('view_customer') || $auth->isGranted('view_teamlead_customer') || $auth->isGranted('view_team_customer')) {
$customers = new MenuItemModel('customer_admin', 'menu.admin_customer', 'admin_customer', [], $this->getIcon('customer'));
$customers = new MenuItemModel('customer_admin', 'menu.admin_customer', 'admin_customer', [], $icons->icon('customer'));
$customers->setChildRoutes(['admin_customer_create', 'admin_customer_permissions', 'customer_details', 'admin_customer_edit', 'admin_customer_delete']);
$menu->addChild($customers);
}
if ($auth->isGranted('view_project') || $auth->isGranted('view_teamlead_project') || $auth->isGranted('view_team_project')) {
$projects = new MenuItemModel('project_admin', 'menu.admin_project', 'admin_project', [], $this->getIcon('project'));
$projects = new MenuItemModel('project_admin', 'menu.admin_project', 'admin_project', [], $icons->icon('project'));
$projects->setChildRoutes(['admin_project_permissions', 'admin_project_create', 'project_details', 'admin_project_edit', 'admin_project_delete']);
$menu->addChild($projects);
}
if ($auth->isGranted('view_activity') || $auth->isGranted('view_teamlead_activity') || $auth->isGranted('view_team_activity')) {
$activities = new MenuItemModel('activity_admin', 'menu.admin_activity', 'admin_activity', [], $this->getIcon('activity'));
$activities = new MenuItemModel('activity_admin', 'menu.admin_activity', 'admin_activity', [], $icons->icon('activity'));
$activities->setChildRoutes(['admin_activity_create', 'activity_details', 'admin_activity_edit', 'admin_activity_delete']);
$menu->addChild($activities);
}
@@ -122,50 +107,43 @@ final class MenuSubscriber implements EventSubscriberInterface
new MenuItemModel('tags', 'menu.tags', 'tags', [], 'fas fa-tags')
);
}
}
private function configureSystemMenu(MenuItemInterface $menu)
{
$auth = $this->security;
// ------------------- system menu -------------------
$menu = $event->getSystemMenu();
if ($auth->isGranted('view_user')) {
$users = new MenuItemModel('user_admin', 'menu.admin_user', 'admin_user', [], $this->getIcon('users'));
$users = new MenuItemModel('user_admin', 'menu.admin_user', 'admin_user', [], $icons->icon('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);
}
if ($auth->isGranted('role_permissions')) {
$users = new MenuItemModel('admin_user_permissions', 'profile.roles', 'admin_user_permissions', [], $this->getIcon('permissions'));
$users = new MenuItemModel('admin_user_permissions', 'profile.roles', 'admin_user_permissions', [], $icons->icon('permissions'));
$menu->addChild($users);
}
if ($auth->isGranted('view_team')) {
$teams = new MenuItemModel('user_team', 'menu.admin_team', 'admin_team', [], $this->getIcon('team'));
$teams = new MenuItemModel('user_team', 'menu.admin_team', 'admin_team', [], $icons->icon('team'));
$teams->setChildRoutes(['admin_team_create', 'admin_team_edit']);
$menu->addChild($teams);
}
if ($auth->isGranted('plugins')) {
$menu->addChild(
new MenuItemModel('plugins', 'menu.plugin', 'plugins', [], $this->getIcon('plugin'))
new MenuItemModel('plugins', 'menu.plugin', 'plugins', [], $icons->icon('plugin'))
);
}
if ($auth->isGranted('system_configuration')) {
$menu->addChild(
new MenuItemModel('system_configuration', 'menu.system_configuration', 'system_configuration', [], $this->getIcon('configuration'))
new MenuItemModel('system_configuration', 'menu.system_configuration', 'system_configuration', [], $icons->icon('configuration'))
);
}
if ($auth->isGranted('system_information')) {
$menu->addChild(
new MenuItemModel('doctor', 'menu.doctor', 'doctor', [], $this->getIcon('doctor'))
new MenuItemModel('doctor', 'menu.doctor', 'doctor', [], $icons->icon('doctor'))
);
}
}
private function getIcon(string $icon)
{
return $this->icons->icon($icon, $icon);
}
}

View File

@@ -65,7 +65,15 @@ class DurationStringToSecondsTransformer implements DataTransformerInterface
}
try {
return $this->formatter->parseDurationString($formatToInt);
$seconds = $this->formatter->parseDurationString($formatToInt);
// DateTime throws if a duration with too many seconds is passed and an amount of so
// many seconds is likely not required in a time-tracking application ;-)
if ($seconds > 315360000000000) {
throw new TransformationFailedException('Maximum duration exceeded.');
}
return $seconds;
} catch (\Exception $e) {
throw new TransformationFailedException($e->getMessage());
}

View File

@@ -68,13 +68,23 @@ class SelectWithApiDataExtension extends AbstractTypeExtension
$apiData['route_params'] = [];
}
$formPrefix = $form->getParent()->getName();
if (!empty($formPrefix)) {
$formPrefix .= '_';
$formPrefixes = [];
$parent = $form->getParent();
do {
$formPrefixes[] = $parent->getName();
} while (($parent = $parent->getParent()) !== null);
$formPrefix = implode('_', array_reverse($formPrefixes));
$formField = $formPrefix;
if (!empty($formField)) {
$formField .= '_';
}
$formField .= $apiData['select'];
$view->vars['attr'] = array_merge($view->vars['attr'], [
'data-related-select' => $formPrefix . $apiData['select'],
'data-form-prefix' => $formPrefix,
'data-related-select' => $formField,
'data-api-url' => $this->router->generate($apiData['route'], $apiData['route_params']),
]);

View File

@@ -17,11 +17,7 @@ use App\Form\Type\CustomerType;
use App\Form\Type\DescriptionType;
use App\Form\Type\ProjectType;
use App\Form\Type\TagsType;
use App\Repository\ActivityRepository;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\ActivityFormTypeQuery;
use App\Repository\Query\CustomerFormTypeQuery;
use App\Repository\Query\ProjectFormTypeQuery;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
@@ -29,18 +25,16 @@ use Symfony\Component\Form\FormEvents;
/**
* Helper functions to manage dependent customer-project-activity fields.
*
* If you always want to show the list of all available projects/activities, use the form types directly.
*/
trait FormTrait
{
protected function addCustomer(FormBuilderInterface $builder, ?Customer $customer = null)
{
$builder->add('customer', CustomerType::class, [
'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) {
$query = new CustomerFormTypeQuery($customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
'query_builder_for_user' => true,
'customers' => $customer,
'data' => $customer ? $customer : '',
'required' => false,
'placeholder' => '',
@@ -49,30 +43,29 @@ trait FormTrait
]);
}
protected function addProject(FormBuilderInterface $builder, bool $isNew, ?Project $project = null, ?Customer $customer = null)
protected function addProject(FormBuilderInterface $builder, bool $isNew, ?Project $project = null, ?Customer $customer = null, array $options = [])
{
$builder->add('project', ProjectType::class, [
$options = array_merge([
'placeholder' => '',
'activity_enabled' => true,
'query_builder' => function (ProjectRepository $repo) use ($builder, $project, $customer) {
$query = new ProjectFormTypeQuery($project, $customer);
$query->setUser($builder->getOption('user'));
'query_builder_for_user' => true,
'join_customer' => true
], $options);
return $repo->getQueryBuilderForFormType($query);
},
]);
$builder->add('project', ProjectType::class, array_merge($options, [
'projects' => $project,
'customers' => $customer,
]));
// replaces the project select after submission, to make sure only projects for the selected customer are displayed
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($builder, $project, $customer, $isNew) {
function (FormEvent $event) use ($builder, $project, $customer, $isNew, $options) {
$data = $event->getData();
$customer = isset($data['customer']) && !empty($data['customer']) ? $data['customer'] : null;
$project = isset($data['project']) && !empty($data['project']) ? $data['project'] : $project;
$event->getForm()->add('project', ProjectType::class, [
'placeholder' => '',
'activity_enabled' => true,
$event->getForm()->add('project', ProjectType::class, array_merge($options, [
'group_by' => null,
'query_builder' => function (ProjectRepository $repo) use ($builder, $project, $customer, $isNew) {
// is there a better wa to prevent starting a record with a hidden project ?
@@ -90,44 +83,36 @@ trait FormTrait
}
$query = new ProjectFormTypeQuery($project, $customer);
$query->setUser($builder->getOption('user'));
$query->setWithCustomer(true);
return $repo->getQueryBuilderForFormType($query);
},
]);
]));
}
);
}
protected function addActivity(FormBuilderInterface $builder, ?Activity $activity = null, ?Project $project = null)
protected function addActivity(FormBuilderInterface $builder, ?Activity $activity = null, ?Project $project = null, array $options = [])
{
$builder->add('activity', ActivityType::class, [
'placeholder' => '',
'query_builder' => function (ActivityRepository $repo) use ($builder, $activity, $project) {
$query = new ActivityFormTypeQuery($activity, $project);
$query->setUser($builder->getOption('user'));
$options = array_merge(['placeholder' => '', 'query_builder_for_user' => true], $options);
return $repo->getQueryBuilderForFormType($query);
},
]);
$options['projects'] = $project;
$options['activities'] = $activity;
$builder->add('activity', ActivityType::class, $options);
// replaces the activity select after submission, to make sure only activities for the selected project are displayed
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($builder, $activity) {
function (FormEvent $event) use ($options) {
$data = $event->getData();
if (!isset($data['project']) || empty($data['project'])) {
return;
}
$event->getForm()->add('activity', ActivityType::class, [
'placeholder' => '',
'query_builder' => function (ActivityRepository $repo) use ($builder, $data, $activity) {
$query = new ActivityFormTypeQuery($activity, $data['project']);
$query->setUser($builder->getOption('user'));
$options['projects'] = $data['project'];
return $repo->getQueryBuilderForFormType($query);
},
]);
$event->getForm()->add('activity', ActivityType::class, $options);
}
);
}

View File

@@ -18,12 +18,7 @@ use App\Form\Type\ProjectType;
use App\Form\Type\TagsType;
use App\Form\Type\UserType;
use App\Form\Type\YesNoType;
use App\Repository\ActivityRepository;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\ActivityFormTypeQuery;
use App\Repository\Query\CustomerFormTypeQuery;
use App\Repository\Query\ProjectFormTypeQuery;
use App\Repository\TimesheetRepository;
use Doctrine\Common\Collections\Criteria;
use Symfony\Component\Form\AbstractType;
@@ -83,12 +78,8 @@ class TimesheetMultiUpdate extends AbstractType
$builder
->add('customer', CustomerType::class, [
'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) {
$query = new CustomerFormTypeQuery($customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
'query_builder_for_user' => true,
'customers' => $customer,
'data' => $customer ? $customer : '',
'required' => false,
'placeholder' => '',
@@ -103,28 +94,20 @@ class TimesheetMultiUpdate extends AbstractType
$projectOptions['group_by'] = null;
}
$builder
->add(
'project',
ProjectType::class,
array_merge($projectOptions, [
'required' => false,
'placeholder' => '',
'activity_enabled' => true,
'query_builder' => function (ProjectRepository $repo) use ($builder, $project, $customer) {
$query = new ProjectFormTypeQuery($project, $customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
])
);
$builder->add('project', ProjectType::class, array_merge($projectOptions, [
'required' => false,
'placeholder' => '',
'activity_enabled' => true,
'customers' => $customer,
'projects' => $project,
'query_builder_for_user' => true,
]));
// replaces the project select after submission, to make sure only projects for the selected customer are displayed
// TODO replace me with FormTrait
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($builder, $project, $customer) {
function (FormEvent $event) use ($project, $customer) {
$data = $event->getData();
$customer = isset($data['customer']) && !empty($data['customer']) ? $data['customer'] : null;
$project = isset($data['project']) && !empty($data['project']) ? $data['project'] : $project;
@@ -134,45 +117,37 @@ class TimesheetMultiUpdate extends AbstractType
'placeholder' => '',
'activity_enabled' => true,
'group_by' => null,
'query_builder' => function (ProjectRepository $repo) use ($builder, $project, $customer) {
$query = new ProjectFormTypeQuery($project, $customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
'customers' => $customer,
'projects' => $project,
'query_builder_for_user' => true,
]);
}
);
$builder
->add('activity', ActivityType::class, [
'required' => false,
'placeholder' => '',
'query_builder' => function (ActivityRepository $repo) use ($activity, $project) {
// TODO respect user (team permission)
return $repo->getQueryBuilderForFormType(new ActivityFormTypeQuery($activity, $project));
},
])
;
$activityOptions = [
'required' => false,
'placeholder' => '',
'activities' => $activity,
'query_builder_for_user' => true,
];
$builder->add('activity', ActivityType::class, array_merge($activityOptions, [
'projects' => $project,
]));
// replaces the activity select after submission, to make sure only activities for the selected project are displayed
// TODO replace me with FormTrait
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($activity) {
function (FormEvent $event) use ($activityOptions) {
$data = $event->getData();
if (!isset($data['project']) || empty($data['project'])) {
return;
}
$event->getForm()->add('activity', ActivityType::class, [
'required' => false,
'placeholder' => '',
'query_builder' => function (ActivityRepository $repo) use ($data, $activity) {
// TODO respect user (team permission)
return $repo->getQueryBuilderForFormType(new ActivityFormTypeQuery($activity, $data['project']));
},
]);
$event->getForm()->add('activity', ActivityType::class, array_merge($activityOptions, [
'projects' => $data['project'],
]));
}
);

View File

@@ -13,8 +13,6 @@ use App\Entity\Customer;
use App\Entity\Project;
use App\Form\Type\CustomerType;
use App\Form\Type\DateTimePickerType;
use App\Repository\CustomerRepository;
use App\Repository\Query\CustomerFormTypeQuery;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
@@ -90,12 +88,8 @@ class ProjectEditForm extends AbstractType
]))
->add('customer', CustomerType::class, [
'placeholder' => (null === $id && null === $customer) ? '' : false,
'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) {
$query = new CustomerFormTypeQuery($customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
'customers' => $customer,
'query_builder_for_user' => true,
]);
$this->addCommonFields($builder, $options);

View File

@@ -0,0 +1,89 @@
<?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\Configuration\SystemConfiguration;
use App\Form\Type\QuickEntryWeekType;
use App\Form\Type\WeekPickerType;
use App\Model\QuickEntryWeek;
use App\Validator\Constraints\QuickEntryModel;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\All;
use Symfony\Component\Validator\Constraints\Valid;
class QuickEntryForm extends AbstractType
{
private $configuration;
public function __construct(SystemConfiguration $configuration)
{
$this->configuration = $configuration;
}
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$startDate = new \DateTime();
if ($builder->getData() !== null) {
/** @var QuickEntryWeek $data */
$data = $builder->getData();
$startDate = $data->getDate();
}
$builder->add('date', WeekPickerType::class, [
'model_timezone' => $options['timezone'],
'view_timezone' => $options['timezone'],
'start_date' => $options['start_date'],
'label' => false,
]);
$builder->add('rows', CollectionType::class, [
'label' => false,
'entry_type' => QuickEntryWeekType::class,
'entry_options' => [
'label' => false,
'duration_minutes' => $this->configuration->getTimesheetIncrementDuration(),
'start_date' => $startDate,
'empty_data' => function (FormInterface $form) use ($options) {
return clone $options['prototype_data'];
},
'prototype_data' => clone $options['prototype_data'],
],
'prototype_data' => $options['prototype_data'],
'allow_add' => true,
'constraints' => [
new Valid(),
new All(['constraints' => [new QuickEntryModel()]])
],
]);
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'timesheet_quick_edit',
'data_class' => QuickEntryWeek::class,
'timezone' => date_default_timezone_get(),
'start_date' => new \DateTime(),
'prototype_data' => null,
]);
}
}

View File

@@ -14,6 +14,7 @@ use App\Repository\ActivityRepository;
use App\Repository\Query\ActivityFormTypeQuery;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
@@ -78,10 +79,30 @@ class ActivityType extends AbstractType
'choice_label' => [$this, 'choiceLabel'],
'group_by' => [$this, 'groupBy'],
'choice_attr' => [$this, 'choiceAttr'],
'query_builder' => function (ActivityRepository $repo) {
return $repo->getQueryBuilderForFormType(new ActivityFormTypeQuery());
},
'query_builder_for_user' => true,
// @var Project|Project[]|int|int[]|null
'projects' => null,
// @var Activity|Activity[]|int|int[]|null
'activities' => null,
// @var Activity|null
'ignore_activity' => null,
]);
$resolver->setDefault('query_builder', function (Options $options) {
return function (ActivityRepository $repo) use ($options) {
$query = new ActivityFormTypeQuery($options['activities'], $options['projects']);
if (true === $options['query_builder_for_user']) {
$query->setUser($options['user']);
}
if (null !== $options['ignore_activity']) {
$query->setActivityToIgnore($options['ignore_activity']);
}
return $repo->getQueryBuilderForFormType($query);
};
});
}
/**

View File

@@ -44,15 +44,23 @@ class CustomerType extends AbstractType
'end_date_param' => '%end%',
'ignore_date' => false,
'project_visibility' => ProjectQuery::SHOW_VISIBLE,
// @var Customer|null
'ignore_customer' => null,
// @var Customer|Customer[]|null
'customers' => null,
]);
$resolver->setDefault('query_builder', function (Options $options) {
return function (CustomerRepository $repo) use ($options) {
$query = new CustomerFormTypeQuery();
$query = new CustomerFormTypeQuery($options['customers']);
if (true === $options['query_builder_for_user']) {
$query->setUser($options['user']);
}
if (null !== $options['ignore_customer']) {
$query->setCustomerToIgnore($options['ignore_customer']);
}
return $repo->getQueryBuilderForFormType($query);
};
});

View File

@@ -38,6 +38,12 @@ class DurationType extends AbstractType
public function buildView(FormView $view, FormInterface $form, array $options)
{
$class = 'duration-input';
if (isset($view->vars['attr']['class'])) {
$class .= ' ' . $view->vars['attr']['class'];
}
$view->vars['attr']['class'] = $class;
if ($options['preset_hours'] === null || $options['preset_minutes'] === null) {
return;
}

View File

@@ -29,6 +29,7 @@ class InitialViewType extends AbstractType
'dashboard' => 'menu.homepage',
'timesheet' => 'menu.timesheet',
'calendar' => 'calendar.title',
'quick_entry' => 'quick_entry.title',
'my_profile' => 'profile.title',
'admin_timesheet' => 'menu.admin_timesheet',
'invoice' => 'menu.invoice',
@@ -49,6 +50,7 @@ class InitialViewType extends AbstractType
'admin_customer' => 'view_customer',
'admin_project' => 'view_project',
'admin_activity' => 'view_activity',
'quick_entry' => 'view_own_timesheet',
];
private $voter;

View File

@@ -68,21 +68,44 @@ class ProjectType extends AbstractType
'activity_visibility' => ActivityQuery::SHOW_VISIBLE,
'ignore_date' => false,
'join_customer' => false,
// @var Project|null
'ignore_project' => null,
// @var Customer|Customer[]|int|int[]|null
'customers' => null,
// @var Project|Project[]|int|int[]|null
'projects' => null,
// @var DateTime|null
'project_date_start' => null,
// @var DateTime|null
'project_date_end' => null,
]);
$resolver->setDefault('query_builder', function (Options $options) {
return function (ProjectRepository $repo) use ($options) {
$query = new ProjectFormTypeQuery();
$query = new ProjectFormTypeQuery($options['projects'], $options['customers']);
if (true === $options['query_builder_for_user']) {
$query->setUser($options['user']);
}
if (true === $options['ignore_date']) {
$query->setIgnoreDate(true);
} else {
if ($options['project_date_start'] !== null) {
$query->setProjectStart($options['project_date_start']);
}
if ($options['project_date_end'] !== null) {
$query->setProjectEnd($options['project_date_end']);
}
}
if (true === $options['join_customer']) {
$query->setWithCustomer(true);
}
if (null !== $options['ignore_project']) {
$query->setProjectToIgnore($options['ignore_project']);
}
return $repo->getQueryBuilderForFormType($query);
};
});

View File

@@ -0,0 +1,108 @@
<?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 App\Entity\Timesheet;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Security\Core\Security;
class QuickEntryTimesheetType extends AbstractType
{
private $security;
public function __construct(Security $security)
{
$this->security = $security;
}
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$durationOptions = [
'label' => false,
'required' => false,
'attr' => [
'placeholder' => '0:00',
],
];
$duration = $options['duration_minutes'];
if ($duration !== null && (int) $duration > 0) {
$durationOptions = array_merge($durationOptions, [
'preset_minutes' => $duration
]);
}
$duration = $options['duration_hours'];
if ($duration !== null && (int) $duration > 0) {
$durationOptions = array_merge($durationOptions, [
'preset_hours' => $duration,
]);
}
$builder->add('duration', DurationType::class, $durationOptions);
$builder->addEventListener(
FormEvents::POST_SET_DATA,
function (FormEvent $event) use ($durationOptions) {
/** @var Timesheet|null $data */
$data = $event->getData();
if (null === $data || null === $data->getEnd()) {
$event->getForm()->get('duration')->setData(null);
}
if (null !== $data && !$this->security->isGranted('edit', $data)) {
$event->getForm()->add('duration', DurationType::class, array_merge(['disabled' => true], $durationOptions));
}
}
);
// make sure that duration is mapped back to end field
$builder->addEventListener(
FormEvents::SUBMIT,
function (FormEvent $event) {
/** @var Timesheet $data */
$data = $event->getData();
$duration = $data->getDuration(false);
try {
if (null !== $duration) {
$end = clone $data->getBegin();
$end->modify('+ ' . $duration . ' seconds');
$data->setEnd($end);
} else {
$data->setDuration(null);
}
} catch (\Exception $e) {
$event->getForm()->addError(new FormError($e->getMessage()));
}
}
);
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Timesheet::class,
'timezone' => date_default_timezone_get(),
'duration_minutes' => null,
'duration_hours' => 10,
]);
}
}

View File

@@ -0,0 +1,180 @@
<?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 App\Model\QuickEntryModel;
use App\Validator\Constraints\QuickEntryTimesheet;
use DateTime;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\All;
use Symfony\Component\Validator\Constraints\Valid;
class QuickEntryWeekType extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$projectOptions = [
'label' => false,
'required' => false,
'join_customer' => true,
'query_builder_for_user' => true,
'placeholder' => '',
'activity_enabled' => true
];
$builder->add('project', ProjectType::class, $projectOptions);
$projectFunction = function (FormEvent $event) use ($projectOptions) {
/** @var QuickEntryModel|null $data */
$data = $event->getData();
if ($data === null || $data->getProject() === null) {
return;
}
$begin = clone $data->getFirstEntry()->getBegin();
$begin->setTime(0, 0, 0);
$projectOptions['project_date_start'] = $begin;
$end = clone $data->getLatestEntry()->getBegin();
$end->setTime(23, 59, 59);
$projectOptions['project_date_end'] = $begin;
$projectOptions['projects'] = [$data->getProject()];
$event->getForm()->add('project', ProjectType::class, $projectOptions);
};
$builder->addEventListener(FormEvents::PRE_SET_DATA, $projectFunction);
$activityOptions = [
'label' => false,
'required' => false,
'placeholder' => '',
'query_builder_for_user' => true,
];
$builder->add('activity', ActivityType::class, $activityOptions);
$activityFunction = function (FormEvent $event) use ($activityOptions) {
/** @var QuickEntryModel|null $data */
$data = $event->getData();
if ($data === null || $data->getActivity() === null) {
return;
}
$activityOptions['activities'] = [$data->getActivity()];
$activityOptions['projects'] = [$data->getProject()];
$event->getForm()->add('activity', ActivityType::class, $activityOptions);
};
$builder->addEventListener(FormEvents::PRE_SET_DATA, $activityFunction);
$builder->add('timesheets', CollectionType::class, [
'entry_type' => QuickEntryTimesheetType::class,
'label' => false,
'entry_options' => [
'label' => false,
'compound' => true,
'timezone' => $options['timezone'],
'duration_minutes' => $options['duration_minutes'],
'duration_hours' => $options['duration_hours'],
],
'allow_add' => true,
'constraints' => [
new Valid(),
new All(['constraints' => [new QuickEntryTimesheet()]])
],
]);
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($options) {
if ($event->getData() === null) {
$event->setData(clone $options['prototype_data']);
}
});
$builder->addModelTransformer(new CallbackTransformer(
function ($transformValue) use ($options) {
/** @var QuickEntryModel|null $transformValue */
if ($transformValue === null || $transformValue->isPrototype()) {
return $transformValue;
}
$project = $transformValue->getProject();
$activity = $transformValue->getActivity();
// this case needs to be handled by the validator
if ($project === null || $activity === null) {
return $transformValue;
}
foreach ($transformValue->getTimesheets() as $timesheet) {
$timesheet->setUser($transformValue->getUser() ?? $options['user']);
$timesheet->setProject($project);
$timesheet->setActivity($activity);
}
return $transformValue;
},
function ($reverseTransformValue) {
return $reverseTransformValue;
}
));
// make sure that duration is mapped back to end field
$builder->addEventListener(
FormEvents::SUBMIT,
function (FormEvent $event) {
/** @var QuickEntryModel $data */
$data = $event->getData();
$newRecords = $data->getNewTimesheet();
$user = $data->getUser();
$project = $data->getProject();
$activity = $data->getActivity();
foreach ($newRecords as $record) {
if ($user !== null) {
$record->setUser($user);
}
if ($project !== null) {
$record->setProject($project);
}
if ($activity !== null) {
$record->setActivity($activity);
}
}
}
);
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => QuickEntryModel::class,
'timezone' => date_default_timezone_get(),
'duration_minutes' => null,
'duration_hours' => 10,
'start_date' => new DateTime(),
'prototype_data' => null,
]);
}
}

View File

@@ -0,0 +1,195 @@
<?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\Activity;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Entity\User;
/**
* @internal
*/
class QuickEntryModel
{
private $user;
private $project;
private $activity;
/**
* @var Timesheet[]
*/
private $timesheets = [];
public function __construct(?User $user = null, ?Project $project = null, ?Activity $activity = null)
{
$this->user = $user;
$this->project = $project;
$this->activity = $activity;
}
public function isPrototype(): bool
{
if ($this->hasExistingTimesheet()) {
return false;
}
if ($this->hasNewTimesheet()) {
return false;
}
return $this->getUser() === null && $this->getProject() === null && $this->getActivity() === null;
}
public function getUser(): ?User
{
return $this->user;
}
public function getProject(): ?Project
{
return $this->project;
}
public function setProject(?Project $project): void
{
$this->project = $project;
}
public function getActivity(): ?Activity
{
return $this->activity;
}
public function setActivity(?Activity $activity): void
{
$this->activity = $activity;
}
public function hasExistingTimesheet(): bool
{
foreach ($this->timesheets as $timesheet) {
if ($timesheet->getId() !== null) {
return true;
}
}
return false;
}
/**
* @return Timesheet[]
*/
public function getNewTimesheet(): array
{
$new = [];
foreach ($this->timesheets as $timesheet) {
if ($timesheet->getId() === null && $timesheet->getDuration(false) !== null) {
$new[] = $timesheet;
}
}
return $new;
}
public function hasNewTimesheet(): bool
{
return \count($this->getNewTimesheet()) > 0;
}
public function hasTimesheetWithDuration(): bool
{
foreach ($this->timesheets as $timesheet) {
if ($timesheet->getDuration(false) !== null) {
return true;
}
}
return false;
}
/**
* @return Timesheet[]
*/
public function getTimesheets(): array
{
return $this->timesheets;
}
public function addTimesheet(Timesheet $timesheet): void
{
$this->timesheets[] = $timesheet;
}
/**
* @param Timesheet[] $timesheets
*/
public function setTimesheets(array $timesheets): void
{
$this->timesheets = [];
foreach ($timesheets as $timesheet) {
$this->addTimesheet($timesheet);
}
}
public function getLatestEntry(): ?Timesheet
{
$latest = null;
foreach ($this->timesheets as $timesheet) {
if ($timesheet->getBegin() === null) {
continue;
}
if ($latest === null) {
$latest = $timesheet;
continue;
}
if ($latest->getBegin() < $timesheet->getBegin()) {
$latest = $timesheet;
}
}
return $latest;
}
public function getFirstEntry(): ?Timesheet
{
$first = null;
foreach ($this->timesheets as $timesheet) {
if ($timesheet->getBegin() === null) {
continue;
}
if ($first === null) {
$first = $timesheet;
continue;
}
if ($first->getBegin() > $timesheet->getBegin()) {
$first = $timesheet;
}
}
return $first;
}
public function __clone()
{
$records = $this->timesheets;
$this->timesheets = [];
foreach ($records as $record) {
$this->timesheets[] = clone $record;
}
}
}

View File

@@ -0,0 +1,50 @@
<?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;
/**
* @internal
*/
class QuickEntryWeek
{
private $date;
private $rows;
/**
* @param \DateTime $startDate
* @param QuickEntryModel[] $rows
*/
public function __construct(\DateTime $startDate, array $rows)
{
$this->date = $startDate;
$this->rows = $rows;
}
public function getDate(): \DateTime
{
return $this->date;
}
/**
* @return QuickEntryModel[]
*/
public function getRows(): array
{
return $this->rows;
}
/**
* @param QuickEntryModel[] $rows
*/
public function setRows(array $rows): void
{
$this->rows = $rows;
}
}

View File

@@ -24,6 +24,7 @@ use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\ORMException;
use Doctrine\ORM\Query;
use Doctrine\ORM\Query\Expr\Andx;
use Doctrine\ORM\QueryBuilder;
use Pagerfanta\Pagerfanta;
@@ -160,16 +161,26 @@ class ActivityRepository extends EntityRepository
return $stats;
}
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [], bool $globalsOnly = false)
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [], bool $globalsOnly = false): void
{
$permissions = $this->getPermissionCriteria($qb, $user, $teams, $globalsOnly);
if ($permissions->count() > 0) {
$qb->andWhere($permissions);
}
}
private function getPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [], bool $globalsOnly = false): Andx
{
$andX = $qb->expr()->andX();
// make sure that all queries without a user see all projects
if (null === $user && empty($teams)) {
return;
return $andX;
}
// make sure that admins see all activities
if (null !== $user && $user->canSeeAllData()) {
return;
return $andX;
}
if (null !== $user) {
@@ -177,33 +188,33 @@ class ActivityRepository extends EntityRepository
}
if (empty($teams)) {
$qb->andWhere('SIZE(a.teams) = 0');
$andX->add('SIZE(a.teams) = 0');
if (!$globalsOnly) {
$qb->andWhere('SIZE(p.teams) = 0');
$qb->andWhere('SIZE(c.teams) = 0');
$andX->add('SIZE(p.teams) = 0');
$andX->add('SIZE(c.teams) = 0');
}
return;
return $andX;
}
$orActivity = $qb->expr()->orX(
'SIZE(a.teams) = 0',
$qb->expr()->isMemberOf(':teams', 'a.teams')
);
$qb->andWhere($orActivity);
$andX->add($orActivity);
if (!$globalsOnly) {
$orProject = $qb->expr()->orX(
'SIZE(p.teams) = 0',
$qb->expr()->isMemberOf(':teams', 'p.teams')
);
$qb->andWhere($orProject);
$andX->add($orProject);
$orCustomer = $qb->expr()->orX(
'SIZE(c.teams) = 0',
$qb->expr()->isMemberOf(':teams', 'c.teams')
);
$qb->andWhere($orCustomer);
$andX->add($orCustomer);
}
$ids = array_values(array_unique(array_map(function (Team $team) {
@@ -211,6 +222,8 @@ class ActivityRepository extends EntityRepository
}, $teams)));
$qb->setParameter('teams', $ids);
return $andX;
}
/**
@@ -242,9 +255,9 @@ class ActivityRepository extends EntityRepository
->addOrderBy('a.name', 'ASC')
;
$where = $qb->expr()->andX();
$mainQuery = $qb->expr()->andX();
$where->add($qb->expr()->eq('a.visible', ':visible'));
$mainQuery->add($qb->expr()->eq('a.visible', ':visible'));
$qb->setParameter('visible', true, \PDO::PARAM_BOOL);
if (!$query->isGlobalsOnly()) {
@@ -254,7 +267,7 @@ class ActivityRepository extends EntityRepository
->leftJoin('a.project', 'p')
->leftJoin('p.customer', 'c');
$where->add(
$mainQuery->add(
$qb->expr()->orX(
$qb->expr()->isNull('a.project'),
$qb->expr()->andX(
@@ -268,9 +281,9 @@ class ActivityRepository extends EntityRepository
}
if ($query->isGlobalsOnly()) {
$where->add($qb->expr()->isNull('a.project'));
$mainQuery->add($qb->expr()->isNull('a.project'));
} elseif ($query->hasProjects()) {
$where->add(
$mainQuery->add(
$qb->expr()->orX(
$qb->expr()->isNull('a.project'),
$qb->expr()->in('a.project', ':project')
@@ -279,28 +292,30 @@ class ActivityRepository extends EntityRepository
$qb->setParameter('project', $query->getProjects());
}
if (null !== $query->getActivityToIgnore()) {
$qb->andWhere($qb->expr()->neq('a.id', ':ignored'));
$qb->setParameter('ignored', $query->getActivityToIgnore());
$permissions = $this->getPermissionCriteria($qb, $query->getUser(), $query->getTeams(), $query->isGlobalsOnly());
if ($permissions->count() > 0) {
$mainQuery->add($permissions);
}
$this->addPermissionCriteria($qb, $query->getUser(), $query->getTeams(), $query->isGlobalsOnly());
$outerQuery = $qb->expr()->orX();
$or = $qb->expr()->orX();
// this must always be the last part before the or
$or->add($where);
// this must always be the last part of the query
if ($query->hasActivities()) {
$or->add($qb->expr()->in('a.id', ':activity'));
$outerQuery->add($qb->expr()->in('a.id', ':activity'));
$qb->setParameter('activity', $query->getActivities());
}
if ($or->count() > 0) {
$qb->andWhere($or);
if (null !== $query->getActivityToIgnore()) {
$mainQuery = $qb->expr()->andX(
$mainQuery,
$qb->expr()->neq('a.id', ':ignored')
);
$qb->setParameter('ignored', $query->getActivityToIgnore());
}
$outerQuery->add($mainQuery);
$qb->andWhere($outerQuery);
return $qb;
}

View File

@@ -25,6 +25,7 @@ use App\Repository\Query\CustomerQuery;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\ORMException;
use Doctrine\ORM\Query;
use Doctrine\ORM\Query\Expr\Andx;
use Doctrine\ORM\QueryBuilder;
use Pagerfanta\Pagerfanta;
@@ -157,14 +158,24 @@ class CustomerRepository extends EntityRepository
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [])
{
$permissions = $this->getPermissionCriteria($qb, $user, $teams);
if ($permissions->count() > 0) {
$qb->andWhere($permissions);
}
}
private function getPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): Andx
{
$andX = $qb->expr()->andX();
// make sure that all queries without a user see all customers
if (null === $user && empty($teams)) {
return;
return $andX;
}
// make sure that admins see all customers
if (null !== $user && $user->canSeeAllData()) {
return;
return $andX;
}
if (null !== $user) {
@@ -172,22 +183,24 @@ class CustomerRepository extends EntityRepository
}
if (empty($teams)) {
$qb->andWhere('SIZE(c.teams) = 0');
$andX->add('SIZE(c.teams) = 0');
return;
return $andX;
}
$or = $qb->expr()->orX(
'SIZE(c.teams) = 0',
$qb->expr()->isMemberOf(':teams', 'c.teams')
);
$qb->andWhere($or);
$andX->add($or);
$ids = array_values(array_unique(array_map(function (Team $team) {
return $team->getId();
}, $teams)));
$qb->setParameter('teams', $ids);
return $andX;
}
/**
@@ -216,21 +229,33 @@ class CustomerRepository extends EntityRepository
->from(Customer::class, 'c')
->orderBy('c.name', 'ASC');
// TODO this where and the next if($query->hasCustomers()) should go into their own $qb->expr()->orX()
$qb->andWhere($qb->expr()->eq('c.visible', ':visible'));
$mainQuery = $qb->expr()->andX();
$mainQuery->add($qb->expr()->eq('c.visible', ':visible'));
$qb->setParameter('visible', true, \PDO::PARAM_BOOL);
$permissions = $this->getPermissionCriteria($qb, $query->getUser(), $query->getTeams());
if ($permissions->count() > 0) {
$mainQuery->add($permissions);
}
$outerQuery = $qb->expr()->orX();
if ($query->hasCustomers()) {
$qb->orWhere($qb->expr()->in('c.id', ':customer'))
->setParameter('customer', $query->getCustomers());
$outerQuery->add($qb->expr()->in('c.id', ':customer'));
$qb->setParameter('customer', $query->getCustomers());
}
if (null !== $query->getCustomerToIgnore()) {
$qb->andWhere($qb->expr()->neq('c.id', ':ignored'));
$mainQuery = $qb->expr()->andX(
$mainQuery,
$qb->expr()->neq('c.id', ':ignored')
);
$qb->setParameter('ignored', $query->getCustomerToIgnore());
}
$this->addPermissionCriteria($qb, $query->getUser(), $query->getTeams());
$outerQuery->add($mainQuery);
$qb->andWhere($outerQuery);
return $qb;
}

View File

@@ -26,6 +26,7 @@ use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\ORMException;
use Doctrine\ORM\Query;
use Doctrine\ORM\Query\Expr\Andx;
use Doctrine\ORM\QueryBuilder;
use Pagerfanta\Pagerfanta;
@@ -182,16 +183,26 @@ class ProjectRepository extends EntityRepository
return $stats;
}
public function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [])
public function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): void
{
$permissions = $this->getPermissionCriteria($qb, $user, $teams);
if ($permissions->count() > 0) {
$qb->andWhere($permissions);
}
}
public function getPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): Andx
{
$andX = $qb->expr()->andX();
// make sure that all queries without a user see all projects
if (null === $user && empty($teams)) {
return;
return $andX;
}
// make sure that admins see all projects
if (null !== $user && $user->canSeeAllData()) {
return;
return $andX;
}
if (null !== $user) {
@@ -199,29 +210,31 @@ class ProjectRepository extends EntityRepository
}
if (empty($teams)) {
$qb->andWhere('SIZE(c.teams) = 0');
$qb->andWhere('SIZE(p.teams) = 0');
$andX->add('SIZE(c.teams) = 0');
$andX->add('SIZE(p.teams) = 0');
return;
return $andX;
}
$orProject = $qb->expr()->orX(
'SIZE(p.teams) = 0',
$qb->expr()->isMemberOf(':teams', 'p.teams')
);
$qb->andWhere($orProject);
$andX->add($orProject);
$orCustomer = $qb->expr()->orX(
'SIZE(c.teams) = 0',
$qb->expr()->isMemberOf(':teams', 'c.teams')
);
$qb->andWhere($orCustomer);
$andX->add($orCustomer);
$ids = array_values(array_unique(array_map(function (Team $team) {
return $team->getId();
}, $teams)));
$qb->setParameter('teams', $ids);
return $andX;
}
/**
@@ -259,57 +272,46 @@ class ProjectRepository extends EntityRepository
$qb->addSelect('c');
}
$qb->andWhere($qb->expr()->eq('p.visible', ':visible'));
$qb->andWhere($qb->expr()->eq('c.visible', ':customer_visible'));
if (!$query->isIgnoreDate()) {
$now = new DateTime();
$qb->andWhere(
$qb->expr()->andX(
$qb->expr()->orX(
$qb->expr()->lte('p.start', ':start'),
$qb->expr()->isNull('p.start')
),
$qb->expr()->orX(
$qb->expr()->gte('p.end', ':start'),
$qb->expr()->isNull('p.end')
)
)
)->setParameter('start', $now);
$qb->andWhere(
$qb->expr()->andX(
$qb->expr()->orX(
$qb->expr()->gte('p.end', ':end'),
$qb->expr()->isNull('p.end')
),
$qb->expr()->orX(
$qb->expr()->lte('p.start', ':end'),
$qb->expr()->isNull('p.start')
)
)
)->setParameter('end', $now);
}
$mainQuery = $qb->expr()->andX();
$mainQuery->add($qb->expr()->eq('p.visible', ':visible'));
$qb->setParameter('visible', true, \PDO::PARAM_BOOL);
$mainQuery->add($qb->expr()->eq('c.visible', ':customer_visible'));
$qb->setParameter('customer_visible', true, \PDO::PARAM_BOOL);
if ($query->hasProjects()) {
$qb->orWhere($qb->expr()->in('p.id', ':project'))
->setParameter('project', $query->getProjects());
if (!$query->isIgnoreDate()) {
$andx = $this->addProjectStartAndEndDate($qb, $query->getProjectStart(), $query->getProjectEnd());
$mainQuery->add($andx);
}
if ($query->hasCustomers()) {
$qb->andWhere($qb->expr()->in('p.customer', ':customer'))
->setParameter('customer', $query->getCustomers());
$mainQuery->add($qb->expr()->in('p.customer', ':customer'));
$qb->setParameter('customer', $query->getCustomers());
}
$permissions = $this->getPermissionCriteria($qb, $query->getUser(), $query->getTeams());
if ($permissions->count() > 0) {
$mainQuery->add($permissions);
}
$outerQuery = $qb->expr()->orX();
if ($query->hasProjects()) {
$outerQuery->add($qb->expr()->in('p.id', ':project'));
$qb->setParameter('project', $query->getProjects());
}
if (null !== $query->getProjectToIgnore()) {
$qb->andWhere($qb->expr()->neq('p.id', ':ignored'));
$mainQuery = $qb->expr()->andX(
$mainQuery,
$qb->expr()->neq('p.id', ':ignored')
);
$qb->setParameter('ignored', $query->getProjectToIgnore());
}
$this->addPermissionCriteria($qb, $query->getUser(), $query->getTeams());
$outerQuery->add($mainQuery);
$qb->andWhere($outerQuery);
return $qb;
}
@@ -369,39 +371,8 @@ class ProjectRepository extends EntityRepository
// begin < to and end = null
// begin > from and end < to
// ... and more ...
$begin = $query->getProjectStart();
$end = $query->getProjectEnd();
if (null !== $begin) {
$qb->andWhere(
$qb->expr()->andX(
$qb->expr()->orX(
$qb->expr()->lte('p.start', ':start'),
$qb->expr()->isNull('p.start')
),
$qb->expr()->orX(
$qb->expr()->gte('p.end', ':start'),
$qb->expr()->isNull('p.end')
)
)
)->setParameter('start', $query->getProjectStart());
}
if (null !== $end) {
$qb->andWhere(
$qb->expr()->andX(
$qb->expr()->orX(
$qb->expr()->gte('p.end', ':end'),
$qb->expr()->isNull('p.end')
),
$qb->expr()->orX(
$qb->expr()->lte('p.start', ':end'),
$qb->expr()->isNull('p.start')
)
)
)->setParameter('end', $query->getProjectEnd());
}
$times = $this->addProjectStartAndEndDate($qb, $query->getProjectStart(), $query->getProjectEnd());
$qb->andWhere($times);
$this->addPermissionCriteria($qb, $query->getCurrentUser());
@@ -440,6 +411,45 @@ class ProjectRepository extends EntityRepository
return $qb;
}
private function addProjectStartAndEndDate(QueryBuilder $qb, ?DateTime $begin, ?DateTime $end): Andx
{
$and = $qb->expr()->andX();
if (null !== $begin) {
$and->add(
$qb->expr()->andX(
$qb->expr()->orX(
$qb->expr()->lte('p.start', ':start'),
$qb->expr()->isNull('p.start')
),
$qb->expr()->orX(
$qb->expr()->gte('p.end', ':start'),
$qb->expr()->isNull('p.end')
)
)
);
$qb->setParameter('start', $begin);
}
if (null !== $end) {
$and->add(
$qb->expr()->andX(
$qb->expr()->orX(
$qb->expr()->gte('p.end', ':end'),
$qb->expr()->isNull('p.end')
),
$qb->expr()->orX(
$qb->expr()->lte('p.start', ':end'),
$qb->expr()->isNull('p.start')
)
)
);
$qb->setParameter('end', $end);
}
return $and;
}
public function countProjectsForQuery(ProjectQuery $query): int
{
$qb = $this->getQueryBuilderForQuery($query);

View File

@@ -14,6 +14,14 @@ use App\Entity\Project;
final class ProjectFormTypeQuery extends BaseFormTypeQuery
{
/**
* @var \DateTime|null
*/
private $projectStart;
/**
* @var \DateTime|null
*/
private $projectEnd;
/**
* @var Project|null
*/
@@ -22,8 +30,8 @@ final class ProjectFormTypeQuery extends BaseFormTypeQuery
private $withCustomer = false;
/**
* @param Project|int|null $project
* @param Customer|int|null $customer
* @param Project|int|null|array<int>|array<Project> $project
* @param Customer|int|null|array<int>|array<Customer> $customer
*/
public function __construct($project = null, $customer = null)
{
@@ -40,13 +48,25 @@ final class ProjectFormTypeQuery extends BaseFormTypeQuery
}
$this->setCustomers($customer);
}
$this->projectStart = $this->projectEnd = new \DateTime();
}
/**
* Whether customers should be joined
*
* @return bool
*/
public function withCustomer(): bool
{
return $this->withCustomer;
}
/**
* Directly join the customer
*
* @param bool $withCustomer
*/
public function setWithCustomer(bool $withCustomer): void
{
$this->withCustomer = $withCustomer;
@@ -60,11 +80,9 @@ final class ProjectFormTypeQuery extends BaseFormTypeQuery
return $this->projectToIgnore;
}
public function setProjectToIgnore(Project $projectToIgnore): ProjectFormTypeQuery
public function setProjectToIgnore(Project $projectToIgnore): void
{
$this->projectToIgnore = $projectToIgnore;
return $this;
}
public function isIgnoreDate(): bool
@@ -72,10 +90,28 @@ final class ProjectFormTypeQuery extends BaseFormTypeQuery
return $this->ignoreDate;
}
public function setIgnoreDate(bool $ignoreDate): ProjectFormTypeQuery
public function setIgnoreDate(bool $ignoreDate): void
{
$this->ignoreDate = $ignoreDate;
}
return $this;
public function getProjectStart(): ?\DateTime
{
return $this->projectStart;
}
public function setProjectStart(?\DateTime $projectStart): void
{
$this->projectStart = $projectStart;
}
public function getProjectEnd(): ?\DateTime
{
return $this->projectEnd;
}
public function setProjectEnd(?\DateTime $projectEnd): void
{
$this->projectEnd = $projectEnd;
}
}

View File

@@ -1052,7 +1052,7 @@ class TimesheetRepository extends EntityRepository
* @param User|null $user
* @param DateTime|null $startFrom
* @param int $limit
* @return array|mixed
* @return Timesheet[]
* @throws \Doctrine\ORM\Query\QueryException
*/
public function getRecentActivities(User $user = null, DateTime $startFrom = null, int $limit = 10)

View File

@@ -99,6 +99,7 @@ final class IconExtension extends AbstractExtension
'users' => 'fas fa-user-friends',
'visibility' => 'far fa-eye',
'warning' => 'fas fa-exclamation-triangle',
'weekly-times' => 'fas fa-th',
'xlsx' => 'fas fa-file-excel',
];

View File

@@ -80,6 +80,14 @@ final class LocaleFormatExtensions extends AbstractExtension
return ($day === 0 || $day === 6);
}),
new TwigTest('today', function ($dateTime) {
if (!$dateTime instanceof \DateTime) {
return false;
}
$compare = new \DateTime('now', $dateTime->getTimezone());
return $compare->format('Y-m-d') === $dateTime->format('Y-m-d');
}),
];
}

View File

@@ -25,7 +25,7 @@ class ProjectValidator extends ConstraintValidator
public function validate($value, Constraint $constraint)
{
if (!($constraint instanceof ProjectConstraint)) {
throw new UnexpectedTypeException($constraint, __NAMESPACE__ . '\Project');
throw new UnexpectedTypeException($constraint, ProjectConstraint::class);
}
if (!\is_object($value) || !($value instanceof Project)) {

View File

@@ -0,0 +1,25 @@
<?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\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
/**
* @Annotation
* @Target({"CLASS"})
*/
class QuickEntryModel extends Constraint
{
public const ACTIVITY_REQUIRED = 'quick-entry-model-01';
public const PROJECT_REQUIRED = 'quick-entry-model-02';
public $messageActivityRequired = 'An activity needs to be selected.';
public $messageProjectRequired = 'A project needs to be selected.';
}

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\Validator\Constraints;
use App\Model\QuickEntryModel;
use App\Validator\Constraints\QuickEntryModel as QuickEntryModelConstraint;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
class QuickEntryModelValidator extends ConstraintValidator
{
/**
* {@inheritdoc}
*/
public function validate($value, Constraint $constraint)
{
if (!$constraint instanceof QuickEntryModelConstraint) {
throw new UnexpectedTypeException($constraint, QuickEntryModelConstraint::class);
}
if (!\is_object($value) || !($value instanceof QuickEntryModel)) {
throw new UnexpectedTypeException($value, QuickEntryModel::class);
}
/** @var QuickEntryModel $model */
$model = $value;
if ($model->isPrototype()) {
return;
}
if ($model->hasExistingTimesheet() || $model->hasNewTimesheet()) {
if ($model->getActivity() === null) {
$this->context->buildViolation($constraint->messageActivityRequired)
->atPath('activity')
->setCode(QuickEntryModelConstraint::ACTIVITY_REQUIRED)
->addViolation();
}
if ($model->getProject() === null) {
$this->context->buildViolation($constraint->messageProjectRequired)
->atPath('project')
->setCode(QuickEntryModelConstraint::PROJECT_REQUIRED)
->addViolation();
}
}
}
}

View File

@@ -0,0 +1,20 @@
<?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\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
/**
* @Annotation
* @Target({"CLASS"})
*/
class QuickEntryTimesheet extends Constraint
{
}

View File

@@ -0,0 +1,61 @@
<?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\Validator\Constraints;
use App\Entity\Timesheet as TimesheetEntity;
use App\Validator\Constraints\QuickEntryTimesheet as QuickEntryTimesheetConstraint;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
class QuickEntryTimesheetValidator extends ConstraintValidator
{
/**
* @var Constraint[]
*/
private $constraints;
/**
* @param Constraint[] $constraints
*/
public function __construct(iterable $constraints)
{
$this->constraints = $constraints;
}
/**
* {@inheritdoc}
*/
public function validate($value, Constraint $constraint)
{
if (!$constraint instanceof QuickEntryTimesheetConstraint) {
throw new UnexpectedTypeException($constraint, QuickEntryTimesheetConstraint::class);
}
if (!\is_object($value) || !($value instanceof TimesheetEntity)) {
throw new UnexpectedTypeException($value, TimesheetEntity::class);
}
/** @var TimesheetEntity $timesheet */
$timesheet = $value;
if ($timesheet->getId() === null && $timesheet->getDuration(false) === null) {
return;
}
foreach ($this->constraints as $constraint) {
$this->context
->getValidator()
->inContext($this->context)
->atPath('duration')
->validate($timesheet, $constraint, [Constraint::DEFAULT_GROUP]);
}
}
}

View File

@@ -18,22 +18,32 @@ use Symfony\Component\Validator\Constraint;
*/
class Timesheet extends Constraint
{
public const MISSING_BEGIN_ERROR = 'kimai-timesheet-81';
public const END_BEFORE_BEGIN_ERROR = 'kimai-timesheet-82';
public const MISSING_ACTIVITY_ERROR = 'kimai-timesheet-84';
public const MISSING_PROJECT_ERROR = 'kimai-timesheet-85';
public const ACTIVITY_PROJECT_MISMATCH_ERROR = 'kimai-timesheet-86';
public const DISABLED_ACTIVITY_ERROR = 'kimai-timesheet-87';
public const DISABLED_PROJECT_ERROR = 'kimai-timesheet-88';
public const DISABLED_CUSTOMER_ERROR = 'kimai-timesheet-89';
public const PROJECT_NOT_STARTED = 'kimai-timesheet-91';
public const PROJECT_ALREADY_ENDED = 'kimai-timesheet-92';
/** @deprecated since 1.15.3 - use TimesheetBasic::MISSING_BEGIN_ERROR instead */
public const MISSING_BEGIN_ERROR = TimesheetBasic::MISSING_BEGIN_ERROR;
/** @deprecated since 1.15.3 - use TimesheetBasic::END_BEFORE_BEGIN_ERROR instead */
public const END_BEFORE_BEGIN_ERROR = TimesheetBasic::END_BEFORE_BEGIN_ERROR;
/** @deprecated since 1.15.3 - use TimesheetBasic::MISSING_ACTIVITY_ERROR instead */
public const MISSING_ACTIVITY_ERROR = TimesheetBasic::MISSING_ACTIVITY_ERROR;
/** @deprecated since 1.15.3 - use TimesheetBasic::MISSING_PROJECT_ERROR instead */
public const MISSING_PROJECT_ERROR = TimesheetBasic::MISSING_PROJECT_ERROR;
/** @deprecated since 1.15.3 - use TimesheetBasic::ACTIVITY_PROJECT_MISMATCH_ERROR instead */
public const ACTIVITY_PROJECT_MISMATCH_ERROR = TimesheetBasic::ACTIVITY_PROJECT_MISMATCH_ERROR;
/** @deprecated since 1.15.3 - use TimesheetBasic::DISABLED_ACTIVITY_ERROR instead */
public const DISABLED_ACTIVITY_ERROR = TimesheetBasic::DISABLED_ACTIVITY_ERROR;
/** @deprecated since 1.15.3 - use TimesheetBasic::DISABLED_PROJECT_ERROR instead */
public const DISABLED_PROJECT_ERROR = TimesheetBasic::DISABLED_PROJECT_ERROR;
/** @deprecated since 1.15.3 - use TimesheetBasic::DISABLED_CUSTOMER_ERROR instead */
public const DISABLED_CUSTOMER_ERROR = TimesheetBasic::DISABLED_CUSTOMER_ERROR;
/** @deprecated since 1.15.3 - use TimesheetBasic::PROJECT_NOT_STARTED instead */
public const PROJECT_NOT_STARTED = TimesheetBasic::PROJECT_NOT_STARTED;
/** @deprecated since 1.15.3 - use TimesheetBasic::PROJECT_ALREADY_ENDED instead */
public const PROJECT_ALREADY_ENDED = TimesheetBasic::PROJECT_ALREADY_ENDED;
protected static $errorNames = [
self::MISSING_BEGIN_ERROR => 'You must submit a begin date.',
self::END_BEFORE_BEGIN_ERROR => 'End date must not be earlier then start date.',
self::MISSING_ACTIVITY_ERROR => 'A timesheet must have an activity.',
self::MISSING_PROJECT_ERROR => 'A timesheet must have a project.',
self::MISSING_ACTIVITY_ERROR => 'An activity needs to be selected.',
self::MISSING_PROJECT_ERROR => 'A project needs to be selected.',
self::ACTIVITY_PROJECT_MISMATCH_ERROR => 'Project mismatch, project specific activity and timesheet project are different.',
self::DISABLED_ACTIVITY_ERROR => 'Cannot start a disabled activity.',
self::DISABLED_PROJECT_ERROR => 'Cannot start a disabled project.',

View File

@@ -0,0 +1,50 @@
<?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\Validator\Constraints;
use Doctrine\Common\Annotations\Annotation\Target;
/**
* @Annotation
* @Target({"CLASS", "PROPERTY", "METHOD", "ANNOTATION"})
*/
class TimesheetBasic extends TimesheetConstraint
{
public const MISSING_BEGIN_ERROR = 'kimai-timesheet-81';
public const END_BEFORE_BEGIN_ERROR = 'kimai-timesheet-82';
public const MISSING_ACTIVITY_ERROR = 'kimai-timesheet-84';
public const MISSING_PROJECT_ERROR = 'kimai-timesheet-85';
public const ACTIVITY_PROJECT_MISMATCH_ERROR = 'kimai-timesheet-86';
public const DISABLED_ACTIVITY_ERROR = 'kimai-timesheet-87';
public const DISABLED_PROJECT_ERROR = 'kimai-timesheet-88';
public const DISABLED_CUSTOMER_ERROR = 'kimai-timesheet-89';
public const PROJECT_NOT_STARTED = 'kimai-timesheet-91';
public const PROJECT_ALREADY_ENDED = 'kimai-timesheet-92';
protected static $errorNames = [
self::MISSING_BEGIN_ERROR => 'You must submit a begin date.',
self::END_BEFORE_BEGIN_ERROR => 'End date must not be earlier then start date.',
self::MISSING_ACTIVITY_ERROR => 'An activity needs to be selected.',
self::MISSING_PROJECT_ERROR => 'A project needs to be selected.',
self::ACTIVITY_PROJECT_MISMATCH_ERROR => 'Project mismatch, project specific activity and timesheet project are different.',
self::DISABLED_ACTIVITY_ERROR => 'Cannot start a disabled activity.',
self::DISABLED_PROJECT_ERROR => 'Cannot start a disabled project.',
self::DISABLED_CUSTOMER_ERROR => 'Cannot start a disabled customer.',
self::PROJECT_NOT_STARTED => 'The project has not started at that time.',
self::PROJECT_ALREADY_ENDED => 'The project is finished at that time.',
];
public $message = 'This timesheet has invalid settings.';
public function getTargets()
{
return self::CLASS_CONSTRAINT;
}
}

View File

@@ -0,0 +1,172 @@
<?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\Validator\Constraints;
use App\Entity\Timesheet as TimesheetEntity;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class TimesheetBasicValidator extends ConstraintValidator
{
/**
* @param TimesheetEntity $timesheet
* @param Constraint $constraint
*/
public function validate($timesheet, Constraint $constraint)
{
if (!($constraint instanceof TimesheetBasic)) {
throw new UnexpectedTypeException($constraint, TimesheetBasic::class);
}
if (!\is_object($timesheet) || !($timesheet instanceof TimesheetEntity)) {
throw new UnexpectedTypeException($timesheet, TimesheetEntity::class);
}
$this->validateBeginAndEnd($timesheet, $this->context);
$this->validateActivityAndProject($timesheet, $this->context);
}
/**
* @param TimesheetEntity $timesheet
* @param ExecutionContextInterface $context
*/
protected function validateBeginAndEnd(TimesheetEntity $timesheet, ExecutionContextInterface $context)
{
$begin = $timesheet->getBegin();
$end = $timesheet->getEnd();
if (null === $begin) {
$context->buildViolation('You must submit a begin date.')
->atPath('begin')
->setTranslationDomain('validators')
->setCode(TimesheetBasic::MISSING_BEGIN_ERROR)
->addViolation();
return;
}
if (null !== $end && $begin > $end) {
$context->buildViolation('End date must not be earlier then start date.')
->atPath('end')
->setTranslationDomain('validators')
->setCode(TimesheetBasic::END_BEFORE_BEGIN_ERROR)
->addViolation();
}
}
/**
* @param TimesheetEntity $timesheet
* @param ExecutionContextInterface $context
*/
protected function validateActivityAndProject(TimesheetEntity $timesheet, ExecutionContextInterface $context)
{
if (null === ($activity = $timesheet->getActivity())) {
$context->buildViolation('An activity needs to be selected.')
->atPath('activity')
->setTranslationDomain('validators')
->setCode(TimesheetBasic::MISSING_ACTIVITY_ERROR)
->addViolation();
}
if (null === ($project = $timesheet->getProject())) {
$context->buildViolation('A project needs to be selected.')
->atPath('project')
->setTranslationDomain('validators')
->setCode(TimesheetBasic::MISSING_PROJECT_ERROR)
->addViolation();
}
if (null === $activity || null === $project) {
return;
}
if (null !== $activity->getProject() && $activity->getProject() !== $project) {
$context->buildViolation('Project mismatch, project specific activity and timesheet project are different.')
->atPath('project')
->setTranslationDomain('validators')
->setCode(TimesheetBasic::ACTIVITY_PROJECT_MISMATCH_ERROR)
->addViolation();
}
$timesheetEnd = $timesheet->getEnd();
$newOrStarted = null === $timesheetEnd || $timesheet->getId() === null;
if ($newOrStarted && !$activity->isVisible()) {
$context->buildViolation('Cannot start a disabled activity.')
->atPath('activity')
->setTranslationDomain('validators')
->setCode(TimesheetBasic::DISABLED_ACTIVITY_ERROR)
->addViolation();
}
if ($newOrStarted && !$project->isVisible()) {
$context->buildViolation('Cannot start a disabled project.')
->atPath('project')
->setTranslationDomain('validators')
->setCode(TimesheetBasic::DISABLED_PROJECT_ERROR)
->addViolation();
}
if ($newOrStarted && !$project->getCustomer()->isVisible()) {
$context->buildViolation('Cannot start a disabled customer.')
->atPath('customer')
->setTranslationDomain('validators')
->setCode(TimesheetBasic::DISABLED_CUSTOMER_ERROR)
->addViolation();
}
$pathStart = 'begin';
$pathEnd = 'end';
$projectBegin = $project->getStart();
$projectEnd = $project->getEnd();
if (null === $projectBegin && null === $projectEnd) {
return;
}
$timesheetStart = $timesheet->getBegin();
$timesheetEnd = $timesheet->getEnd();
if (null !== $timesheetStart && $pathStart !== null) {
if (null !== $projectBegin && $timesheetStart->getTimestamp() < $projectBegin->getTimestamp()) {
$context->buildViolation('The project has not started at that time.')
->atPath($pathStart)
->setTranslationDomain('validators')
->setCode(TimesheetBasic::PROJECT_NOT_STARTED)
->addViolation();
} elseif (null !== $projectEnd && $timesheetStart->getTimestamp() > $projectEnd->getTimestamp()) {
$context->buildViolation('The project is finished at that time.')
->atPath($pathStart)
->setTranslationDomain('validators')
->setCode(TimesheetBasic::PROJECT_ALREADY_ENDED)
->addViolation();
}
}
if (null !== $timesheetEnd && $pathEnd !== null) {
if (null !== $projectEnd && $timesheetEnd->getTimestamp() > $projectEnd->getTimestamp()) {
$context->buildViolation('The project is finished at that time.')
->atPath($pathEnd)
->setTranslationDomain('validators')
->setCode(TimesheetBasic::PROJECT_ALREADY_ENDED)
->addViolation();
} elseif (null !== $projectBegin && $timesheetEnd->getTimestamp() < $projectBegin->getTimestamp()) {
$context->buildViolation('The project has not started at that time.')
->atPath($pathEnd)
->setTranslationDomain('validators')
->setCode(TimesheetBasic::PROJECT_NOT_STARTED)
->addViolation();
}
}
}
}

View File

@@ -76,6 +76,11 @@ final class TimesheetBudgetUsedValidator extends ConstraintValidator
$duration = $timesheet->getEnd()->getTimestamp() - $timesheet->getBegin()->getTimestamp();
}
// this validator needs a project to calculate the rates
if ($timesheet->getProject() === null) {
return;
}
$timeRate = $this->rateService->calculate($timesheet);
$rate = $timeRate->getRate();

View File

@@ -0,0 +1,30 @@
<?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\Validator\Constraints;
final class TimesheetExported extends TimesheetConstraint
{
public const TIMESHEET_EXPORTED = 'kimai-timesheet-exported-01';
protected static $errorNames = [
self::TIMESHEET_EXPORTED => 'This timesheet is already exported.',
];
public $message = 'This timesheet is already exported.';
/**
* @var \DateTime|string|null
*/
public $now;
public function getTargets()
{
return self::CLASS_CONSTRAINT;
}
}

View File

@@ -0,0 +1,55 @@
<?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\Validator\Constraints;
use App\Entity\Timesheet as TimesheetEntity;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class TimesheetExportedValidator extends ConstraintValidator
{
private $security;
public function __construct(Security $security)
{
$this->security = $security;
}
/**
* @param TimesheetEntity $timesheet
* @param Constraint $constraint
*/
public function validate($timesheet, Constraint $constraint)
{
if (!($constraint instanceof TimesheetExported)) {
throw new UnexpectedTypeException($constraint, TimesheetExported::class);
}
if (!\is_object($timesheet) || !($timesheet instanceof TimesheetEntity)) {
throw new UnexpectedTypeException($timesheet, TimesheetEntity::class);
}
if (!$timesheet->isExported()) {
return;
}
if (null !== $this->security->getUser() && $this->security->isGranted('edit_exported_timesheet')) {
return;
}
$this->context->buildViolation(TimesheetExported::getErrorName(TimesheetExported::TIMESHEET_EXPORTED))
->atPath('exported')
->setTranslationDomain('validators')
->setCode(TimesheetExported::TIMESHEET_EXPORTED)
->addViolation();
}
}

View File

@@ -12,12 +12,15 @@ namespace App\Validator\Constraints;
final class TimesheetLongRunning extends TimesheetConstraint
{
public const LONG_RUNNING = 'kimai-timesheet-long-running-01';
public const MAXIMUM = 'kimai-timesheet-long-running-02';
protected static $errorNames = [
self::LONG_RUNNING => 'TIMESHEET_LONG_RUNNING',
self::MAXIMUM => 'MAXIMUM',
];
public $message = 'Maximum duration of {{ value }} hours exceeded.';
public $maximumMessage = 'Maximum duration exceeded.';
public function getTargets()
{

View File

@@ -42,6 +42,18 @@ final class TimesheetLongRunningValidator extends ConstraintValidator
return;
}
// one year is currently the maximum that can be logged (which is already not logically)
// the database column could hold more data, but let's limit it here
if ($timesheet->getDuration() > 31536000) {
$this->context->buildViolation($constraint->maximumMessage)
->setTranslationDomain('validators')
->atPath('duration')
->setCode(TimesheetLongRunning::MAXIMUM)
->addViolation();
return;
}
$maxMinutes = $this->systemConfiguration->getTimesheetLongRunningDuration();
if ($maxMinutes <= 0) {

View File

@@ -28,7 +28,7 @@ class TimesheetMultiUpdate extends Constraint
protected static $errorNames = [
self::MISSING_ACTIVITY_ERROR => 'You need to choose an activity, if the project should be changed.',
self::MISSING_PROJECT_ERROR => 'A timesheet must have a project.',
self::MISSING_PROJECT_ERROR => 'A project needs to be selected.',
self::ACTIVITY_PROJECT_MISMATCH_ERROR => 'Project mismatch: chosen project does not match the activity project.',
self::DISABLED_ACTIVITY_ERROR => 'Cannot start a disabled activity.',
self::DISABLED_PROJECT_ERROR => 'Cannot start a disabled project.',

View File

@@ -13,7 +13,6 @@ use App\Entity\Timesheet as TimesheetEntity;
use App\Validator\Constraints\Timesheet as TimesheetConstraint;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class TimesheetValidator extends ConstraintValidator
@@ -45,10 +44,6 @@ final class TimesheetValidator extends ConstraintValidator
throw new UnexpectedTypeException($timesheet, TimesheetEntity::class);
}
$this->validateBeginAndEnd($timesheet, $this->context);
$this->validateActivityAndProject($timesheet, $this->context);
$this->validateActiveLimit($timesheet, $this->context);
foreach ($this->constraints as $constraint) {
$this->context
->getValidator()
@@ -56,142 +51,4 @@ final class TimesheetValidator extends ConstraintValidator
->validate($timesheet, $constraint, [Constraint::DEFAULT_GROUP]);
}
}
/**
* @param TimesheetEntity $timesheet
* @param ExecutionContextInterface $context
*/
protected function validateActiveLimit(TimesheetEntity $timesheet, ExecutionContextInterface $context)
{
// TODO check active entries against hard_limit
}
/**
* @param TimesheetEntity $timesheet
* @param ExecutionContextInterface $context
*/
protected function validateBeginAndEnd(TimesheetEntity $timesheet, ExecutionContextInterface $context)
{
$begin = $timesheet->getBegin();
$end = $timesheet->getEnd();
if (null === $begin) {
$context->buildViolation('You must submit a begin date.')
->atPath('begin')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::MISSING_BEGIN_ERROR)
->addViolation();
return;
}
if (null !== $end && $begin > $end) {
$context->buildViolation('End date must not be earlier then start date.')
->atPath('end')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::END_BEFORE_BEGIN_ERROR)
->addViolation();
}
}
/**
* @param TimesheetEntity $timesheet
* @param ExecutionContextInterface $context
*/
protected function validateActivityAndProject(TimesheetEntity $timesheet, ExecutionContextInterface $context)
{
if (null === ($activity = $timesheet->getActivity())) {
$context->buildViolation('A timesheet must have an activity.')
->atPath('activity')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::MISSING_ACTIVITY_ERROR)
->addViolation();
}
if (null === ($project = $timesheet->getProject())) {
$context->buildViolation('A timesheet must have a project.')
->atPath('project')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::MISSING_PROJECT_ERROR)
->addViolation();
}
if (null === $activity || null === $project) {
return;
}
if (null !== $activity->getProject() && $activity->getProject() !== $project) {
$context->buildViolation('Project mismatch, project specific activity and timesheet project are different.')
->atPath('project')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::ACTIVITY_PROJECT_MISMATCH_ERROR)
->addViolation();
}
$timesheetEnd = $timesheet->getEnd();
if (null === $timesheetEnd && !$activity->isVisible()) {
$context->buildViolation('Cannot start a disabled activity.')
->atPath('activity')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::DISABLED_ACTIVITY_ERROR)
->addViolation();
}
if (null === $timesheetEnd && !$project->isVisible()) {
$context->buildViolation('Cannot start a disabled project.')
->atPath('project')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::DISABLED_PROJECT_ERROR)
->addViolation();
}
if (null === $timesheetEnd && !$project->getCustomer()->isVisible()) {
$context->buildViolation('Cannot start a disabled customer.')
->atPath('customer')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::DISABLED_CUSTOMER_ERROR)
->addViolation();
}
$projectBegin = $project->getStart();
$projectEnd = $project->getEnd();
if (null !== $projectBegin || null !== $projectEnd) {
$timesheetStart = $timesheet->getBegin();
$timesheetEnd = $timesheet->getEnd();
if (null !== $timesheetStart) {
if (null !== $projectBegin && $timesheetStart->getTimestamp() < $projectBegin->getTimestamp()) {
$context->buildViolation('The project has not started at that time.')
->atPath('begin')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::PROJECT_NOT_STARTED)
->addViolation();
} elseif (null !== $projectEnd && $timesheetStart->getTimestamp() > $projectEnd->getTimestamp()) {
$context->buildViolation('The project is finished at that time.')
->atPath('begin')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::PROJECT_ALREADY_ENDED)
->addViolation();
}
}
if (null !== $timesheetEnd) {
if (null !== $projectEnd && $timesheetEnd->getTimestamp() > $projectEnd->getTimestamp()) {
$context->buildViolation('The project is finished at that time.')
->atPath('end')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::PROJECT_ALREADY_ENDED)
->addViolation();
} elseif (null !== $projectBegin && $timesheetEnd->getTimestamp() < $projectBegin->getTimestamp()) {
$context->buildViolation('The project has not started at that time.')
->atPath('end')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::PROJECT_NOT_STARTED)
->addViolation();
}
}
}
}
}