diff --git a/app/Http/Controllers/Api/V1/ReportController.php b/app/Http/Controllers/Api/V1/ReportController.php index c1e5ec166..218f39af9 100644 --- a/app/Http/Controllers/Api/V1/ReportController.php +++ b/app/Http/Controllers/Api/V1/ReportController.php @@ -111,6 +111,7 @@ public function store(Organization $organization, ReportStoreRequest $request, T $properties->timezone = $timezone; $properties->roundingType = $request->getPropertyRoundingType(); $properties->roundingMinutes = $request->getPropertyRoundingMinutes(); + $properties->showAmounts = $request->getPropertyShowAmounts(); $report->properties = $properties; if ($isPublic) { $report->share_secret = $reportService->generateSecret(); diff --git a/app/Http/Controllers/Api/V1/TimeEntryController.php b/app/Http/Controllers/Api/V1/TimeEntryController.php index 1778b41b6..18e9dc915 100644 --- a/app/Http/Controllers/Api/V1/TimeEntryController.php +++ b/app/Http/Controllers/Api/V1/TimeEntryController.php @@ -235,7 +235,7 @@ public function indexExport(Organization $organization, TimeEntryIndexExportRequ } $user = $this->user(); $timezone = $user->timezone; - $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; + $showBillableRate = ($this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates) && $request->getShowAmounts(); $roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null; $roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null; @@ -432,7 +432,7 @@ public function aggregateExport(Organization $organization, TimeEntryAggregateEx } $debug = $request->getDebug(); $user = $this->user(); - $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; + $showBillableRate = ($this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates) && $request->getShowAmounts(); $group = $request->getGroup(); $subGroup = $request->getSubGroup(); diff --git a/app/Http/Requests/V1/Report/ReportStoreRequest.php b/app/Http/Requests/V1/Report/ReportStoreRequest.php index 68747d5f4..8c2b379f8 100644 --- a/app/Http/Requests/V1/Report/ReportStoreRequest.php +++ b/app/Http/Requests/V1/Report/ReportStoreRequest.php @@ -177,6 +177,11 @@ function (string $attribute, mixed $value, \Closure $fail): void { 'numeric', 'integer', ], + // Whether to show amounts in the report + 'properties.show_amounts' => [ + 'nullable', + 'boolean', + ], ]; } @@ -281,4 +286,13 @@ public function getPropertyRoundingMinutes(): ?int return (int) $this->input('properties.rounding_minutes'); } + + public function getPropertyShowAmounts(): ?bool + { + if (! $this->has('properties.show_amounts') || $this->input('properties.show_amounts') === null) { + return null; + } + + return (bool) $this->input('properties.show_amounts'); + } } diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php index 519a2a107..a58e67119 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php @@ -203,9 +203,23 @@ function (string $attribute, mixed $value, \Closure $fail): void { 'numeric', 'integer', ], + 'show_amounts' => [ + 'nullable', + 'string', + 'in:true,false', + ], ]; } + public function getShowAmounts(): bool + { + if (! $this->has('show_amounts') || $this->input('show_amounts') === null) { + return true; + } + + return $this->input('show_amounts') === 'true'; + } + public function getDebug(): bool { return $this->input('debug') === 'true'; diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php index 30246f3c7..9dbeb8537 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php @@ -182,9 +182,23 @@ function (string $attribute, mixed $value, \Closure $fail): void { 'numeric', 'integer', ], + 'show_amounts' => [ + 'nullable', + 'string', + 'in:true,false', + ], ]; } + public function getShowAmounts(): bool + { + if (! $this->has('show_amounts') || $this->input('show_amounts') === null) { + return true; + } + + return $this->input('show_amounts') === 'true'; + } + public function getDebug(): bool { return $this->input('debug', 'false') === 'true'; diff --git a/app/Http/Resources/V1/Report/DetailedReportResource.php b/app/Http/Resources/V1/Report/DetailedReportResource.php index b8e6e9d40..248e47319 100644 --- a/app/Http/Resources/V1/Report/DetailedReportResource.php +++ b/app/Http/Resources/V1/Report/DetailedReportResource.php @@ -64,6 +64,8 @@ public function toArray(Request $request): array 'rounding_type' => $this->resource->properties->roundingType?->value, /** @var int|null $rounding_minutes Rounding minutes for time entries */ 'rounding_minutes' => $this->resource->properties->roundingMinutes, + /** @var bool|null $show_amounts Whether to show amounts in the report */ + 'show_amounts' => $this->resource->properties->showAmounts, ], /** @var string $created_at Date when the report was created */ 'created_at' => $this->formatDateTime($this->resource->created_at), diff --git a/app/Http/Resources/V1/Report/DetailedWithDataReportResource.php b/app/Http/Resources/V1/Report/DetailedWithDataReportResource.php index 3129f45f7..061ce7628 100644 --- a/app/Http/Resources/V1/Report/DetailedWithDataReportResource.php +++ b/app/Http/Resources/V1/Report/DetailedWithDataReportResource.php @@ -93,6 +93,8 @@ public function toArray(Request $request): array 'interval_format' => $this->resource->organization->interval_format->value, /** @var TimeFormat $time_format Time format */ 'time_format' => $this->resource->organization->time_format->value, + /** @var bool $show_amounts Whether to show amounts in the report */ + 'show_amounts' => $this->resource->properties->showAmounts ?? true, 'properties' => [ /** @var string $group Type of first grouping */ 'group' => $this->resource->properties->group->value, diff --git a/app/Service/Dto/ReportPropertiesDto.php b/app/Service/Dto/ReportPropertiesDto.php index 6a476342b..97a3bd3c6 100644 --- a/app/Service/Dto/ReportPropertiesDto.php +++ b/app/Service/Dto/ReportPropertiesDto.php @@ -68,6 +68,8 @@ class ReportPropertiesDto implements Castable public ?int $roundingMinutes = null; + public ?bool $showAmounts = null; + /** * Get the caster class to use when casting from / to this cast target. * @@ -129,6 +131,8 @@ public function get(Model $model, string $key, mixed $value, array $attributes): $dto->roundingType = isset($data->roundingType) ? TimeEntryRoundingType::from($data->roundingType) : null; // Note: roundingMinutes was added later so it is possible that the value is missing in persisted reports in the DB $dto->roundingMinutes = isset($data->roundingMinutes) ? (int) $data->roundingMinutes : null; + // Note: showAmounts was added later so it is possible that the value is missing in persisted reports in the DB + $dto->showAmounts = isset($data->showAmounts) ? (bool) $data->showAmounts : null; return $dto; } @@ -157,6 +161,7 @@ public function set(Model $model, string $key, mixed $value, array $attributes): 'timezone' => $value->timezone, 'roundingType' => $value->roundingType?->value, 'roundingMinutes' => $value->roundingMinutes, + 'showAmounts' => $value->showAmounts, ]; $jsonString = json_encode($data); diff --git a/e2e/reporting.spec.ts b/e2e/reporting.spec.ts index 85cf77b5d..e4b597cf3 100644 --- a/e2e/reporting.spec.ts +++ b/e2e/reporting.spec.ts @@ -11,6 +11,7 @@ import { createTimeEntryWithBillableStatusViaApi, createBareTimeEntryViaApi, createPublicProjectViaApi, + createBillableProjectViaApi, updateOrganizationSettingViaApi, } from './utils/api'; @@ -660,6 +661,76 @@ test('test that rounding can be enabled', async ({ page, ctx }) => { await expect(page.getByRole('button', { name: /Rounding on/ })).toBeVisible(); }); +// ────────────────────────────────────────────────── +// Show Amount Controls Tests +// ────────────────────────────────────────────────── + +test('test that toggling show amount off hides the Cost column', async ({ page, ctx }) => { + const projectName = 'ShowAmountProj ' + Math.floor(Math.random() * 10000); + + const project = await createBillableProjectViaApi(ctx, { + name: projectName, + billable_rate: 10000, // 100.00 per hour + }); + await createTimeEntryViaApi(ctx, { + description: `Entry for ${projectName}`, + duration: '1h', + projectId: project.id, + billable: true, + }); + + await goToReporting(page); + await expect(page.getByRole('button', { name: 'Export' })).toBeVisible(); + + // Cost column should be visible by default for admin users with billable projects + await expect(page.getByText('Cost', { exact: true })).toBeVisible(); + await expect(page.getByText(/Show amount: on/)).toBeVisible(); + + // Toggle show amount off + const reportUpdatePromise = waitForReportingUpdate(page); + await page.getByText(/Show amount: on/).click(); + await page.getByRole('option', { name: 'Off' }).click(); + await reportUpdatePromise; + + // Cost column should no longer be visible + await expect(page.getByText('Cost', { exact: true })).not.toBeVisible(); + await expect(page.getByText(/Show amount: off/)).toBeVisible(); +}); + +test('test that toggling show amount back on restores the Cost column', async ({ page, ctx }) => { + const projectName = 'ShowAmountToggle ' + Math.floor(Math.random() * 10000); + + const project = await createBillableProjectViaApi(ctx, { + name: projectName, + billable_rate: 10000, + }); + await createTimeEntryViaApi(ctx, { + description: `Entry for ${projectName}`, + duration: '1h', + projectId: project.id, + billable: true, + }); + + await goToReporting(page); + await expect(page.getByRole('button', { name: 'Export' })).toBeVisible(); + + // Toggle off + let reportUpdatePromise = waitForReportingUpdate(page); + await page.getByText(/Show amount: on/).click(); + await page.getByRole('option', { name: 'Off' }).click(); + await reportUpdatePromise; + await expect(page.getByText('Cost', { exact: true })).not.toBeVisible(); + + // Toggle back on + reportUpdatePromise = waitForReportingUpdate(page); + await page.getByText(/Show amount: off/).click(); + await page.getByRole('option', { name: 'On' }).click(); + await reportUpdatePromise; + + // Cost column should be visible again + await expect(page.getByText('Cost', { exact: true })).toBeVisible(); +}); + // ────────────────────────────────────────────────── // Export Tests // ────────────────────────────────────────────────── diff --git a/e2e/shared-reports.spec.ts b/e2e/shared-reports.spec.ts index ff262b17f..d9b1f8ad7 100644 --- a/e2e/shared-reports.spec.ts +++ b/e2e/shared-reports.spec.ts @@ -916,3 +916,47 @@ test('test that shared report displays cost column correctly aligned with data r const costValues = table.getByText(/100/); await expect(costValues).toHaveCount(2); }); + +test('test that shared report hides Cost column when saved with show amounts off', async ({ + page, + ctx, +}) => { + const projectName = 'HideAmountProj ' + Math.floor(Math.random() * 10000); + const reportName = 'HideAmountReport ' + Math.floor(Math.random() * 10000); + + const project = await createBillableProjectViaApi(ctx, { + name: projectName, + billable_rate: 10000, // 100.00 per hour + }); + await createTimeEntryViaApi(ctx, { + description: `Entry for ${projectName}`, + duration: '1h', + projectId: project.id, + billable: true, + }); + + await goToReporting(page); + await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible(); + + // Toggle show amounts off before saving the report + const reportUpdatePromise = waitForReportingUpdate(page); + await page.getByText(/Show amount: on/).click(); + await page.getByRole('option', { name: 'Off' }).click(); + await reportUpdatePromise; + + // Cost column should be hidden in the overview before saving + await expect(page.getByText('Cost', { exact: true })).not.toBeVisible(); + + // Save the report with show_amounts = false + const { shareableLink } = await saveAsSharedReport(page, reportName); + + // Navigate to the shared report + await page.goto(shareableLink); + await expect(page.getByText('Reporting')).toBeVisible(); + await expect(page.getByText(projectName)).toBeVisible(); + + // Cost column should NOT be visible since show_amounts was off when the report was saved + await expect(page.getByText('Cost', { exact: true })).not.toBeVisible(); + // Duration column should still be visible + await expect(page.getByText('Duration', { exact: true })).toBeVisible(); +}); diff --git a/resources/js/Components/Common/Reporting/ReportingFilterBar.vue b/resources/js/Components/Common/Reporting/ReportingFilterBar.vue index 3b2b8fe9f..3b6f42288 100644 --- a/resources/js/Components/Common/Reporting/ReportingFilterBar.vue +++ b/resources/js/Components/Common/Reporting/ReportingFilterBar.vue @@ -1,5 +1,5 @@