From 30c7782d6ecca9c0a1dc4f4b61006e7bd5e9e27d Mon Sep 17 00:00:00 2001 From: Kevin Papst Date: Fri, 18 Mar 2022 22:31:50 +0100 Subject: [PATCH] automatic billable calculation (#3200) --- config/packages/kimai.yaml | 7 ++- src/API/TimesheetController.php | 12 ++-- src/Configuration/SystemConfiguration.php | 5 -- .../SystemConfigurationController.php | 4 -- .../TimesheetAbstractController.php | 8 +++ src/Controller/TimesheetTeamController.php | 10 +++- src/Entity/Activity.php | 24 +++++++- src/Entity/Customer.php | 24 +++++++- src/Entity/Project.php | 24 +++++++- src/Entity/Timesheet.php | 25 ++++++++ src/Form/API/TimesheetApiEditForm.php | 11 ++++ src/Form/EntityFormTrait.php | 5 +- src/Form/MultiUpdate/TimesheetMultiUpdate.php | 2 +- src/Form/TimesheetEditForm.php | 57 +++++++++++++++++-- src/Form/Type/BillableType.php | 1 - src/Form/Type/TimesheetBillableType.php | 44 ++++++++++++++ src/Migrations/Version20220315224645.php | 49 ++++++++++++++++ src/Timesheet/TimesheetService.php | 3 +- src/Voter/TimesheetVoter.php | 2 + templates/activity/details.html.twig | 8 +++ templates/activity/edit.html.twig | 9 ++- templates/customer/details.html.twig | 8 +++ templates/customer/edit.html.twig | 9 ++- templates/project/details.html.twig | 8 +++ templates/project/edit.html.twig | 9 ++- templates/timesheet/layout-edit.html.twig | 51 +++++++++++------ tests/API/APIControllerBaseTest.php | 11 ++++ tests/Controller/PermissionControllerTest.php | 2 +- tests/Entity/ActivityTest.php | 7 +++ tests/Entity/CustomerTest.php | 7 +++ tests/Entity/ProjectTest.php | 7 +++ .../EntityWithMetaFieldsExporterTest.php | 1 + translations/messages.de.xlf | 4 ++ translations/messages.en.xlf | 4 ++ 34 files changed, 409 insertions(+), 53 deletions(-) create mode 100644 src/Form/Type/TimesheetBillableType.php create mode 100644 src/Migrations/Version20220315224645.php diff --git a/config/packages/kimai.yaml b/config/packages/kimai.yaml index 61c9d847..42703db2 100644 --- a/config/packages/kimai.yaml +++ b/config/packages/kimai.yaml @@ -101,6 +101,7 @@ kimai: RATE: ['view_rate_own_timesheet','edit_rate_own_timesheet'] RATE_OTHER: ['view_rate_other_timesheet','edit_rate_other_timesheet'] EXPORT: ['create_export','edit_export_own_timesheet','edit_export_other_timesheet'] + BILLABLE: ['edit_billable_own_timesheet','edit_billable_other_timesheet'] TEAMS: ['view_team','create_team','edit_team','delete_team'] LOCKDOWN: ['lockdown_grace_timesheet','lockdown_override_timesheet'] REPORTING: ['view_reporting','view_other_reporting'] @@ -111,9 +112,9 @@ kimai: SINGLE_SUPER_ADMIN: ['hourly-rate_own_profile','hourly-rate_other_profile','roles_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet','teams_own_profile','view_team_member','upload_invoice_template','view_all_data'] # link above sets to one complete set for each user role ROLE_USER: ['@TIMESHEET','@PROFILE','@REPORTING','@SINGLE_USER'] - ROLE_TEAMLEAD: ['@ACTIVITIES_TEAMLEAD','@PROJECTS_TEAMLEAD','@CUSTOMERS_TEAMLEAD','@TIMESHEET_OTHER','@INVOICE','@TIMESHEET','@PROFILE','@EXPORT','@TAGS','@REPORTING','@SINGLE_TEAMLEAD'] - ROLE_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_ADMIN','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@LOCKDOWN','@REPORTING','@SINGLE_ADMIN'] - ROLE_SUPER_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_ADMIN','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@PROFILE_OTHER','@USER','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@LOCKDOWN','@REPORTING','@SINGLE_SUPER_ADMIN'] + ROLE_TEAMLEAD: ['@ACTIVITIES_TEAMLEAD','@PROJECTS_TEAMLEAD','@CUSTOMERS_TEAMLEAD','@TIMESHEET_OTHER','@INVOICE','@TIMESHEET','@PROFILE','@EXPORT','@BILLABLE','@TAGS','@REPORTING','@SINGLE_TEAMLEAD'] + ROLE_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_ADMIN','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@BILLABLE','@TAGS','@LOCKDOWN','@REPORTING','@SINGLE_ADMIN'] + ROLE_SUPER_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_ADMIN','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@PROFILE_OTHER','@USER','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@BILLABLE','@TAGS','@LOCKDOWN','@REPORTING','@SINGLE_SUPER_ADMIN'] # mapping "sets" or permissions to user roles ("role name" = [array of "set names"]) maps: ROLE_USER: ['ROLE_USER'] diff --git a/src/API/TimesheetController.php b/src/API/TimesheetController.php index e2ce8124..5dc589b4 100644 --- a/src/API/TimesheetController.php +++ b/src/API/TimesheetController.php @@ -317,6 +317,7 @@ class TimesheetController extends BaseApiController $form = $this->createForm(TimesheetApiEditForm::class, $timesheet, [ 'include_rate' => $this->isGranted('edit_rate', $timesheet), 'include_exported' => $this->isGranted('edit_export', $timesheet), + 'include_billable' => $this->isGranted('edit_billable', $timesheet), 'include_user' => $this->isGranted('create_other_timesheet'), 'allow_begin_datetime' => $mode->canUpdateTimesWithAPI(), 'allow_end_datetime' => $mode->canUpdateTimesWithAPI(), @@ -394,6 +395,7 @@ class TimesheetController extends BaseApiController $form = $this->createForm(TimesheetApiEditForm::class, $timesheet, [ 'include_rate' => $this->isGranted('edit_rate', $timesheet), 'include_exported' => $this->isGranted('edit_export', $timesheet), + 'include_billable' => $this->isGranted('edit_billable', $timesheet), 'include_user' => $this->isGranted('edit', $timesheet), 'allow_begin_datetime' => $mode->canUpdateTimesWithAPI(), 'allow_end_datetime' => $mode->canUpdateTimesWithAPI(), @@ -614,12 +616,10 @@ class TimesheetController extends BaseApiController @trigger_error('Setting the "copy" attribute in "restart timesheet" API to something else then "all" is deprecated', E_USER_DEPRECATED); } - $copyTimesheet - ->setHourlyRate($timesheet->getHourlyRate()) - ->setFixedRate($timesheet->getFixedRate()) - ->setDescription($timesheet->getDescription()) - ->setBillable($timesheet->isBillable()) - ; + $copyTimesheet->setHourlyRate($timesheet->getHourlyRate()); + $copyTimesheet->setFixedRate($timesheet->getFixedRate()); + $copyTimesheet->setDescription($timesheet->getDescription()); + $copyTimesheet->setBillable($timesheet->isBillable()); foreach ($timesheet->getTags() as $tag) { $copyTimesheet->addTag($tag); diff --git a/src/Configuration/SystemConfiguration.php b/src/Configuration/SystemConfiguration.php index 32f1dc54..f7fc9002 100644 --- a/src/Configuration/SystemConfiguration.php +++ b/src/Configuration/SystemConfiguration.php @@ -232,11 +232,6 @@ class SystemConfiguration implements SystemBundleConfiguration return (string) $this->find('timesheet.default_begin'); } - public function getTimesheetDefaultBillable(): bool - { - return (bool) $this->find('defaults.timesheet.billable'); - } - public function isTimesheetAllowFutureTimes(): bool { return (bool) $this->find('timesheet.rules.allow_future_times'); diff --git a/src/Controller/SystemConfigurationController.php b/src/Controller/SystemConfigurationController.php index f07a26fd..b5e3b9c4 100644 --- a/src/Controller/SystemConfigurationController.php +++ b/src/Controller/SystemConfigurationController.php @@ -383,10 +383,6 @@ final class SystemConfigurationController extends AbstractController ->setConstraints([ new GreaterThanOrEqual(['value' => 0]) ]), - (new Configuration()) - ->setName('defaults.timesheet.billable') - ->setType(YesNoType::class) - ->setOptions(['help' => 'default_value_new', 'label' => 'label.billable']), ]), (new SystemConfigurationModel('quick_entry')) ->setTranslation('quick_entry.title') diff --git a/src/Controller/TimesheetAbstractController.php b/src/Controller/TimesheetAbstractController.php index a536a1f2..d2c213f9 100644 --- a/src/Controller/TimesheetAbstractController.php +++ b/src/Controller/TimesheetAbstractController.php @@ -455,6 +455,7 @@ abstract class TimesheetAbstractController extends AbstractController 'action' => $this->generateUrl($this->getMultiUpdateRoute(), []), 'method' => 'POST', 'include_exported' => $this->isGranted($this->getPermissionEditExport()), + 'include_billable' => $this->isGranted($this->getPermissionEditBillable()), 'include_rate' => $this->isGranted($this->getPermissionEditRate()), 'include_user' => $this->includeUserInForms('multi'), ]); @@ -482,6 +483,7 @@ abstract class TimesheetAbstractController extends AbstractController 'action' => $action, 'include_rate' => $this->isGranted('edit_rate', $entry), 'include_exported' => $this->isGranted('edit_export', $entry), + 'include_billable' => $this->isGranted('edit_billable', $entry), 'include_user' => $this->includeUserInForms('create'), 'allow_begin_datetime' => $mode->canEditBegin(), 'allow_end_datetime' => $mode->canEditEnd(), @@ -510,6 +512,7 @@ abstract class TimesheetAbstractController extends AbstractController ]), 'include_rate' => $this->isGranted('edit_rate', $entry), 'include_exported' => $this->isGranted('edit_export', $entry), + 'include_billable' => $this->isGranted('edit_billable', $entry), 'include_user' => $this->includeUserInForms('edit'), 'allow_begin_datetime' => $mode->canEditBegin(), 'allow_end_datetime' => $mode->canEditEnd(), @@ -549,6 +552,11 @@ abstract class TimesheetAbstractController extends AbstractController return 'edit_export_own_timesheet'; } + protected function getPermissionEditBillable(): string + { + return 'edit_billable_own_timesheet'; + } + protected function getPermissionEditRate(): string { return 'edit_rate_own_timesheet'; diff --git a/src/Controller/TimesheetTeamController.php b/src/Controller/TimesheetTeamController.php index 9cd14726..fcae6876 100644 --- a/src/Controller/TimesheetTeamController.php +++ b/src/Controller/TimesheetTeamController.php @@ -153,8 +153,9 @@ class TimesheetTeamController extends TimesheetAbstractController return $this->createForm(TimesheetMultiUserEditForm::class, $entry, [ 'action' => $this->generateUrl('admin_timesheet_create_multiuser'), - 'include_rate' => $this->isGranted('edit_rate', $entry), - 'include_exported' => $this->isGranted('edit_export', $entry), + 'include_rate' => $this->isGranted($this->getPermissionEditRate()), + 'include_exported' => $this->isGranted($this->getPermissionEditExport()), + 'include_billable' => $this->isGranted($this->getPermissionEditBillable()), 'include_user' => $this->includeUserInForms('create'), 'allow_begin_datetime' => $mode->canEditBegin(), 'allow_end_datetime' => $mode->canEditEnd(), @@ -205,6 +206,11 @@ class TimesheetTeamController extends TimesheetAbstractController return 'edit_export_other_timesheet'; } + protected function getPermissionEditBillable(): string + { + return 'edit_billable_other_timesheet'; + } + protected function getPermissionEditRate(): string { return 'edit_rate_other_timesheet'; diff --git a/src/Entity/Activity.php b/src/Entity/Activity.php index b47f81e1..fc6d2b90 100644 --- a/src/Entity/Activity.php +++ b/src/Entity/Activity.php @@ -47,7 +47,7 @@ use Symfony\Component\Validator\Constraints as Assert; * } * ) * - * @Exporter\Order({"id", "name", "project", "budget", "timeBudget", "budgetType", "color", "visible", "comment"}) + * @Exporter\Order({"id", "name", "project", "budget", "timeBudget", "budgetType", "color", "visible", "comment", "billable"}) * @Exporter\Expose("project", label="label.project", exp="object.getProject() === null ? null : object.getProject().getName()") */ class Activity implements EntityWithMetaFields, EntityWithBudget @@ -123,6 +123,18 @@ class Activity implements EntityWithMetaFields, EntityWithBudget * @Assert\NotNull() */ private $visible = true; + /** + * @var bool + * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * + * @Exporter\Expose(label="label.billable", type="boolean") + * + * @ORM\Column(name="billable", type="boolean", nullable=false) + * @Assert\NotNull() + */ + private $billable = true; /** * Meta fields * @@ -227,6 +239,16 @@ class Activity implements EntityWithMetaFields, EntityWithBudget return $this->visible; } + public function setBillable(bool $billable): void + { + $this->billable = $billable; + } + + public function isBillable(): bool + { + return $this->billable; + } + /** * @return Collection|MetaTableTypeInterface[] */ diff --git a/src/Entity/Customer.php b/src/Entity/Customer.php index 17da4b26..27a25534 100644 --- a/src/Entity/Customer.php +++ b/src/Entity/Customer.php @@ -27,7 +27,7 @@ use Symfony\Component\Validator\Constraints as Assert; * * @Serializer\ExclusionPolicy("all") * - * @Exporter\Order({"id", "name", "company", "number", "vatId", "address", "contact","email", "phone", "mobile", "fax", "homepage", "country", "currency", "timezone", "budget", "timeBudget", "budgetType", "color", "visible", "teams", "comment"}) + * @Exporter\Order({"id", "name", "company", "number", "vatId", "address", "contact","email", "phone", "mobile", "fax", "homepage", "country", "currency", "timezone", "budget", "timeBudget", "budgetType", "color", "visible", "teams", "comment", "billable"}) * @ Exporter\Expose("teams", label="label.team", exp="object.getTeams().toArray()", type="array") */ class Customer implements EntityWithMetaFields, EntityWithBudget @@ -98,6 +98,18 @@ class Customer implements EntityWithMetaFields, EntityWithBudget * @Assert\NotNull() */ private $visible = true; + /** + * @var bool + * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * + * @Exporter\Expose(label="label.billable", type="boolean") + * + * @ORM\Column(name="billable", type="boolean", nullable=false) + * @Assert\NotNull() + */ + private $billable = true; /** * @var string|null * @@ -353,6 +365,16 @@ class Customer implements EntityWithMetaFields, EntityWithBudget return $this->visible; } + public function setBillable(bool $billable): void + { + $this->billable = $billable; + } + + public function isBillable(): bool + { + return $this->billable; + } + public function setCompany(?string $company): Customer { $this->company = $company; diff --git a/src/Entity/Project.php b/src/Entity/Project.php index d46c0617..bdd47b95 100644 --- a/src/Entity/Project.php +++ b/src/Entity/Project.php @@ -48,7 +48,7 @@ use Symfony\Component\Validator\Constraints as Assert; * } * ) * - * @Exporter\Order({"id", "name", "customer", "orderNumber", "orderDate", "start", "end", "budget", "timeBudget", "budgetType", "color", "visible", "teams", "comment"}) + * @Exporter\Order({"id", "name", "customer", "orderNumber", "orderDate", "start", "end", "budget", "timeBudget", "budgetType", "color", "visible", "teams", "comment", "billable"}) * @Exporter\Expose("customer", label="label.customer", exp="object.getCustomer() === null ? null : object.getCustomer().getName()") * @ Exporter\Expose("teams", label="label.team", exp="object.getTeams().toArray()", type="array") */ @@ -195,6 +195,18 @@ class Project implements EntityWithMetaFields, EntityWithBudget * @Assert\NotNull() */ private $visible = true; + /** + * @var bool + * + * @Serializer\Expose() + * @Serializer\Groups({"Default"}) + * + * @Exporter\Expose(label="label.billable", type="boolean") + * + * @ORM\Column(name="billable", type="boolean", nullable=false) + * @Assert\NotNull() + */ + private $billable = true; /** * Meta fields * @@ -294,6 +306,16 @@ class Project implements EntityWithMetaFields, EntityWithBudget return $this->visible; } + public function setBillable(bool $billable): void + { + $this->billable = $billable; + } + + public function isBillable(): bool + { + return $this->billable; + } + public function getOrderNumber(): ?string { return $this->orderNumber; diff --git a/src/Entity/Timesheet.php b/src/Entity/Timesheet.php index e54e8130..097b2e6e 100644 --- a/src/Entity/Timesheet.php +++ b/src/Entity/Timesheet.php @@ -98,6 +98,11 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface */ public const OVERTIME = 'overtime'; + public const BILLABLE_AUTOMATIC = 'auto'; + public const BILLABLE_YES = 'yes'; + public const BILLABLE_NO = 'no'; + public const BILLABLE_DEFAULT = 'default'; + /** * @var int|null * @@ -269,6 +274,11 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface * @Assert\NotNull() */ private $billable = true; + /** + * Internal property used to determine whether the billable field should be calculated automatically. + * @var string + */ + private $billableMode = self::BILLABLE_DEFAULT; /** * @var string * @@ -649,6 +659,11 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface return $this->billable; } + public function getBillable(): bool + { + return $this->billable; + } + public function setBillable(bool $billable): Timesheet { $this->billable = $billable; @@ -656,6 +671,16 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface return $this; } + public function getBillableMode(): string + { + return $this->billableMode; + } + + public function setBillableMode(string $billableMode): void + { + $this->billableMode = $billableMode; + } + public function getFixedRate(): ?float { return $this->fixedRate; diff --git a/src/Form/API/TimesheetApiEditForm.php b/src/Form/API/TimesheetApiEditForm.php index ef411c10..e4def9c1 100644 --- a/src/Form/API/TimesheetApiEditForm.php +++ b/src/Form/API/TimesheetApiEditForm.php @@ -10,12 +10,22 @@ namespace App\Form\API; use App\Form\TimesheetEditForm; +use App\Form\Type\BillableType; use App\Form\Type\TagsInputType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; class TimesheetApiEditForm extends TimesheetEditForm { + protected function addBillable(FormBuilderInterface $builder, array $options) + { + if (!$options['include_billable']) { + return; + } + + $builder->add('billable', BillableType::class, []); + } + /** * {@inheritdoc} */ @@ -49,6 +59,7 @@ class TimesheetApiEditForm extends TimesheetEditForm // because the docs are cached without these fields otherwise 'include_user' => true, 'include_exported' => true, + 'include_billable' => true, 'include_rate' => true, ]); } diff --git a/src/Form/EntityFormTrait.php b/src/Form/EntityFormTrait.php index 7ed89064..e7705381 100644 --- a/src/Form/EntityFormTrait.php +++ b/src/Form/EntityFormTrait.php @@ -9,6 +9,7 @@ namespace App\Form; +use App\Form\Type\BillableType; use App\Form\Type\BudgetType; use App\Form\Type\DurationType; use App\Form\Type\MetaFieldsCollectionType; @@ -48,7 +49,9 @@ trait EntityFormTrait $builder ->add('visible', YesNoType::class, [ 'label' => 'label.visible', - ]); + ]) + ->add('billable', BillableType::class) + ; } /** diff --git a/src/Form/MultiUpdate/TimesheetMultiUpdate.php b/src/Form/MultiUpdate/TimesheetMultiUpdate.php index c6db2994..5414d6aa 100644 --- a/src/Form/MultiUpdate/TimesheetMultiUpdate.php +++ b/src/Form/MultiUpdate/TimesheetMultiUpdate.php @@ -265,7 +265,7 @@ class TimesheetMultiUpdate extends AbstractType 'include_user' => false, 'include_rate' => false, 'include_exported' => false, - 'include_billable' => true, + 'include_billable' => false, ]); } } diff --git a/src/Form/TimesheetEditForm.php b/src/Form/TimesheetEditForm.php index 14b7bce2..659bdb7e 100644 --- a/src/Form/TimesheetEditForm.php +++ b/src/Form/TimesheetEditForm.php @@ -10,7 +10,6 @@ namespace App\Form; use App\Entity\Timesheet; -use App\Form\Type\BillableType; use App\Form\Type\DateTimePickerType; use App\Form\Type\DescriptionType; use App\Form\Type\DurationType; @@ -18,10 +17,12 @@ use App\Form\Type\FixedRateType; use App\Form\Type\HourlyRateType; use App\Form\Type\MetaFieldsCollectionType; use App\Form\Type\TagsType; +use App\Form\Type\TimesheetBillableType; use App\Form\Type\UserType; use App\Form\Type\YesNoType; use App\Repository\CustomerRepository; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\CallbackTransformer; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvents; @@ -169,7 +170,7 @@ class TimesheetEditForm extends AbstractType { $durationOptions = [ 'required' => false, - 'docu_chapter' => 'timesheet.html#duration-format', + 'docu_chapter' => 'duration-format.html', 'attr' => [ 'placeholder' => '0:00', ], @@ -261,11 +262,57 @@ class TimesheetEditForm extends AbstractType protected function addBillable(FormBuilderInterface $builder, array $options) { - if (!$options['include_billable']) { - return; + if ($options['include_billable']) { + $builder->add('billableMode', TimesheetBillableType::class, []); } - $builder->add('billable', BillableType::class, []); + $builder->addModelTransformer(new CallbackTransformer( + function (Timesheet $record) { + if ($record->getBillableMode() === Timesheet::BILLABLE_DEFAULT) { + if ($record->isBillable()) { + $record->setBillableMode(Timesheet::BILLABLE_YES); + } else { + $record->setBillableMode(Timesheet::BILLABLE_NO); + } + } + + return $record; + }, + function (Timesheet $record) { + switch ($record->getBillableMode()) { + case Timesheet::BILLABLE_NO: + $record->setBillable(false); + break; + case Timesheet::BILLABLE_YES: + $record->setBillable(true); + break; + case Timesheet::BILLABLE_AUTOMATIC: + $billable = true; + + $activity = $record->getActivity(); + if ($activity !== null && !$activity->isBillable()) { + $billable = false; + } + + $project = $record->getProject(); + if ($billable && $project !== null && !$project->isBillable()) { + $billable = false; + } + + if ($billable && $project !== null) { + $customer = $project->getCustomer(); + if ($customer !== null && !$customer->isBillable()) { + $billable = false; + } + } + + $record->setBillable($billable); + break; + } + + return $record; + } + )); } /** diff --git a/src/Form/Type/BillableType.php b/src/Form/Type/BillableType.php index d58db76e..224e1cb4 100644 --- a/src/Form/Type/BillableType.php +++ b/src/Form/Type/BillableType.php @@ -14,7 +14,6 @@ use Symfony\Component\OptionsResolver\OptionsResolver; /** * Custom form field type to select if something is billable. - * To be used in combination with the invoicing system. */ class BillableType extends AbstractType { diff --git a/src/Form/Type/TimesheetBillableType.php b/src/Form/Type/TimesheetBillableType.php new file mode 100644 index 00000000..6f078a7e --- /dev/null +++ b/src/Form/Type/TimesheetBillableType.php @@ -0,0 +1,44 @@ +setDefaults([ + 'label' => 'label.billable', + 'choices' => [ + 'automatic' => Timesheet::BILLABLE_AUTOMATIC, + 'yes' => Timesheet::BILLABLE_YES, + 'no' => Timesheet::BILLABLE_NO, + ], + ]); + } + + /** + * {@inheritdoc} + */ + public function getParent() + { + return ChoiceType::class; + } +} diff --git a/src/Migrations/Version20220315224645.php b/src/Migrations/Version20220315224645.php new file mode 100644 index 00000000..1df28040 --- /dev/null +++ b/src/Migrations/Version20220315224645.php @@ -0,0 +1,49 @@ +getTable('kimai2_customers'); + $customers->addColumn('billable', 'boolean', ['notnull' => true, 'default' => true]); + + $projects = $schema->getTable('kimai2_projects'); + $projects->addColumn('billable', 'boolean', ['notnull' => true, 'default' => true]); + + $activities = $schema->getTable('kimai2_activities'); + $activities->addColumn('billable', 'boolean', ['notnull' => true, 'default' => true]); + + $this->addSql('DELETE from kimai2_configuration WHERE `name` = "defaults.timesheet.billable"'); + } + + public function down(Schema $schema): void + { + $customers = $schema->getTable('kimai2_customers'); + $customers->dropColumn('billable'); + + $projects = $schema->getTable('kimai2_projects'); + $projects->dropColumn('billable'); + + $activities = $schema->getTable('kimai2_activities'); + $activities->dropColumn('billable'); + } +} diff --git a/src/Timesheet/TimesheetService.php b/src/Timesheet/TimesheetService.php index 44e80eff..0cba0e20 100644 --- a/src/Timesheet/TimesheetService.php +++ b/src/Timesheet/TimesheetService.php @@ -110,7 +110,7 @@ final class TimesheetService $mode = $this->trackingModeService->getActiveMode(); $mode->create($timesheet, $request); - $timesheet->setBillable($this->configuration->getTimesheetDefaultBillable()); + $timesheet->setBillableMode(Timesheet::BILLABLE_AUTOMATIC); return $timesheet; } @@ -118,6 +118,7 @@ final class TimesheetService /** * @param Timesheet $timesheet * @param Timesheet $copyFrom + * @return Timesheet * @throws ValidationFailedException for invalid timesheets or running timesheets that should be stopped * @throws InvalidArgumentException for already persisted timesheets * @throws AccessDeniedException if user is not allowed to start timesheet diff --git a/src/Voter/TimesheetVoter.php b/src/Voter/TimesheetVoter.php index 0c3b12c6..33dc4a01 100644 --- a/src/Voter/TimesheetVoter.php +++ b/src/Voter/TimesheetVoter.php @@ -44,6 +44,7 @@ final class TimesheetVoter extends Voter self::VIEW_RATE, self::EDIT_RATE, self::EDIT_EXPORT, + 'edit_billable', 'duplicate' ]; @@ -130,6 +131,7 @@ final class TimesheetVoter extends Voter case self::VIEW: case self::EXPORT: case self::EDIT_EXPORT: + case 'edit_billable': $permission .= $attribute; break; diff --git a/templates/activity/details.html.twig b/templates/activity/details.html.twig index fbd4acf6..8e1c56c0 100644 --- a/templates/activity/details.html.twig +++ b/templates/activity/details.html.twig @@ -41,6 +41,14 @@ {% endif %} + {% if not activity.billable %} + + {{ 'label.billable'|trans }} + + {{ widgets.label_boolean(activity.billable) }} + + + {% endif %} {% if not activity.global %} {{ 'label.customer'|trans }} diff --git a/templates/activity/edit.html.twig b/templates/activity/edit.html.twig index 140dd2fb..7ac2f0bd 100644 --- a/templates/activity/edit.html.twig +++ b/templates/activity/edit.html.twig @@ -41,7 +41,14 @@ {% endif %} - {{ form_row(form.visible) }} +
+
+ {{ form_row(form.visible) }} +
+
+ {{ form_row(form.billable) }} +
+
{% 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) %} {{ form_row(meta) }} diff --git a/templates/customer/details.html.twig b/templates/customer/details.html.twig index 0d5f19c8..653a822d 100644 --- a/templates/customer/details.html.twig +++ b/templates/customer/details.html.twig @@ -34,6 +34,14 @@ {{ widgets.label_boolean(customer.visible) }} {% endif %} + {% if not customer.billable %} + + {{ 'label.billable'|trans }} + + {{ widgets.label_boolean(customer.billable) }} + + + {% endif %} {% if customer.address is not empty %} {{ 'label.address'|trans }} diff --git a/templates/customer/edit.html.twig b/templates/customer/edit.html.twig index 56b38a8e..3ff3ea68 100644 --- a/templates/customer/edit.html.twig +++ b/templates/customer/edit.html.twig @@ -80,7 +80,14 @@ {% endif %} - {{ form_row(form.visible) }} +
+
+ {{ form_row(form.visible) }} +
+
+ {{ form_row(form.billable) }} +
+
{% 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) %} {{ form_row(meta) }} diff --git a/templates/project/details.html.twig b/templates/project/details.html.twig index a1392b38..efb98712 100644 --- a/templates/project/details.html.twig +++ b/templates/project/details.html.twig @@ -49,6 +49,14 @@ {% if is_granted('details', project) %} + {% if not project.billable %} + + {{ 'label.billable'|trans }} + + {{ widgets.label_boolean(project.billable) }} + + + {% endif %} {% if project.orderNumber is not empty %} {{ 'label.orderNumber'|trans }} diff --git a/templates/project/edit.html.twig b/templates/project/edit.html.twig index d496c7be..2096d263 100644 --- a/templates/project/edit.html.twig +++ b/templates/project/edit.html.twig @@ -52,7 +52,14 @@ {% endif %} - {{ form_row(form.visible) }} +
+
+ {{ form_row(form.visible) }} +
+
+ {{ form_row(form.billable) }} +
+
{% 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) %} {{ form_row(meta) }} diff --git a/templates/timesheet/layout-edit.html.twig b/templates/timesheet/layout-edit.html.twig index 5688e398..93423092 100644 --- a/templates/timesheet/layout-edit.html.twig +++ b/templates/timesheet/layout-edit.html.twig @@ -78,14 +78,39 @@ {% endif %} {% endif %} - {% if form.fixedRate is defined and form.hourlyRate is defined %} + {% set colWidth = 0 %} + {% if form.fixedRate is defined %} + {% set colWidth = colWidth + 1 %} + {% endif %} + {% if form.hourlyRate is defined %} + {% set colWidth = colWidth + 1 %} + {% endif %} + {% if form.billable is defined %} + {% set colWidth = colWidth + 1 %} + {% elseif form.billableMode is defined %} + {% set colWidth = colWidth + 1 %} + {% endif %} + {% if colWidth > 0 %}
-
- {{ form_row(form.fixedRate, {'row_attr': {'class': 'timesheet_edit_form_row_fixedRate'}}) }} -
-
- {{ form_row(form.hourlyRate, {'row_attr': {'class': 'timesheet_edit_form_row_hourlyRate'}}) }} -
+ {% if form.fixedRate is defined %} +
+ {{ form_row(form.fixedRate, {'row_attr': {'class': 'timesheet_edit_form_row_fixedRate'}}) }} +
+ {% endif %} + {% if form.hourlyRate is defined %} +
+ {{ form_row(form.hourlyRate, {'row_attr': {'class': 'timesheet_edit_form_row_hourlyRate'}}) }} +
+ {% endif %} + {% if form.billable is defined %} +
+ {{ form_row(form.billable, {'row_attr': {'class': 'timesheet_edit_form_row_billable'}}) }} +
+ {% elseif form.billableMode is defined %} +
+ {{ form_row(form.billableMode, {'row_attr': {'class': 'timesheet_edit_form_row_billable'}}) }} +
+ {% endif %}
{% endif %} {% if form.metaFields is defined and form.metaFields is not empty %} @@ -93,19 +118,11 @@ {{ form_row(meta) }} {% endfor %} {% endif %} - {% if form.exported is defined or form.billable is defined %} - {% set ebLength = (form.exported is defined and form.billable is defined) ? 6 : 12 %} + {% if form.exported is defined %}
- {% if form.exported is defined %} -
+
{{ form_row(form.exported, {'row_attr': {'class': 'timesheet_edit_form_row_exported'}}) }}
- {% endif %} - {% if form.billable is defined %} -
- {{ form_row(form.billable, {'row_attr': {'class': 'timesheet_edit_form_row_billable'}}) }} -
- {% endif %}
{% endif %} {{ form_widget(form) }} diff --git a/tests/API/APIControllerBaseTest.php b/tests/API/APIControllerBaseTest.php index 99080afb..7751f7de 100644 --- a/tests/API/APIControllerBaseTest.php +++ b/tests/API/APIControllerBaseTest.php @@ -374,6 +374,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest 'id' => 'int', 'name' => 'string', 'visible' => 'bool', + 'billable' => 'bool', 'color' => '@string', 'number' => '@string', 'comment' => '@string', @@ -385,6 +386,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest 'id' => 'int', 'name' => 'string', 'visible' => 'boolean', + 'billable' => 'bool', 'color' => '@string', 'number' => '@string', 'comment' => '@string', @@ -399,6 +401,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest 'id' => 'int', 'name' => 'string', 'visible' => 'bool', + 'billable' => 'bool', 'color' => '@string', 'metaFields' => ['result' => 'array', 'type' => 'CustomerMeta'], 'teams' => ['result' => 'array', 'type' => 'Team'], @@ -427,6 +430,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest 'id' => 'int', 'name' => 'string', 'visible' => 'bool', + 'billable' => 'bool', 'color' => '@string', 'customer' => 'int', 'comment' => '@string', @@ -438,6 +442,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest 'id' => 'int', 'name' => 'string', 'visible' => 'bool', + 'billable' => 'bool', 'color' => '@string', 'customer' => ['result' => 'object', 'type' => 'Customer'], 'comment' => '@string', @@ -449,6 +454,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest 'id' => 'int', 'name' => 'string', 'visible' => 'bool', + 'billable' => 'bool', 'customer' => 'int', 'color' => '@string', 'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'], @@ -465,6 +471,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest 'id' => 'int', 'name' => 'string', 'visible' => 'bool', + 'billable' => 'bool', 'customer' => 'int', 'color' => '@string', 'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'], @@ -486,6 +493,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest 'id' => 'int', 'name' => 'string', 'visible' => 'bool', + 'billable' => 'bool', 'project' => '@int', 'color' => '@string', 'comment' => '@string', @@ -496,6 +504,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest 'id' => 'int', 'name' => 'string', 'visible' => 'bool', + 'billable' => 'bool', 'project' => ['result' => 'object', 'type' => '@ProjectExpanded'], 'color' => '@string', 'comment' => '@string', @@ -507,6 +516,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest 'id' => 'int', 'name' => 'string', 'visible' => 'bool', + 'billable' => 'bool', 'project' => '@int', 'color' => '@string', 'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'], @@ -521,6 +531,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest 'id' => 'int', 'name' => 'string', 'visible' => 'bool', + 'billable' => 'bool', 'project' => '@int', 'color' => '@string', 'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'], diff --git a/tests/Controller/PermissionControllerTest.php b/tests/Controller/PermissionControllerTest.php index 3a41ae71..30546f31 100644 --- a/tests/Controller/PermissionControllerTest.php +++ b/tests/Controller/PermissionControllerTest.php @@ -35,7 +35,7 @@ class PermissionControllerTest extends ControllerBaseTest $client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN); $this->assertAccessIsGranted($client, '/admin/permissions'); $this->assertHasDataTable($client); - $this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 121); + $this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 123); $this->assertPageActions($client, [ //'back' => $this->createUrl('/admin/user/'), 'create modal-ajax-form' => $this->createUrl('/admin/permissions/roles/create'), diff --git a/tests/Entity/ActivityTest.php b/tests/Entity/ActivityTest.php index c7c7c964..ca4a4d2a 100644 --- a/tests/Entity/ActivityTest.php +++ b/tests/Entity/ActivityTest.php @@ -32,6 +32,7 @@ class ActivityTest extends AbstractEntityTest $this->assertNull($sut->getName()); $this->assertNull($sut->getComment()); $this->assertTrue($sut->isVisible()); + $this->assertTrue($sut->isBillable()); $this->assertTrue($sut->isGlobal()); $this->assertNull($sut->getColor()); self::assertFalse($sut->hasColor()); @@ -57,6 +58,11 @@ class ActivityTest extends AbstractEntityTest $this->assertInstanceOf(Activity::class, $sut->setVisible(false)); $this->assertFalse($sut->isVisible()); + $sut->setVisible(false); + self::assertFalse($sut->isVisible()); + $sut->setVisible(true); + self::assertTrue($sut->isVisible()); + $this->assertInstanceOf(Activity::class, $sut->setComment('hello world')); $this->assertEquals('hello world', $sut->getComment()); @@ -145,6 +151,7 @@ class ActivityTest extends AbstractEntityTest ['label.color', 'string'], ['label.visible', 'boolean'], ['label.comment', 'string'], + ['label.billable', 'boolean'], ]; self::assertCount(\count($expected), $columns); diff --git a/tests/Entity/CustomerTest.php b/tests/Entity/CustomerTest.php index 96bdcd75..684b05f8 100644 --- a/tests/Entity/CustomerTest.php +++ b/tests/Entity/CustomerTest.php @@ -31,6 +31,7 @@ class CustomerTest extends AbstractEntityTest self::assertNull($sut->getNumber()); self::assertNull($sut->getComment()); self::assertTrue($sut->isVisible()); + self::assertTrue($sut->isBillable()); self::assertNull($sut->getCompany()); self::assertNull($sut->getVatId()); @@ -71,6 +72,11 @@ class CustomerTest extends AbstractEntityTest self::assertInstanceOf(Customer::class, $sut->setVisible(false)); self::assertFalse($sut->isVisible()); + $sut->setVisible(false); + self::assertFalse($sut->isVisible()); + $sut->setVisible(true); + self::assertTrue($sut->isVisible()); + self::assertInstanceOf(Customer::class, $sut->setComment('hello world')); self::assertEquals('hello world', $sut->getComment()); @@ -198,6 +204,7 @@ class CustomerTest extends AbstractEntityTest ['label.color', 'string'], ['label.visible', 'boolean'], ['label.comment', 'string'], + ['label.billable', 'boolean'], ]; self::assertCount(\count($expected), $columns); diff --git a/tests/Entity/ProjectTest.php b/tests/Entity/ProjectTest.php index af77ea34..3e72515b 100644 --- a/tests/Entity/ProjectTest.php +++ b/tests/Entity/ProjectTest.php @@ -36,6 +36,7 @@ class ProjectTest extends AbstractEntityTest self::assertNull($sut->getEnd()); self::assertNull($sut->getComment()); self::assertTrue($sut->isVisible()); + self::assertTrue($sut->isBillable()); self::assertNull($sut->getColor()); self::assertFalse($sut->hasColor()); self::assertInstanceOf(Collection::class, $sut->getMetaFields()); @@ -96,6 +97,11 @@ class ProjectTest extends AbstractEntityTest self::assertInstanceOf(Project::class, $sut->setVisible(false)); self::assertFalse($sut->isVisible()); + + $sut->setVisible(false); + self::assertFalse($sut->isVisible()); + $sut->setVisible(true); + self::assertTrue($sut->isVisible()); } public function testMetaFields() @@ -173,6 +179,7 @@ class ProjectTest extends AbstractEntityTest ['label.color', 'string'], ['label.visible', 'boolean'], ['label.comment', 'string'], + ['label.billable', 'boolean'], ]; self::assertCount(\count($expected), $columns); diff --git a/tests/Export/Spreadsheet/EntityWithMetaFieldsExporterTest.php b/tests/Export/Spreadsheet/EntityWithMetaFieldsExporterTest.php index ea5da53c..16f47917 100644 --- a/tests/Export/Spreadsheet/EntityWithMetaFieldsExporterTest.php +++ b/tests/Export/Spreadsheet/EntityWithMetaFieldsExporterTest.php @@ -72,6 +72,7 @@ class EntityWithMetaFieldsExporterTest extends TestCase self::assertEquals('#ababab', $worksheet->getCellByColumnAndRow(++$i, 2)->getValue()); self::assertFalse($worksheet->getCellByColumnAndRow(++$i, 2)->getValue()); self::assertEquals('Lorem Ipsum', $worksheet->getCellByColumnAndRow(++$i, 2)->getValue()); + self::assertTrue($worksheet->getCellByColumnAndRow(++$i, 2)->getValue()); self::assertEquals('some magic', $worksheet->getCellByColumnAndRow(++$i, 2)->getValue()); self::assertEquals('is happening', $worksheet->getCellByColumnAndRow(++$i, 2)->getValue()); } diff --git a/translations/messages.de.xlf b/translations/messages.de.xlf index 1c2608d2..0bde86fe 100644 --- a/translations/messages.de.xlf +++ b/translations/messages.de.xlf @@ -21,6 +21,10 @@ both Beides + + automatic + Automatisch + This is a mandatory field Pflichtfeld diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 78b65c9d..6e5459e6 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -21,6 +21,10 @@ both Both + + automatic + Automatic + This is a mandatory field Mandatory