mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-17 04:32:15 +01:00
add grouping by tag on reporting page
This commit is contained in:
@@ -20,6 +20,7 @@ enum TimeEntryAggregationType: string
|
|||||||
case Client = 'client';
|
case Client = 'client';
|
||||||
case Billable = 'billable';
|
case Billable = 'billable';
|
||||||
case Description = 'description';
|
case Description = 'description';
|
||||||
|
case Tag = 'tag';
|
||||||
|
|
||||||
public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType
|
public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use App\Enums\TimeEntryRoundingType;
|
|||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use App\Models\Tag;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
use App\Models\TimeEntry;
|
use App\Models\TimeEntry;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@@ -17,6 +18,7 @@ use Carbon\CarbonTimeZone;
|
|||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class TimeEntryAggregationService
|
class TimeEntryAggregationService
|
||||||
@@ -48,6 +50,10 @@ class TimeEntryAggregationService
|
|||||||
$group1Select = null;
|
$group1Select = null;
|
||||||
$group2Select = null;
|
$group2Select = null;
|
||||||
$groupBy = null;
|
$groupBy = null;
|
||||||
|
// If any grouping is by tag, expand rows per tag via CROSS JOIN LATERAL on the JSONB array
|
||||||
|
if (($group1Type === TimeEntryAggregationType::Tag) || ($group2Type === TimeEntryAggregationType::Tag)) {
|
||||||
|
$timeEntriesQuery->crossJoin(DB::raw("LATERAL jsonb_array_elements_text(coalesce(tags, '[]'::jsonb)) as tag(tag)"));
|
||||||
|
}
|
||||||
if ($group1Type !== null) {
|
if ($group1Type !== null) {
|
||||||
$group1Select = $this->getGroupByQuery($group1Type, $timezone, $startOfWeek);
|
$group1Select = $this->getGroupByQuery($group1Type, $timezone, $startOfWeek);
|
||||||
$groupBy = ['group_1'];
|
$groupBy = ['group_1'];
|
||||||
@@ -294,6 +300,17 @@ class TimeEntryAggregationService
|
|||||||
'color' => null,
|
'color' => null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
} elseif ($type === TimeEntryAggregationType::Tag) {
|
||||||
|
$tags = Tag::query()
|
||||||
|
->whereIn('id', $keys)
|
||||||
|
->select('id', 'name')
|
||||||
|
->get();
|
||||||
|
foreach ($tags as $tag) {
|
||||||
|
$descriptorMap[$tag->id] = [
|
||||||
|
'description' => $tag->name,
|
||||||
|
'color' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $descriptorMap;
|
return $descriptorMap;
|
||||||
@@ -436,6 +453,8 @@ class TimeEntryAggregationService
|
|||||||
return 'billable';
|
return 'billable';
|
||||||
} elseif ($group === TimeEntryAggregationType::Description) {
|
} elseif ($group === TimeEntryAggregationType::Description) {
|
||||||
return 'description';
|
return 'description';
|
||||||
|
} elseif ($group === TimeEntryAggregationType::Tag) {
|
||||||
|
return 'tag';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -12,11 +12,19 @@ import { useProjectsStore } from '@/utils/useProjects';
|
|||||||
import { useMembersStore } from '@/utils/useMembers';
|
import { useMembersStore } from '@/utils/useMembers';
|
||||||
import { useTasksStore } from '@/utils/useTasks';
|
import { useTasksStore } from '@/utils/useTasks';
|
||||||
import { useClientsStore } from '@/utils/useClients';
|
import { useClientsStore } from '@/utils/useClients';
|
||||||
|
import { useTagsStore } from '@/utils/useTags';
|
||||||
import { CheckCircleIcon, UserCircleIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
|
import { CheckCircleIcon, UserCircleIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
|
||||||
import { DocumentTextIcon, FolderIcon } from '@heroicons/vue/16/solid';
|
import { DocumentTextIcon, FolderIcon } from '@heroicons/vue/16/solid';
|
||||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||||
|
|
||||||
export type GroupingOption = 'project' | 'task' | 'user' | 'billable' | 'client' | 'description';
|
export type GroupingOption =
|
||||||
|
| 'project'
|
||||||
|
| 'task'
|
||||||
|
| 'user'
|
||||||
|
| 'billable'
|
||||||
|
| 'client'
|
||||||
|
| 'description'
|
||||||
|
| 'tag';
|
||||||
|
|
||||||
export const useReportingStore = defineStore('reporting', () => {
|
export const useReportingStore = defineStore('reporting', () => {
|
||||||
const reportingGraphResponse = ref<ReportingResponse | null>(null);
|
const reportingGraphResponse = ref<ReportingResponse | null>(null);
|
||||||
@@ -73,6 +81,7 @@ export const useReportingStore = defineStore('reporting', () => {
|
|||||||
billable: 'Non-Billable',
|
billable: 'Non-Billable',
|
||||||
client: 'No Client',
|
client: 'No Client',
|
||||||
description: 'No Description',
|
description: 'No Description',
|
||||||
|
tag: 'No Tag',
|
||||||
} as Record<string, string>;
|
} as Record<string, string>;
|
||||||
|
|
||||||
function getNameForReportingRowEntry(key: string | null, type: string | null) {
|
function getNameForReportingRowEntry(key: string | null, type: string | null) {
|
||||||
@@ -106,6 +115,11 @@ export const useReportingStore = defineStore('reporting', () => {
|
|||||||
const { clients } = storeToRefs(clientsStore);
|
const { clients } = storeToRefs(clientsStore);
|
||||||
return clients.value.find((client) => client.id === key)?.name;
|
return clients.value.find((client) => client.id === key)?.name;
|
||||||
}
|
}
|
||||||
|
if (type === 'tag') {
|
||||||
|
const tagsStore = useTagsStore();
|
||||||
|
const { tags } = storeToRefs(tagsStore);
|
||||||
|
return tags.value.find((tag) => tag.id === key)?.name;
|
||||||
|
}
|
||||||
if (type === 'billable') {
|
if (type === 'billable') {
|
||||||
if (key === '0') {
|
if (key === '0') {
|
||||||
return 'Non-Billable';
|
return 'Non-Billable';
|
||||||
@@ -151,6 +165,11 @@ export const useReportingStore = defineStore('reporting', () => {
|
|||||||
value: 'description',
|
value: 'description',
|
||||||
icon: DocumentTextIcon,
|
icon: DocumentTextIcon,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Tags',
|
||||||
|
value: 'tag',
|
||||||
|
icon: DocumentTextIcon,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1872,6 +1872,147 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_aggregate_endpoint_groups_by_tag(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:view:all',
|
||||||
|
]);
|
||||||
|
$tag1 = Tag::factory()->forOrganization($data->organization)->create();
|
||||||
|
$tag2 = Tag::factory()->forOrganization($data->organization)->create();
|
||||||
|
$start = Carbon::now()->timezone($data->user->timezone);
|
||||||
|
// Entry with two tags => contributes to both tag groups
|
||||||
|
TimeEntry::factory()
|
||||||
|
->forOrganization($data->organization)
|
||||||
|
->forMember($data->member)
|
||||||
|
->startWithDuration($start, 100)
|
||||||
|
->create([
|
||||||
|
'tags' => [$tag1->getKey(), $tag2->getKey()],
|
||||||
|
]);
|
||||||
|
// Entry with one tag
|
||||||
|
TimeEntry::factory()
|
||||||
|
->forOrganization($data->organization)
|
||||||
|
->forMember($data->member)
|
||||||
|
->startWithDuration($start, 50)
|
||||||
|
->create([
|
||||||
|
'tags' => [$tag1->getKey()],
|
||||||
|
]);
|
||||||
|
// Entry with no tags should not appear in tag grouping
|
||||||
|
TimeEntry::factory()
|
||||||
|
->forOrganization($data->organization)
|
||||||
|
->forMember($data->member)
|
||||||
|
->startWithDuration($start, 25)
|
||||||
|
->create([
|
||||||
|
'tags' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.time-entries.aggregate', [
|
||||||
|
$data->organization->getKey(),
|
||||||
|
'group' => 'tag',
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertSuccessful();
|
||||||
|
$response->assertExactJson([
|
||||||
|
'data' => [
|
||||||
|
'seconds' => 250, // total seconds across all groups
|
||||||
|
'cost' => 0,
|
||||||
|
'grouped_type' => 'tag',
|
||||||
|
'grouped_data' => [
|
||||||
|
[
|
||||||
|
'key' => $tag1->getKey(),
|
||||||
|
'seconds' => 150, // 100 + 50
|
||||||
|
'cost' => 0,
|
||||||
|
'grouped_type' => null,
|
||||||
|
'grouped_data' => null,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => $tag2->getKey(),
|
||||||
|
'seconds' => 100, // 100 from first entry
|
||||||
|
'cost' => 0,
|
||||||
|
'grouped_type' => null,
|
||||||
|
'grouped_data' => null,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_aggregate_endpoint_groups_by_project_and_sub_group_tag(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:view:all',
|
||||||
|
]);
|
||||||
|
$project = Project::factory()->forOrganization($data->organization)->create();
|
||||||
|
$tag1 = Tag::factory()->forOrganization($data->organization)->create();
|
||||||
|
$tag2 = Tag::factory()->forOrganization($data->organization)->create();
|
||||||
|
$start = Carbon::now()->timezone($data->user->timezone);
|
||||||
|
|
||||||
|
TimeEntry::factory()
|
||||||
|
->forOrganization($data->organization)
|
||||||
|
->forMember($data->member)
|
||||||
|
->forProject($project)
|
||||||
|
->startWithDuration($start, 120)
|
||||||
|
->create([
|
||||||
|
'tags' => [$tag1->getKey()],
|
||||||
|
]);
|
||||||
|
TimeEntry::factory()
|
||||||
|
->forOrganization($data->organization)
|
||||||
|
->forMember($data->member)
|
||||||
|
->forProject($project)
|
||||||
|
->startWithDuration($start, 60)
|
||||||
|
->create([
|
||||||
|
'tags' => [$tag2->getKey()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.time-entries.aggregate', [
|
||||||
|
$data->organization->getKey(),
|
||||||
|
'group' => 'project',
|
||||||
|
'sub_group' => 'tag',
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertSuccessful();
|
||||||
|
$response->assertExactJson([
|
||||||
|
'data' => [
|
||||||
|
'seconds' => 180,
|
||||||
|
'cost' => 0,
|
||||||
|
'grouped_type' => 'project',
|
||||||
|
'grouped_data' => [
|
||||||
|
[
|
||||||
|
'key' => $project->getKey(),
|
||||||
|
'seconds' => 180,
|
||||||
|
'cost' => 0,
|
||||||
|
'grouped_type' => 'tag',
|
||||||
|
'grouped_data' => [
|
||||||
|
[
|
||||||
|
'key' => $tag1->getKey(),
|
||||||
|
'seconds' => 120,
|
||||||
|
'cost' => 0,
|
||||||
|
'grouped_type' => null,
|
||||||
|
'grouped_data' => null,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => $tag2->getKey(),
|
||||||
|
'seconds' => 60,
|
||||||
|
'cost' => 0,
|
||||||
|
'grouped_type' => null,
|
||||||
|
'grouped_data' => null,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_aggregate_endpoint_with_no_group(): void
|
public function test_aggregate_endpoint_with_no_group(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
Reference in New Issue
Block a user