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 @@
+
@@ -189,15 +194,13 @@ onMounted(async () => {
-
-
+
Group by
@@ -205,31 +208,27 @@ onMounted(async () => {
and
{{ getGroupLabel(subGroup) }}
-
+
Name
-
Duration
-
Cost
+
Duration
+
Cost
-
-
-
+
+
+
Total
-
+
{{
formatReportingDuration(
aggregatedTableTimeEntries.seconds,
@@ -238,24 +237,22 @@ onMounted(async () => {
)
}}
-
+
{{
aggregatedTableTimeEntries.cost
? formatCents(
- aggregatedTableTimeEntries.cost,
- reportCurrency,
- reportCurrencyFormat,
- reportCurrencySymbol,
- reportNumberFormat
- )
+ aggregatedTableTimeEntries.cost,
+ reportCurrency,
+ reportCurrencyFormat,
+ reportCurrencySymbol,
+ reportNumberFormat
+ )
: '--'
}}
-
+
No time entries found
diff --git a/resources/js/packages/api/src/index.ts b/resources/js/packages/api/src/index.ts
index ce846c920..6600629fb 100644
--- a/resources/js/packages/api/src/index.ts
+++ b/resources/js/packages/api/src/index.ts
@@ -86,6 +86,7 @@ export type AggregatedTimeEntriesQueryParams = ZodiosQueryParamsByAlias<
end: string;
rounding_type?: string;
rounding_minutes?: number;
+ show_amounts?: boolean;
};
export type OrganizationResponse = ZodiosResponseByAlias
;
diff --git a/resources/js/packages/api/src/openapi.json.client.ts b/resources/js/packages/api/src/openapi.json.client.ts
index 823b7eb6f..4038f9118 100644
--- a/resources/js/packages/api/src/openapi.json.client.ts
+++ b/resources/js/packages/api/src/openapi.json.client.ts
@@ -457,6 +457,7 @@ const ReportStoreRequest = z
timezone: z.union([z.string(), z.null()]).optional(),
rounding_type: TimeEntryRoundingType.optional(),
rounding_minutes: z.union([z.number(), z.null()]).optional(),
+ show_amounts: z.union([z.boolean(), z.null()]).optional(),
})
.passthrough(),
})
@@ -486,6 +487,7 @@ const DetailedReportResource = z
task_ids: z.union([z.array(z.string()), z.null()]),
rounding_type: z.union([z.string(), z.null()]),
rounding_minutes: z.union([z.number(), z.null()]),
+ show_amounts: z.union([z.boolean(), z.null()]),
})
.passthrough(),
created_at: z.string(),
@@ -513,6 +515,7 @@ const DetailedWithDataReportResource = z
date_format: DateFormat,
interval_format: IntervalFormat,
time_format: TimeFormat,
+ show_amounts: z.boolean(),
properties: z
.object({
group: z.string(),
@@ -4381,6 +4384,11 @@ If the group parameters are all set to `null` or are all missing, the
type: 'Query',
schema: z.array(z.string()).min(1).optional(),
},
+ {
+ name: 'show_amounts',
+ type: 'Query',
+ schema: z.boolean().optional(),
+ },
],
response: z.union([
z.object({ download_url: z.string() }).passthrough(),
@@ -4514,6 +4522,11 @@ If the group parameters are all set to `null` or are all missing, the
type: 'Query',
schema: z.array(z.string()).min(1).optional(),
},
+ {
+ name: 'show_amounts',
+ type: 'Query',
+ schema: z.boolean().optional(),
+ },
],
response: z.union([
z.object({ download_url: z.string() }).passthrough(),
diff --git a/resources/views/reports/time-entry-aggregate/spreadsheet.blade.php b/resources/views/reports/time-entry-aggregate/spreadsheet.blade.php
index 18e152ca3..7427a6748 100644
--- a/resources/views/reports/time-entry-aggregate/spreadsheet.blade.php
+++ b/resources/views/reports/time-entry-aggregate/spreadsheet.blade.php
@@ -20,9 +20,11 @@
Duration (decimal)
|
+ @if($showBillableRate)
Amount ({{ Str::upper($currency) }})
|
+ @endif
@@ -148,6 +150,7 @@
=0
@endif
+ @if($showBillableRate)
@if($counter > 1)
@@ -156,6 +159,7 @@
=0
@endif
|
+ @endif
@endif
diff --git a/tests/Unit/Endpoint/Api/V1/Public/PublicReportEndpointTest.php b/tests/Unit/Endpoint/Api/V1/Public/PublicReportEndpointTest.php
index 610c80da5..f27979a1b 100644
--- a/tests/Unit/Endpoint/Api/V1/Public/PublicReportEndpointTest.php
+++ b/tests/Unit/Endpoint/Api/V1/Public/PublicReportEndpointTest.php
@@ -122,6 +122,7 @@ public function test_show_returns_detailed_information_about_the_report(): void
'name' => $report->name,
'description' => $report->description,
'public_until' => $report->public_until?->toIso8601ZuluString(),
+ 'show_amounts' => true,
'currency' => $organization->currency,
'number_format' => $organization->number_format,
'interval_format' => $organization->interval_format,
@@ -669,6 +670,90 @@ public function test_show_returns_only_entries_without_tags_when_none_tag_filter
]);
}
+ public function test_show_returns_show_amounts_false_when_report_has_it_set(): void
+ {
+ // Arrange
+ $organization = Organization::factory()->create();
+ $reportDto = new ReportPropertiesDto;
+ $reportDto->start = now()->subDays(2);
+ $reportDto->end = now();
+ $reportDto->group = TimeEntryAggregationType::Project;
+ $reportDto->subGroup = TimeEntryAggregationType::Task;
+ $reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day;
+ $reportDto->weekStart = Weekday::Monday;
+ $reportDto->timezone = 'Europe/Vienna';
+ $reportDto->showAmounts = false;
+ $report = Report::factory()->forOrganization($organization)->public()->create([
+ 'public_until' => null,
+ 'properties' => $reportDto,
+ ]);
+
+ // Act
+ $response = $this->getJson(route('api.v1.public.reports.show'), [
+ 'X-Api-Key' => $report->share_secret,
+ ]);
+
+ // Assert
+ $response->assertOk();
+ $response->assertJsonPath('show_amounts', false);
+ }
+
+ public function test_show_returns_show_amounts_true_when_report_has_it_set(): void
+ {
+ // Arrange
+ $organization = Organization::factory()->create();
+ $reportDto = new ReportPropertiesDto;
+ $reportDto->start = now()->subDays(2);
+ $reportDto->end = now();
+ $reportDto->group = TimeEntryAggregationType::Project;
+ $reportDto->subGroup = TimeEntryAggregationType::Task;
+ $reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day;
+ $reportDto->weekStart = Weekday::Monday;
+ $reportDto->timezone = 'Europe/Vienna';
+ $reportDto->showAmounts = true;
+ $report = Report::factory()->forOrganization($organization)->public()->create([
+ 'public_until' => null,
+ 'properties' => $reportDto,
+ ]);
+
+ // Act
+ $response = $this->getJson(route('api.v1.public.reports.show'), [
+ 'X-Api-Key' => $report->share_secret,
+ ]);
+
+ // Assert
+ $response->assertOk();
+ $response->assertJsonPath('show_amounts', true);
+ }
+
+ public function test_show_returns_show_amounts_true_when_report_has_no_preference_set(): void
+ {
+ // Arrange
+ $organization = Organization::factory()->create();
+ $reportDto = new ReportPropertiesDto;
+ $reportDto->start = now()->subDays(2);
+ $reportDto->end = now();
+ $reportDto->group = TimeEntryAggregationType::Project;
+ $reportDto->subGroup = TimeEntryAggregationType::Task;
+ $reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day;
+ $reportDto->weekStart = Weekday::Monday;
+ $reportDto->timezone = 'Europe/Vienna';
+ // showAmounts intentionally left null (not set)
+ $report = Report::factory()->forOrganization($organization)->public()->create([
+ 'public_until' => null,
+ 'properties' => $reportDto,
+ ]);
+
+ // Act
+ $response = $this->getJson(route('api.v1.public.reports.show'), [
+ 'X-Api-Key' => $report->share_secret,
+ ]);
+
+ // Assert: defaults to true when not set
+ $response->assertOk();
+ $response->assertJsonPath('show_amounts', true);
+ }
+
public function test_show_applies_not_contains_tag_match_type(): void
{
// Arrange
diff --git a/tests/Unit/Endpoint/Api/V1/ReportEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ReportEndpointTest.php
index 9b298f98d..823fc926a 100644
--- a/tests/Unit/Endpoint/Api/V1/ReportEndpointTest.php
+++ b/tests/Unit/Endpoint/Api/V1/ReportEndpointTest.php
@@ -746,4 +746,93 @@ public function test_store_endpoint_rejects_invalid_tag_match_type(): void
$response->assertStatus(422);
$response->assertInvalid(['properties.tag_match_type']);
}
+
+ public function test_store_endpoint_returns_null_for_show_amounts_when_not_set(): void
+ {
+ // Arrange — simulates a report created before show_amounts existed (backward compatibility)
+ $data = $this->createUserWithPermission([
+ 'reports:create',
+ ]);
+ Passport::actingAs($data->user);
+
+ // Act
+ $response = $this->withoutExceptionHandling()->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [
+ 'name' => 'Report without show amounts',
+ 'is_public' => false,
+ 'properties' => [
+ 'start' => Carbon::now()->subDays(30)->toIso8601ZuluString(),
+ 'end' => Carbon::now()->toIso8601ZuluString(),
+ 'group' => TimeEntryAggregationType::Project->value,
+ 'sub_group' => TimeEntryAggregationType::Task->value,
+ 'history_group' => TimeEntryAggregationType::Day->value,
+ ],
+ ]);
+
+ // Assert
+ $response->assertStatus(201);
+ /** @var Report $report */
+ $report = Report::query()->findOrFail($response->json('data.id'));
+ $this->assertNull($report->properties->showAmounts);
+ $response->assertJsonPath('data.properties.show_amounts', null);
+ }
+
+ public function test_store_endpoint_persists_show_amounts_property(): 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' => 'Report with show amounts',
+ 'is_public' => false,
+ 'properties' => [
+ 'start' => Carbon::now()->subDays(30)->toIso8601ZuluString(),
+ 'end' => Carbon::now()->toIso8601ZuluString(),
+ 'group' => TimeEntryAggregationType::Project->value,
+ 'sub_group' => TimeEntryAggregationType::Task->value,
+ 'history_group' => TimeEntryAggregationType::Day->value,
+ 'show_amounts' => false,
+ ],
+ ]);
+
+ // Assert
+ $response->assertStatus(201);
+ /** @var Report $report */
+ $report = Report::query()->findOrFail($response->json('data.id'));
+ $this->assertFalse($report->properties->showAmounts);
+ $response->assertJsonPath('data.properties.show_amounts', false);
+ }
+
+ public function test_store_endpoint_persists_show_amounts_true(): 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' => 'Report with show amounts true',
+ 'is_public' => false,
+ 'properties' => [
+ 'start' => Carbon::now()->subDays(30)->toIso8601ZuluString(),
+ 'end' => Carbon::now()->toIso8601ZuluString(),
+ 'group' => TimeEntryAggregationType::Project->value,
+ 'sub_group' => TimeEntryAggregationType::Task->value,
+ 'history_group' => TimeEntryAggregationType::Day->value,
+ 'show_amounts' => true,
+ ],
+ ]);
+
+ // Assert
+ $response->assertStatus(201);
+ /** @var Report $report */
+ $report = Report::query()->findOrFail($response->json('data.id'));
+ $this->assertTrue($report->properties->showAmounts);
+ $response->assertJsonPath('data.properties.show_amounts', true);
+ }
}
diff --git a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php
index 0661f7ab8..28b38d5f2 100644
--- a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php
+++ b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php
@@ -1560,6 +1560,102 @@ public function test_aggregate_export_endpoints_can_create_a_pdf_report_as_emplo
$this->assertResponseCode($response, 200);
}
+ public function test_index_export_endpoint_hides_amounts_when_show_amounts_is_false(): void
+ {
+ // Arrange
+ $data = $this->createUserWithPermission([
+ 'time-entries:view:all',
+ ]);
+ $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
+ Passport::actingAs($data->user);
+
+ // Act
+ $response = $this->getJson(route('api.v1.time-entries.index-export', [
+ $data->organization->getKey(),
+ 'format' => ExportFormat::CSV,
+ 'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
+ 'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
+ 'show_amounts' => 'false',
+ ]));
+
+ // Assert
+ $this->assertResponseCode($response, 200);
+ }
+
+ public function test_index_export_endpoint_rejects_invalid_show_amounts_value(): void
+ {
+ // Arrange
+ $data = $this->createUserWithPermission([
+ 'time-entries:view:all',
+ ]);
+ Passport::actingAs($data->user);
+
+ // Act
+ $response = $this->getJson(route('api.v1.time-entries.index-export', [
+ $data->organization->getKey(),
+ 'format' => ExportFormat::CSV,
+ 'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
+ 'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
+ 'show_amounts' => 'invalid',
+ ]));
+
+ // Assert
+ $response->assertStatus(422);
+ $response->assertInvalid(['show_amounts']);
+ }
+
+ public function test_aggregate_export_endpoint_hides_amounts_when_show_amounts_is_false(): void
+ {
+ // Arrange
+ $data = $this->createUserWithPermission([
+ 'time-entries:view:all',
+ ]);
+ $client = Client::factory()->forOrganization($data->organization)->create();
+ $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
+ TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
+ Passport::actingAs($data->user);
+
+ // Act
+ $response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
+ $data->organization->getKey(),
+ 'format' => ExportFormat::CSV,
+ 'group' => TimeEntryAggregationType::Client,
+ 'sub_group' => TimeEntryAggregationType::Project,
+ 'history_group' => TimeEntryAggregationTypeInterval::Month,
+ 'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
+ 'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
+ 'show_amounts' => 'false',
+ ]));
+
+ // Assert
+ $this->assertResponseCode($response, 200);
+ }
+
+ public function test_aggregate_export_endpoint_rejects_invalid_show_amounts_value(): void
+ {
+ // Arrange
+ $data = $this->createUserWithPermission([
+ 'time-entries:view:all',
+ ]);
+ Passport::actingAs($data->user);
+
+ // Act
+ $response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
+ $data->organization->getKey(),
+ 'format' => ExportFormat::CSV,
+ 'group' => TimeEntryAggregationType::Client,
+ 'sub_group' => TimeEntryAggregationType::Project,
+ 'history_group' => TimeEntryAggregationTypeInterval::Month,
+ 'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
+ 'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
+ 'show_amounts' => 'invalid',
+ ]));
+
+ // Assert
+ $response->assertStatus(422);
+ $response->assertInvalid(['show_amounts']);
+ }
+
public function test_index_export_endpoint_with_client_ids_filter_returns_filtered_entries(): void
{
// Arrange