diff --git a/src/Entity/Project.php b/src/Entity/Project.php index 8a263b55..0776d81c 100644 --- a/src/Entity/Project.php +++ b/src/Entity/Project.php @@ -252,6 +252,15 @@ class Project implements EntityWithMetaFields, EntityWithBudget * @ORM\Column(name="invoice_text", type="text", nullable=true) */ private $invoiceText; + /** + * Whether this project allows booking of global activities + * + * @var bool + * + * @ORM\Column(name="global_activities", type="boolean", nullable=false, options={"default": true}) + * @Assert\NotNull() + */ + private $globalActivities = true; public function __construct() { @@ -419,6 +428,16 @@ class Project implements EntityWithMetaFields, EntityWithBudget return $this; } + public function isGlobalActivities(): bool + { + return $this->globalActivities; + } + + public function setGlobalActivities(bool $globalActivities): void + { + $this->globalActivities = $globalActivities; + } + /** * @return Collection|MetaTableTypeInterface[] */ diff --git a/src/Form/ProjectEditForm.php b/src/Form/ProjectEditForm.php index f5d08893..6804594e 100644 --- a/src/Form/ProjectEditForm.php +++ b/src/Form/ProjectEditForm.php @@ -13,6 +13,7 @@ use App\Entity\Customer; use App\Entity\Project; use App\Form\Type\CustomerType; use App\Form\Type\DateTimePickerType; +use App\Form\Type\YesNoType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextType; @@ -95,7 +96,11 @@ class ProjectEditForm extends AbstractType 'placeholder' => (null === $id && null === $customer) ? '' : false, 'customers' => $customer, 'query_builder_for_user' => true, - ]); + ]) + ->add('globalActivities', YesNoType::class, [ + 'label' => 'label.globalActivities', + ]) + ; $this->addCommonFields($builder, $options); } diff --git a/src/Migrations/Version20220722125847.php b/src/Migrations/Version20220722125847.php new file mode 100644 index 00000000..c931d4ea --- /dev/null +++ b/src/Migrations/Version20220722125847.php @@ -0,0 +1,38 @@ +getTable('kimai2_projects'); + $projects->addColumn('global_activities', 'boolean', ['notnull' => true, 'default' => true]); + } + + public function down(Schema $schema): void + { + $projects = $schema->getTable('kimai2_projects'); + $projects->dropColumn('global_activities'); + } +} diff --git a/src/Repository/ActivityRepository.php b/src/Repository/ActivityRepository.php index 9ffed3cf..bf1d3e2a 100644 --- a/src/Repository/ActivityRepository.php +++ b/src/Repository/ActivityRepository.php @@ -286,12 +286,28 @@ class ActivityRepository extends EntityRepository if ($query->isGlobalsOnly()) { $mainQuery->add($qb->expr()->isNull('a.project')); } elseif ($query->hasProjects()) { - $mainQuery->add( - $qb->expr()->orX( - $qb->expr()->isNull('a.project'), - $qb->expr()->in('a.project', ':project') - ) + $orX = $qb->expr()->orX( + $qb->expr()->in('a.project', ':project') ); + + $includeGlobals = true; + // projects have a setting to disallow global activities, and we check for it only + // if we query for exactly one project (usually used in dropdown queries) + if (\count($query->getProjects()) === 1) { + $project = $query->getProjects()[0]; + if (!$project instanceof Project) { + $project = $this->getEntityManager()->getRepository(Project::class)->find($project); + } + if ($project instanceof Project) { + $includeGlobals = $project->isGlobalActivities(); + } + } + + if ($includeGlobals) { + $orX->add($qb->expr()->isNull('a.project')); + } + + $mainQuery->add($orX); $qb->setParameter('project', $query->getProjects()); } @@ -381,7 +397,21 @@ class ActivityRepository extends EntityRepository ); if (!$query->isExcludeGlobals()) { - $orX->add($qb->expr()->isNull('a.project')); + $includeGlobals = true; + // projects have a setting to disallow global activities, and we check for it only + // if we query for exactly one project (usually used in dropdown queries) + if (\count($query->getProjects()) === 1) { + $project = $query->getProjects()[0]; + if (!$project instanceof Project) { + $project = $this->getEntityManager()->getRepository(Project::class)->find($project); + } + if ($project instanceof Project) { + $includeGlobals = $project->isGlobalActivities(); + } + } + if ($includeGlobals) { + $orX->add($qb->expr()->isNull('a.project')); + } } $where->add($orX); diff --git a/src/Validator/Constraints/TimesheetBasic.php b/src/Validator/Constraints/TimesheetBasic.php index 4eedeaff..d09b004f 100644 --- a/src/Validator/Constraints/TimesheetBasic.php +++ b/src/Validator/Constraints/TimesheetBasic.php @@ -27,6 +27,7 @@ class TimesheetBasic extends TimesheetConstraint public const DISABLED_CUSTOMER_ERROR = 'kimai-timesheet-89'; public const PROJECT_NOT_STARTED = 'kimai-timesheet-91'; public const PROJECT_ALREADY_ENDED = 'kimai-timesheet-92'; + public const PROJECT_DISALLOWS_GLOBAL_ACTIVITY = 'kimai-timesheet-93'; protected static $errorNames = [ self::MISSING_BEGIN_ERROR => 'You must submit a begin date.', @@ -39,6 +40,7 @@ class TimesheetBasic extends TimesheetConstraint self::DISABLED_CUSTOMER_ERROR => 'Cannot start a disabled customer.', self::PROJECT_NOT_STARTED => 'The project has not started at that time.', self::PROJECT_ALREADY_ENDED => 'The project is finished at that time.', + self::PROJECT_DISALLOWS_GLOBAL_ACTIVITY => 'Global activities are forbidden for the selected project.', ]; public $message = 'This timesheet has invalid settings.'; diff --git a/src/Validator/Constraints/TimesheetBasicValidator.php b/src/Validator/Constraints/TimesheetBasicValidator.php index 14f0aeab..d37ef2ad 100644 --- a/src/Validator/Constraints/TimesheetBasicValidator.php +++ b/src/Validator/Constraints/TimesheetBasicValidator.php @@ -124,6 +124,14 @@ final class TimesheetBasicValidator extends ConstraintValidator ->addViolation(); } + if (!$project->isGlobalActivities() && $activity->isGlobal()) { + $context->buildViolation('Global activities are forbidden for the selected project.') + ->atPath('activity') + ->setTranslationDomain('validators') + ->setCode(TimesheetBasic::PROJECT_DISALLOWS_GLOBAL_ACTIVITY) + ->addViolation(); + } + $pathStart = 'begin'; $pathEnd = 'end'; diff --git a/templates/project/details.html.twig b/templates/project/details.html.twig index 6c4d9fe4..659f547d 100644 --- a/templates/project/details.html.twig +++ b/templates/project/details.html.twig @@ -116,6 +116,14 @@ {% endif %} + {% if not project.globalActivities %} + + {{ 'label.globalActivities'|trans }} + + {{ widgets.label_boolean(project.globalActivities) }} + + + {% endif %} {% for metaField in project.visibleMetaFields|sort((a, b) => a.order <=> b.order) %} {{ metaField.label|trans }} diff --git a/templates/project/edit.html.twig b/templates/project/edit.html.twig index 1210ab50..bb584b10 100644 --- a/templates/project/edit.html.twig +++ b/templates/project/edit.html.twig @@ -60,12 +60,15 @@ {{ form_row(form.invoiceText) }} {% endif %}
-
+
{{ form_row(form.visible) }}
-
+
{{ form_row(form.billable) }}
+
+ {{ form_row(form.globalActivities) }} +
{% if form.metaFields is defined and form.metaFields is not empty %} {% for meta in form.metaFields|sort((a, b) => a.vars.data.order <=> b.vars.data.order) %} diff --git a/tests/Entity/ProjectTest.php b/tests/Entity/ProjectTest.php index 78a4bf05..92441a93 100644 --- a/tests/Entity/ProjectTest.php +++ b/tests/Entity/ProjectTest.php @@ -38,6 +38,7 @@ class ProjectTest extends AbstractEntityTest self::assertNull($sut->getInvoiceText()); self::assertTrue($sut->isVisible()); self::assertTrue($sut->isBillable()); + self::assertTrue($sut->isGlobalActivities()); self::assertNull($sut->getColor()); self::assertFalse($sut->hasColor()); self::assertInstanceOf(Collection::class, $sut->getMetaFields()); @@ -106,6 +107,9 @@ class ProjectTest extends AbstractEntityTest self::assertFalse($sut->isVisible()); $sut->setVisible(true); self::assertTrue($sut->isVisible()); + + $sut->setGlobalActivities(false); + self::assertFalse($sut->isGlobalActivities()); } public function testMetaFields() diff --git a/translations/messages.de.xlf b/translations/messages.de.xlf index 450d46c3..29f3a70c 100644 --- a/translations/messages.de.xlf +++ b/translations/messages.de.xlf @@ -1201,6 +1201,10 @@ label.globalsOnly Nur globale + + label.globalActivities + Globale Tätigkeiten erlauben + label.batch_meta_fields Zusätzliche Felder @@ -1221,7 +1225,7 @@ label.includeBudgetType_month Einträge mit „Monats-Budget“ anzeigen - + label.budgetIndependent Unabhängig vom „Budget-Typ“ anzeigen diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index e4d10a41..c8ccaa83 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -1201,6 +1201,10 @@ label.globalsOnly Only global + + label.globalActivities + Allow global activities + label.batch_meta_fields Additional fields @@ -1221,7 +1225,7 @@ label.includeBudgetType_month Show entries with "monthly" budget - + label.budgetIndependent Show regardless of "budget-type" diff --git a/translations/validators.de.xlf b/translations/validators.de.xlf index 95b3cacb..9c7e9169 100644 --- a/translations/validators.de.xlf +++ b/translations/validators.de.xlf @@ -86,6 +86,10 @@ This timesheet is already exported. Dieser Eintrag wurde bereits exportiert. + + Global activities are forbidden for the selected project. + Globale Aktivitäten sind in dem gewählten Projekt nicht erlaubt. +