From 58d7b33366da526e2f92f8eccf373c570ae1d18a Mon Sep 17 00:00:00 2001 From: Beda Schmid Date: Wed, 24 Jun 2026 10:36:42 -0300 Subject: [PATCH] Add filter to include/exclude tags [Added] - Introduced a tag filter feature allowing users to specify whether tags should be included or excluded in time entry filters. This supports 'contains' and 'not_contains' modes. --- .../Api/V1/Public/ReportController.php | 2 +- .../Controllers/Api/V1/ReportController.php | 1 + .../Api/V1/TimeEntryController.php | 4 +- .../Requests/V1/Report/ReportStoreRequest.php | 5 +++ .../TimeEntryAggregateExportRequest.php | 4 ++ .../TimeEntry/TimeEntryAggregateRequest.php | 4 ++ .../TimeEntry/TimeEntryIndexExportRequest.php | 4 ++ .../V1/TimeEntry/TimeEntryIndexRequest.php | 4 ++ .../V1/Report/DetailedReportResource.php | 2 + app/Service/Dto/ReportPropertiesDto.php | 27 +++++++++++++ app/Service/TimeEntryFilter.php | 23 +++++++++-- .../Common/Reporting/ReportingFilterBar.vue | 40 +++++++++++++++++++ .../Common/Reporting/ReportingOverview.vue | 4 ++ resources/js/Pages/ReportingDetailed.vue | 4 ++ .../packages/api/src/openapi.json.client.ts | 22 ++++++++++ .../js/packages/ui/src/Tag/TagDropdown.vue | 1 + resources/js/types/reporting.ts | 1 + 17 files changed, 146 insertions(+), 6 deletions(-) diff --git a/app/Http/Controllers/Api/V1/Public/ReportController.php b/app/Http/Controllers/Api/V1/Public/ReportController.php index 1aec632f..fd55d978 100644 --- a/app/Http/Controllers/Api/V1/Public/ReportController.php +++ b/app/Http/Controllers/Api/V1/Public/ReportController.php @@ -59,7 +59,7 @@ class ReportController extends Controller $filter->addBillable($properties->billable); $filter->addMemberIdsFilter($properties->memberIds?->toArray()); $filter->addProjectIdsFilter($properties->projectIds?->toArray()); - $filter->addTagIdsFilter($properties->tagIds?->toArray()); + $filter->addTagIdsFilter($properties->tagIds?->toArray(), $properties->tagFilter); $filter->addTaskIdsFilter($properties->taskIds?->toArray()); $filter->addClientIdsFilter($properties->clientIds?->toArray()); $timeEntriesQuery = $filter->get(); diff --git a/app/Http/Controllers/Api/V1/ReportController.php b/app/Http/Controllers/Api/V1/ReportController.php index 1f89fa01..a2db7632 100644 --- a/app/Http/Controllers/Api/V1/ReportController.php +++ b/app/Http/Controllers/Api/V1/ReportController.php @@ -96,6 +96,7 @@ class ReportController extends Controller $properties->setClientIds($request->input('properties.client_ids', null)); $properties->setProjectIds($request->input('properties.project_ids', null)); $properties->setTagIds($request->input('properties.tag_ids', null)); + $properties->setTagFilter($request->input('properties.tag_filter', null)); $properties->setTaskIds($request->input('properties.task_ids', null)); $properties->weekStart = $request->has('properties.week_start') ? Weekday::from($request->input('properties.week_start')) : $user->week_start; $timezone = $user->timezone; diff --git a/app/Http/Controllers/Api/V1/TimeEntryController.php b/app/Http/Controllers/Api/V1/TimeEntryController.php index 4dad185b..15e5e09c 100644 --- a/app/Http/Controllers/Api/V1/TimeEntryController.php +++ b/app/Http/Controllers/Api/V1/TimeEntryController.php @@ -203,7 +203,7 @@ class TimeEntryController extends Controller $filter->addMemberIdFilter($member); $filter->addMemberIdsFilter($request->input('member_ids')); $filter->addProjectIdsFilter($request->input('project_ids')); - $filter->addTagIdsFilter($request->input('tag_ids')); + $filter->addTagIdsFilter($request->input('tag_ids'), $request->input('tag_filter')); $filter->addTaskIdsFilter($request->input('task_ids')); $filter->addClientIdsFilter($request->input('client_ids')); $filter->addBillableFilter($request->input('billable')); @@ -559,7 +559,7 @@ class TimeEntryController extends Controller $filter->addMemberIdFilter($member); $filter->addMemberIdsFilter($request->input('member_ids')); $filter->addProjectIdsFilter($request->input('project_ids')); - $filter->addTagIdsFilter($request->input('tag_ids')); + $filter->addTagIdsFilter($request->input('tag_ids'), $request->input('tag_filter')); $filter->addTaskIdsFilter($request->input('task_ids')); $filter->addClientIdsFilter($request->input('client_ids')); $filter->addBillableFilter($request->input('billable')); diff --git a/app/Http/Requests/V1/Report/ReportStoreRequest.php b/app/Http/Requests/V1/Report/ReportStoreRequest.php index 443bf01c..ae7023b2 100644 --- a/app/Http/Requests/V1/Report/ReportStoreRequest.php +++ b/app/Http/Requests/V1/Report/ReportStoreRequest.php @@ -124,6 +124,11 @@ class ReportStoreRequest extends BaseFormRequest } }, ], + 'properties.tag_filter' => [ + 'nullable', + 'string', + 'in:'.TimeEntryFilter::TAG_FILTER_CONTAINS.','.TimeEntryFilter::TAG_FILTER_NOT_CONTAINS, + ], 'properties.task_ids' => [ 'nullable', 'array', diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php index a356198c..a9a65703 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php @@ -139,6 +139,10 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest })->uuid()->validate($attribute, $value, $fail); }, ], + 'tag_filter' => [ + 'string', + 'in:'.TimeEntryFilter::TAG_FILTER_CONTAINS.','.TimeEntryFilter::TAG_FILTER_NOT_CONTAINS, + ], // Filter by task IDs, task IDs are OR combined 'task_ids' => [ 'array', diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php index 92378f82..269e8381 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php @@ -125,6 +125,10 @@ class TimeEntryAggregateRequest extends BaseFormRequest })->uuid()->validate($attribute, $value, $fail); }, ], + 'tag_filter' => [ + 'string', + 'in:'.TimeEntryFilter::TAG_FILTER_CONTAINS.','.TimeEntryFilter::TAG_FILTER_NOT_CONTAINS, + ], // Filter by task IDs, task IDs are OR combined 'task_ids' => [ 'array', diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php index 70447609..1f0c307a 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php @@ -110,6 +110,10 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest })->uuid()->validate($attribute, $value, $fail); }, ], + 'tag_filter' => [ + 'string', + 'in:'.TimeEntryFilter::TAG_FILTER_CONTAINS.','.TimeEntryFilter::TAG_FILTER_NOT_CONTAINS, + ], // Filter by task IDs, task IDs are OR combined 'task_ids' => [ 'array', diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php index 230e5134..d88a55b3 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php @@ -103,6 +103,10 @@ class TimeEntryIndexRequest extends BaseFormRequest })->uuid()->validate($attribute, $value, $fail); }, ], + 'tag_filter' => [ + 'string', + 'in:'.TimeEntryFilter::TAG_FILTER_CONTAINS.','.TimeEntryFilter::TAG_FILTER_NOT_CONTAINS, + ], // Filter by task IDs, task IDs are OR combined 'task_ids' => [ 'array', diff --git a/app/Http/Resources/V1/Report/DetailedReportResource.php b/app/Http/Resources/V1/Report/DetailedReportResource.php index 45bb1798..bc666543 100644 --- a/app/Http/Resources/V1/Report/DetailedReportResource.php +++ b/app/Http/Resources/V1/Report/DetailedReportResource.php @@ -56,6 +56,8 @@ class DetailedReportResource extends BaseResource 'project_ids' => $this->resource->properties->projectIds?->toArray(), /** @var array|null $tags_ids Filter by tag IDs, tag IDs are OR combined */ 'tag_ids' => $this->resource->properties->tagIds?->toArray(), + /** @var string|null $tag_filter Tag filter mode */ + 'tag_filter' => $this->resource->properties->tagFilter, /** @var array|null $task_ids Filter by task IDs, task IDs are OR combined */ 'task_ids' => $this->resource->properties->taskIds?->toArray(), /** @var string|null $rounding_type Rounding type for time entries */ diff --git a/app/Service/Dto/ReportPropertiesDto.php b/app/Service/Dto/ReportPropertiesDto.php index a3ff85db..de193563 100644 --- a/app/Service/Dto/ReportPropertiesDto.php +++ b/app/Service/Dto/ReportPropertiesDto.php @@ -56,6 +56,8 @@ class ReportPropertiesDto implements Castable */ public ?Collection $tagIds = null; + public ?string $tagFilter = null; + /** * @var Collection|null */ @@ -115,6 +117,7 @@ class ReportPropertiesDto implements Castable $dto->clientIds = $data->clientIds !== null ? ReportPropertiesDto::idArrayToCollection($data->clientIds) : null; $dto->projectIds = $data->projectIds !== null ? ReportPropertiesDto::idArrayToCollection($data->projectIds) : null; $dto->tagIds = $data->tagIds !== null ? ReportPropertiesDto::idArrayToCollection($data->tagIds) : null; + $dto->tagFilter = isset($data->tagFilter) ? ReportPropertiesDto::tagFilterValue($data->tagFilter) : null; $dto->taskIds = $data->taskIds ? ReportPropertiesDto::idArrayToCollection($data->taskIds) : null; $dto->group = TimeEntryAggregationType::from($data->group); $dto->subGroup = TimeEntryAggregationType::from($data->subGroup); @@ -144,6 +147,7 @@ class ReportPropertiesDto implements Castable 'clientIds' => $value->clientIds?->toArray(), 'projectIds' => $value->projectIds?->toArray(), 'tagIds' => $value->tagIds?->toArray(), + 'tagFilter' => $value->tagFilter, 'taskIds' => $value->taskIds?->toArray(), 'group' => $value->group->value, 'subGroup' => $value->subGroup->value, @@ -184,6 +188,24 @@ class ReportPropertiesDto implements Castable return $collection; } + /** + * @return 'contains'|'not_contains'|null + */ + public static function tagFilterValue(mixed $tagFilter): ?string + { + if ($tagFilter === null) { + return null; + } + if (! is_string($tagFilter)) { + throw new \InvalidArgumentException('The given tag filter is not a string'); + } + if (! in_array($tagFilter, [TimeEntryFilter::TAG_FILTER_CONTAINS, TimeEntryFilter::TAG_FILTER_NOT_CONTAINS], true)) { + throw new \InvalidArgumentException('The given tag filter is not valid'); + } + + return $tagFilter; + } + /** * @param array|null $memberIds */ @@ -216,6 +238,11 @@ class ReportPropertiesDto implements Castable $this->tagIds = $tagIds !== null ? ReportPropertiesDto::idArrayToCollection($tagIds) : null; } + public function setTagFilter(mixed $tagFilter): void + { + $this->tagFilter = ReportPropertiesDto::tagFilterValue($tagFilter); + } + /** * @param array|null $taskIds */ diff --git a/app/Service/TimeEntryFilter.php b/app/Service/TimeEntryFilter.php index 0a0d670d..221d4b66 100644 --- a/app/Service/TimeEntryFilter.php +++ b/app/Service/TimeEntryFilter.php @@ -14,6 +14,10 @@ class TimeEntryFilter { public const string NONE_VALUE = 'none'; + public const string TAG_FILTER_CONTAINS = 'contains'; + + public const string TAG_FILTER_NOT_CONTAINS = 'not_contains'; + /** * @var Builder */ @@ -192,15 +196,22 @@ class TimeEntryFilter /** * @param array|null $tagIds */ - public function addTagIdsFilter(?array $tagIds): self + public function addTagIdsFilter(?array $tagIds, ?string $tagFilter = self::TAG_FILTER_CONTAINS): self { if ($tagIds === null) { return $this; } + if ($tagFilter === null) { + $tagFilter = self::TAG_FILTER_CONTAINS; + } + if (! in_array($tagFilter, [self::TAG_FILTER_CONTAINS, self::TAG_FILTER_NOT_CONTAINS], true)) { + Log::warning('Invalid tag filter value', ['value' => $tagFilter]); + $tagFilter = self::TAG_FILTER_CONTAINS; + } $includeNone = in_array(self::NONE_VALUE, $tagIds, true); $tagIds = array_values(array_filter($tagIds, fn (string $id): bool => $id !== self::NONE_VALUE)); - $this->builder->where(function (Builder $builder) use ($tagIds, $includeNone): void { + $tagCondition = function (Builder $builder) use ($tagIds, $includeNone): void { foreach ($tagIds as $tagId) { $builder->orWhereJsonContains('tags', $tagId); } @@ -209,7 +220,13 @@ class TimeEntryFilter $query->whereJsonLength('tags', 0)->orWhereNull('tags'); }); } - }); + }; + + if ($tagFilter === self::TAG_FILTER_NOT_CONTAINS) { + $this->builder->whereNot($tagCondition); + } else { + $this->builder->where($tagCondition); + } return $this; } diff --git a/resources/js/Components/Common/Reporting/ReportingFilterBar.vue b/resources/js/Components/Common/Reporting/ReportingFilterBar.vue index d80cd213..00208b5a 100644 --- a/resources/js/Components/Common/Reporting/ReportingFilterBar.vue +++ b/resources/js/Components/Common/Reporting/ReportingFilterBar.vue @@ -14,6 +14,7 @@ import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue'; import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue'; import { useTagsQuery } from '@/utils/useTagsQuery'; import { useTagsStore } from '@/utils/useTags'; +import type { TagFilter } from '@/types/reporting'; type TimeEntryRoundingType = 'up' | 'down' | 'nearest'; @@ -22,6 +23,7 @@ const selectedProjects = defineModel('selectedProjects', { required: t const selectedTasks = defineModel('selectedTasks', { required: true }); const selectedClients = defineModel('selectedClients', { required: true }); const selectedTags = defineModel('selectedTags', { required: true }); +const tagFilter = defineModel('tagFilter', { required: true }); const billable = defineModel<'true' | 'false' | null>('billable', { required: true }); const roundingEnabled = defineModel('roundingEnabled', { required: true }); const roundingType = defineModel('roundingType', { required: true }); @@ -93,6 +95,44 @@ async function createTag(name: string) { title="Tags" :icon="TagIcon" /> +