Add client_ids filter to time entry export

This commit is contained in:
Gregor Vostrak
2026-02-02 01:57:40 +01:00
parent 73b4d66386
commit b2af9c6bf1
8 changed files with 125 additions and 34 deletions

View File

@@ -6,6 +6,7 @@ namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat; use App\Enums\ExportFormat;
use App\Enums\TimeEntryRoundingType; use App\Enums\TimeEntryRoundingType;
use App\Models\Client;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
@@ -58,6 +59,23 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}), }),
], ],
// Filter by client IDs, client IDs are OR combined
'client_ids' => [
'array',
'min:1',
],
'client_ids.*' => [
'string',
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
},
],
// Filter by project IDs, project IDs are OR combined // Filter by project IDs, project IDs are OR combined
'project_ids' => [ 'project_ids' => [
'array', 'array',

View File

@@ -205,7 +205,9 @@ test('test that merging a placeholder member works', async ({ page }) => {
page.getByRole('button', { name: 'Merge Member' }).click(), page.getByRole('button', { name: 'Merge Member' }).click(),
page.waitForResponse( page.waitForResponse(
(response) => (response) =>
response.url().includes('/member/') && response.url().includes('/merge-into') && response.ok() response.url().includes('/member/') &&
response.url().includes('/merge-into') &&
response.ok()
), ),
]); ]);

View File

@@ -52,11 +52,7 @@ export async function createClient(page: Page, clientName: string) {
await expect(page.getByText(clientName)).toBeVisible(); await expect(page.getByText(clientName)).toBeVisible();
} }
export async function createProjectWithClient( export async function createProjectWithClient(page: Page, projectName: string, clientName: string) {
page: Page,
projectName: string,
clientName: string
) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects'); await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await expect(page.getByRole('button', { name: 'Create Project' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Create Project' })).toBeVisible();
await page.getByRole('button', { name: 'Create Project' }).click(); await page.getByRole('button', { name: 'Create Project' }).click();

View File

@@ -51,9 +51,7 @@ async function createTag(name: string) {
<MainContainer class="sm:flex space-y-4 sm:space-y-0 justify-between"> <MainContainer class="sm:flex space-y-4 sm:space-y-0 justify-between">
<div class="flex flex-wrap items-center space-y-2 sm:space-y-0 space-x-3"> <div class="flex flex-wrap items-center space-y-2 sm:space-y-0 space-x-3">
<div class="text-sm font-medium">Filters</div> <div class="text-sm font-medium">Filters</div>
<MemberMultiselectDropdown <MemberMultiselectDropdown v-model="selectedMembers" @submit="emit('submit')">
v-model="selectedMembers"
@submit="emit('submit')">
<template #trigger> <template #trigger>
<ReportingFilterBadge <ReportingFilterBadge
:count="selectedMembers.length" :count="selectedMembers.length"
@@ -62,9 +60,7 @@ async function createTag(name: string) {
:icon="UserGroupIcon" /> :icon="UserGroupIcon" />
</template> </template>
</MemberMultiselectDropdown> </MemberMultiselectDropdown>
<ProjectMultiselectDropdown <ProjectMultiselectDropdown v-model="selectedProjects" @submit="emit('submit')">
v-model="selectedProjects"
@submit="emit('submit')">
<template #trigger> <template #trigger>
<ReportingFilterBadge <ReportingFilterBadge
:count="selectedProjects.length" :count="selectedProjects.length"
@@ -73,9 +69,7 @@ async function createTag(name: string) {
:icon="FolderIcon" /> :icon="FolderIcon" />
</template> </template>
</ProjectMultiselectDropdown> </ProjectMultiselectDropdown>
<TaskMultiselectDropdown <TaskMultiselectDropdown v-model="selectedTasks" @submit="emit('submit')">
v-model="selectedTasks"
@submit="emit('submit')">
<template #trigger> <template #trigger>
<ReportingFilterBadge <ReportingFilterBadge
:count="selectedTasks.length" :count="selectedTasks.length"
@@ -84,9 +78,7 @@ async function createTag(name: string) {
:icon="CheckCircleIcon" /> :icon="CheckCircleIcon" />
</template> </template>
</TaskMultiselectDropdown> </TaskMultiselectDropdown>
<ClientMultiselectDropdown <ClientMultiselectDropdown v-model="selectedClients" @submit="emit('submit')">
v-model="selectedClients"
@submit="emit('submit')">
<template #trigger> <template #trigger>
<ReportingFilterBadge <ReportingFilterBadge
:count="selectedClients.length" :count="selectedClients.length"

View File

@@ -182,13 +182,13 @@ const groupedPieChartData = computed(() => {
aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => { aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
const name = getNameForReportingRowEntry( const name = getNameForReportingRowEntry(
entry.key, entry.key,
aggregatedTableTimeEntries.value?.grouped_type aggregatedTableTimeEntries.value?.grouped_type ?? null
); );
let color = getRandomColorWithSeed(entry.key ?? 'none'); let color = getRandomColorWithSeed(entry.key ?? 'none');
if ( if (
name && name &&
aggregatedTableTimeEntries.value?.grouped_type && aggregatedTableTimeEntries.value?.grouped_type &&
emptyPlaceholder[aggregatedTableTimeEntries.value?.grouped_type] === name emptyPlaceholder[aggregatedTableTimeEntries.value.grouped_type] === name
) { ) {
color = '#CCCCCC'; color = '#CCCCCC';
} else if (aggregatedTableTimeEntries.value?.grouped_type === 'project') { } else if (aggregatedTableTimeEntries.value?.grouped_type === 'project') {
@@ -200,7 +200,7 @@ const groupedPieChartData = computed(() => {
name: name:
getNameForReportingRowEntry( getNameForReportingRowEntry(
entry.key, entry.key,
aggregatedTableTimeEntries.value?.grouped_type aggregatedTableTimeEntries.value?.grouped_type ?? null
) ?? '', ) ?? '',
color: color, color: color,
}; };
@@ -215,7 +215,7 @@ const tableData = computed(() => {
cost: entry.cost, cost: entry.cost,
description: getNameForReportingRowEntry( description: getNameForReportingRowEntry(
entry.key, entry.key,
aggregatedTableTimeEntries.value?.grouped_type aggregatedTableTimeEntries.value?.grouped_type ?? null
), ),
grouped_data: grouped_data:
entry.grouped_data?.map((el) => { entry.grouped_data?.map((el) => {
@@ -256,13 +256,12 @@ const tableData = computed(() => {
v-model:rounding-type="roundingType" v-model:rounding-type="roundingType"
v-model:rounding-minutes="roundingMinutes" v-model:rounding-minutes="roundingMinutes"
v-model:start-date="startDate" v-model:start-date="startDate"
v-model:end-date="endDate" v-model:end-date="endDate" />
/>
<MainContainer> <MainContainer>
<div class="pt-10 w-full px-3 relative"> <div class="pt-10 w-full px-3 relative">
<ReportingChart <ReportingChart
:grouped-type="aggregatedGraphTimeEntries?.grouped_type" :grouped-type="aggregatedGraphTimeEntries?.grouped_type ?? null"
:grouped-data="aggregatedGraphTimeEntries?.grouped_data"></ReportingChart> :grouped-data="aggregatedGraphTimeEntries?.grouped_data ?? null"></ReportingChart>
</div> </div>
</MainContainer> </MainContainer>
<MainContainer> <MainContainer>
@@ -273,13 +272,13 @@ const tableData = computed(() => {
<span>Group by</span> <span>Group by</span>
<ReportingGroupBySelect <ReportingGroupBySelect
v-model="group" v-model="group"
:group-by-options="groupByOptions" :group-by-options="groupByOptions"></ReportingGroupBySelect>
></ReportingGroupBySelect>
<span>and</span> <span>and</span>
<ReportingGroupBySelect <ReportingGroupBySelect
v-model="subGroup" v-model="subGroup"
:group-by-options="groupByOptions.filter((el) => el.value !== group)" :group-by-options="
></ReportingGroupBySelect> groupByOptions.filter((el) => el.value !== group)
"></ReportingGroupBySelect>
</div> </div>
<div class="grid items-center" style="grid-template-columns: 1fr 100px 150px"> <div class="grid items-center" style="grid-template-columns: 1fr 100px 150px">
<div <div

View File

@@ -1,6 +1,6 @@
<script setup lang="ts" generic="T"> <script setup lang="ts" generic="T">
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue'; import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { computed, ref, watch } from 'vue'; import { computed, type Ref, ref, watch } from 'vue';
import Checkbox from '@/packages/ui/src/Input/Checkbox.vue'; import Checkbox from '@/packages/ui/src/Input/Checkbox.vue';
import { import {
ComboboxAnchor, ComboboxAnchor,
@@ -27,7 +27,7 @@ const props = defineProps<{
const open = ref(false); const open = ref(false);
const searchValue = ref(''); const searchValue = ref('');
const sortedItems = ref<T[]>([]); const sortedItems = ref<T[]>([]) as Ref<T[]>;
watch(open, (isOpen) => { watch(open, (isOpen) => {
if (isOpen) { if (isOpen) {
@@ -43,7 +43,9 @@ watch(open, (isOpen) => {
const filteredItems = computed(() => { const filteredItems = computed(() => {
const search = searchValue.value.toLowerCase().trim(); const search = searchValue.value.toLowerCase().trim();
if (!search) return sortedItems.value; if (!search) return sortedItems.value;
return sortedItems.value.filter((item) => props.getNameForItem(item).toLowerCase().includes(search)); return sortedItems.value.filter((item) =>
props.getNameForItem(item).toLowerCase().includes(search)
);
}); });
const showNoItem = computed(() => { const showNoItem = computed(() => {

View File

@@ -1,5 +1,9 @@
import { useQuery } from '@tanstack/vue-query'; import { useQuery } from '@tanstack/vue-query';
import { api, type AggregatedTimeEntriesQueryParams, type ReportingResponse } from '@/packages/api/src'; import {
api,
type AggregatedTimeEntriesQueryParams,
type ReportingResponse,
} from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
import { computed, type ComputedRef, unref } from 'vue'; import { computed, type ComputedRef, unref } from 'vue';

View File

@@ -1559,6 +1559,84 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$this->assertResponseCode($response, 200); $this->assertResponseCode($response, 200);
} }
public function test_index_export_endpoint_with_client_ids_filter_returns_filtered_entries(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
$clientA = Client::factory()->forOrganization($data->organization)->create();
$clientB = Client::factory()->forOrganization($data->organization)->create();
$projectA = Project::factory()->forOrganization($data->organization)->forClient($clientA)->create();
$projectB = Project::factory()->forOrganization($data->organization)->forClient($clientB)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forProject($projectA)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($projectB)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index-export', [
$data->organization->getKey(),
'format' => ExportFormat::CSV,
'client_ids' => [$clientA->getKey()],
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_index_export_endpoint_with_none_client_ids_filter_succeeds(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index-export', [
$data->organization->getKey(),
'format' => ExportFormat::CSV,
'client_ids' => [TimeEntryFilter::NONE_VALUE],
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_index_export_endpoint_with_client_ids_of_other_organization_fails_validation(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
$otherData = $this->createUserWithPermission([
'time-entries:view:all',
]);
$otherClient = Client::factory()->forOrganization($otherData->organization)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index-export', [
$data->organization->getKey(),
'format' => ExportFormat::CSV,
'client_ids' => [$otherClient->getKey()],
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
]));
// Assert
$response->assertStatus(422);
$response->assertJsonValidationErrorFor('client_ids.0');
}
public function test_aggregate_endpoint_fails_if_user_has_only_access_to_own_time_entries_but_does_not_filter_for_this(): void public function test_aggregate_endpoint_fails_if_user_has_only_access_to_own_time_entries_but_does_not_filter_for_this(): void
{ {
// Arrange // Arrange