diff --git a/app/Http/Controllers/Api/V1/ReportController.php b/app/Http/Controllers/Api/V1/ReportController.php index 46fe7208..b8f2fd21 100644 --- a/app/Http/Controllers/Api/V1/ReportController.php +++ b/app/Http/Controllers/Api/V1/ReportController.php @@ -107,6 +107,8 @@ class ReportController extends Controller } } $properties->timezone = $timezone; + $properties->roundingType = $request->getPropertyRoundingType(); + $properties->roundingMinutes = $request->getPropertyRoundingMinutes(); $report->properties = $properties; if ($isPublic) { $report->share_secret = $reportService->generateSecret(); diff --git a/app/Http/Requests/V1/Report/ReportStoreRequest.php b/app/Http/Requests/V1/Report/ReportStoreRequest.php index 696af297..da609ba1 100644 --- a/app/Http/Requests/V1/Report/ReportStoreRequest.php +++ b/app/Http/Requests/V1/Report/ReportStoreRequest.php @@ -6,6 +6,7 @@ namespace App\Http\Requests\V1\Report; use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationTypeInterval; +use App\Enums\TimeEntryRoundingType; use App\Enums\Weekday; use App\Http\Requests\V1\BaseFormRequest; use App\Models\Organization; @@ -128,6 +129,18 @@ class ReportStoreRequest extends BaseFormRequest 'nullable', 'timezone:all', ], + // Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null. + 'properties.rounding_type' => [ + 'nullable', + 'string', + Rule::enum(TimeEntryRoundingType::class), + ], + // Defines the length of the interval that the time entry rounding rounds to. + 'properties.rounding_minutes' => [ + 'nullable', + 'numeric', + 'integer', + ], ]; } @@ -205,4 +218,22 @@ class ReportStoreRequest extends BaseFormRequest { return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group')); } + + public function getPropertyRoundingType(): ?TimeEntryRoundingType + { + if (! $this->has('properties.rounding_type') || $this->input('properties.rounding_type') === null) { + return null; + } + + return TimeEntryRoundingType::from($this->input('properties.rounding_type')); + } + + public function getPropertyRoundingMinutes(): ?int + { + if (! $this->has('properties.rounding_minutes') || $this->input('properties.rounding_minutes') === null) { + return null; + } + + return (int) $this->input('properties.rounding_minutes'); + } } diff --git a/app/Http/Resources/V1/Report/DetailedReportResource.php b/app/Http/Resources/V1/Report/DetailedReportResource.php index 1391f150..45bb1798 100644 --- a/app/Http/Resources/V1/Report/DetailedReportResource.php +++ b/app/Http/Resources/V1/Report/DetailedReportResource.php @@ -58,6 +58,10 @@ class DetailedReportResource extends BaseResource 'tag_ids' => $this->resource->properties->tagIds?->toArray(), /** @var array|null $task_ids Filter by task IDs, task IDs are OR combined */ 'task_ids' => $this->resource->properties->taskIds?->toArray(), + /** @var string|null $rounding_type Rounding type for time entries */ + 'rounding_type' => $this->resource->properties->roundingType?->value, + /** @var int|null $rounding_minutes Rounding minutes for time entries */ + 'rounding_minutes' => $this->resource->properties->roundingMinutes, ], /** @var string $created_at Date when the report was created */ 'created_at' => $this->formatDateTime($this->resource->created_at), diff --git a/package-lock.json b/package-lock.json index 0bcec0c4..193c3a8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@tanstack/vue-table": "^8.21.2", "@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-typescript": "^14.3.0", - "@vueuse/core": "^12.5.0", + "@vueuse/core": "^12.8.2", "@vueuse/integrations": "^12.5.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/package.json b/package.json index 9cb9d4ca..159f4fda 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "@tanstack/vue-table": "^8.21.2", "@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-typescript": "^14.3.0", - "@vueuse/core": "^12.5.0", + "@vueuse/core": "^12.8.2", "@vueuse/integrations": "^12.5.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/resources/css/app.css b/resources/css/app.css index 8f40c4e6..ee9798f6 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -205,7 +205,7 @@ body { --destructive: 0 84.2% 60.2%; --destructive-foreground: var(--color-text-primary); --border: var(--color-border-primary); - --input: var(--theme-color-input-background); + --input: var(--color-border-tertiary); --ring: var(--theme-color-ring); --chart-1: var(--color-accent-400); --chart-2: var(--color-accent-500); @@ -232,7 +232,7 @@ body { --destructive: 0 62.8% 30.6%; --destructive-foreground: var(--color-text-primary); --border: var(--color-border-primary); - --input: var(--theme-color-input-background); + --input: var(--color-border-tertiary); --ring: var(--theme-color-ring); --chart-1: var(--color-accent-200); --chart-2: var(--color-accent-300); diff --git a/resources/js/Components/Common/Reporting/ReportingOverview.vue b/resources/js/Components/Common/Reporting/ReportingOverview.vue index e3bd9c0e..e8f67357 100644 --- a/resources/js/Components/Common/Reporting/ReportingOverview.vue +++ b/resources/js/Components/Common/Reporting/ReportingOverview.vue @@ -16,6 +16,7 @@ import { import { formatCents } from '@/packages/ui/src/utils/money'; import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue'; import ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.vue'; +import ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.vue'; import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue'; import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue'; import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue'; @@ -33,7 +34,7 @@ import ReportSaveButton from '@/Components/Common/Report/ReportSaveButton.vue'; import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue'; import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue'; -import { computed, type ComputedRef, inject, onMounted, ref } from 'vue'; +import { computed, type ComputedRef, inject, onMounted, ref, watch } from 'vue'; import { type GroupingOption, useReportingStore } from '@/utils/useReporting'; import { storeToRefs } from 'pinia'; import { @@ -54,6 +55,9 @@ import type { ExportFormat } from '@/types/reporting'; import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color'; import { useProjectsStore } from '@/utils/useProjects'; +// TimeEntryRoundingType is now defined in ReportingRoundingControls component +type TimeEntryRoundingType = 'up' | 'down' | 'nearest'; + const { handleApiRequestNotifications } = useNotificationsStore(); const startDate = useSessionStorage( @@ -71,6 +75,9 @@ const selectedTasks = ref([]); const selectedClients = ref([]); const billable = ref<'true' | 'false' | null>(null); +const roundingEnabled = ref(false); +const roundingType = ref('nearest'); +const roundingMinutes = ref(15); const group = useStorage('reporting-group', 'project'); const subGroup = useStorage('reporting-sub-group', 'task'); @@ -84,6 +91,11 @@ const { groupByOptions } = reportingStore; const organization = inject>('organization'); +// Watch rounding enabled state to trigger updates +watch(roundingEnabled, () => { + updateReporting(); +}); + function getFilterAttributes(): AggregatedTimeEntriesQueryParams { let params: AggregatedTimeEntriesQueryParams = { start: getLocalizedDayJs(startDate.value).startOf('day').utc().format(), @@ -111,6 +123,8 @@ function getFilterAttributes(): AggregatedTimeEntriesQueryParams { getCurrentRole() === 'employee' ? getCurrentMembershipId() : undefined, + rounding_type: roundingEnabled.value ? roundingType.value : undefined, + rounding_minutes: roundingEnabled.value ? roundingMinutes.value : undefined, }; return params; } @@ -395,6 +409,11 @@ const tableData = computed(() => { :icon="BillableIcon"> +
{
-

+

No time entries found

Try to change the filters and time range

diff --git a/resources/js/Components/Common/Reporting/ReportingRoundingControls.vue b/resources/js/Components/Common/Reporting/ReportingRoundingControls.vue new file mode 100644 index 00000000..ae800f5e --- /dev/null +++ b/resources/js/Components/Common/Reporting/ReportingRoundingControls.vue @@ -0,0 +1,203 @@ + + + \ No newline at end of file diff --git a/resources/js/Components/ui/button/index.ts b/resources/js/Components/ui/button/index.ts index d4f8e7d1..48070397 100644 --- a/resources/js/Components/ui/button/index.ts +++ b/resources/js/Components/ui/button/index.ts @@ -11,7 +11,7 @@ export const buttonVariants = cva( destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', outline: - 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', + 'border shadow-xs hover:text-accent-foreground border-input dark:border-input hover:bg-white/15', secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground', diff --git a/resources/js/Components/ui/switch/Switch.vue b/resources/js/Components/ui/switch/Switch.vue index beea7457..156a6a52 100644 --- a/resources/js/Components/ui/switch/Switch.vue +++ b/resources/js/Components/ui/switch/Switch.vue @@ -26,12 +26,12 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits) diff --git a/resources/js/Pages/ReportingDetailed.vue b/resources/js/Pages/ReportingDetailed.vue index 71049892..aea9cae8 100644 --- a/resources/js/Pages/ReportingDetailed.vue +++ b/resources/js/Pages/ReportingDetailed.vue @@ -16,6 +16,7 @@ import { } from '@heroicons/vue/20/solid'; import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue'; import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue'; +import ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.vue'; import { computed, onMounted, ref, watch } from 'vue'; import { getDayJsInstance, @@ -69,6 +70,9 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing'; import {canCreateProjects, canViewAllTimeEntries} from '@/utils/permissions'; import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue'; +// TimeEntryRoundingType is now defined in ReportingRoundingControls component +type TimeEntryRoundingType = 'up' | 'down' | 'nearest'; + const startDate = useSessionStorage( 'reporting-start-date', getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format() @@ -83,9 +87,17 @@ const selectedMembers = ref([]); const selectedTasks = ref([]); const selectedClients = ref([]); const billable = ref<'true' | 'false' | null>(null); +const roundingEnabled = ref(false); +const roundingType = ref('nearest'); +const roundingMinutes = ref(15); const { members } = storeToRefs(useMembersStore()); const pageLimit = 15; + +// Watch rounding enabled state to trigger updates +watch(roundingEnabled, () => { + updateFilteredTimeEntries(); +}); const currentPage = ref(1); function getFilterAttributes() { @@ -115,6 +127,8 @@ function getFilterAttributes() { : undefined, tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined, billable: billable.value !== null ? billable.value : undefined, + rounding_type: roundingEnabled.value ? roundingType.value : undefined, + rounding_minutes: roundingEnabled.value ? roundingMinutes.value : undefined, }; return params; } @@ -359,7 +373,13 @@ async function downloadExport(format: ExportFormat) {
-
+
+ + & { start: string; end: string }; +> & { + start: string; + end: string; + rounding_type?: string; + rounding_minutes?: number; +}; export type OrganizationResponse = ZodiosResponseByAlias< SolidTimeApi, diff --git a/resources/js/types/reporting.d.ts b/resources/js/types/reporting.ts similarity index 100% rename from resources/js/types/reporting.d.ts rename to resources/js/types/reporting.ts diff --git a/tests/Unit/Endpoint/Api/V1/ReportEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ReportEndpointTest.php index 0239e050..ff821777 100644 --- a/tests/Unit/Endpoint/Api/V1/ReportEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/ReportEndpointTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Tests\Unit\Endpoint\Api\V1; use App\Enums\TimeEntryAggregationType; +use App\Enums\TimeEntryRoundingType; use App\Enums\Weekday; use App\Http\Controllers\Api\V1\ReportController; use App\Models\Report; @@ -162,6 +163,61 @@ class ReportEndpointTest extends ApiEndpointTestAbstract ); } + public function test_store_endpoint_creates_new_report_with_rounding_properties(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'reports:create', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->withoutExceptionHandling()->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [ + 'name' => 'Test Report with Rounding', + 'description' => 'Test description', + 'is_public' => true, + 'public_until' => Carbon::now()->addDays(30)->toIso8601ZuluString(), + 'properties' => [ + 'start' => Carbon::now()->subDays(30)->toIso8601ZuluString(), + 'end' => Carbon::now()->toIso8601ZuluString(), + 'active' => true, + 'member_ids' => [], + 'billable' => true, + 'client_ids' => [], + 'project_ids' => [], + 'tag_ids' => [], + 'task_ids' => [], + 'group' => TimeEntryAggregationType::Project->value, + 'sub_group' => TimeEntryAggregationType::Task->value, + 'history_group' => TimeEntryAggregationType::Day->value, + 'week_start' => Weekday::Monday->value, + 'timezone' => 'Europe/Berlin', + 'rounding_type' => 'nearest', + 'rounding_minutes' => 15, + ], + ]); + + // Assert + $response->assertStatus(201); + /** @var Report $report */ + $report = Report::query()->findOrFail($response->json('data.id')); + $response->assertJson(fn (AssertableJson $json) => $json + ->has('data') + ->where('data.name', 'Test Report with Rounding') + ->where('data.description', 'Test description') + ->where('data.is_public', true) + ->where('data.shareable_link', $report->getShareableLink()) + ->where('data.properties.group', TimeEntryAggregationType::Project->value) + ->where('data.properties.sub_group', TimeEntryAggregationType::Task->value) + ->where('data.properties.rounding_type', 'nearest') + ->where('data.properties.rounding_minutes', 15) + ); + + // Also verify the properties are saved in the database + $this->assertSame(TimeEntryRoundingType::Nearest, $report->properties->roundingType); + $this->assertSame(15, $report->properties->roundingMinutes); + } + public function test_update_endpoint_fails_if_user_has_no_permission_to_update_report(): void { // Arrange