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.
This commit is contained in:
Beda Schmid
2026-06-24 10:36:42 -03:00
committed by Gregor Vostrak
parent 50f57f6997
commit 58d7b33366
17 changed files with 146 additions and 6 deletions

View File

@@ -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<string[]>('selectedProjects', { required: t
const selectedTasks = defineModel<string[]>('selectedTasks', { required: true });
const selectedClients = defineModel<string[]>('selectedClients', { required: true });
const selectedTags = defineModel<string[]>('selectedTags', { required: true });
const tagFilter = defineModel<TagFilter>('tagFilter', { required: true });
const billable = defineModel<'true' | 'false' | null>('billable', { required: true });
const roundingEnabled = defineModel<boolean>('roundingEnabled', { required: true });
const roundingType = defineModel<TimeEntryRoundingType>('roundingType', { required: true });
@@ -93,6 +95,44 @@ async function createTag(name: string) {
title="Tags"
:icon="TagIcon" />
</template>
<template #content-before-list>
<div class="mt-2 border-b border-card-background-separator pb-2">
<div
class="mb-1.5 px-2 text-xs font-medium text-text-tertiary uppercase">
Match
</div>
<div class="space-y-1">
<button
type="button"
class="w-full rounded-md px-2 py-1.5 text-left text-sm font-medium"
:class="
tagFilter === 'contains'
? 'bg-card-background-active text-text-primary'
: 'text-text-secondary hover:bg-card-background-active'
"
@click="
tagFilter = 'contains';
emit('submit');
">
Contains
</button>
<button
type="button"
class="w-full rounded-md px-2 py-1.5 text-left text-sm font-medium"
:class="
tagFilter === 'not_contains'
? 'bg-card-background-active text-text-primary'
: 'text-text-secondary hover:bg-card-background-active'
"
@click="
tagFilter = 'not_contains';
emit('submit');
">
Does Not Contain
</button>
</div>
</div>
</template>
</TagDropdown>
<Select v-model="billable" @update:model-value="emit('submit')">

View File

@@ -49,6 +49,7 @@ import type { ExportFormat } from '@/types/reporting';
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
import { useAggregatedTimeEntriesQuery } from '@/utils/useAggregatedTimeEntriesQuery';
import type { TagFilter } from '@/types/reporting';
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
@@ -67,6 +68,7 @@ const selectedProjects = ref<string[]>([]);
const selectedMembers = ref<string[]>([]);
const selectedTasks = ref<string[]>([]);
const selectedClients = ref<string[]>([]);
const tagFilter = ref<TagFilter>('contains');
const billable = ref<'true' | 'false' | null>(null);
const roundingEnabled = ref<boolean>(false);
@@ -122,6 +124,7 @@ const filterParams = computed<AggregatedTimeEntriesQueryParams>(() => {
task_ids: selectedTasks.value.length > 0 ? selectedTasks.value : undefined,
client_ids: selectedClients.value.length > 0 ? selectedClients.value : undefined,
tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,
tag_filter: selectedTags.value.length > 0 ? tagFilter.value : undefined,
billable: billable.value !== null ? billable.value : undefined,
member_id: getCurrentRole() === 'employee' ? getCurrentMembershipId() : undefined,
rounding_type: roundingEnabled.value ? roundingType.value : undefined,
@@ -366,6 +369,7 @@ const tableData = computed(() => {
v-model:selected-tasks="selectedTasks"
v-model:selected-clients="selectedClients"
v-model:selected-tags="selectedTags"
v-model:tag-filter="tagFilter"
v-model:billable="billable"
v-model:rounding-enabled="roundingEnabled"
v-model:rounding-type="roundingType"

View File

@@ -67,6 +67,7 @@ import ReportingFilterBar from '@/Components/Common/Reporting/ReportingFilterBar
import { useTimeEntriesReportQuery } from '@/utils/useTimeEntriesReportQuery';
import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import type { TagFilter } from '@/types/reporting';
// TimeEntryRoundingType is now defined in ReportingRoundingControls component
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
@@ -84,6 +85,7 @@ const selectedProjects = ref<string[]>([]);
const selectedMembers = ref<string[]>([]);
const selectedTasks = ref<string[]>([]);
const selectedClients = ref<string[]>([]);
const tagFilter = ref<TagFilter>('contains');
const billable = ref<'true' | 'false' | null>(null);
const roundingEnabled = ref<boolean>(false);
const roundingType = ref<TimeEntryRoundingType>('nearest');
@@ -115,6 +117,7 @@ function getFilterAttributes() {
task_ids: selectedTasks.value.length > 0 ? selectedTasks.value : undefined,
client_ids: selectedClients.value.length > 0 ? selectedClients.value : undefined,
tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,
tag_filter: selectedTags.value.length > 0 ? tagFilter.value : undefined,
billable: billable.value !== null ? billable.value : undefined,
rounding_type: roundingEnabled.value ? roundingType.value : undefined,
rounding_minutes: roundingEnabled.value ? roundingMinutes.value : undefined,
@@ -337,6 +340,7 @@ async function downloadExport(format: ExportFormat) {
v-model:selected-tasks="selectedTasks"
v-model:selected-clients="selectedClients"
v-model:selected-tags="selectedTags"
v-model:tag-filter="tagFilter"
v-model:billable="billable"
v-model:rounding-enabled="roundingEnabled"
v-model:rounding-type="roundingType"

View File

@@ -448,6 +448,7 @@ const ReportStoreRequest = z
client_ids: z.union([z.array(z.string()), z.null()]).optional(),
project_ids: z.union([z.array(z.string()), z.null()]).optional(),
tag_ids: z.union([z.array(z.string()), z.null()]).optional(),
tag_filter: z.enum(['contains', 'not_contains']).optional(),
task_ids: z.union([z.array(z.string()), z.null()]).optional(),
group: TimeEntryAggregationType,
sub_group: TimeEntryAggregationType,
@@ -481,6 +482,7 @@ const DetailedReportResource = z
client_ids: z.union([z.array(z.string()), z.null()]),
project_ids: z.union([z.array(z.string()), z.null()]),
tag_ids: z.union([z.array(z.string()), z.null()]),
tag_filter: z.union([z.enum(['contains', 'not_contains']), z.null()]),
task_ids: z.union([z.array(z.string()), z.null()]),
rounding_type: z.union([z.string(), z.null()]),
rounding_minutes: z.union([z.number(), z.null()]),
@@ -3784,6 +3786,11 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
type: 'Query',
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'tag_filter',
type: 'Query',
schema: z.enum(['contains', 'not_contains']).optional(),
},
{
name: 'task_ids',
type: 'Query',
@@ -4165,6 +4172,11 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
type: 'Query',
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'tag_filter',
type: 'Query',
schema: z.enum(['contains', 'not_contains']).optional(),
},
{
name: 'task_ids',
type: 'Query',
@@ -4359,6 +4371,11 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
type: 'Query',
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'tag_filter',
type: 'Query',
schema: z.enum(['contains', 'not_contains']).optional(),
},
{
name: 'task_ids',
type: 'Query',
@@ -4487,6 +4504,11 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
type: 'Query',
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'tag_filter',
type: 'Query',
schema: z.enum(['contains', 'not_contains']).optional(),
},
{
name: 'task_ids',
type: 'Query',

View File

@@ -114,6 +114,7 @@ const showCreateTagModal = ref(false);
class="w-full rounded-md border border-input-border bg-input-background px-3 py-1.5 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none"
placeholder="Search for a Tag..." />
</ComboboxAnchor>
<slot name="content-before-list"></slot>
<ComboboxContent
:dismiss-able="false"
position="inline"

View File

@@ -1 +1,2 @@
export type ExportFormat = 'xlsx' | 'csv' | 'ods' | 'pdf';
export type TagFilter = 'contains' | 'not_contains';