mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-16 20:22:15 +01:00
Add client_ids filter to time entry export
This commit is contained in:
@@ -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',
|
||||||
|
|||||||
@@ -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()
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user