Compare commits

..

3 Commits

Author SHA1 Message Date
Constantin Graf
04f0e769bb Project name is now unique per client and organization 2025-04-25 17:47:14 +02:00
Constantin Graf
37453aef77 Api docs for date time format 2025-04-25 17:41:58 +02:00
Constantin Graf
3607eac06a Updated composer dependencies 2025-04-25 12:21:44 +02:00
19 changed files with 54 additions and 435 deletions

View File

@@ -40,7 +40,6 @@ class HandleInertiaRequests extends Middleware
public function share(Request $request): array
{
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
/** @var BillingContract $billing */
$billing = app(BillingContract::class);
@@ -49,7 +48,6 @@ class HandleInertiaRequests extends Middleware
return array_merge(parent::share($request), [
'has_billing_extension' => $hasBilling,
'has_invoicing_extension' => $hasInvoicing,
'billing' => $billing !== null && $currentOrganization !== null ? [
'has_subscription' => $billing->hasSubscription($currentOrganization),
'has_trial' => $billing->hasTrial($currentOrganization),

View File

@@ -124,59 +124,34 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
$timeEntry->is_imported = true;
// Start
$start = null;
try {
$startDateStr = $record['Start Date'];
$startTimeStr = $record['Start Time'];
$startStr = $startDateStr.' '.$startTimeStr;
$matches = [];
$checkResult = preg_match('/^([0-9]{1,2})\/([0-9]{1,2})\/([0-9]{4}) ([0-9]{1,2}):([0-9]{1,2})(:[0-9]{1,2})? (AM|PM)$/', $startStr, $matches);
if ($checkResult === 1) {
if ((int) $matches[1] > 12) {
throw new ImportException('Start date ("'.$startDateStr.'") is invalid, please select the correct date format before exporting from Clockify');
}
if ($matches[6] === '') {
$start = Carbon::createFromFormat('m/d/Y h:i A', $startStr, $timezone);
} else {
$start = Carbon::createFromFormat('m/d/Y H:i:s A', $startStr, $timezone);
}
if (preg_match('/^[0-9]{1,2}:[0-9]{1,2} (AM|PM)$/', $record['Start Time']) === 1) {
$start = Carbon::createFromFormat('m/d/Y h:i A', $record['Start Date'].' '.$record['Start Time'], $timezone);
} else {
$start = Carbon::createFromFormat('m/d/Y H:i:s A', $record['Start Date'].' '.$record['Start Time'], $timezone);
}
} catch (InvalidFormatException) {
throw new ImportException('Start date ("'.$startDateStr.'") or time ("'.$startTimeStr.'") are invalid');
throw new ImportException('Start date ("'.$record['Start Date'].'") or time ("'.$record['Start Time'].'") are invalid');
}
if ($start === null) {
throw new ImportException('Start date ("'.$startDateStr.'") or time ("'.$startTimeStr.'") are invalid');
throw new ImportException('Start date ("'.$record['Start Date'].'") or time ("'.$record['Start Time'].'") are invalid');
}
$timeEntry->start = $start->utc();
// End
$end = null;
try {
$endDateStr = $record['End Date'];
$endTimeStr = $record['End Time'];
$endStr = $endDateStr.' '.$endTimeStr;
$matches = [];
$checkResult = preg_match('/^([0-9]{1,2})\/([0-9]{1,2})\/([0-9]{4}) ([0-9]{1,2}):([0-9]{1,2})(:[0-9]{1,2})? (AM|PM)$/', $endStr, $matches);
if ($checkResult === 1) {
if ((int) $matches[1] > 12) {
throw new ImportException('Start date ("'.$endDateStr.'") is invalid, please select the correct date format before exporting from Clockify');
}
if ($matches[6] === '') {
$end = Carbon::createFromFormat('m/d/Y h:i A', $endStr, $timezone);
} else {
$end = Carbon::createFromFormat('m/d/Y H:i:s A', $endStr, $timezone);
}
if (preg_match('/^[0-9]{1,2}:[0-9]{1,2} (AM|PM)$/', $record['End Time']) === 1) {
$end = Carbon::createFromFormat('m/d/Y h:i A', $record['End Date'].' '.$record['End Time'], $timezone);
} else {
$end = Carbon::createFromFormat('m/d/Y H:i:s A', $record['End Date'].' '.$record['End Time'], $timezone);
}
} catch (InvalidFormatException) {
throw new ImportException('End date ("'.$endDateStr.'") or time ("'.$endTimeStr.'") are invalid');
throw new ImportException('End date ("'.$record['End Date'].'") or time ("'.$record['End Time'].'") are invalid');
}
if ($end === null) {
throw new ImportException('End date ("'.$endDateStr.'") or time ("'.$endTimeStr.'") are invalid');
throw new ImportException('End date ("'.$record['End Date'].'") or time ("'.$record['End Time'].'") are invalid');
}
$timeEntry->end = $end->utc();
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
$timeEntry,
$projectMember,

View File

@@ -280,20 +280,6 @@ class TimeEntryAggregationService
'color' => null,
];
}
} elseif ($type === TimeEntryAggregationType::Description) {
foreach ($keys as $key) {
$descriptorMap[$key] = [
'description' => $key,
'color' => null,
];
}
} elseif ($type === TimeEntryAggregationType::Billable) {
foreach ($keys as $key) {
$descriptorMap[$key] = [
'description' => $key === '0' ? 'Non-billable' : 'Billable',
'color' => null,
];
}
}
return $descriptorMap;

View File

@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('projects', function (Blueprint $table): void {
$table->bigInteger('spent_time')->unsigned()->default(0)->change();
});
Schema::table('tasks', function (Blueprint $table): void {
$table->bigInteger('spent_time')->unsigned()->default(0)->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('projects', function (Blueprint $table): void {
$table->integer('spent_time')->unsigned()->default(0)->change();
});
Schema::table('tasks', function (Blueprint $table): void {
$table->integer('spent_time')->unsigned()->default(0)->change();
});
}
};

View File

@@ -1,9 +1,13 @@
<script setup lang="ts">
import { onMounted } from "vue";
import { useTheme } from "@/utils/theme.js";
import { onMounted, watch } from "vue";
import { theme } from "@/utils/theme.js";
onMounted(async () => {
useTheme()
document.documentElement.classList.add(theme.value);
watch(theme, (newTheme, oldTheme) => {
document.documentElement.classList.remove(oldTheme);
document.documentElement.classList.add(newTheme);
});
});
</script>

View File

@@ -20,24 +20,24 @@ import {
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
import UserSettingsIcon from '@/Components/UserSettingsIcon.vue';
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import { onMounted, ref } from "vue";
import { onMounted, ref, watch } from "vue";
import NotificationContainer from '@/Components/NotificationContainer.vue';
import { initializeStores, refreshStores } from '@/utils/init';
import {
canManageBilling,
canUpdateOrganization,
canViewClients, canViewInvoices,
canViewClients,
canViewMembers,
canViewProjects, canViewReport,
canViewTags,
} from '@/utils/permissions';
import { isBillingActivated, isInvoicingActivated } from '@/utils/billing';
import { isBillingActivated } from '@/utils/billing';
import type { User } from '@/types/models';
import { ArrowsRightLeftIcon } from '@heroicons/vue/16/solid';
import { fetchToken, isTokenValid } from '@/utils/session';
import UpdateSidebarNotification from '@/Components/UpdateSidebarNotification.vue';
import BillingBanner from '@/Components/Billing/BillingBanner.vue';
import { useTheme } from "@/utils/theme";
import { theme } from "@/utils/theme";
defineProps({
title: String,
@@ -47,7 +47,12 @@ const showSidebarMenu = ref(false);
const isUnloading = ref(false);
onMounted(async () => {
useTheme()
document.documentElement.classList.add(theme.value);
watch(theme, (newTheme, oldTheme) => {
document.documentElement.classList.remove(oldTheme);
document.documentElement.classList.add(newTheme);
});
// make sure that the initial requests are only loaded once, this can be removed once we move away from inertia
if (window.initialDataLoaded !== true) {
window.initialDataLoaded = true;
@@ -183,7 +188,6 @@ const page = usePage<{
:current="route().current('tags')"
:href="route('tags')"></NavigationSidebarItem>
<NavigationSidebarItem
v-if="isInvoicingActivated() && canViewInvoices()"
title="Invoices"
:icon="DocumentTextIcon"
:current="route().current('invoices')"
@@ -268,6 +272,8 @@ const page = usePage<{
v-if="$slots.header"
class="bg-default-background border-b border-default-background-separator shadow">
<div class="pt-8 pb-3">
<MainContainer>
<slot name="header" />
</MainContainer>

View File

@@ -339,8 +339,6 @@ const tableData = computed(() => {
@submit="updateReporting">
<template #trigger>
<ReportingFilterBadge
:count="selectedClients.length"
:active="selectedClients.length > 0"
title="Clients"
:icon="FolderIcon"></ReportingFilterBadge>
</template>

View File

@@ -308,8 +308,6 @@ async function downloadExport(format: ExportFormat) {
@submit="updateFilteredTimeEntries">
<template #trigger>
<ReportingFilterBadge
:count="selectedClients.length"
:active="selectedClients.length > 0"
title="Clients"
:icon="FolderIcon"></ReportingFilterBadge>
</template>

View File

@@ -14,7 +14,6 @@ import { api } from '@/packages/api/src';
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
import { useReportingStore } from '@/utils/useReporting';
import { Head } from '@inertiajs/vue3';
import { useTheme } from "@/utils/theme";
const sharedSecret = ref<string | null>(null);
@@ -137,10 +136,6 @@ function getGroupLabel(key: string) {
return option.value === key;
})?.label;
}
onMounted(async () => {
useTheme();
})
</script>
<template>

View File

@@ -66,7 +66,6 @@ const InvoiceResource = z
buyer_name: z.string(),
status: z.string(),
date: z.string(),
due_at: z.string(),
created_at: z.union([z.string(), z.null()]),
updated_at: z.union([z.string(), z.null()]),
})
@@ -107,8 +106,6 @@ const InvoiceStoreRequest = z
discount_type: InvoiceDiscountType.optional(),
footer: z.union([z.string(), z.null()]).optional(),
notes: z.union([z.string(), z.null()]).optional(),
payment_terms: z.union([z.string(), z.null()]).optional(),
is_eu_reverse_charge: z.boolean().optional(),
entries: z
.array(
z
@@ -130,7 +127,7 @@ const InvoiceEntryResource = z
name: z.string(),
description: z.union([z.string(), z.null()]),
unit_price: z.number().int(),
quantity: z.string(),
quantity: z.number().int(),
order_index: z.number().int(),
created_at: z.union([z.string(), z.null()]),
updated_at: z.union([z.string(), z.null()]),
@@ -161,7 +158,7 @@ const DetailedInvoiceResource = z
buyer_address_country: z.string(),
buyer_phone: z.string(),
buyer_email: z.string(),
paid_at: z.union([z.string(), z.null()]),
paid_at: z.string(),
due_at: z.string(),
discount_type: z.string(),
discount_amount: z.string(),
@@ -171,8 +168,6 @@ const DetailedInvoiceResource = z
date: z.string(),
footer: z.string(),
notes: z.string(),
payment_terms: z.string(),
is_eu_reverse_charge: z.string(),
billing_period_start: z.string(),
billing_period_end: z.string(),
created_at: z.union([z.string(), z.null()]),
@@ -216,8 +211,6 @@ const InvoiceUpdateRequest = z
discount_type: InvoiceDiscountType,
footer: z.union([z.string(), z.null()]),
notes: z.union([z.string(), z.null()]),
payment_terms: z.union([z.string(), z.null()]),
is_eu_reverse_charge: z.boolean(),
entries: z.array(
z
.object({
@@ -232,9 +225,6 @@ const InvoiceUpdateRequest = z
})
.partial()
.passthrough();
const InvoiceDownloadRequest = z
.object({ with_e_invoice: z.boolean() })
.passthrough();
const InvoiceSettingResource = z
.object({
seller_name: z.union([z.string(), z.null()]),
@@ -472,9 +462,9 @@ const ReportStoreRequest = z
task_ids: z
.union([z.array(z.string().uuid()), z.null()])
.optional(),
group: TimeEntryAggregationType,
sub_group: TimeEntryAggregationType,
history_group: TimeEntryAggregationTypeInterval,
group: TimeEntryAggregationType.optional(),
sub_group: TimeEntryAggregationType.optional(),
history_group: TimeEntryAggregationTypeInterval.optional(),
week_start: Weekday.optional(),
timezone: z.union([z.string(), z.null()]).optional(),
})
@@ -762,7 +752,6 @@ export const schemas = {
DetailedInvoiceResource,
InvoiceStatus,
InvoiceUpdateRequest,
InvoiceDownloadRequest,
InvoiceSettingResource,
InvoiceSettingUpdateRequest,
MemberResource,
@@ -1391,7 +1380,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.void(),
response: z.null(),
errors: [
{
status: 400,
@@ -1676,7 +1665,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.void(),
response: z.null(),
errors: [
{
status: 400,
@@ -1733,7 +1722,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.void(),
response: z.null(),
errors: [
{
status: 401,
@@ -1769,7 +1758,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.void(),
response: z.null(),
errors: [
{
status: 401,
@@ -2061,7 +2050,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.void(),
response: z.null(),
errors: [
{
status: 401,
@@ -2086,11 +2075,6 @@ const endpoints = makeApi([
alias: 'downloadInvoice',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: z.object({ with_e_invoice: z.boolean() }).passthrough(),
},
{
name: 'organization',
type: 'Path',
@@ -2119,16 +2103,6 @@ const endpoints = makeApi([
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
},
{
@@ -2192,7 +2166,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.void(),
response: z.null(),
errors: [
{
status: 400,
@@ -2384,7 +2358,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.void(),
response: z.null(),
errors: [
{
status: 400,
@@ -2431,7 +2405,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.void(),
response: z.null(),
errors: [
{
status: 400,
@@ -2478,7 +2452,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.void(),
response: z.null(),
errors: [
{
status: 400,
@@ -2576,7 +2550,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.void(),
response: z.null(),
errors: [
{
status: 401,
@@ -2828,7 +2802,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.void(),
response: z.null(),
errors: [
{
status: 400,
@@ -3201,7 +3175,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.void(),
response: z.null(),
errors: [
{
status: 401,
@@ -3369,7 +3343,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.void(),
response: z.null(),
errors: [
{
status: 400,
@@ -3596,7 +3570,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.void(),
response: z.null(),
errors: [
{
status: 400,
@@ -3976,7 +3950,7 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
schema: z.string(),
},
],
response: z.void(),
response: z.null(),
errors: [
{
status: 401,
@@ -4591,7 +4565,7 @@ Please note that the access token is only shown in this response and cannot be r
schema: z.string(),
},
],
response: z.void(),
response: z.null(),
errors: [
{
status: 400,
@@ -4633,7 +4607,7 @@ Please note that the access token is only shown in this response and cannot be r
schema: z.string(),
},
],
response: z.void(),
response: z.null(),
errors: [
{
status: 400,
@@ -4698,11 +4672,6 @@ Please note that the access token is only shown in this response and cannot be r
description: `Unauthenticated`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,

View File

@@ -1,6 +1,5 @@
<script setup lang="ts">
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
import { watch } from "vue";
const props = withDefaults(
defineProps<{
@@ -37,12 +36,6 @@ function onOpenChange(value: boolean) {
}
}
watch(open, (value) => {
if (value === false) {
emit('submit');
}
});
</script>
<template>

View File

@@ -9,14 +9,6 @@ export function isBillingActivated() {
return page.props.has_billing_extension;
}
export function isInvoicingActivated() {
const page = usePage<{
has_invoicing_extension: boolean;
}>();
return page.props.has_invoicing_extension;
}
export function isInTrial() {
const page = usePage<{
billing: {

View File

@@ -122,7 +122,3 @@ export function canDeleteReport() {
export function canViewAllTimeEntries() {
return currentUserHasPermission('time-entries:view:all');
}
export function canViewInvoices() {
return currentUserHasPermission('invoices:view');
}

View File

@@ -22,12 +22,4 @@ const theme = computed(() => {
return themeSetting.value
});
function useTheme() {
document.documentElement.classList.add(theme.value);
watch(theme, (newTheme, oldTheme) => {
document.documentElement.classList.remove(oldTheme);
document.documentElement.classList.add(newTheme);
});
}
export { type themeOption, themeSetting, theme, useTheme };
export { type themeOption, themeSetting, theme };

View File

@@ -1,2 +0,0 @@
"Project","Client","Description","Task","User","Group","Email","Tags","Type","Billable","Invoiced","Invoice ID","Start Date","Start Time","End Date","End Time","Duration (h)","Duration (decimal)","Billable Rate (EUR)","Billable Amount (EUR)","Date of creation"
"Real World Project","Real World Client","\\ 🔥 Special characters ''''''`!@#$%^&*()_+\-=\[\]{};':''\\|,.''<>\/?~ \\\","A giant task","Peter Tester","Group1, Group2","peter.test@email.test","","Regular","Yes","Yes","Invoice100","13/15/2024","11:00:00 AM","10/15/2024","11:30:00 AM","00:30:00","0.50","1000.00","500.00","10/15/2024"
1 Project Client Description Task User Group Email Tags Type Billable Invoiced Invoice ID Start Date Start Time End Date End Time Duration (h) Duration (decimal) Billable Rate (EUR) Billable Amount (EUR) Date of creation
2 Real World Project Real World Client \\ 🔥 Special characters ''''''`!@#$%^&*()_+\-=\[\]{};':''\\|,.''<>\/?~ \\\ A giant task Peter Tester Group1, Group2 peter.test@email.test Regular Yes Yes Invoice100 13/15/2024 11:00:00 AM 10/15/2024 11:30:00 AM 00:30:00 0.50 1000.00 500.00 10/15/2024

View File

@@ -185,19 +185,4 @@ class ProjectModelTest extends ModelTestAbstract
// Assert
$this->assertFalse($isArchived);
}
public function test_project_can_store_big_amounts_of_spent_time(): void
{
// Arrange
$project = Project::factory()->create();
$spentTime = 100 * 365 * 24 * 60 * 60; // 100 years in seconds
// Act
$project->spent_time = $spentTime;
$project->save();
$project->refresh();
// Assert
$this->assertSame($spentTime, $project->spent_time);
}
}

View File

@@ -114,19 +114,4 @@ class TaskModelTest extends ModelTestAbstract
// Assert
$this->assertFalse($task->is_done);
}
public function test_task_can_store_big_amounts_of_spent_time(): void
{
// Arrange
$task = Task::factory()->create();
$spentTime = 100 * 365 * 24 * 60 * 60; // 100 years in seconds
// Act
$task->spent_time = $spentTime;
$task->save();
$task->refresh();
// Assert
$this->assertSame($spentTime, $task->spent_time);
}
}

View File

@@ -94,25 +94,4 @@ class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract
$this->assertSame(0, $report->projectsCreated);
$this->assertSame(0, $report->clientsCreated);
}
public function test_import_fails_if_month_in_date_is_bigger_than_12(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new ClockifyTimeEntriesImporter;
$importer->init($organization);
$data = Storage::disk('testfiles')->get('clockify_time_entries_import_test_3.csv');
// Act
try {
$importer->importData($data, $timezone);
} catch (ImportException $e) {
// Assert
$this->assertSame('Start date ("13/15/2024") is invalid, please select the correct date format before exporting from Clockify', $e->getMessage());
return;
}
$this->fail();
}
}

View File

@@ -498,198 +498,4 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
],
], $result);
}
public function test_aggregated_time_entries_with_descriptions_by_description_and_billable(): void
{
// Arrange
TimeEntry::factory()->startWithDuration(now(), 10)->create([
'description' => 'TEST 1',
'billable' => true,
]);
TimeEntry::factory()->startWithDuration(now(), 10)->create([
'description' => '',
'billable' => false,
]);
TimeEntry::factory()->startWithDuration(now(), 10)->create([
'description' => 'TEST 1',
'billable' => false,
]);
TimeEntry::factory()->startWithDuration(now(), 10)->create([
'description' => '',
'billable' => false,
]);
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntriesWithDescriptions(
$query,
TimeEntryAggregationType::Description,
TimeEntryAggregationType::Billable,
'Europe/Vienna',
Weekday::Monday,
false,
null,
null,
true
);
// Assert
$this->assertSame([
'seconds' => 40,
'cost' => 0,
'grouped_type' => 'description',
'grouped_data' => [
[
'key' => null,
'seconds' => 20,
'cost' => 0,
'grouped_type' => 'billable',
'grouped_data' => [
[
'key' => '0',
'seconds' => 20,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
'description' => 'Non-billable',
'color' => null,
],
],
'description' => null,
'color' => null,
],
[
'key' => 'TEST 1',
'seconds' => 20,
'cost' => 0,
'grouped_type' => 'billable',
'grouped_data' => [
[
'key' => '0',
'seconds' => 10,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
'description' => 'Non-billable',
'color' => null,
],
[
'key' => '1',
'seconds' => 10,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
'description' => 'Billable',
'color' => null,
],
],
'description' => 'TEST 1',
'color' => null,
],
],
], $result);
}
public function test_aggregated_time_entries_with_descriptions_by_client_and_project(): void
{
// Arrange
$client1 = Client::factory()->create();
$client2 = Client::factory()->create();
$project1 = Project::factory()->forClient($client1)->create();
$project2 = Project::factory()->forClient($client2)->create();
$project3 = Project::factory()->create();
TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project1)->create();
TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project2)->create();
TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project3)->create();
TimeEntry::factory()->startWithDuration(now(), 10)->create();
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntriesWithDescriptions(
$query,
TimeEntryAggregationType::Client,
TimeEntryAggregationType::Project,
'Europe/Vienna',
Weekday::Monday,
false,
null,
null,
true
);
// Assert
$this->assertEqualsCanonicalizing([
'seconds' => 40,
'cost' => 0,
'grouped_type' => 'client',
'grouped_data' => [
[
'key' => null,
'seconds' => 20,
'cost' => 0,
'grouped_type' => 'project',
'grouped_data' => [
[
'key' => null,
'seconds' => 10,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
'description' => null,
'color' => null,
],
[
'key' => $project3->getKey(),
'seconds' => 10,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
'description' => $project3->name,
'color' => $project3->color,
],
],
'description' => null,
'color' => null,
],
[
'key' => $client1->getKey(),
'seconds' => 10,
'cost' => 0,
'grouped_type' => 'project',
'grouped_data' => [
[
'key' => $project1->getKey(),
'seconds' => 10,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
'description' => $project1->name,
'color' => $project1->color,
],
],
'description' => $client1->name,
'color' => null,
],
[
'key' => $client2->getKey(),
'seconds' => 10,
'cost' => 0,
'grouped_type' => 'project',
'grouped_data' => [
[
'key' => $project2->getKey(),
'seconds' => 10,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
'description' => $project2->name,
'color' => $project2->color,
],
],
'description' => $client2->name,
'color' => null,
],
],
], $result);
}
}