added project start and end date (#1303)
* added sortable js library * activity in invoice is optional * added javascript widget for paginated boxes * fix activity dropdown for globals only * added timesheet service to reduce code duplication * use repository to query for teams in dropdowns * added project validator * validate project start and end against timesheet * include begin and end in dynamic form requests for projects * added timezone and language option to import flag, improve timesheet import speed * deactivate cross-timezone filter * add virtual fields to field order list * composer update * added param to ignore dates * position loader icon fixed - fixes #1330 * permission problem when creating a new project - fixes #1340 * remove dev dependencies webserver and thanks bundle * stop information leak (begin and end date) in duration mode - fixes #1307 * unify timesheet edit dialog for user and admins * fix security issue, own rates exposed to unauthorized users in multi-update dialog
This commit is contained in:
@@ -16,6 +16,7 @@ use App\Event\ProjectMetaDefinitionEvent;
|
||||
use App\Form\API\ProjectApiEditForm;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\Query\ProjectQuery;
|
||||
use App\Timesheet\UserDateTimeFactory;
|
||||
use App\Utils\SearchTerm;
|
||||
use FOS\RestBundle\Controller\Annotations as Rest;
|
||||
use FOS\RestBundle\Controller\Annotations\RouteResource;
|
||||
@@ -29,6 +30,7 @@ use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\Validator\Constraints;
|
||||
|
||||
/**
|
||||
* @RouteResource("Project")
|
||||
@@ -49,16 +51,21 @@ class ProjectController extends BaseApiController
|
||||
* @var EventDispatcherInterface
|
||||
*/
|
||||
private $dispatcher;
|
||||
/**
|
||||
* @var UserDateTimeFactory
|
||||
*/
|
||||
private $dateTime;
|
||||
|
||||
public function __construct(ViewHandlerInterface $viewHandler, ProjectRepository $repository, EventDispatcherInterface $dispatcher)
|
||||
public function __construct(ViewHandlerInterface $viewHandler, ProjectRepository $repository, EventDispatcherInterface $dispatcher, UserDateTimeFactory $dateTime)
|
||||
{
|
||||
$this->viewHandler = $viewHandler;
|
||||
$this->repository = $repository;
|
||||
$this->dispatcher = $dispatcher;
|
||||
$this->dateTime = $dateTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a collection of projects
|
||||
* Returns a collection of projects.
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
@@ -69,7 +76,10 @@ class ProjectController extends BaseApiController
|
||||
* )
|
||||
* )
|
||||
* @Rest\QueryParam(name="customer", requirements="\d+", strict=true, nullable=true, description="Customer ID to filter projects")
|
||||
* @Rest\QueryParam(name="visible", requirements="\d+", strict=true, nullable=true, description="Visibility status to filter projects (1=visible, 2=hidden, 3=both)")
|
||||
* @Rest\QueryParam(name="visible", requirements="\d+", strict=true, nullable=true, description="Visibility status to filter projects. Allowed values: 1=visible, 2=hidden, 3=both (default; 1)")
|
||||
* @Rest\QueryParam(name="start", requirements=@Constraints\DateTime(format="Y-m-d\TH:i:s"), strict=true, nullable=true, description="Only projects that started before this date will be included. Allowed format: HTML5 (default: now, if end is also empty)")
|
||||
* @Rest\QueryParam(name="end", requirements=@Constraints\DateTime(format="Y-m-d\TH:i:s"), strict=true, nullable=true, description="Only projects that ended after this date will be included. Allowed format: HTML5 (default: now, if start is also empty)")
|
||||
* @Rest\QueryParam(name="ignoreDates", requirements="1", strict=true, nullable=true, description="If set, start and end are completely ignored. Allowed values: 1 (default: off)")
|
||||
* @Rest\QueryParam(name="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order. Allowed values: ASC, DESC (default: ASC)")
|
||||
* @Rest\QueryParam(name="orderBy", requirements="id|name|customer", strict=true, nullable=true, description="The field by which results will be ordered. Allowed values: id, name, customer (default: name)")
|
||||
* @Rest\QueryParam(name="term", requirements="[a-zA-Z0-9 \-,:]+", strict=true, nullable=true, description="Free search term")
|
||||
@@ -98,6 +108,27 @@ class ProjectController extends BaseApiController
|
||||
$query->setVisibility($visible);
|
||||
}
|
||||
|
||||
$ignoreDates = false;
|
||||
if (null !== $paramFetcher->get('ignoreDates')) {
|
||||
$ignoreDates = intval($paramFetcher->get('ignoreDates')) === 1;
|
||||
}
|
||||
|
||||
if (!$ignoreDates) {
|
||||
if (null !== ($begin = $paramFetcher->get('start')) && !empty($begin)) {
|
||||
$query->setProjectStart($this->dateTime->createDateTime($begin));
|
||||
}
|
||||
|
||||
if (null !== ($end = $paramFetcher->get('end')) && !empty($end)) {
|
||||
$query->setProjectEnd($this->dateTime->createDateTime($end));
|
||||
}
|
||||
|
||||
if (empty($begin) && empty($end)) {
|
||||
$now = $this->dateTime->createDateTime();
|
||||
$query->setProjectStart($now);
|
||||
$query->setProjectEnd($now);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($term = $paramFetcher->get('term'))) {
|
||||
$query->setSearchTerm(new SearchTerm($term));
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ use App\Repository\Query\TimesheetQuery;
|
||||
use App\Repository\TagRepository;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use App\Timesheet\RoundingService;
|
||||
use App\Timesheet\TimesheetService;
|
||||
use App\Timesheet\TrackingMode\TrackingModeInterface;
|
||||
use App\Timesheet\TrackingModeService;
|
||||
use App\Timesheet\UserDateTimeFactory;
|
||||
@@ -81,6 +82,10 @@ class TimesheetController extends BaseApiController
|
||||
* @var RoundingService
|
||||
*/
|
||||
private $roundingService;
|
||||
/**
|
||||
* @var TimesheetService
|
||||
*/
|
||||
private $service;
|
||||
|
||||
public function __construct(
|
||||
ViewHandlerInterface $viewHandler,
|
||||
@@ -90,7 +95,8 @@ class TimesheetController extends BaseApiController
|
||||
TagRepository $tagRepository,
|
||||
TrackingModeService $trackingModeService,
|
||||
EventDispatcherInterface $dispatcher,
|
||||
RoundingService $roundingService
|
||||
RoundingService $roundingService,
|
||||
TimesheetService $service
|
||||
) {
|
||||
$this->viewHandler = $viewHandler;
|
||||
$this->repository = $repository;
|
||||
@@ -100,6 +106,7 @@ class TimesheetController extends BaseApiController
|
||||
$this->trackingModeService = $trackingModeService;
|
||||
$this->dispatcher = $dispatcher;
|
||||
$this->roundingService = $roundingService;
|
||||
$this->service = $service;
|
||||
}
|
||||
|
||||
protected function getTrackingMode(): TrackingModeInterface
|
||||
@@ -295,14 +302,9 @@ class TimesheetController extends BaseApiController
|
||||
*/
|
||||
public function postAction(Request $request): Response
|
||||
{
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet->setUser($this->getUser());
|
||||
|
||||
$event = new TimesheetMetaDefinitionEvent($timesheet);
|
||||
$this->dispatcher->dispatch($event);
|
||||
$timesheet = $this->service->createNewTimesheet($this->getUser(), $request);
|
||||
|
||||
$mode = $this->getTrackingMode();
|
||||
$mode->create($timesheet, $request);
|
||||
|
||||
$form = $this->createForm(TimesheetApiEditForm::class, $timesheet, [
|
||||
'include_rate' => $this->isGranted('edit_rate', $timesheet),
|
||||
@@ -316,18 +318,16 @@ class TimesheetController extends BaseApiController
|
||||
$form->submit($request->request->all(), false);
|
||||
|
||||
if ($form->isValid()) {
|
||||
if (null === $timesheet->getEnd()) {
|
||||
if (!$this->isGranted('start', $timesheet)) {
|
||||
throw new AccessDeniedHttpException('You are not allowed to start this timesheet record');
|
||||
try {
|
||||
$this->service->saveNewTimesheet($timesheet);
|
||||
} catch (\Exception $ex) {
|
||||
if ($ex->getMessage() === 'timesheet.start.exceeded_limit') {
|
||||
throw new BadRequestHttpException('Too many active timesheets');
|
||||
} else {
|
||||
throw $ex;
|
||||
}
|
||||
$this->repository->stopActiveEntries(
|
||||
$timesheet->getUser(),
|
||||
$this->configuration->getActiveEntriesHardLimit()
|
||||
);
|
||||
}
|
||||
|
||||
$this->repository->save($timesheet);
|
||||
|
||||
$view = new View($timesheet, 200);
|
||||
$view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']);
|
||||
|
||||
@@ -559,7 +559,7 @@ class TimesheetController extends BaseApiController
|
||||
throw new AccessDeniedHttpException('You are not allowed to stop this timesheet');
|
||||
}
|
||||
|
||||
$this->repository->stopRecording($timesheet);
|
||||
$this->service->stopTimesheet($timesheet);
|
||||
|
||||
$view = new View($timesheet, 200);
|
||||
$view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']);
|
||||
@@ -600,13 +600,10 @@ class TimesheetController extends BaseApiController
|
||||
throw new AccessDeniedHttpException('You are not allowed to re-start this timesheet');
|
||||
}
|
||||
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
$copyTimesheet = $this->service->createNewTimesheet($this->getUser());
|
||||
|
||||
$copyTimesheet = new Timesheet();
|
||||
$copyTimesheet
|
||||
->setBegin($this->dateTime->createDateTime())
|
||||
->setUser($user)
|
||||
->setActivity($timesheet->getActivity())
|
||||
->setProject($timesheet->getProject())
|
||||
;
|
||||
@@ -642,12 +639,7 @@ class TimesheetController extends BaseApiController
|
||||
throw new BadRequestHttpException($errors[0]->getPropertyPath() . ' = ' . $errors[0]->getMessage());
|
||||
}
|
||||
|
||||
$this->repository->stopActiveEntries(
|
||||
$user,
|
||||
$this->configuration->getActiveEntriesHardLimit()
|
||||
);
|
||||
|
||||
$this->repository->save($copyTimesheet);
|
||||
$this->service->saveNewTimesheet($copyTimesheet);
|
||||
|
||||
$view = new View($copyTimesheet, 200);
|
||||
$view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']);
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\User;
|
||||
use Symfony\Bridge\Doctrine\RegistryInterface;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\QuestionHelper;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
@@ -21,34 +21,23 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
|
||||
/**
|
||||
* Command used to create application user.
|
||||
*/
|
||||
class CreateUserCommand extends Command
|
||||
final class CreateUserCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var UserPasswordEncoderInterface
|
||||
*/
|
||||
protected $encoder;
|
||||
private $encoder;
|
||||
/**
|
||||
* @var RegistryInterface
|
||||
* @var ManagerRegistry
|
||||
*/
|
||||
protected $doctrine;
|
||||
private $doctrine;
|
||||
/**
|
||||
* @var ValidatorInterface
|
||||
*/
|
||||
protected $validator;
|
||||
private $validator;
|
||||
|
||||
/**
|
||||
* @param UserPasswordEncoderInterface $encoder
|
||||
* @param RegistryInterface $registry
|
||||
* @param ValidatorInterface $validator
|
||||
*/
|
||||
public function __construct(
|
||||
UserPasswordEncoderInterface $encoder,
|
||||
RegistryInterface $registry,
|
||||
ValidatorInterface $validator
|
||||
) {
|
||||
public function __construct(UserPasswordEncoderInterface $encoder, ManagerRegistry $registry, ValidatorInterface $validator)
|
||||
{
|
||||
$this->encoder = $encoder;
|
||||
$this->doctrine = $registry;
|
||||
$this->validator = $validator;
|
||||
@@ -130,14 +119,14 @@ class CreateUserCommand extends Command
|
||||
$entityManager->persist($user);
|
||||
$entityManager->flush();
|
||||
$io->success('Success! Created user: ' . $user->getUsername());
|
||||
|
||||
return 0;
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Failed to create user: ' . $user->getUsername());
|
||||
$io->error('Reason: ' . $ex->getMessage());
|
||||
|
||||
return 2;
|
||||
}
|
||||
|
||||
return 2;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -448,6 +448,7 @@ class ImportTimesheetCommand extends Command
|
||||
if (is_int($customer)) {
|
||||
$tmpFallback = $this->customers->find($fallback);
|
||||
} else {
|
||||
/** @var Customer|null $tmpFallback */
|
||||
$tmpFallback = $this->customers->findOneBy(['name' => $fallback]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,25 +11,34 @@ namespace App\Command;
|
||||
|
||||
use App\Doctrine\TimesheetSubscriber;
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\ActivityMeta;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\CustomerMeta;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\ProjectMeta;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
use App\Entity\UserPreference;
|
||||
use App\Timesheet\Util;
|
||||
use Doctrine\Common\Persistence\ObjectManager;
|
||||
use DateTime;
|
||||
use DateTimeZone;
|
||||
use Doctrine\DBAL\Configuration;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\DriverManager;
|
||||
use Doctrine\DBAL\Types\DateTimeType;
|
||||
use Doctrine\DBAL\Types\Type;
|
||||
use Symfony\Bridge\Doctrine\RegistryInterface;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
use Exception;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
|
||||
/**
|
||||
@@ -39,7 +48,7 @@ use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
* This command is way to messy and complex to be tested ... so we use something, which I actually don't like:
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
class KimaiImporterCommand extends Command
|
||||
final class KimaiImporterCommand extends Command
|
||||
{
|
||||
// minimum required Kimai and database version, lower versions are not supported by this command
|
||||
public const MIN_VERSION = '1.0.1';
|
||||
@@ -57,7 +66,7 @@ class KimaiImporterCommand extends Command
|
||||
protected $validator;
|
||||
/**
|
||||
* Connection to the Kimai v2 database to write imported data to
|
||||
* @var RegistryInterface
|
||||
* @var ManagerRegistry
|
||||
*/
|
||||
protected $doctrine;
|
||||
/**
|
||||
@@ -96,16 +105,8 @@ class KimaiImporterCommand extends Command
|
||||
*/
|
||||
protected $oldActivities = [];
|
||||
|
||||
/**
|
||||
* @param UserPasswordEncoderInterface $encoder
|
||||
* @param RegistryInterface $registry
|
||||
* @param ValidatorInterface $validator
|
||||
*/
|
||||
public function __construct(
|
||||
UserPasswordEncoderInterface $encoder,
|
||||
RegistryInterface $registry,
|
||||
ValidatorInterface $validator
|
||||
) {
|
||||
public function __construct(UserPasswordEncoderInterface $encoder, ManagerRegistry $registry, ValidatorInterface $validator)
|
||||
{
|
||||
$this->encoder = $encoder;
|
||||
$this->doctrine = $registry;
|
||||
$this->validator = $validator;
|
||||
@@ -125,12 +126,14 @@ class KimaiImporterCommand extends Command
|
||||
->addArgument(
|
||||
'connection',
|
||||
InputArgument::REQUIRED,
|
||||
'The database connection as URL, e.g.: mysql://user:password@127.0.0.1:3306/kimai?charset=latin1'
|
||||
'The database connection as URL, e.g.: mysql://user:password@127.0.0.1:3306/kimai?charset=utf8'
|
||||
)
|
||||
->addArgument('prefix', InputArgument::REQUIRED, 'The database prefix for the old Kimai v1 tables')
|
||||
->addArgument('password', InputArgument::REQUIRED, 'The new password for all imported user')
|
||||
->addArgument('country', InputArgument::OPTIONAL, 'The default country for customer (2-character uppercase)', 'DE')
|
||||
->addArgument('currency', InputArgument::OPTIONAL, 'The default currency for customer (code like EUR, CHF, GBP or USD)', 'EUR')
|
||||
->addOption('timezone', null, InputOption::VALUE_OPTIONAL, 'Default timezone for imported users', date_default_timezone_get())
|
||||
->addOption('language', null, InputOption::VALUE_OPTIONAL, 'Default language for imported users', User::DEFAULT_LANGUAGE)
|
||||
;
|
||||
}
|
||||
|
||||
@@ -140,7 +143,7 @@ class KimaiImporterCommand extends Command
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
{
|
||||
// do not convert the times, Kimai 1 stored them already in UTC
|
||||
Type::overrideType(Type::DATETIME, DateTimeType::class);
|
||||
Type::overrideType(Types::DATETIME_MUTABLE, DateTimeType::class);
|
||||
|
||||
// don't calculate rates ... this was done in Kimai 1
|
||||
$this->deactivateLifecycleCallbacks($this->getDoctrine()->getConnection());
|
||||
@@ -157,25 +160,25 @@ class KimaiImporterCommand extends Command
|
||||
if (trim(strlen($password)) < 6) {
|
||||
$io->error('Password length is not sufficient, at least 6 character are required');
|
||||
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
$country = $input->getArgument('country');
|
||||
if (2 != trim(strlen($country))) {
|
||||
$io->error('Country code needs to be exactly 2 character');
|
||||
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
$currency = $input->getArgument('currency');
|
||||
if (3 != trim(strlen($currency))) {
|
||||
$io->error('Currency code needs to be exactly 3 character');
|
||||
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!$this->checkDatabaseVersion($io, self::MIN_VERSION, self::MIN_REVISION)) {
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
$bytesStart = memory_get_usage(true);
|
||||
@@ -183,66 +186,66 @@ class KimaiImporterCommand extends Command
|
||||
// pre-load all data to make sure we can fully import everything
|
||||
try {
|
||||
$users = $this->fetchAllFromImport('users');
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to load users: ' . $ex->getMessage());
|
||||
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$customer = $this->fetchAllFromImport('customers');
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to load customers: ' . $ex->getMessage());
|
||||
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$projects = $this->fetchAllFromImport('projects');
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to load projects: ' . $ex->getMessage());
|
||||
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$activities = $this->fetchAllFromImport('activities');
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to load activities: ' . $ex->getMessage());
|
||||
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$activityToProject = $this->fetchAllFromImport('projects_activities');
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to load activities-project mapping: ' . $ex->getMessage());
|
||||
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$records = $this->fetchAllFromImport('timeSheet');
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to load timeSheet: ' . $ex->getMessage());
|
||||
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$fixedRates = $this->fetchAllFromImport('fixedRates');
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to load fixedRates: ' . $ex->getMessage());
|
||||
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$rates = $this->fetchAllFromImport('rates');
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to load rates: ' . $ex->getMessage());
|
||||
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
$bytesCached = memory_get_usage(true);
|
||||
@@ -252,57 +255,55 @@ class KimaiImporterCommand extends Command
|
||||
$allImports = 0;
|
||||
|
||||
try {
|
||||
$counter = $this->importUsers($io, $password, $users, $rates);
|
||||
$counter = $this->importUsers($io, $password, $users, $rates, $input->getOption('timezone'), $input->getOption('language'));
|
||||
$allImports += $counter;
|
||||
$io->success('Imported users: ' . $counter);
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to import users: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString());
|
||||
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$counter = $this->importCustomers($io, $customer, $country, $currency);
|
||||
$allImports += $counter;
|
||||
$io->success('Imported customers: ' . $counter);
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to import customers: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString());
|
||||
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$counter = $this->importProjects($io, $projects, $fixedRates, $rates);
|
||||
$allImports += $counter;
|
||||
$io->success('Imported projects: ' . $counter);
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to import projects: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString());
|
||||
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$counter = $this->importActivities($io, $activities, $activityToProject, $fixedRates, $rates);
|
||||
$allImports += $counter;
|
||||
$io->success('Imported activities: ' . $counter);
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to import activities: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString());
|
||||
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$counter = $this->importTimesheetRecords($io, $records, $fixedRates, $rates);
|
||||
$allImports += $counter;
|
||||
$io->success('Imported timesheet records: ' . $counter);
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to import timesheet records: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString());
|
||||
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// TODO support expenses - new database required
|
||||
|
||||
$bytesImported = memory_get_usage(true);
|
||||
|
||||
$io->success(
|
||||
@@ -313,6 +314,8 @@ class KimaiImporterCommand extends Command
|
||||
'Total consumption for importing ' . $allImports . ' new database entries: ' .
|
||||
$this->bytesHumanReadable($bytesImported - $bytesStart)
|
||||
);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -323,7 +326,6 @@ class KimaiImporterCommand extends Command
|
||||
* @param string $requiredVersion
|
||||
* @param string $requiredRevision
|
||||
* @return bool
|
||||
* @throws \Doctrine\DBAL\DBALException
|
||||
*/
|
||||
protected function checkDatabaseVersion(SymfonyStyle $io, $requiredVersion, $requiredRevision)
|
||||
{
|
||||
@@ -419,7 +421,7 @@ class KimaiImporterCommand extends Command
|
||||
}
|
||||
|
||||
/**
|
||||
* @return RegistryInterface
|
||||
* @return ManagerRegistry
|
||||
*/
|
||||
protected function getDoctrine()
|
||||
{
|
||||
@@ -436,7 +438,7 @@ class KimaiImporterCommand extends Command
|
||||
$errors = $this->validator->validate($object);
|
||||
|
||||
if ($errors->count() > 0) {
|
||||
/** @var \Symfony\Component\Validator\ConstraintViolation $error */
|
||||
/** @var ConstraintViolation $error */
|
||||
foreach ($errors as $error) {
|
||||
$io->error(
|
||||
(string) $error
|
||||
@@ -476,10 +478,12 @@ class KimaiImporterCommand extends Command
|
||||
* @param string $password
|
||||
* @param array $users
|
||||
* @param array $rates
|
||||
* @param string $timezone
|
||||
* @param string $language
|
||||
* @return int
|
||||
* @throws \Exception
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function importUsers(SymfonyStyle $io, $password, $users, $rates)
|
||||
protected function importUsers(SymfonyStyle $io, $password, $users, $rates, $timezone, $language)
|
||||
{
|
||||
$counter = 0;
|
||||
$entityManager = $this->getDoctrine()->getManager();
|
||||
@@ -501,7 +505,7 @@ class KimaiImporterCommand extends Command
|
||||
$user->setPassword($pwd);
|
||||
|
||||
if (!$this->validateImport($io, $user)) {
|
||||
throw new \Exception('Failed to validate user: ' . $user->getUsername());
|
||||
throw new Exception('Failed to validate user: ' . $user->getUsername());
|
||||
}
|
||||
|
||||
// find and migrate user preferences
|
||||
@@ -525,6 +529,14 @@ class KimaiImporterCommand extends Command
|
||||
$user->addPreference($newPref);
|
||||
}
|
||||
|
||||
// set default values if they were not set in the the user preferences
|
||||
$defaults = ['language' => $language, 'timezone' => $timezone];
|
||||
foreach ($defaults as $key => $default) {
|
||||
if (null === $user->getPreferenceValue($key)) {
|
||||
$user->setPreferenceValue($key, $default);
|
||||
}
|
||||
}
|
||||
|
||||
// find hourly rate
|
||||
foreach ($rates as $ratesRow) {
|
||||
if ($ratesRow['userID'] === $oldUser['userID'] && $ratesRow['activityID'] === null && $ratesRow['projectID'] === null) {
|
||||
@@ -543,7 +555,7 @@ class KimaiImporterCommand extends Command
|
||||
$io->success('Created user: ' . $user->getUsername());
|
||||
}
|
||||
++$counter;
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to create user: ' . $user->getUsername());
|
||||
$io->error('Reason: ' . $ex->getMessage());
|
||||
}
|
||||
@@ -584,7 +596,7 @@ class KimaiImporterCommand extends Command
|
||||
* @param string $country
|
||||
* @param string $currency
|
||||
* @return int
|
||||
* @throws \Exception
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function importCustomers(SymfonyStyle $io, $customers, $country, $currency)
|
||||
{
|
||||
@@ -617,8 +629,15 @@ class KimaiImporterCommand extends Command
|
||||
->setCurrency(strtoupper($currency))
|
||||
;
|
||||
|
||||
$metaField = new CustomerMeta();
|
||||
$metaField->setName('_imported_id');
|
||||
$metaField->setValue($oldCustomer['customerID']);
|
||||
$metaField->setIsVisible(false);
|
||||
|
||||
$customer->setMetaField($metaField);
|
||||
|
||||
if (!$this->validateImport($io, $customer)) {
|
||||
throw new \Exception('Failed to validate customer: ' . $customer->getName());
|
||||
throw new Exception('Failed to validate customer: ' . $customer->getName());
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -628,7 +647,7 @@ class KimaiImporterCommand extends Command
|
||||
$io->success('Created customer: ' . $customer->getName());
|
||||
}
|
||||
++$counter;
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Reason: ' . $ex->getMessage());
|
||||
$io->error('Failed to create customer: ' . $customer->getName());
|
||||
}
|
||||
@@ -659,7 +678,7 @@ class KimaiImporterCommand extends Command
|
||||
* @param array $fixedRates
|
||||
* @param array $rates
|
||||
* @return int
|
||||
* @throws \Exception
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function importProjects(SymfonyStyle $io, $projects, array $fixedRates, array $rates)
|
||||
{
|
||||
@@ -684,6 +703,13 @@ class KimaiImporterCommand extends Command
|
||||
->setBudget($oldProject['budget'] ?: 0)
|
||||
;
|
||||
|
||||
$metaField = new ProjectMeta();
|
||||
$metaField->setName('_imported_id');
|
||||
$metaField->setValue($oldProject['projectID']);
|
||||
$metaField->setIsVisible(false);
|
||||
|
||||
$project->setMetaField($metaField);
|
||||
|
||||
foreach ($fixedRates as $fixedRow) {
|
||||
if ($fixedRow['activityID'] !== null || $fixedRow['projectID'] === null) {
|
||||
continue;
|
||||
@@ -703,7 +729,7 @@ class KimaiImporterCommand extends Command
|
||||
}
|
||||
|
||||
if (!$this->validateImport($io, $project)) {
|
||||
throw new \Exception('Failed to validate project: ' . $project->getName());
|
||||
throw new Exception('Failed to validate project: ' . $project->getName());
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -713,7 +739,7 @@ class KimaiImporterCommand extends Command
|
||||
$io->success('Created project: ' . $project->getName() . ' for customer: ' . $customer->getName());
|
||||
}
|
||||
++$counter;
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to create project: ' . $project->getName());
|
||||
$io->error('Reason: ' . $ex->getMessage());
|
||||
}
|
||||
@@ -748,7 +774,7 @@ class KimaiImporterCommand extends Command
|
||||
* @param array $fixedRates
|
||||
* @param array $rates
|
||||
* @return int
|
||||
* @throws \Exception
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function importActivities(SymfonyStyle $io, array $activities, array $activityToProject, array $fixedRates, array $rates)
|
||||
{
|
||||
@@ -781,7 +807,7 @@ class KimaiImporterCommand extends Command
|
||||
}
|
||||
foreach ($oldActivityMapping[$oldActivity['activityID']] as $projectId) {
|
||||
if (!isset($this->projects[$projectId])) {
|
||||
throw new \Exception(
|
||||
throw new Exception(
|
||||
'Invalid project linked to activity ' . $oldActivity['name'] . ': ' . $projectId
|
||||
);
|
||||
}
|
||||
@@ -802,7 +828,7 @@ class KimaiImporterCommand extends Command
|
||||
* @param array $rates
|
||||
* @param int $projectId
|
||||
* @return Activity
|
||||
* @throws \Exception
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function createActivity(
|
||||
SymfonyStyle $io,
|
||||
@@ -826,7 +852,7 @@ class KimaiImporterCommand extends Command
|
||||
}
|
||||
|
||||
if (null !== $projectId && !isset($this->projects[$projectId])) {
|
||||
throw new \Exception(
|
||||
throw new Exception(
|
||||
sprintf('Did not find project [%s], skipping activity creation [%s] %s', $projectId, $activityId, $name)
|
||||
);
|
||||
}
|
||||
@@ -844,6 +870,13 @@ class KimaiImporterCommand extends Command
|
||||
$activity->setProject($project);
|
||||
}
|
||||
|
||||
$metaField = new ActivityMeta();
|
||||
$metaField->setName('_imported_id');
|
||||
$metaField->setValue($oldActivity['activityID']);
|
||||
$metaField->setIsVisible(false);
|
||||
|
||||
$activity->setMetaField($metaField);
|
||||
|
||||
foreach ($fixedRates as $fixedRow) {
|
||||
if ($fixedRow['activityID'] === null) {
|
||||
continue;
|
||||
@@ -871,7 +904,7 @@ class KimaiImporterCommand extends Command
|
||||
}
|
||||
|
||||
if (!$this->validateImport($io, $activity)) {
|
||||
throw new \Exception('Failed to validate activity: ' . $activity->getName());
|
||||
throw new Exception('Failed to validate activity: ' . $activity->getName());
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -880,7 +913,7 @@ class KimaiImporterCommand extends Command
|
||||
if ($this->debug) {
|
||||
$io->success('Created activity: ' . $activity->getName());
|
||||
}
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to create activity: ' . $activity->getName());
|
||||
$io->error('Reason: ' . $ex->getMessage());
|
||||
}
|
||||
@@ -921,7 +954,7 @@ class KimaiImporterCommand extends Command
|
||||
* @param array $fixedRates
|
||||
* @param array $rates
|
||||
* @return int
|
||||
* @throws \Exception
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function importTimesheetRecords(SymfonyStyle $io, array $records, array $fixedRates, array $rates)
|
||||
{
|
||||
@@ -1010,7 +1043,7 @@ class KimaiImporterCommand extends Command
|
||||
$io->success('Created deactivated user: ' . $user->getUsername());
|
||||
}
|
||||
$userCounter++;
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to create user: ' . $user->getUsername());
|
||||
$io->error('Reason: ' . $ex->getMessage());
|
||||
$failed++;
|
||||
@@ -1043,11 +1076,11 @@ class KimaiImporterCommand extends Command
|
||||
|
||||
$user = $this->users[$oldRecord['userID']];
|
||||
$timezone = $user->getTimezone();
|
||||
$dateTimezone = new \DateTimeZone('UTC');
|
||||
$dateTimezone = new DateTimeZone('UTC');
|
||||
|
||||
$begin = new \DateTime('@' . $oldRecord['start']);
|
||||
$begin = new DateTime('@' . $oldRecord['start']);
|
||||
$begin->setTimezone($dateTimezone);
|
||||
$end = new \DateTime('@' . $oldRecord['end']);
|
||||
$end = new DateTime('@' . $oldRecord['end']);
|
||||
$end->setTimezone($dateTimezone);
|
||||
|
||||
// ---------- workaround for localizeDates ----------
|
||||
@@ -1075,30 +1108,33 @@ class KimaiImporterCommand extends Command
|
||||
;
|
||||
|
||||
if (!$this->validateImport($io, $timesheet)) {
|
||||
$io->error('Failed to validate timesheet record: ' . $oldRecord['timeEntryID'] . ' - skipping!');
|
||||
$io->caution('Failed to validate timesheet record: ' . $oldRecord['timeEntryID'] . ' - skipping!');
|
||||
$failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$entityManager->persist($timesheet);
|
||||
$entityManager->flush();
|
||||
if ($this->debug) {
|
||||
$io->success('Created timesheet record: ' . $timesheet->getId());
|
||||
}
|
||||
++$counter;
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to create timesheet record: ' . $ex->getMessage());
|
||||
$failed++;
|
||||
}
|
||||
|
||||
$io->write('.');
|
||||
if (0 == $counter % 80) {
|
||||
$io->writeln(' (' . $counter . '/' . $total . ')');
|
||||
$entityManager->flush();
|
||||
$entityManager->clear(Timesheet::class);
|
||||
$io->writeln(' (' . $counter . '/' . $total . ')');
|
||||
}
|
||||
}
|
||||
|
||||
$entityManager->flush();
|
||||
$entityManager->clear(Timesheet::class);
|
||||
|
||||
for ($i = 0; $i < 80 - ($counter % 80); $i++) {
|
||||
$io->write(' ');
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ class VersionCommand extends Command
|
||||
return 0;
|
||||
}
|
||||
|
||||
$io->writeln('Kimai 2 - ' . Constants::VERSION . ' ' . Constants::STATUS . ' (' . Constants::NAME . ') by Kevin Papst and contributors.');
|
||||
$io->writeln(Constants::SOFTWARE . ' - ' . Constants::VERSION . ' ' . Constants::STATUS . ' (' . Constants::NAME . ') by Kevin Papst and contributors.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -17,11 +17,11 @@ class Constants
|
||||
/**
|
||||
* The current release version
|
||||
*/
|
||||
public const VERSION = '1.6.2';
|
||||
public const VERSION = '1.7';
|
||||
/**
|
||||
* The current release status, either "stable" or "dev"
|
||||
*/
|
||||
public const STATUS = 'stable';
|
||||
public const STATUS = 'dev';
|
||||
/**
|
||||
* The software name
|
||||
*/
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Configuration\TimesheetConfiguration;
|
||||
use App\Entity\MetaTableTypeInterface;
|
||||
use App\Entity\Tag;
|
||||
use App\Entity\Timesheet;
|
||||
@@ -27,6 +26,7 @@ use App\Repository\ProjectRepository;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Repository\TagRepository;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use App\Timesheet\TimesheetService;
|
||||
use App\Timesheet\TrackingMode\TrackingModeInterface;
|
||||
use App\Timesheet\TrackingModeService;
|
||||
use App\Timesheet\UserDateTimeFactory;
|
||||
@@ -42,10 +42,6 @@ abstract class TimesheetAbstractController extends AbstractController
|
||||
* @var UserDateTimeFactory
|
||||
*/
|
||||
protected $dateTime;
|
||||
/**
|
||||
* @var TimesheetConfiguration
|
||||
*/
|
||||
protected $configuration;
|
||||
/**
|
||||
* @var TimesheetRepository
|
||||
*/
|
||||
@@ -62,21 +58,25 @@ abstract class TimesheetAbstractController extends AbstractController
|
||||
* @var ServiceExport
|
||||
*/
|
||||
protected $exportService;
|
||||
/**
|
||||
* @var TimesheetService
|
||||
*/
|
||||
private $service;
|
||||
|
||||
public function __construct(
|
||||
UserDateTimeFactory $dateTime,
|
||||
TimesheetConfiguration $configuration,
|
||||
TimesheetRepository $repository,
|
||||
TrackingModeService $service,
|
||||
TrackingModeService $trackingModeService,
|
||||
EventDispatcherInterface $dispatcher,
|
||||
ServiceExport $exportService
|
||||
ServiceExport $exportService,
|
||||
TimesheetService $timesheetService
|
||||
) {
|
||||
$this->dateTime = $dateTime;
|
||||
$this->configuration = $configuration;
|
||||
$this->repository = $repository;
|
||||
$this->trackingModeService = $service;
|
||||
$this->trackingModeService = $trackingModeService;
|
||||
$this->dispatcher = $dispatcher;
|
||||
$this->exportService = $exportService;
|
||||
$this->service = $timesheetService;
|
||||
}
|
||||
|
||||
protected function getTrackingMode(): TrackingModeInterface
|
||||
@@ -84,16 +84,6 @@ abstract class TimesheetAbstractController extends AbstractController
|
||||
return $this->trackingModeService->getActiveMode();
|
||||
}
|
||||
|
||||
protected function getSoftLimit(): int
|
||||
{
|
||||
return $this->configuration->getActiveEntriesSoftLimit();
|
||||
}
|
||||
|
||||
protected function getRepository(): TimesheetRepository
|
||||
{
|
||||
return $this->repository;
|
||||
}
|
||||
|
||||
protected function index($page, Request $request, string $renderTemplate, string $location): Response
|
||||
{
|
||||
$query = new TimesheetQuery();
|
||||
@@ -127,7 +117,7 @@ abstract class TimesheetAbstractController extends AbstractController
|
||||
|
||||
$this->prepareQuery($query);
|
||||
|
||||
$pager = $this->getRepository()->getPagerfantaForQuery($query);
|
||||
$pager = $this->repository->getPagerfantaForQuery($query);
|
||||
|
||||
return $this->render($renderTemplate, [
|
||||
'entries' => $pager,
|
||||
@@ -164,7 +154,7 @@ abstract class TimesheetAbstractController extends AbstractController
|
||||
|
||||
if ($editForm->isSubmitted() && $editForm->isValid()) {
|
||||
try {
|
||||
$this->getRepository()->save($entry);
|
||||
$this->repository->save($entry);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute($this->getTimesheetRoute(), ['page' => $request->get('page', 1)]);
|
||||
@@ -199,8 +189,7 @@ abstract class TimesheetAbstractController extends AbstractController
|
||||
|
||||
protected function create(Request $request, string $renderTemplate, ProjectRepository $projectRepository, ActivityRepository $activityRepository, TagRepository $tagRepository): Response
|
||||
{
|
||||
$entry = new Timesheet();
|
||||
$entry->setUser($this->getUser());
|
||||
$entry = $this->service->createNewTimesheet($this->getUser());
|
||||
|
||||
if ($request->query->get('project')) {
|
||||
$project = $projectRepository->find($request->query->get('project'));
|
||||
@@ -218,24 +207,15 @@ abstract class TimesheetAbstractController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
$event = new TimesheetMetaDefinitionEvent($entry);
|
||||
$this->dispatcher->dispatch($event);
|
||||
$this->service->prepareNewTimesheet($entry, $request);
|
||||
|
||||
$mode = $this->getTrackingMode();
|
||||
$mode->create($entry, $request);
|
||||
|
||||
$createForm = $this->getCreateForm($entry, $mode);
|
||||
$createForm->handleRequest($request);
|
||||
|
||||
if ($createForm->isSubmitted() && $createForm->isValid()) {
|
||||
try {
|
||||
if (null === $entry->getEnd()) {
|
||||
$this->getRepository()->stopActiveEntries(
|
||||
$entry->getUser(),
|
||||
$this->configuration->getActiveEntriesHardLimit()
|
||||
);
|
||||
}
|
||||
$this->getRepository()->save($entry);
|
||||
$this->service->saveNewTimesheet($entry);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute($this->getTimesheetRoute());
|
||||
@@ -273,7 +253,7 @@ abstract class TimesheetAbstractController extends AbstractController
|
||||
|
||||
$this->prepareQuery($query);
|
||||
|
||||
$entries = $this->getRepository()->getTimesheetsForQuery($query);
|
||||
$entries = $this->repository->getTimesheetsForQuery($query);
|
||||
|
||||
$exporter = $this->exportService->getTimesheetExporterById($exporterId);
|
||||
|
||||
@@ -300,13 +280,20 @@ abstract class TimesheetAbstractController extends AbstractController
|
||||
|
||||
// remove all, which are not allowed to be edited
|
||||
$timesheets = [];
|
||||
$disallowed = 0;
|
||||
/** @var Timesheet $timesheet */
|
||||
foreach ($dto->getEntities() as $timesheet) {
|
||||
if (!$this->isGranted('edit', $timesheet)) {
|
||||
$disallowed++;
|
||||
continue;
|
||||
}
|
||||
$timesheets[] = $timesheet;
|
||||
}
|
||||
|
||||
if ($disallowed > 0) {
|
||||
$this->flashWarning(sprintf('You are missing the permission to edit %s timesheets', $disallowed));
|
||||
}
|
||||
|
||||
$dto->setEntities($timesheets);
|
||||
|
||||
if (count($dto->getEntities()) === 0) {
|
||||
@@ -343,6 +330,7 @@ abstract class TimesheetAbstractController extends AbstractController
|
||||
$timesheet->setExported($dto->isExported());
|
||||
$execute = true;
|
||||
}
|
||||
// setting both values allows to erase wrong
|
||||
if (null !== $dto->getHourlyRate()) {
|
||||
$timesheet->setFixedRate(null);
|
||||
$timesheet->setHourlyRate($dto->getHourlyRate());
|
||||
@@ -411,6 +399,7 @@ abstract class TimesheetAbstractController extends AbstractController
|
||||
'action' => $this->generateUrl($this->getMultiUpdateRoute(), []),
|
||||
'method' => 'POST',
|
||||
'include_exported' => $this->isGranted($this->getPermissionEditExport()),
|
||||
'include_rate' => $this->isGranted($this->getPermissionEditRate()),
|
||||
'include_user' => $this->includeUserInForms('multi'),
|
||||
]);
|
||||
}
|
||||
@@ -424,7 +413,7 @@ abstract class TimesheetAbstractController extends AbstractController
|
||||
|
||||
return $this->createForm(MultiUpdateTable::class, $dto, [
|
||||
'action' => $this->generateUrl($this->getTimesheetRoute()),
|
||||
'repository' => $this->getRepository(),
|
||||
'repository' => $this->repository,
|
||||
'method' => 'POST',
|
||||
]);
|
||||
}
|
||||
@@ -487,6 +476,11 @@ abstract class TimesheetAbstractController extends AbstractController
|
||||
return 'edit_export_own_timesheet';
|
||||
}
|
||||
|
||||
protected function getPermissionEditRate(): string
|
||||
{
|
||||
return 'edit_rate_own_timesheet';
|
||||
}
|
||||
|
||||
protected function getCreateFormClassName(): string
|
||||
{
|
||||
return TimesheetEditForm::class;
|
||||
|
||||
@@ -108,6 +108,11 @@ class TimesheetTeamController extends TimesheetAbstractController
|
||||
return 'edit_export_other_timesheet';
|
||||
}
|
||||
|
||||
protected function getPermissionEditRate(): string
|
||||
{
|
||||
return 'edit_rate_other_timesheet';
|
||||
}
|
||||
|
||||
protected function getCreateFormClassName(): string
|
||||
{
|
||||
return TimesheetAdminEditForm::class;
|
||||
|
||||
@@ -47,12 +47,12 @@ class TeamFixtures extends Fixture implements DependentFixtureInterface
|
||||
|
||||
/**
|
||||
* @param ObjectManager $manager
|
||||
* @return User[]
|
||||
* @return array<int|string, User>
|
||||
*/
|
||||
protected function getAllUsers(ObjectManager $manager)
|
||||
protected function getAllUsers(ObjectManager $manager): array
|
||||
{
|
||||
$all = [];
|
||||
/* @var User[] $entries */
|
||||
/** @var User[] $entries */
|
||||
$entries = $manager->getRepository(User::class)->findAll();
|
||||
foreach ($entries as $temp) {
|
||||
$all[$temp->getId()] = $temp;
|
||||
@@ -63,13 +63,13 @@ class TeamFixtures extends Fixture implements DependentFixtureInterface
|
||||
|
||||
/**
|
||||
* @param ObjectManager $manager
|
||||
* @return Project[]
|
||||
* @return array<int|string, Project>
|
||||
*/
|
||||
protected function getAllProjects(ObjectManager $manager)
|
||||
protected function getAllProjects(ObjectManager $manager): array
|
||||
{
|
||||
$all = [];
|
||||
|
||||
/* @var Project[] $entries */
|
||||
/** @var Project[] $entries */
|
||||
$entries = $manager->getRepository(Project::class)->findAll();
|
||||
foreach ($entries as $temp) {
|
||||
$all[$temp->getId()] = $temp;
|
||||
|
||||
@@ -35,8 +35,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
|
||||
public const MIN_TIMESHEETS_PER_USER = 50;
|
||||
public const MAX_TIMESHEETS_PER_USER = 500;
|
||||
public const MAX_TIMESHEETS_TOTAL = 5000;
|
||||
public const MIN_RUNNING_TIMESHEETS_PER_USER = 0;
|
||||
public const MAX_RUNNING_TIMESHEETS_PER_USER = 3;
|
||||
public const MAX_RUNNING_TIMESHEETS_PER_USER = 2;
|
||||
public const TIMERANGE_DAYS = 1095; // 3 years
|
||||
public const TIMERANGE_RUNNING = 1047; // in minutes = 17:45 hours
|
||||
public const MIN_MINUTES_PER_ENTRY = 15;
|
||||
@@ -79,7 +78,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
|
||||
// random amount of timesheet entries for every user
|
||||
$timesheetForUser = rand(self::MIN_TIMESHEETS_PER_USER, self::MAX_TIMESHEETS_PER_USER);
|
||||
for ($i = 1; $i <= $timesheetForUser; $i++) {
|
||||
if ($all > self::MAX_TIMESHEETS_TOTAL) {
|
||||
if ($all > self::MAX_TIMESHEETS_TOTAL && $i > self::MIN_TIMESHEETS_PER_USER) {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -109,7 +108,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
|
||||
}
|
||||
|
||||
// create active recordings for test user
|
||||
$activeEntries = rand(self::MIN_RUNNING_TIMESHEETS_PER_USER, self::MAX_RUNNING_TIMESHEETS_PER_USER);
|
||||
$activeEntries = rand(0, self::MAX_RUNNING_TIMESHEETS_PER_USER);
|
||||
for ($i = 0; $i < $activeEntries; $i++) {
|
||||
$entry = $this->createTimesheetEntry(
|
||||
$user,
|
||||
@@ -144,12 +143,12 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
|
||||
|
||||
/**
|
||||
* @param ObjectManager $manager
|
||||
* @return Tag[]
|
||||
* @return array<int|string, Tag>
|
||||
*/
|
||||
protected function getAllTags(ObjectManager $manager)
|
||||
protected function getAllTags(ObjectManager $manager): array
|
||||
{
|
||||
$all = [];
|
||||
/* @var Tag[] $entries */
|
||||
/** @var Tag[] $entries */
|
||||
$entries = $manager->getRepository(Tag::class)->findAll();
|
||||
foreach ($entries as $temp) {
|
||||
$all[$temp->getId()] = $temp;
|
||||
@@ -160,12 +159,12 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
|
||||
|
||||
/**
|
||||
* @param ObjectManager $manager
|
||||
* @return User[]
|
||||
* @return array<int|string, User>
|
||||
*/
|
||||
protected function getAllUsers(ObjectManager $manager)
|
||||
protected function getAllUsers(ObjectManager $manager): array
|
||||
{
|
||||
$all = [];
|
||||
/* @var User[] $entries */
|
||||
/** @var User[] $entries */
|
||||
$entries = $manager->getRepository(User::class)->findAll();
|
||||
foreach ($entries as $temp) {
|
||||
$all[$temp->getId()] = $temp;
|
||||
@@ -176,12 +175,12 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
|
||||
|
||||
/**
|
||||
* @param ObjectManager $manager
|
||||
* @return Project[]
|
||||
* @return array<int|string, Project>
|
||||
*/
|
||||
protected function getAllProjects(ObjectManager $manager)
|
||||
protected function getAllProjects(ObjectManager $manager): array
|
||||
{
|
||||
$all = [];
|
||||
/* @var Project[] $entries */
|
||||
/** @var Project[] $entries */
|
||||
$entries = $manager->getRepository(Project::class)->findAll();
|
||||
foreach ($entries as $temp) {
|
||||
$all[$temp->getId()] = $temp;
|
||||
@@ -192,12 +191,12 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
|
||||
|
||||
/**
|
||||
* @param ObjectManager $manager
|
||||
* @return Activity[]
|
||||
* @return array<int|string, Activity>
|
||||
*/
|
||||
protected function getAllActivities(ObjectManager $manager)
|
||||
protected function getAllActivities(ObjectManager $manager): array
|
||||
{
|
||||
$all = [];
|
||||
/* @var Activity[] $entries */
|
||||
/** @var Activity[] $entries */
|
||||
$entries = $manager->getRepository(Activity::class)->findAll();
|
||||
foreach ($entries as $temp) {
|
||||
$all[$temp->getId()] = $temp;
|
||||
|
||||
@@ -33,7 +33,7 @@ class AppExtension extends Extension
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// @deprecated since 0.9, duration_only will be removed with 1.0
|
||||
// @deprecated since 0.9, duration_only will be removed with 2.0
|
||||
if (isset($config['timesheet']['duration_only'])) {
|
||||
@trigger_error('Configuration "kimai.timesheet.duration_only" is deprecated, please remove it', E_USER_DEPRECATED);
|
||||
if (true === $config['timesheet']['duration_only'] && 'duration_only' !== $config['timesheet']['mode']) {
|
||||
|
||||
@@ -22,6 +22,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
* }
|
||||
* )
|
||||
* @ORM\Entity(repositoryClass="App\Repository\ProjectRepository")
|
||||
* @App\Validator\Constraints\Project
|
||||
*
|
||||
* columns={"customer_id","visible","name"} => IDX_407F12069395C3F37AB0E8595E237E06 => project administration without filter
|
||||
* columns={"customer_id","visible","id"} => IDX_407F12069395C3F37AB0E859BF396750 => used in joins between project and customer, eg. dropdowns and activity administration page
|
||||
@@ -67,6 +68,28 @@ class Project implements EntityWithMetaFields
|
||||
* @ORM\Column(name="order_date", type="datetime", nullable=true)
|
||||
*/
|
||||
private $orderDate;
|
||||
/**
|
||||
* @var \DateTime
|
||||
*
|
||||
* @ORM\Column(name="start", type="datetime", nullable=true)
|
||||
*/
|
||||
private $start;
|
||||
/**
|
||||
* @var \DateTime
|
||||
*
|
||||
* @ORM\Column(name="end", type="datetime", nullable=true)
|
||||
*/
|
||||
private $end;
|
||||
/**
|
||||
* @var string
|
||||
*
|
||||
* @ORM\Column(name="timezone", type="string", length=64, nullable=true)
|
||||
*/
|
||||
private $timezone;
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
private $localized = false;
|
||||
/**
|
||||
* @var string
|
||||
*
|
||||
@@ -192,8 +215,41 @@ class Project implements EntityWithMetaFields
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure begin and end date have the correct timezone.
|
||||
* This will be called once for each item after being loaded from the database.
|
||||
*/
|
||||
protected function localizeDates()
|
||||
{
|
||||
if ($this->localized) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (null === $this->timezone) {
|
||||
$this->timezone = date_default_timezone_get();
|
||||
}
|
||||
|
||||
$timezone = new \DateTimeZone($this->timezone);
|
||||
|
||||
if (null !== $this->orderDate) {
|
||||
$this->orderDate->setTimeZone($timezone);
|
||||
}
|
||||
|
||||
if (null !== $this->start) {
|
||||
$this->start->setTimeZone($timezone);
|
||||
}
|
||||
|
||||
if (null !== $this->end) {
|
||||
$this->end->setTimeZone($timezone);
|
||||
}
|
||||
|
||||
$this->localized = true;
|
||||
}
|
||||
|
||||
public function getOrderDate(): ?\DateTime
|
||||
{
|
||||
$this->localizeDates();
|
||||
|
||||
return $this->orderDate;
|
||||
}
|
||||
|
||||
@@ -201,6 +257,46 @@ class Project implements EntityWithMetaFields
|
||||
{
|
||||
$this->orderDate = $orderDate;
|
||||
|
||||
if (null !== $orderDate) {
|
||||
$this->timezone = $orderDate->getTimezone()->getName();
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStart(): ?\DateTime
|
||||
{
|
||||
$this->localizeDates();
|
||||
|
||||
return $this->start;
|
||||
}
|
||||
|
||||
public function setStart(?\DateTime $start): Project
|
||||
{
|
||||
$this->start = $start;
|
||||
|
||||
if (null !== $start) {
|
||||
$this->timezone = $start->getTimezone()->getName();
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEnd(): ?\DateTime
|
||||
{
|
||||
$this->localizeDates();
|
||||
|
||||
return $this->end;
|
||||
}
|
||||
|
||||
public function setEnd(?\DateTime $end): Project
|
||||
{
|
||||
$this->end = $end;
|
||||
|
||||
if (null !== $end) {
|
||||
$this->timezone = $end->getTimezone()->getName();
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ class Timesheet implements EntityWithMetaFields, InvoiceItemInterface
|
||||
/**
|
||||
* @var Tag[]|ArrayCollection
|
||||
*
|
||||
* @ORM\ManyToMany(targetEntity="Tag", inversedBy="timesheets", cascade={"persist"})
|
||||
* @ORM\ManyToMany(targetEntity="App\Entity\Tag", inversedBy="timesheets", cascade={"persist"})
|
||||
* @ORM\JoinTable(
|
||||
* name="kimai2_timesheet_tags",
|
||||
* joinColumns={
|
||||
|
||||
@@ -17,6 +17,7 @@ use App\Event\MetaDisplayEventInterface;
|
||||
use App\Event\ProjectMetaDisplayEvent;
|
||||
use App\Event\TimesheetMetaDisplayEvent;
|
||||
use App\Event\UserPreferenceDisplayEvent;
|
||||
use App\Repository\Query\CustomerQuery;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Twig\DateExtensions;
|
||||
use DateTime;
|
||||
@@ -158,8 +159,10 @@ abstract class AbstractSpreadsheetRenderer
|
||||
*/
|
||||
protected function fromArrayToSpreadsheet(array $timesheets, TimesheetQuery $query): Spreadsheet
|
||||
{
|
||||
$customerQuery = $query->copyTo(new CustomerQuery());
|
||||
|
||||
$timesheetMetaFields = $this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT));
|
||||
$customerMetaFields = $this->findMetaColumns(new CustomerMetaDisplayEvent($query, CustomerMetaDisplayEvent::EXPORT));
|
||||
$customerMetaFields = $this->findMetaColumns(new CustomerMetaDisplayEvent($customerQuery, CustomerMetaDisplayEvent::EXPORT));
|
||||
$projectMetaFields = $this->findMetaColumns(new ProjectMetaDisplayEvent($query, ProjectMetaDisplayEvent::EXPORT));
|
||||
$activityMetaFields = $this->findMetaColumns(new ActivityMetaDisplayEvent($query, ActivityMetaDisplayEvent::EXPORT));
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ use App\Event\MetaDisplayEventInterface;
|
||||
use App\Event\ProjectMetaDisplayEvent;
|
||||
use App\Event\TimesheetMetaDisplayEvent;
|
||||
use App\Event\UserPreferenceDisplayEvent;
|
||||
use App\Repository\Query\CustomerQuery;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -62,8 +63,10 @@ class HtmlRenderer
|
||||
*/
|
||||
public function render(array $timesheets, TimesheetQuery $query): Response
|
||||
{
|
||||
$customerQuery = $query->copyTo(new CustomerQuery());
|
||||
|
||||
$timesheetMetaFields = $this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT));
|
||||
$customerMetaFields = $this->findMetaColumns(new CustomerMetaDisplayEvent($query, CustomerMetaDisplayEvent::EXPORT));
|
||||
$customerMetaFields = $this->findMetaColumns(new CustomerMetaDisplayEvent($customerQuery, CustomerMetaDisplayEvent::EXPORT));
|
||||
$projectMetaFields = $this->findMetaColumns(new ProjectMetaDisplayEvent($query, ProjectMetaDisplayEvent::EXPORT));
|
||||
$activityMetaFields = $this->findMetaColumns(new ActivityMetaDisplayEvent($query, ActivityMetaDisplayEvent::EXPORT));
|
||||
|
||||
|
||||
160
src/Form/FormTrait.php
Normal file
160
src/Form/FormTrait.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?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\Entity\Activity;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
use App\Form\Type\ActivityType;
|
||||
use App\Form\Type\CustomerType;
|
||||
use App\Form\Type\ProjectType;
|
||||
use App\Form\Type\TagsInputType;
|
||||
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\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormEvent;
|
||||
use Symfony\Component\Form\FormEvents;
|
||||
|
||||
/**
|
||||
* Defines the form used to manipulate Timesheet entries.
|
||||
*/
|
||||
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);
|
||||
},
|
||||
'data' => $customer ? $customer : '',
|
||||
'required' => false,
|
||||
'placeholder' => '',
|
||||
'mapped' => false,
|
||||
'project_enabled' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function addProject(FormBuilderInterface $builder, bool $isNew, ?Project $project = null, ?Customer $customer = null)
|
||||
{
|
||||
$builder->add('project', ProjectType::class, [
|
||||
'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);
|
||||
},
|
||||
]);
|
||||
|
||||
// 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) {
|
||||
$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,
|
||||
'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 ?
|
||||
if ($isNew && !empty($project) && (is_int($project) || is_string($project))) {
|
||||
/** @var Project $project */
|
||||
$project = $repo->find($project);
|
||||
if (null !== $project) {
|
||||
if (!$project->getCustomer()->isVisible()) {
|
||||
$customer = null;
|
||||
$project = null;
|
||||
} elseif (!$project->isVisible()) {
|
||||
$project = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
$query = new ProjectFormTypeQuery($project, $customer);
|
||||
$query->setUser($builder->getOption('user'));
|
||||
|
||||
return $repo->getQueryBuilderForFormType($query);
|
||||
},
|
||||
]);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
protected function addActivity(FormBuilderInterface $builder, ?Activity $activity = null, ?Project $project = null)
|
||||
{
|
||||
$builder
|
||||
->add('activity', ActivityType::class, [
|
||||
'placeholder' => '',
|
||||
'query_builder' => function (ActivityRepository $repo) use ($activity, $project) {
|
||||
return $repo->getQueryBuilderForFormType(new ActivityFormTypeQuery($activity, $project));
|
||||
},
|
||||
])
|
||||
;
|
||||
|
||||
// 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 ($activity) {
|
||||
$data = $event->getData();
|
||||
if (!isset($data['project']) || empty($data['project'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$event->getForm()->add('activity', ActivityType::class, [
|
||||
'placeholder' => '',
|
||||
'query_builder' => function (ActivityRepository $repo) use ($data, $activity) {
|
||||
if (!empty($activity) && is_string($activity)) {
|
||||
$activity = $repo->find($activity);
|
||||
}
|
||||
|
||||
return $repo->getQueryBuilderForFormType(new ActivityFormTypeQuery($activity, $data['project']));
|
||||
},
|
||||
]);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
protected function addDescription(FormBuilderInterface $builder)
|
||||
{
|
||||
$builder
|
||||
->add('description', TextareaType::class, [
|
||||
'label' => 'label.description',
|
||||
'required' => false,
|
||||
'attr' => [
|
||||
'autofocus' => 'autofocus'
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
protected function addTags(FormBuilderInterface $builder)
|
||||
{
|
||||
$builder
|
||||
->add('tags', TagsInputType::class, [
|
||||
// documentation is for NelmioApiDocBundle
|
||||
'documentation' => [
|
||||
'type' => 'string',
|
||||
'description' => 'Comma separated list of tags',
|
||||
],
|
||||
'required' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -196,13 +196,15 @@ class TimesheetMultiUpdate extends AbstractType
|
||||
]);
|
||||
}
|
||||
|
||||
$builder
|
||||
->add('fixedRate', FixedRateType::class, [
|
||||
'currency' => $currency,
|
||||
])
|
||||
->add('hourlyRate', HourlyRateType::class, [
|
||||
'currency' => $currency,
|
||||
]);
|
||||
if ($options['include_rate']) {
|
||||
$builder
|
||||
->add('fixedRate', FixedRateType::class, [
|
||||
'currency' => $currency,
|
||||
])
|
||||
->add('hourlyRate', HourlyRateType::class, [
|
||||
'currency' => $currency,
|
||||
]);
|
||||
}
|
||||
|
||||
$builder->add('entities', HiddenType::class, [
|
||||
'required' => false,
|
||||
@@ -241,6 +243,7 @@ class TimesheetMultiUpdate extends AbstractType
|
||||
'csrf_field_name' => '_token',
|
||||
'csrf_token_id' => 'timesheet_multiupdate',
|
||||
'include_user' => false,
|
||||
'include_rate' => false,
|
||||
'include_exported' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -63,6 +63,14 @@ class ProjectEditForm extends AbstractType
|
||||
'label' => 'label.orderDate',
|
||||
'required' => false,
|
||||
])
|
||||
->add('start', DateTimePickerType::class, [
|
||||
'label' => 'label.project_start',
|
||||
'required' => false,
|
||||
])
|
||||
->add('end', DateTimePickerType::class, [
|
||||
'label' => 'label.project_end',
|
||||
'required' => false,
|
||||
])
|
||||
->add('customer', CustomerType::class, [
|
||||
'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) {
|
||||
$query = new CustomerFormTypeQuery($customer);
|
||||
|
||||
@@ -9,30 +9,20 @@
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Form\Type\ActivityType;
|
||||
use App\Form\Type\CustomerType;
|
||||
use App\Form\Type\DateTimePickerType;
|
||||
use App\Form\Type\DurationType;
|
||||
use App\Form\Type\FixedRateType;
|
||||
use App\Form\Type\HourlyRateType;
|
||||
use App\Form\Type\MetaFieldsCollectionType;
|
||||
use App\Form\Type\ProjectType;
|
||||
use App\Form\Type\TagsInputType;
|
||||
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\Timesheet\UserDateTimeFactory;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormEvent;
|
||||
use Symfony\Component\Form\FormEvents;
|
||||
@@ -43,6 +33,8 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
*/
|
||||
class TimesheetEditForm extends AbstractType
|
||||
{
|
||||
use FormTrait;
|
||||
|
||||
/**
|
||||
* @var CustomerRepository
|
||||
*/
|
||||
@@ -141,7 +133,7 @@ class TimesheetEditForm extends AbstractType
|
||||
$this->addCustomer($builder, $customer);
|
||||
}
|
||||
|
||||
$this->addProject($builder, $customerCount, $isNew, $project, $customer);
|
||||
$this->addProject($builder, $isNew, $project, $customer);
|
||||
$this->addActivity($builder, $activity, $project);
|
||||
$this->addDescription($builder);
|
||||
$this->addTags($builder);
|
||||
@@ -169,114 +161,6 @@ class TimesheetEditForm extends AbstractType
|
||||
return true;
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
'data' => $customer ? $customer : '',
|
||||
'required' => false,
|
||||
'placeholder' => '',
|
||||
'mapped' => false,
|
||||
'project_enabled' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function addProject(FormBuilderInterface $builder, int $customerCount, bool $isNew, ?Project $project = null, ?Customer $customer = null)
|
||||
{
|
||||
$projectOptions = [];
|
||||
|
||||
if ($customerCount < 2) {
|
||||
$projectOptions['group_by'] = null;
|
||||
}
|
||||
|
||||
$builder
|
||||
->add(
|
||||
'project',
|
||||
ProjectType::class,
|
||||
array_merge($projectOptions, [
|
||||
'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);
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
// 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) {
|
||||
$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,
|
||||
'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 ?
|
||||
if ($isNew && !empty($project) && (is_int($project) || is_string($project))) {
|
||||
/** @var Project $project */
|
||||
$project = $repo->find($project);
|
||||
if (null !== $project) {
|
||||
if (!$project->getCustomer()->isVisible()) {
|
||||
$customer = null;
|
||||
$project = null;
|
||||
} elseif (!$project->isVisible()) {
|
||||
$project = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
$query = new ProjectFormTypeQuery($project, $customer);
|
||||
$query->setUser($builder->getOption('user'));
|
||||
|
||||
return $repo->getQueryBuilderForFormType($query);
|
||||
},
|
||||
]);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
protected function addActivity(FormBuilderInterface $builder, ?Activity $activity = null, ?Project $project = null)
|
||||
{
|
||||
$builder
|
||||
->add('activity', ActivityType::class, [
|
||||
'placeholder' => '',
|
||||
'query_builder' => function (ActivityRepository $repo) use ($activity, $project) {
|
||||
return $repo->getQueryBuilderForFormType(new ActivityFormTypeQuery($activity, $project));
|
||||
},
|
||||
])
|
||||
;
|
||||
|
||||
// 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 ($activity) {
|
||||
$data = $event->getData();
|
||||
if (!isset($data['project']) || empty($data['project'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$event->getForm()->add('activity', ActivityType::class, [
|
||||
'placeholder' => '',
|
||||
'query_builder' => function (ActivityRepository $repo) use ($data, $activity) {
|
||||
return $repo->getQueryBuilderForFormType(new ActivityFormTypeQuery($activity, $data['project']));
|
||||
},
|
||||
]);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
protected function addBegin(FormBuilderInterface $builder, array $dateTimeOptions)
|
||||
{
|
||||
$builder->add('begin', DateTimePickerType::class, array_merge($dateTimeOptions, [
|
||||
@@ -330,31 +214,6 @@ class TimesheetEditForm extends AbstractType
|
||||
);
|
||||
}
|
||||
|
||||
protected function addDescription(FormBuilderInterface $builder)
|
||||
{
|
||||
$builder
|
||||
->add('description', TextareaType::class, [
|
||||
'label' => 'label.description',
|
||||
'required' => false,
|
||||
'attr' => [
|
||||
'autofocus' => 'autofocus'
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
protected function addTags(FormBuilderInterface $builder)
|
||||
{
|
||||
$builder
|
||||
->add('tags', TagsInputType::class, [
|
||||
// documentation is for NelmioApiDocBundle
|
||||
'documentation' => [
|
||||
'type' => 'string',
|
||||
'description' => 'Comma separated list of tags for this timesheet record',
|
||||
],
|
||||
'required' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function addRates(FormBuilderInterface $builder, $currency, array $options)
|
||||
{
|
||||
if (!$options['include_rate']) {
|
||||
|
||||
@@ -71,18 +71,20 @@ abstract class AbstractToolbarForm extends AbstractType
|
||||
]);
|
||||
}
|
||||
|
||||
protected function addCustomerChoice(FormBuilderInterface $builder, bool $required = false)
|
||||
protected function addCustomerChoice(FormBuilderInterface $builder, array $options = [])
|
||||
{
|
||||
// just a fake field for having this field at the right position in the frontend
|
||||
$builder->add('customer', HiddenType::class);
|
||||
|
||||
$builder->addEventListener(
|
||||
FormEvents::PRE_SUBMIT,
|
||||
function (FormEvent $event) use ($builder, $required) {
|
||||
function (FormEvent $event) use ($builder, $options) {
|
||||
$data = $event->getData();
|
||||
$event->getForm()->add('customer', CustomerType::class, [
|
||||
'required' => $required,
|
||||
$event->getForm()->add('customer', CustomerType::class, array_merge([
|
||||
'required' => false,
|
||||
'project_enabled' => true,
|
||||
'end_date_param' => '%daterange%',
|
||||
'start_date_param' => '%daterange%',
|
||||
'query_builder' => function (CustomerRepository $repo) use ($builder, $data) {
|
||||
$query = new CustomerFormTypeQuery();
|
||||
$query->setUser($builder->getOption('user'));
|
||||
@@ -92,7 +94,7 @@ abstract class AbstractToolbarForm extends AbstractType
|
||||
|
||||
return $repo->getQueryBuilderForFormType($query);
|
||||
},
|
||||
]);
|
||||
], $options));
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -130,19 +132,19 @@ abstract class AbstractToolbarForm extends AbstractType
|
||||
]);
|
||||
}
|
||||
|
||||
protected function addProjectChoice(FormBuilderInterface $builder)
|
||||
protected function addProjectChoice(FormBuilderInterface $builder, array $options = [])
|
||||
{
|
||||
// just a fake field for having this field at the right position in the frontend
|
||||
$builder->add('project', HiddenType::class);
|
||||
|
||||
$builder->addEventListener(
|
||||
FormEvents::PRE_SUBMIT,
|
||||
function (FormEvent $event) use ($builder) {
|
||||
function (FormEvent $event) use ($builder, $options) {
|
||||
$data = $event->getData();
|
||||
$event->getForm()->add('project', ProjectType::class, [
|
||||
$event->getForm()->add('project', ProjectType::class, array_merge([
|
||||
'required' => false,
|
||||
'activity_enabled' => true,
|
||||
'query_builder' => function (ProjectRepository $repo) use ($builder, $data) {
|
||||
'query_builder' => function (ProjectRepository $repo) use ($builder, $data, $options) {
|
||||
$query = new ProjectFormTypeQuery();
|
||||
$query->setUser($builder->getOption('user'));
|
||||
|
||||
@@ -152,10 +154,13 @@ abstract class AbstractToolbarForm extends AbstractType
|
||||
if (isset($data['project']) && !empty($data['project'])) {
|
||||
$query->setProject($data['project']);
|
||||
}
|
||||
if (isset($options['ignore_date']) && true === $options['ignore_date']) {
|
||||
$query->setIgnoreDate(true);
|
||||
}
|
||||
|
||||
return $repo->getQueryBuilderForFormType($query);
|
||||
},
|
||||
]);
|
||||
], $options));
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -175,7 +180,12 @@ abstract class AbstractToolbarForm extends AbstractType
|
||||
$query = new ActivityFormTypeQuery();
|
||||
|
||||
if (isset($data['activity']) && !empty($data['activity'])) {
|
||||
$query->setActivity($data['activity']);
|
||||
$activity = $data['activity'];
|
||||
if (is_string($data['activity'])) {
|
||||
$activity = $repo->find($data['activity']);
|
||||
}
|
||||
|
||||
$query->setActivity($activity);
|
||||
}
|
||||
if (isset($data['project']) && !empty($data['project'])) {
|
||||
$query->setProject($data['project']);
|
||||
|
||||
@@ -31,8 +31,8 @@ class ExportToolbarForm extends AbstractToolbarForm
|
||||
$this->addTimesheetStateChoice($builder);
|
||||
$this->addUsersChoice($builder);
|
||||
$this->addDateRangeChoice($builder);
|
||||
$this->addCustomerChoice($builder);
|
||||
$this->addProjectChoice($builder);
|
||||
$this->addCustomerChoice($builder, ['start_date_param' => null, 'end_date_param' => null, 'ignore_date' => true]);
|
||||
$this->addProjectChoice($builder, ['ignore_date' => true]);
|
||||
$this->addActivityChoice($builder);
|
||||
$this->addExportType($builder);
|
||||
$this->addTagInputField($builder);
|
||||
|
||||
@@ -32,8 +32,8 @@ class InvoiceToolbarForm extends AbstractToolbarForm
|
||||
$this->addUsersChoice($builder);
|
||||
}
|
||||
$this->addDateRangeChoice($builder);
|
||||
$this->addCustomerChoice($builder, true);
|
||||
$this->addProjectChoice($builder);
|
||||
$this->addCustomerChoice($builder, ['required' => true, 'start_date_param' => null, 'end_date_param' => null, 'ignore_date' => true, 'placeholder' => '']);
|
||||
$this->addProjectChoice($builder, ['ignore_date' => true]);
|
||||
$this->addActivityChoice($builder);
|
||||
$this->addTagInputField($builder);
|
||||
$this->addExportStateChoice($builder);
|
||||
|
||||
@@ -39,6 +39,9 @@ class CustomerType extends AbstractType
|
||||
'choice_label' => 'name',
|
||||
'query_builder_for_user' => true,
|
||||
'project_enabled' => false,
|
||||
'start_date_param' => '%begin%',
|
||||
'end_date_param' => '%end%',
|
||||
'ignore_date' => false,
|
||||
'project_visibility' => ProjectQuery::SHOW_VISIBLE,
|
||||
]);
|
||||
|
||||
@@ -55,11 +58,29 @@ class CustomerType extends AbstractType
|
||||
|
||||
$resolver->setDefault('api_data', function (Options $options) {
|
||||
if (true === $options['project_enabled']) {
|
||||
$routeParams = ['customer' => '%customer%', 'visible' => $options['project_visibility']];
|
||||
$emptyRouteParams = ['visible' => $options['project_visibility']];
|
||||
|
||||
if (!$options['ignore_date']) {
|
||||
if (!empty($options['start_date_param'])) {
|
||||
$routeParams['start'] = $options['start_date_param'];
|
||||
$emptyRouteParams['start'] = $options['start_date_param'];
|
||||
}
|
||||
|
||||
if (!empty($options['end_date_param'])) {
|
||||
$routeParams['end'] = $options['end_date_param'];
|
||||
$emptyRouteParams['end'] = $options['end_date_param'];
|
||||
}
|
||||
} else {
|
||||
$routeParams['ignoreDates'] = 1;
|
||||
$emptyRouteParams['ignoreDates'] = 1;
|
||||
}
|
||||
|
||||
return [
|
||||
'select' => 'project',
|
||||
'route' => 'get_projects',
|
||||
'route_params' => ['customer' => '-s-', 'visible' => $options['project_visibility']],
|
||||
'empty_route_params' => ['visible' => $options['project_visibility']],
|
||||
'route_params' => $routeParams,
|
||||
'empty_route_params' => $emptyRouteParams,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ class ProjectType extends AbstractType
|
||||
'query_builder_for_user' => true,
|
||||
'activity_enabled' => false,
|
||||
'activity_visibility' => ActivityQuery::SHOW_VISIBLE,
|
||||
'ignore_date' => false,
|
||||
]);
|
||||
|
||||
$resolver->setDefault('query_builder', function (Options $options) {
|
||||
@@ -73,6 +74,9 @@ class ProjectType extends AbstractType
|
||||
if (true === $options['query_builder_for_user']) {
|
||||
$query->setUser($options['user']);
|
||||
}
|
||||
if (true === $options['ignore_date']) {
|
||||
$query->setIgnoreDate(true);
|
||||
}
|
||||
|
||||
return $repo->getQueryBuilderForFormType($query);
|
||||
};
|
||||
@@ -83,7 +87,7 @@ class ProjectType extends AbstractType
|
||||
return [
|
||||
'select' => 'activity',
|
||||
'route' => 'get_activities',
|
||||
'route_params' => ['project' => '-s-', 'visible' => $options['activity_visibility']],
|
||||
'route_params' => ['project' => '%project%', 'visible' => $options['activity_visibility']],
|
||||
'empty_route_params' => ['globals' => 'true', 'visible' => $options['activity_visibility']],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -10,9 +10,12 @@
|
||||
namespace App\Form\Type;
|
||||
|
||||
use App\Entity\Team;
|
||||
use App\Entity\User;
|
||||
use App\Repository\Query\TeamQuery;
|
||||
use App\Repository\TeamRepository;
|
||||
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\OptionsResolver\Options;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class TeamType extends AbstractType
|
||||
@@ -25,13 +28,26 @@ class TeamType extends AbstractType
|
||||
$resolver->setDefaults([
|
||||
'class' => Team::class,
|
||||
'label' => 'label.team',
|
||||
'query_builder' => function (TeamRepository $repo) {
|
||||
return $repo->createQueryBuilder('t')->orderBy('t.name', 'ASC');
|
||||
},
|
||||
'teamlead_only' => true,
|
||||
'choice_label' => function (Team $team) {
|
||||
return $team->getName();
|
||||
},
|
||||
]);
|
||||
|
||||
$resolver->setDefault('query_builder', function (Options $options) {
|
||||
return function (TeamRepository $repo) use ($options) {
|
||||
/** @var User $user */
|
||||
$user = $options['user'];
|
||||
$query = new TeamQuery();
|
||||
$query->setCurrentUser($user);
|
||||
|
||||
if (!$options['teamlead_only']) {
|
||||
$query->setTeams($user->getTeams()->toArray());
|
||||
}
|
||||
|
||||
return $repo->getQueryBuilderForFormType($query);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
namespace App\Form\Type;
|
||||
|
||||
use App\Repository\Query\VisibilityQuery;
|
||||
use App\Repository\Query\VisibilityInterface;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
@@ -27,9 +27,9 @@ class VisibilityType extends AbstractType
|
||||
$resolver->setDefaults([
|
||||
'label' => 'label.visible',
|
||||
'choices' => [
|
||||
'both' => VisibilityQuery::SHOW_BOTH,
|
||||
'yes' => VisibilityQuery::SHOW_VISIBLE,
|
||||
'no' => VisibilityQuery::SHOW_HIDDEN,
|
||||
'both' => VisibilityInterface::SHOW_BOTH,
|
||||
'yes' => VisibilityInterface::SHOW_VISIBLE,
|
||||
'no' => VisibilityInterface::SHOW_HIDDEN,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ abstract class AbstractMergedCalculator extends AbstractCalculator
|
||||
$invoiceItem->setProject($entry->getProject());
|
||||
}
|
||||
|
||||
if (empty($invoiceItem->getDescription())) {
|
||||
if (empty($invoiceItem->getDescription()) && null !== $entry->getActivity()) {
|
||||
$invoiceItem->setDescription($entry->getActivity()->getName());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,10 @@ class ActivityInvoiceCalculator extends AbstractSumInvoiceCalculator implements
|
||||
{
|
||||
protected function calculateSumIdentifier(InvoiceItemInterface $invoiceItem): string
|
||||
{
|
||||
if (null === $invoiceItem->getActivity()) {
|
||||
throw new \Exception('Cannot work with invoice items that do not have an activity');
|
||||
}
|
||||
|
||||
if (null === $invoiceItem->getActivity()->getId()) {
|
||||
throw new \Exception('Cannot handle un-persisted activities');
|
||||
}
|
||||
|
||||
@@ -364,25 +364,23 @@ final class InvoiceModel
|
||||
$amount = $invoiceItem->getAmount();
|
||||
}
|
||||
|
||||
if (empty($description)) {
|
||||
$description = $invoiceItem->getActivity()->getName();
|
||||
}
|
||||
|
||||
$activity = $invoiceItem->getActivity();
|
||||
$project = $invoiceItem->getProject();
|
||||
$customer = $project->getCustomer();
|
||||
$currency = $customer->getCurrency();
|
||||
$user = $invoiceItem->getUser();
|
||||
$begin = $invoiceItem->getBegin();
|
||||
$end = $invoiceItem->getEnd();
|
||||
|
||||
if (empty($description) && null !== $activity) {
|
||||
$description = $activity->getName();
|
||||
}
|
||||
|
||||
// this should never happen!
|
||||
if (empty($appliedRate)) {
|
||||
$appliedRate = 0;
|
||||
}
|
||||
|
||||
$activity = $invoiceItem->getActivity();
|
||||
$project = $invoiceItem->getProject();
|
||||
$customer = $project->getCustomer();
|
||||
$currency = $customer->getCurrency();
|
||||
|
||||
$begin = $invoiceItem->getBegin();
|
||||
$end = $invoiceItem->getEnd();
|
||||
|
||||
$values = [
|
||||
'entry.row' => '',
|
||||
'entry.description' => $description,
|
||||
|
||||
44
src/Migrations/Version20191204120823.php
Normal file
44
src/Migrations/Version20191204120823.php
Normal 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 App\Doctrine\AbstractMigration;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
|
||||
/**
|
||||
* Adds project_start and project_end to projects tables
|
||||
*
|
||||
* @version 1.7
|
||||
*/
|
||||
final class Version20191204120823 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Adds project_start and project_end to projects tables';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$projects = $schema->getTable('kimai2_projects');
|
||||
$projects->addColumn('start', 'datetime', ['notnull' => false]);
|
||||
$projects->addColumn('end', 'datetime', ['notnull' => false]);
|
||||
$projects->addColumn('timezone', 'string', ['notnull' => false, 'length' => 64]);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$projects = $schema->getTable('kimai2_projects');
|
||||
$projects->dropColumn('timezone');
|
||||
$projects->dropColumn('end');
|
||||
$projects->dropColumn('start');
|
||||
}
|
||||
}
|
||||
@@ -182,6 +182,36 @@ class ProjectRepository extends EntityRepository
|
||||
|
||||
$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);
|
||||
}
|
||||
|
||||
$qb->setParameter('visible', true, \PDO::PARAM_BOOL);
|
||||
$qb->setParameter('customer_visible', true, \PDO::PARAM_BOOL);
|
||||
|
||||
@@ -246,6 +276,47 @@ class ProjectRepository extends EntityRepository
|
||||
->setParameter('customer', $query->getCustomer());
|
||||
}
|
||||
|
||||
// this is far from being perfect, possible enhancements:
|
||||
// there could also be a range selection to be able to select all projects that were active between from and to
|
||||
// begin = null and end = null
|
||||
// begin = null and end <= to
|
||||
// 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());
|
||||
}
|
||||
|
||||
$this->addPermissionCriteria($qb, $query->getCurrentUser());
|
||||
|
||||
if ($query->hasSearchTerm()) {
|
||||
|
||||
@@ -92,6 +92,12 @@ final class ActivityFormTypeQuery
|
||||
|
||||
public function isGlobalsOnly(): bool
|
||||
{
|
||||
return null === $this->activity && null === $this->project;
|
||||
return
|
||||
(
|
||||
null === $this->activity ||
|
||||
($this->activity instanceof Activity && null === $this->activity->getProject())
|
||||
)
|
||||
&&
|
||||
null === $this->project;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,15 +26,15 @@ class BaseQuery
|
||||
public const DEFAULT_PAGE = 1;
|
||||
|
||||
/**
|
||||
* @deprecated since 1.4, will be removed with 1.6
|
||||
* @deprecated since 1.4, will be removed with 2.0
|
||||
*/
|
||||
public const RESULT_TYPE_OBJECTS = 'Objects';
|
||||
/**
|
||||
* @deprecated since 1.4, will be removed with 1.6
|
||||
* @deprecated since 1.4, will be removed with 2.0
|
||||
*/
|
||||
public const RESULT_TYPE_PAGER = 'PagerFanta';
|
||||
/**
|
||||
* @deprecated since 1.4, will be removed with 1.6
|
||||
* @deprecated since 1.4, will be removed with 2.0
|
||||
*/
|
||||
public const RESULT_TYPE_QUERYBUILDER = 'QueryBuilder';
|
||||
|
||||
@@ -63,7 +63,7 @@ class BaseQuery
|
||||
private $order = self::ORDER_ASC;
|
||||
/**
|
||||
* @var string
|
||||
* @deprecated since 1.4, will be removed with 1.6
|
||||
* @deprecated since 1.4, will be removed with 2.0
|
||||
*/
|
||||
private $resultType = self::RESULT_TYPE_PAGER;
|
||||
/**
|
||||
@@ -79,6 +79,23 @@ class BaseQuery
|
||||
*/
|
||||
private $searchTerm;
|
||||
|
||||
/**
|
||||
* @param Team[] $teams
|
||||
* @return $this
|
||||
*/
|
||||
public function setTeams(?array $teams): self
|
||||
{
|
||||
$this->teams = [];
|
||||
|
||||
if (null !== $teams) {
|
||||
foreach ($teams as $team) {
|
||||
$this->addTeam($team);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addTeam(Team $team): self
|
||||
{
|
||||
$this->teams[$team->getId()] = $team;
|
||||
@@ -189,7 +206,7 @@ class BaseQuery
|
||||
*/
|
||||
public function getResultType()
|
||||
{
|
||||
@trigger_error('BaseQuery::getResultType() is deprecated and will be removed with 1.6', E_USER_DEPRECATED);
|
||||
@trigger_error('BaseQuery::getResultType() is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
|
||||
|
||||
return $this->resultType;
|
||||
}
|
||||
@@ -254,4 +271,27 @@ class BaseQuery
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function copyTo(BaseQuery $query): BaseQuery
|
||||
{
|
||||
$query->setDefaults($this->defaults);
|
||||
if (null !== $this->getCurrentUser()) {
|
||||
$query->setCurrentUser($this->getCurrentUser());
|
||||
}
|
||||
$query->setOrder($this->getOrder());
|
||||
$query->setOrderBy($this->getOrderBy());
|
||||
$query->setSearchTerm($this->getSearchTerm());
|
||||
$query->setPage($this->getPage());
|
||||
$query->setPageSize($this->getPageSize());
|
||||
|
||||
foreach ($this->getTeams() as $team) {
|
||||
$query->addTeam($team);
|
||||
}
|
||||
|
||||
if ($this instanceof VisibilityInterface && $query instanceof VisibilityInterface) {
|
||||
$query->setVisibility($this->getVisibility());
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@ namespace App\Repository\Query;
|
||||
/**
|
||||
* Can be used for advanced queries with the: CustomerRepository
|
||||
*/
|
||||
class CustomerQuery extends VisibilityQuery
|
||||
class CustomerQuery extends BaseQuery implements VisibilityInterface
|
||||
{
|
||||
use VisibilityTrait;
|
||||
|
||||
public const CUSTOMER_ORDER_ALLOWED = ['id', 'name', 'comment', 'country', 'number'];
|
||||
|
||||
public function __construct()
|
||||
|
||||
@@ -36,6 +36,10 @@ final class ProjectFormTypeQuery
|
||||
* @var array<Team>
|
||||
*/
|
||||
private $teams = [];
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
private $ignoreDate = false;
|
||||
|
||||
/**
|
||||
* @param Project|int|null $project
|
||||
@@ -126,4 +130,16 @@ final class ProjectFormTypeQuery
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isIgnoreDate(): bool
|
||||
{
|
||||
return $this->ignoreDate;
|
||||
}
|
||||
|
||||
public function setIgnoreDate(bool $ignoreDate): ProjectFormTypeQuery
|
||||
{
|
||||
$this->ignoreDate = $ignoreDate;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,18 +14,27 @@ use App\Entity\Customer;
|
||||
/**
|
||||
* Can be used for advanced queries with the: ProjectRepository
|
||||
*/
|
||||
class ProjectQuery extends CustomerQuery
|
||||
class ProjectQuery extends BaseQuery implements VisibilityInterface
|
||||
{
|
||||
public const PROJECT_ORDER_ALLOWED = ['id', 'name', 'comment', 'customer', 'orderNumber'];
|
||||
use VisibilityTrait;
|
||||
|
||||
public const PROJECT_ORDER_ALLOWED = ['id', 'name', 'comment', 'customer', 'orderNumber', 'projectStart', 'projectEnd'];
|
||||
|
||||
/**
|
||||
* @var Customer|int|null
|
||||
*/
|
||||
private $customer;
|
||||
/**
|
||||
* @var \DateTime
|
||||
*/
|
||||
private $projectStart;
|
||||
/**
|
||||
* @var \DateTime
|
||||
*/
|
||||
private $projectEnd;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->setDefaults([
|
||||
'orderBy' => 'name',
|
||||
]);
|
||||
@@ -49,4 +58,28 @@ class ProjectQuery extends CustomerQuery
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getProjectStart(): ?\DateTime
|
||||
{
|
||||
return $this->projectStart;
|
||||
}
|
||||
|
||||
public function setProjectStart(?\DateTime $projectStart): ProjectQuery
|
||||
{
|
||||
$this->projectStart = $projectStart;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getProjectEnd(): ?\DateTime
|
||||
{
|
||||
return $this->projectEnd;
|
||||
}
|
||||
|
||||
public function setProjectEnd(?\DateTime $projectEnd): ProjectQuery
|
||||
{
|
||||
$this->projectEnd = $projectEnd;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@ namespace App\Repository\Query;
|
||||
/**
|
||||
* Can be used for advanced queries with the: UserRepository
|
||||
*/
|
||||
class UserQuery extends VisibilityQuery
|
||||
class UserQuery extends BaseQuery implements VisibilityInterface
|
||||
{
|
||||
use VisibilityTrait;
|
||||
|
||||
public const USER_ORDER_ALLOWED = ['id', 'alias', 'username', 'title', 'email'];
|
||||
|
||||
/**
|
||||
|
||||
31
src/Repository/Query/VisibilityInterface.php
Normal file
31
src/Repository/Query/VisibilityInterface.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?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\Repository\Query;
|
||||
|
||||
interface VisibilityInterface
|
||||
{
|
||||
public const SHOW_VISIBLE = 1;
|
||||
public const SHOW_HIDDEN = 2;
|
||||
public const SHOW_BOTH = 3;
|
||||
|
||||
public const ALLOWED_VISIBILITY_STATES = [
|
||||
self::SHOW_BOTH,
|
||||
self::SHOW_VISIBLE,
|
||||
self::SHOW_HIDDEN,
|
||||
];
|
||||
|
||||
public function getVisibility(): int;
|
||||
|
||||
/**
|
||||
* @param int $visibility
|
||||
* @return mixed
|
||||
*/
|
||||
public function setVisibility($visibility);
|
||||
}
|
||||
@@ -11,43 +11,10 @@ namespace App\Repository\Query;
|
||||
|
||||
/**
|
||||
* Query class for Repositories with a visibility field.
|
||||
*
|
||||
* @deprecated since 1.7, will be removed with 2.0
|
||||
*/
|
||||
class VisibilityQuery extends BaseQuery
|
||||
class VisibilityQuery extends BaseQuery implements VisibilityInterface
|
||||
{
|
||||
public const SHOW_VISIBLE = 1;
|
||||
public const SHOW_HIDDEN = 2;
|
||||
public const SHOW_BOTH = 3;
|
||||
|
||||
public const ALLOWED_VISIBILITY_STATES = [
|
||||
self::SHOW_BOTH,
|
||||
self::SHOW_VISIBLE,
|
||||
self::SHOW_HIDDEN,
|
||||
];
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
private $visibility = self::SHOW_VISIBLE;
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*/
|
||||
public function getVisibility()
|
||||
{
|
||||
return $this->visibility;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $visibility
|
||||
* @return $this
|
||||
*/
|
||||
public function setVisibility($visibility)
|
||||
{
|
||||
$visibility = (int) $visibility;
|
||||
if (in_array($visibility, self::ALLOWED_VISIBILITY_STATES, true)) {
|
||||
$this->visibility = $visibility;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
use VisibilityTrait;
|
||||
}
|
||||
|
||||
33
src/Repository/Query/VisibilityTrait.php
Normal file
33
src/Repository/Query/VisibilityTrait.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?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\Repository\Query;
|
||||
|
||||
trait VisibilityTrait
|
||||
{
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
private $visibility = VisibilityInterface::SHOW_VISIBLE;
|
||||
|
||||
public function getVisibility(): int
|
||||
{
|
||||
return $this->visibility;
|
||||
}
|
||||
|
||||
public function setVisibility($visibility)
|
||||
{
|
||||
$visibility = (int) $visibility;
|
||||
if (in_array($visibility, VisibilityInterface::ALLOWED_VISIBILITY_STATES, true)) {
|
||||
$this->visibility = $visibility;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,25 @@ class TeamRepository extends EntityRepository
|
||||
$entityManager->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a query builder that is used for TeamType and your own 'query_builder' option.
|
||||
*
|
||||
* @param TeamQuery $query
|
||||
* @return QueryBuilder
|
||||
*/
|
||||
public function getQueryBuilderForFormType(TeamQuery $query): QueryBuilder
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
|
||||
$qb->select('t')
|
||||
->from(Team::class, 't')
|
||||
->orderBy('t.name', 'ASC');
|
||||
|
||||
$this->addPermissionCriteria($qb, $query->getCurrentUser(), $query->getTeams());
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
public function getPagerfantaForQuery(TeamQuery $query): Pagerfanta
|
||||
{
|
||||
$paginator = new Pagerfanta($this->getPaginatorForQuery($query));
|
||||
@@ -134,6 +153,11 @@ class TeamRepository extends EntityRepository
|
||||
return $qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param QueryBuilder $qb
|
||||
* @param User|null $user
|
||||
* @param Team[] $teams
|
||||
*/
|
||||
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [])
|
||||
{
|
||||
// make sure that all queries without a user see all user
|
||||
@@ -146,10 +170,21 @@ class TeamRepository extends EntityRepository
|
||||
return;
|
||||
}
|
||||
|
||||
$or = $qb->expr()->orX();
|
||||
|
||||
if (null !== $user) {
|
||||
$qb
|
||||
->andWhere('t.teamlead = :id')
|
||||
->setParameter('id', $user);
|
||||
$or->add($qb->expr()->eq('t.teamlead', ':id'));
|
||||
$qb->setParameter('id', $user);
|
||||
}
|
||||
|
||||
if (!empty($teams)) {
|
||||
$ids = [];
|
||||
foreach ($teams as $team) {
|
||||
$ids[] = $team->getId();
|
||||
}
|
||||
$or->add($qb->expr()->in('t.id', $ids));
|
||||
}
|
||||
|
||||
$qb->andWhere($or);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -827,6 +827,11 @@ class TimesheetRepository extends EntityRepository
|
||||
|
||||
private function getDatetimeFieldSql(string $field): string
|
||||
{
|
||||
return sprintf('CONVERT_TZ(%s, \'UTC\', t.timezone)', $field);
|
||||
// this would change the selected data for queries that join across multiple timezones
|
||||
// but due to tax laws, this is disabled - exports/invoices should *always* include the data from
|
||||
// the own timezone, not from the original users timezone
|
||||
// return sprintf('CONVERT_TZ(%s, \'UTC\', t.timezone)', $field);
|
||||
|
||||
return $field;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ class UserRepository extends EntityRepository implements UserLoaderInterface
|
||||
*/
|
||||
public function findByQuery(UserQuery $query)
|
||||
{
|
||||
@trigger_error('UserRepository::findByQuery() is deprecated and will be removed with 1.6', E_USER_DEPRECATED);
|
||||
@trigger_error('UserRepository::findByQuery() is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
|
||||
$qb = $this->getQueryBuilderForQuery($query);
|
||||
|
||||
if (BaseQuery::RESULT_TYPE_PAGER === $query->getResultType()) {
|
||||
|
||||
118
src/Timesheet/TimesheetService.php
Normal file
118
src/Timesheet/TimesheetService.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?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\Timesheet;
|
||||
|
||||
use App\Configuration\TimesheetConfiguration;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
use App\Event\TimesheetMetaDefinitionEvent;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
final class TimesheetService
|
||||
{
|
||||
/**
|
||||
* @var TimesheetRepository
|
||||
*/
|
||||
private $repository;
|
||||
/**
|
||||
* @var TimesheetConfiguration
|
||||
*/
|
||||
private $configuration;
|
||||
/**
|
||||
* @var TrackingModeService
|
||||
*/
|
||||
private $trackingModeService;
|
||||
/**
|
||||
* @var EventDispatcherInterface
|
||||
*/
|
||||
private $dispatcher;
|
||||
/**
|
||||
* @var AuthorizationCheckerInterface
|
||||
*/
|
||||
private $auth;
|
||||
|
||||
public function __construct(
|
||||
TimesheetConfiguration $configuration,
|
||||
TimesheetRepository $repository,
|
||||
TrackingModeService $service,
|
||||
EventDispatcherInterface $dispatcher,
|
||||
AuthorizationCheckerInterface $security
|
||||
) {
|
||||
$this->configuration = $configuration;
|
||||
$this->repository = $repository;
|
||||
$this->trackingModeService = $service;
|
||||
$this->dispatcher = $dispatcher;
|
||||
$this->auth = $security;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls prepareNewTimesheet() automatically.
|
||||
*
|
||||
* @param User $user
|
||||
* @param Request|null $request
|
||||
* @return Timesheet
|
||||
*/
|
||||
public function createNewTimesheet(User $user, ?Request $request = null): Timesheet
|
||||
{
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet->setUser($user);
|
||||
|
||||
if (null !== $request) {
|
||||
$this->prepareNewTimesheet($timesheet, $request);
|
||||
}
|
||||
|
||||
return $timesheet;
|
||||
}
|
||||
|
||||
public function prepareNewTimesheet(Timesheet $timesheet, ?Request $request = null)
|
||||
{
|
||||
if (null !== $timesheet->getId()) {
|
||||
throw new \InvalidArgumentException('Cannot prepare timesheet, already persisted');
|
||||
}
|
||||
|
||||
$event = new TimesheetMetaDefinitionEvent($timesheet);
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
$mode = $this->trackingModeService->getActiveMode();
|
||||
$mode->create($timesheet, $request);
|
||||
|
||||
return $timesheet;
|
||||
}
|
||||
|
||||
public function saveNewTimesheet(Timesheet $timesheet)
|
||||
{
|
||||
if (null !== $timesheet->getId()) {
|
||||
throw new \InvalidArgumentException('Cannot create timesheet, already persisted');
|
||||
}
|
||||
|
||||
if (null === $timesheet->getEnd()) {
|
||||
if (!$this->auth->isGranted('start', $timesheet)) {
|
||||
throw new AccessDeniedHttpException('You are not allowed to start this timesheet record');
|
||||
}
|
||||
$this->repository->stopActiveEntries(
|
||||
$timesheet->getUser(),
|
||||
$this->configuration->getActiveEntriesHardLimit()
|
||||
);
|
||||
}
|
||||
|
||||
$this->repository->save($timesheet);
|
||||
|
||||
return $timesheet;
|
||||
}
|
||||
|
||||
public function stopTimesheet(Timesheet $timesheet)
|
||||
{
|
||||
return $this->repository->stopRecording($timesheet);
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,12 @@ abstract class AbstractTrackingMode implements TrackingModeInterface
|
||||
$this->configuration = $configuration;
|
||||
}
|
||||
|
||||
public function create(Timesheet $timesheet, Request $request): void
|
||||
public function create(Timesheet $timesheet, ?Request $request = null): void
|
||||
{
|
||||
if (null === $request) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->setBeginEndFromRequest($timesheet, $request);
|
||||
$this->setFromToFromRequest($timesheet, $request);
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ final class DefaultMode extends AbstractTrackingMode
|
||||
return true;
|
||||
}
|
||||
|
||||
public function create(Timesheet $timesheet, Request $request): void
|
||||
public function create(Timesheet $timesheet, ?Request $request = null): void
|
||||
{
|
||||
parent::create($timesheet, $request);
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ final class DurationFixedBeginMode implements TrackingModeInterface
|
||||
return false;
|
||||
}
|
||||
|
||||
public function create(Timesheet $timesheet, Request $request): void
|
||||
public function create(Timesheet $timesheet, ?Request $request = null): void
|
||||
{
|
||||
if (null === $timesheet->getBegin()) {
|
||||
$timesheet->setBegin($this->dateTime->createDateTime());
|
||||
|
||||
@@ -44,7 +44,7 @@ final class DurationOnlyMode extends AbstractTrackingMode
|
||||
return false;
|
||||
}
|
||||
|
||||
public function create(Timesheet $timesheet, Request $request): void
|
||||
public function create(Timesheet $timesheet, ?Request $request = null): void
|
||||
{
|
||||
if (null === $timesheet->getBegin()) {
|
||||
$timesheet->setBegin($this->dateTime->createDateTime());
|
||||
|
||||
@@ -45,7 +45,7 @@ final class PunchInOutMode implements TrackingModeInterface
|
||||
return false;
|
||||
}
|
||||
|
||||
public function create(Timesheet $timesheet, Request $request): void
|
||||
public function create(Timesheet $timesheet, ?Request $request = null): void
|
||||
{
|
||||
if (null === $timesheet->getBegin()) {
|
||||
$timesheet->setBegin($this->dateTime->createDateTime());
|
||||
|
||||
@@ -15,6 +15,8 @@ use Symfony\Component\HttpFoundation\Request;
|
||||
/**
|
||||
* A tracking-mode defines the behaviour of the user timesheet.
|
||||
* It is NOT used for the timesheet administration.
|
||||
*
|
||||
* @internal do not implement this interface in your bundle, but rather drop a PR to add it to Kimai core
|
||||
*/
|
||||
interface TrackingModeInterface
|
||||
{
|
||||
@@ -23,9 +25,9 @@ interface TrackingModeInterface
|
||||
* before form data is rendered/processed.
|
||||
*
|
||||
* @param Timesheet $timesheet
|
||||
* @param Request $request
|
||||
* @param Request|null $request
|
||||
*/
|
||||
public function create(Timesheet $timesheet, Request $request): void;
|
||||
public function create(Timesheet $timesheet, ?Request $request = null): void;
|
||||
|
||||
/**
|
||||
* Whether the user can edit the begin datetime.
|
||||
|
||||
@@ -38,6 +38,7 @@ final class IconExtension extends AbstractExtension
|
||||
'download' => 'fas fa-download',
|
||||
'duration' => 'far fa-hourglass',
|
||||
'edit' => 'far fa-edit',
|
||||
'end' => 'fas fa-stopwatch',
|
||||
'export' => 'fas fa-file-export',
|
||||
'filter' => 'fas fa-filter',
|
||||
'help' => 'far fa-question-circle',
|
||||
@@ -55,6 +56,8 @@ final class IconExtension extends AbstractExtension
|
||||
'off' => 'fas fa-toggle-off',
|
||||
'on' => 'fas fa-toggle-on',
|
||||
'pdf' => 'fas fa-file-pdf',
|
||||
'pause' => 'fas fa-pause',
|
||||
'pause-small' => 'far fa-pause-circle',
|
||||
'permissions' => 'fas fa-user-lock',
|
||||
'phone' => 'fas fa-phone',
|
||||
'plugin' => 'fas fa-plug',
|
||||
@@ -67,7 +70,7 @@ final class IconExtension extends AbstractExtension
|
||||
'search' => 'fas fa-search',
|
||||
'settings' => 'fas fa-cog',
|
||||
'shop' => 'fas fa-shopping-cart',
|
||||
'start' => 'fas fa-play-circle',
|
||||
'start' => 'fas fa-play',
|
||||
'start-small' => 'far fa-play-circle',
|
||||
'stop' => 'fas fa-stop',
|
||||
'stop-small' => 'far fa-stop-circle',
|
||||
|
||||
33
src/Validator/Constraints/Project.php
Normal file
33
src/Validator/Constraints/Project.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?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;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
|
||||
/**
|
||||
* @Annotation
|
||||
* @Target({"CLASS", "PROPERTY", "METHOD", "ANNOTATION"})
|
||||
*/
|
||||
class Project extends Constraint
|
||||
{
|
||||
public const END_BEFORE_BEGIN_ERROR = 'kimai-project-00';
|
||||
|
||||
protected static $errorNames = [
|
||||
self::END_BEFORE_BEGIN_ERROR => 'End date must not be earlier then start date.',
|
||||
];
|
||||
|
||||
public $message = 'This project has invalid settings.';
|
||||
|
||||
public function getTargets()
|
||||
{
|
||||
return self::CLASS_CONSTRAINT;
|
||||
}
|
||||
}
|
||||
48
src/Validator/Constraints/ProjectValidator.php
Normal file
48
src/Validator/Constraints/ProjectValidator.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?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\Project;
|
||||
use App\Validator\Constraints\Project as ProjectConstraint;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidator;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
|
||||
class ProjectValidator extends ConstraintValidator
|
||||
{
|
||||
/**
|
||||
* @param Project|mixed $value
|
||||
* @param Constraint $constraint
|
||||
*/
|
||||
public function validate($value, Constraint $constraint)
|
||||
{
|
||||
if (!($constraint instanceof ProjectConstraint)) {
|
||||
throw new UnexpectedTypeException($constraint, __NAMESPACE__ . '\Project');
|
||||
}
|
||||
|
||||
if (!is_object($value) || !($value instanceof Project)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validateProject($value, $this->context);
|
||||
}
|
||||
|
||||
protected function validateProject(Project $project, ExecutionContextInterface $context)
|
||||
{
|
||||
if (null !== $project->getStart() && null !== $project->getEnd() && $project->getStart()->getTimestamp() > $project->getEnd()->getTimestamp()) {
|
||||
$context->buildViolation('End date must not be earlier then start date.')
|
||||
->atPath('end')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(ProjectConstraint::END_BEFORE_BEGIN_ERROR)
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,16 +18,18 @@ use Symfony\Component\Validator\Constraint;
|
||||
*/
|
||||
class Timesheet extends Constraint
|
||||
{
|
||||
public const MISSING_BEGIN_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d81';
|
||||
public const END_BEFORE_BEGIN_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d82';
|
||||
public const BEGIN_IN_FUTURE_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d83';
|
||||
public const MISSING_ACTIVITY_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d84';
|
||||
public const MISSING_PROJECT_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d85';
|
||||
public const ACTIVITY_PROJECT_MISMATCH_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d86';
|
||||
public const DISABLED_ACTIVITY_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d87';
|
||||
public const DISABLED_PROJECT_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d88';
|
||||
public const DISABLED_CUSTOMER_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d89';
|
||||
public const START_DISALLOWED = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d90';
|
||||
public const MISSING_BEGIN_ERROR = 'kimai-timesheet-81';
|
||||
public const END_BEFORE_BEGIN_ERROR = 'kimai-timesheet-82';
|
||||
public const BEGIN_IN_FUTURE_ERROR = 'kimai-timesheet-83';
|
||||
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 START_DISALLOWED = 'kimai-timesheet-90';
|
||||
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.',
|
||||
@@ -40,6 +42,8 @@ class Timesheet extends Constraint
|
||||
self::DISABLED_PROJECT_ERROR => 'Cannot start a disabled project.',
|
||||
self::DISABLED_CUSTOMER_ERROR => 'Cannot start a disabled customer.',
|
||||
self::START_DISALLOWED => 'You are not allowed to start this timesheet record.',
|
||||
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.';
|
||||
|
||||
@@ -166,7 +166,9 @@ class TimesheetValidator extends ConstraintValidator
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
if (null === $timesheet->getEnd() && !$activity->isVisible()) {
|
||||
$timesheetEnd = $timesheet->getEnd();
|
||||
|
||||
if (null === $timesheetEnd && !$activity->isVisible()) {
|
||||
$context->buildViolation('Cannot start a disabled activity.')
|
||||
->atPath('activity')
|
||||
->setTranslationDomain('validators')
|
||||
@@ -174,7 +176,7 @@ class TimesheetValidator extends ConstraintValidator
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
if (null === $timesheet->getEnd() && !$project->isVisible()) {
|
||||
if (null === $timesheetEnd && !$project->isVisible()) {
|
||||
$context->buildViolation('Cannot start a disabled project.')
|
||||
->atPath('project')
|
||||
->setTranslationDomain('validators')
|
||||
@@ -182,12 +184,52 @@ class TimesheetValidator extends ConstraintValidator
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
if (null === $timesheet->getEnd() && !$project->getCustomer()->isVisible()) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,8 +70,8 @@ class ActivityVoter extends AbstractVoter
|
||||
return true;
|
||||
}
|
||||
|
||||
$project = $subject->getProject();
|
||||
if (null === $project) {
|
||||
// new and global activities have no project
|
||||
if (null === ($project = $subject->getProject())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -90,8 +90,13 @@ class ProjectVoter extends AbstractVoter
|
||||
}
|
||||
}
|
||||
|
||||
// new projects have no customer
|
||||
if (null === ($customer = $subject->getCustomer())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var Team $team */
|
||||
foreach ($subject->getCustomer()->getTeams() as $team) {
|
||||
foreach ($customer->getTeams() as $team) {
|
||||
if ($hasTeamleadPermission && $user->isTeamleadOf($team)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user