mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 16:22:16 +01:00
Compare commits
9 Commits
feature/si
...
feature/ta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
480c07151c | ||
|
|
73d1d55583 | ||
|
|
fb69aadf0d | ||
|
|
c4feb2e579 | ||
|
|
0a751d9330 | ||
|
|
e33f3538ce | ||
|
|
61ae7edce5 | ||
|
|
db2ae25efd | ||
|
|
a9673de861 |
@@ -20,6 +20,7 @@ enum TimeEntryAggregationType: string
|
||||
case Client = 'client';
|
||||
case Billable = 'billable';
|
||||
case Description = 'description';
|
||||
case Tag = 'tag';
|
||||
|
||||
public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Enums\TimeEntryRoundingType;
|
||||
use App\Enums\Weekday;
|
||||
use App\Models\Client;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
@@ -17,6 +18,7 @@ use Carbon\CarbonTimeZone;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TimeEntryAggregationService
|
||||
@@ -48,6 +50,10 @@ class TimeEntryAggregationService
|
||||
$group1Select = null;
|
||||
$group2Select = 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) {
|
||||
$group1Select = $this->getGroupByQuery($group1Type, $timezone, $startOfWeek);
|
||||
$groupBy = ['group_1'];
|
||||
@@ -294,6 +300,17 @@ class TimeEntryAggregationService
|
||||
'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;
|
||||
@@ -436,6 +453,8 @@ class TimeEntryAggregationService
|
||||
return 'billable';
|
||||
} elseif ($group === TimeEntryAggregationType::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 { useTasksStore } from '@/utils/useTasks';
|
||||
import { useClientsStore } from '@/utils/useClients';
|
||||
import { useTagsStore } from '@/utils/useTags';
|
||||
import { CheckCircleIcon, UserCircleIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
|
||||
import { DocumentTextIcon, FolderIcon } from '@heroicons/vue/16/solid';
|
||||
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', () => {
|
||||
const reportingGraphResponse = ref<ReportingResponse | null>(null);
|
||||
@@ -73,6 +81,7 @@ export const useReportingStore = defineStore('reporting', () => {
|
||||
billable: 'Non-Billable',
|
||||
client: 'No Client',
|
||||
description: 'No Description',
|
||||
tag: 'No Tag',
|
||||
} as Record<string, string>;
|
||||
|
||||
function getNameForReportingRowEntry(key: string | null, type: string | null) {
|
||||
@@ -106,6 +115,11 @@ export const useReportingStore = defineStore('reporting', () => {
|
||||
const { clients } = storeToRefs(clientsStore);
|
||||
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 (key === '0') {
|
||||
return 'Non-Billable';
|
||||
@@ -151,6 +165,11 @@ export const useReportingStore = defineStore('reporting', () => {
|
||||
value: 'description',
|
||||
icon: DocumentTextIcon,
|
||||
},
|
||||
{
|
||||
label: 'Tags',
|
||||
value: 'tag',
|
||||
icon: DocumentTextIcon,
|
||||
},
|
||||
];
|
||||
|
||||
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
|
||||
{
|
||||
// Arrange
|
||||
|
||||
Reference in New Issue
Block a user