Compare commits

..

8 Commits

Author SHA1 Message Date
Gregor Vostrak
4623697f79 add feedback button in sidebar 2025-10-01 13:10:29 +02:00
Gregor Vostrak
1c787a0ad0 clarify UserSettingsIcon Dropdown Profile Settings Item Description 2025-10-01 12:15:46 +02:00
Gregor Vostrak
5e678edb9d remove bottom padding for toast container
This became redundant due to the floating feedback bubble removal
2025-09-30 16:58:24 +02:00
Gregor Vostrak
65e145b20f improve focus states and keyboard navigation for organization switcher and user settings dropdown 2025-09-30 16:45:48 +02:00
Gregor Vostrak
d2b636842a update organization switcher to use shadcn dropdownmenu 2025-09-30 16:28:17 +02:00
Gregor Vostrak
1510884f3b change profile dropdown to shadcn, add feedback entry 2025-09-30 13:10:55 +02:00
Gregor Vostrak
7f89fd8ea1 fix overflow issues in short calendar events 2025-09-29 12:19:27 +02:00
Gregor Vostrak
0b45f3b473 change create bucket script to work with new minio client versions 2025-09-29 12:09:15 +02:00
5 changed files with 304 additions and 337 deletions

View File

@@ -20,7 +20,6 @@ enum TimeEntryAggregationType: string
case Client = 'client';
case Billable = 'billable';
case Description = 'description';
case Tag = 'tag';
public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType
{

View File

@@ -10,7 +10,6 @@ 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;
@@ -18,7 +17,6 @@ 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
@@ -50,10 +48,6 @@ 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'];
@@ -300,17 +294,6 @@ 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;
@@ -453,8 +436,6 @@ 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

View File

@@ -12,19 +12,11 @@ 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'
| 'tag';
export type GroupingOption = 'project' | 'task' | 'user' | 'billable' | 'client' | 'description';
export const useReportingStore = defineStore('reporting', () => {
const reportingGraphResponse = ref<ReportingResponse | null>(null);
@@ -81,7 +73,6 @@ 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) {
@@ -115,11 +106,6 @@ 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';
@@ -165,11 +151,6 @@ export const useReportingStore = defineStore('reporting', () => {
value: 'description',
icon: DocumentTextIcon,
},
{
label: 'Tags',
value: 'tag',
icon: DocumentTextIcon,
},
];
return {

View File

@@ -1872,147 +1872,6 @@ 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