diff --git a/_data/menu-documentation.yml b/_data/menu-documentation.yml index 9d59f1562..f8aa79b37 100644 --- a/_data/menu-documentation.yml +++ b/_data/menu-documentation.yml @@ -1,7 +1,7 @@ - title: Administration pages: [installation, updates, docker, backups] - title: User manual - pages: [timesheet, invoices, export, users, customer, project, activity, tags] + pages: [timesheet, invoices, export, users, customer, project, activity, tags, meta-fields] - title: Configuration pages: [configurations, user-preferences, calendar, dashboard, emails, permissions, ldap] - title: Developer diff --git a/_documentation/developers.md b/_documentation/developers.md index ce55b6ce2..531be465b 100644 --- a/_documentation/developers.md +++ b/_documentation/developers.md @@ -43,10 +43,7 @@ To rebuild all assets you have to execute: yarn run prod ``` -You can find more information at: - -- https://symfony.com/doc/current/frontend/encore/installation.html -- https://symfony.com/doc/current/frontend.html +You can find more information [here](https://symfony.com/doc/current/frontend/encore/installation.html) and [here](https://symfony.com/doc/current/frontend.html). ## local.yaml @@ -69,30 +66,26 @@ composer kimai:tests-unit composer kimai:tests-integration ``` -Or you simply run all tests with: -```bash -composer kimai:tests -vendor/bin/phpunit -``` +Or you simply run all tests with one of: +- `composer kimai:tests` +- `vendor/bin/phpunit` ## Static code analysis via PHPStan Besides automated tests Kimai relies on PHPStan to detect code problems. -You can run the checks before CI process kicks in via: - ```bash composer kimai:phpstan ``` ## Coding styles -You can run the code formatter with the built-in command like that: +You can run the code sniffer with the built-in command like that: ```bash composer kimai:codestyle ``` -You can also automatically fix the violations by running: +And you can also automatically fix the violations by running: ```bash composer kimai:codestyle-fix @@ -216,14 +209,14 @@ In the config `kimai.invoice.documents`, you can add a list of directories with ### Adding invoice calculator An invoice calculator is a class implementing `App\Invoice\CalculatorInterface` and it is responsible for calculating -invoice rates, taxes and taking care of all timesheet entries that should be displayed. +invoice rates, taxes and taking care to aggregate all timesheet entries that should be displayed. Every invoice calculator class will be automatically available, after refreshing the application cache with `bin/console cache:clear`. This "magic" happens in the [InvoiceServiceCompilerPass]({{ site.kimai_v2_file }}/src/DependencyInjection/Compiler/InvoiceServiceCompilerPass.php), which finds the classes by the interface `CalculatorInterface`. The ID of the calculator must be unique, please prefix it with your vendor or bundle name and make sure it only contains -character as it will be stored in a database column. +alpha-numeric characters, as it will be stored in a database column. Translations are stored in the `invoice-calculator.xx.xliff`. @@ -237,7 +230,7 @@ This "magic" happens in the [InvoiceServiceCompilerPass]({{ site.kimai_v2_file } which finds the classes by the interface `NumberGeneratorInterface`. The ID of the number generator must be unique, please prefix it with your vendor or bundle name and make sure it only contains -character as it will be stored in a database column. +alpha-numeric characters, as it will be stored in a database column. Translations are stored in the `invoice-numbergenerator.xx.xliff`. @@ -252,10 +245,7 @@ which finds the classes by the interface `RendererInterface`. ## Adding export renderer -An export renderer is a class implementing `App\Export\RendererInterface` and it is responsible to convert ar array of `Timesheet` objects -into a downloadable/printable document. - -Every export renderer class will be automatically available when refreshing the application cache by the [ExportServiceCompilerPass]({{ site.kimai_v2_file }}/src/DependencyInjection/Compiler/ExportServiceCompilerPass.php): +See [export]({% link _documentation/export.md %}) documentation. ## Adding timesheet calculator @@ -271,3 +261,11 @@ You can apply several rules in your config file [local.yaml]({% link _documentat The configuration for "rounding rules" can be fetched from the container parameter `kimai.timesheet.rounding`. The configuration for "hourly-rates multiplication factors" can be fetched from the container parameter `kimai.timesheet.rates`. + +## Adding custom fields (meta fields) + +See [meta fields]({% link _documentation/meta-fields.md %}) documentation. + +## Adding UserPreference + +See [user preferences]({% link _documentation/user-preferences.md %}) documentation. diff --git a/_documentation/export.md b/_documentation/export.md index 513e48878..cd58482cf 100644 --- a/_documentation/export.md +++ b/_documentation/export.md @@ -2,6 +2,7 @@ title: Export description: Export your timesheet data with Kimai into several different formats since_version: 0.8 +toc: true --- The export module allows you to export filtered timesheet data into several formats. @@ -26,3 +27,54 @@ So all customer, projects, activities, all hourly rates, the personal time worke Exported records cannot be edited or deleted any longer. For further information read the [timesheet documentation]({% link _documentation/timesheet.md %}). + +## Adding export renderer + +An export renderer is a class implementing `App\Export\RendererInterface` and it is responsible to convert an array of +`Timesheet` objects into a downloadable/printable document. + +Every export renderer class will be automatically available when refreshing the application cache, thanks to the +[ExportServiceCompilerPass]({{ site.kimai_v2_file }}/src/DependencyInjection/Compiler/ExportServiceCompilerPass.php). + +Each renderer is represented by a "button" below the datatable on the export screen. + +A simple example, which only shows the IDs of the included timesheet records could look like this: + +```php +use App\Entity\Timesheet; +use App\Export\RendererInterface; +use App\Repository\Query\TimesheetQuery; +use Symfony\Component\HttpFoundation\Response; + +final class TimesheetIdRenderer implements RendererInterface +{ + public function render(array $timesheets, TimesheetQuery $query): Response + { + $ids = array_map(function(Timesheet $timesheet) { + return $timesheet->getId(); + }, $timesheets); + + $response = new Response(); + $response->setContent(sprintf('Included IDs: %s', implode(', ', $ids))); + + return $response; + } + + public function getId(): string + { + return 'ext_array_dump'; + } + + public function getIcon(): string + { + return 'fas fa-file-code'; + } + + public function getTitle(): string + { + return 'Show IDs'; + } +} +``` + +All you need to do is to register it as a service in the Symfony DI container. diff --git a/_documentation/invoices.md b/_documentation/invoices.md index 1ddecfaea..cbd277438 100644 --- a/_documentation/invoices.md +++ b/_documentation/invoices.md @@ -147,6 +147,7 @@ If a customer was selected the following values exist as well: | ${customer.country} | The customer country | | ${customer.homepage} | The customer homepage | | ${customer.comment} | The customer comment | +| ${customer.meta.X} | The customer [meta field]({% link _documentation/meta-fields.md %}) named `X` (if visible) | If a project was selected the following values exist as well: @@ -156,6 +157,16 @@ If a project was selected the following values exist as well: | ${project.name} | The project name | | ${project.comment} | The project name | | ${project.order_number} | The project order number | +| ${project.meta.X} | The project [meta field]({% link _documentation/meta-fields.md %}) named `X` (if visible) | + +If an activity was selected the following values exist as well: + +| Key | Description | +|---|---| +| ${activity.id} | The activity ID | +| ${activity.name} | The activity name | +| ${activity.comment} | The activity name | +| ${activity.meta.X} | The activity [meta field]({% link _documentation/meta-fields.md %}) named `X` (if visible) | ### Timesheet entry variables @@ -187,6 +198,7 @@ For each timesheet entry you can use the variables from the following table. | ${entry.project_id} | Project ID | 10 | | ${entry.customer} | Customer name | Acme Studios | | ${entry.customer_id} | Customer ID | 3 | +| ${entry.meta.X} | The [meta field]({% link _documentation/meta-fields.md %}) named `X` (if visible) | ## Configure search path diff --git a/_documentation/meta-fields.md b/_documentation/meta-fields.md new file mode 100644 index 000000000..fd7d0afe2 --- /dev/null +++ b/_documentation/meta-fields.md @@ -0,0 +1,116 @@ +--- +title: Custom fields +description: Use your own custom/meta fields +toc: true +since_version: 1.0 +--- + +Kimai supports custom fields for the following object types: + +- `User` via `UserPreference` (see [user preferences]({% link _documentation/user-preferences.md %})) +- `Timesheet` via `TimesheetMeta` +- `Customer` via `CustomerMeta` +- `Project` via `ProjectMeta` +- `Activity` via `ActivityMeta` + +## Custom fields + +Using the fields for internal reasons (eg. importing and linking to IDs of external apps) is simple. +You can add these fields programmatically at any time: +```php +$externalId = (new TimesheetMeta())->setName('externalID')->setValue(1); +$timesheet = new Timesheet(); +$timesheet->setMetaField($externalId); +``` + +But what if you want the field to be editable by users? + +Well, this is possible through the registration via an EventSubscriber, where you add your custom fields. + +## Add editable custom fields + +The following example adds a custom fields to each entity types "edit" and "create" forms: +- `Timesheet` via `TimesheetMeta` +- `Customer` via `CustomerMeta` +- `Project` via `ProjectMeta` +- `Activity` via `ActivityMeta` + +This example might seem a little awkward first, as I wanted to add only one example for all +possible entity types and that definitely doesn't make the code prettier ;-) +But I hope you get the point and see in `prepareEntity` what needs to be done to setup new +custom fields, which can be edited by the user. + +```php +use App\Entity\ActivityMeta; +use App\Entity\CustomerMeta; +use App\Entity\EntityWithMetaFields; +use App\Entity\MetaTableTypeInterface; +use App\Entity\ProjectMeta; +use App\Entity\TimesheetMeta; +use App\Event\ActivityMetaDefinitionEvent; +use App\Event\CustomerMetaDefinitionEvent; +use App\Event\ProjectMetaDefinitionEvent; +use App\Event\TimesheetMetaDefinitionEvent; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Validator\Constraints\Length; + +class MetaFieldSubscriber implements EventSubscriberInterface +{ + public static function getSubscribedEvents(): array + { + return [ + TimesheetMetaDefinitionEvent::class => ['loadTimesheetMeta', 200], + CustomerMetaDefinitionEvent::class => ['loadCustomerMeta', 200], + ProjectMetaDefinitionEvent::class => ['loadProjectMeta', 200], + ActivityMetaDefinitionEvent::class => ['loadActivityMeta', 200], + ]; + } + + public function loadTimesheetMeta(TimesheetMetaDefinitionEvent $event) + { + $this->prepareEntity($event->getEntity(), new TimesheetMeta()); + } + + public function loadCustomerMeta(CustomerMetaDefinitionEvent $event) + { + $this->prepareEntity($event->getEntity(), new CustomerMeta()); + } + + public function loadProjectMeta(ProjectMetaDefinitionEvent $event) + { + $this->prepareEntity($event->getEntity(), new ProjectMeta()); + } + + public function loadActivityMeta(ActivityMetaDefinitionEvent $event) + { + $this->prepareEntity($event->getEntity(), new ActivityMeta()); + } + + private function prepareEntity(EntityWithMetaFields $entity, MetaTableTypeInterface $definition) + { + $definition + ->setName('Location') + ->setType(TextType::class) + ->addConstraint(new Length(['max' => 255])) + ->setIsVisible(true); + + $entity->setMetaField($definition); + } +} +``` + +## Visibility + +Each meta field has its own visibility, which determines whether the field will be exposed +in the following places: + +- Export +- Invoice +- API + +The default visibility is `false` (hidden). If you want to use the meta fields value +in your invoices, then you have to set its visibility to true (see EventSubscriber example above). + +Be aware: the visibility is stored with the meta field, so changing its value via the EventSubscriber +does NOT change the visibility of already saved meta fields, just for new ones. diff --git a/_documentation/timesheet.md b/_documentation/timesheet.md index 12335e2c1..4f18379a0 100644 --- a/_documentation/timesheet.md +++ b/_documentation/timesheet.md @@ -246,5 +246,5 @@ kimai: Exported records will be locked to prevent manipulation of cleared data. -There is the permission `edit_exported_timesheet` which allows to edit and delete these locked entries nevertheless, -which by default is given to users with `ROLE_ADMIN` and `ROLE_SUPER_ADMIN`. +The [permission]({% link _documentation/permissions.md %}) `edit_exported_timesheet` does allow to edit and delete these +locked entries nevertheless, which by default is given to users with `ROLE_ADMIN` and `ROLE_SUPER_ADMIN`. diff --git a/_documentation/user-preferences.md b/_documentation/user-preferences.md index afd414ccd..a4b5b34fd 100644 --- a/_documentation/user-preferences.md +++ b/_documentation/user-preferences.md @@ -71,3 +71,41 @@ on mobile devices will be replaced by a link to the calendar. ## Show daily statistics in timesheet If activated, the personal timesheet visually groups and shows statistics for all records within one day. + +## Adding new UserPreference + +Developers can register new user preferences from within [their plugin]({% link _documentation/plugins.md %}) as easy as that: + +```php +use App\Entity\UserPreference; +use App\Event\UserPreferenceEvent; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; + +class UserProfileSubscriber implements EventSubscriberInterface +{ + public static function getSubscribedEvents(): array + { + return [ + UserPreferenceEvent::CONFIGURE => ['loadUserPreferences', 200] + ]; + } + + public function loadUserPreferences(UserPreferenceEvent $event) + { + if (null === ($user = $event->getUser())) { + return; + } + + // You attach every field to the event and all the heavy lifting is done by Kimai. + // The value is the default as long as the user has not yet updated his preferences, + // otherwise it will be overwritten with the users choice, stored in the database. + $event->addUserPreference( + (new UserPreference()) + ->setName('fooooo-bar') + ->setValue(false) + ->setType(CheckboxType::class) + ); + } +} +```