Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/Http/Controllers/Api/V1/ReportController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions app/Http/Controllers/Api/V1/TimeEntryController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
Expand Down
14 changes: 14 additions & 0 deletions app/Http/Requests/V1/Report/ReportStoreRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
];
}

Expand Down Expand Up @@ -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');
}
}
14 changes: 14 additions & 0 deletions app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
14 changes: 14 additions & 0 deletions app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 2 additions & 0 deletions app/Http/Resources/V1/Report/DetailedReportResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions app/Service/Dto/ReportPropertiesDto.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down
71 changes: 71 additions & 0 deletions e2e/reporting.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
createTimeEntryWithBillableStatusViaApi,
createBareTimeEntryViaApi,
createPublicProjectViaApi,
createBillableProjectViaApi,
updateOrganizationSettingViaApi,
} from './utils/api';

Expand Down Expand Up @@ -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
// ──────────────────────────────────────────────────
Expand Down
44 changes: 44 additions & 0 deletions e2e/shared-reports.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
28 changes: 27 additions & 1 deletion resources/js/Components/Common/Reporting/ReportingFilterBar.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { CheckCircleIcon, TagIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
import { CheckCircleIcon, TagIcon, UserGroupIcon, EyeIcon, EyeSlashIcon } from '@heroicons/vue/20/solid';
import { FolderIcon } from '@heroicons/vue/16/solid';
import { Check } from '@lucide/vue';
import { RadioGroupIndicator, RadioGroupItem, RadioGroupRoot, type AcceptableValue } from 'reka-ui';
Expand Down Expand Up @@ -30,6 +30,7 @@ const billable = defineModel<'true' | 'false' | null>('billable', { required: tr
const roundingEnabled = defineModel<boolean>('roundingEnabled', { required: true });
const roundingType = defineModel<TimeEntryRoundingType>('roundingType', { required: true });
const roundingMinutes = defineModel<number>('roundingMinutes', { required: true });
const showAmounts = defineModel<boolean>('showAmounts', { required: true });
const startDate = defineModel<string>('startDate', { required: true });
const endDate = defineModel<string>('endDate', { required: true });

Expand Down Expand Up @@ -167,6 +168,31 @@ async function createTag(name: string) {
v-model:type="roundingType"
v-model:minutes="roundingMinutes"
@change="emit('submit')" />
<Select
:model-value="showAmounts ? 'true' : 'false'"
@update:model-value="(v) => (showAmounts = v === 'true')">
<SelectTrigger
size="sm"
variant="outline"
:active="!showAmounts"
:show-chevron="false">
<SelectValue class="flex items-center gap-2">
<EyeSlashIcon
v-if="!showAmounts"
class="h-4 dark:text-accent-300/80 text-accent-400/80" />
<EyeIcon
v-else
class="h-4 text-text-quaternary" />
<span class="text-text-secondary">
Show amount: {{ showAmounts ? 'on' : 'off' }}
</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="true">On</SelectItem>
<SelectItem value="false">Off</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<DateRangePicker
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ const billable = ref<'true' | 'false' | null>(null);
const roundingEnabled = ref<boolean>(false);
const roundingType = ref<TimeEntryRoundingType>('nearest');
const roundingMinutes = ref<number>(15);
const showAmounts = useStorage<boolean>('reporting-show-amounts', true);

const group = useStorage<GroupingOption>('reporting-group', 'project');
const subGroup = useStorage<GroupingOption>('reporting-sub-group', 'task');
Expand All @@ -83,12 +84,14 @@ const { groupByOptions, getNameForReportingRowEntry, emptyPlaceholder } = report

const organization = inject<ComputedRef<Organization>>('organization');

const showBillableRate = computed(() => {
const canSeeBillableRate = computed(() => {
return !!(
getCurrentRole() !== 'employee' || organization?.value?.employees_can_see_billable_rates
);
});

const showBillableRate = computed(() => canSeeBillableRate.value && showAmounts.value);

// Ensure sub-group falls back when it collides with group
watch(
group,
Expand Down Expand Up @@ -129,6 +132,7 @@ const filterParams = computed<AggregatedTimeEntriesQueryParams>(() => {
member_id: getCurrentRole() === 'employee' ? getCurrentMembershipId() : undefined,
rounding_type: roundingEnabled.value ? roundingType.value : undefined,
rounding_minutes: roundingEnabled.value ? roundingMinutes.value : undefined,
show_amounts: showAmounts.value,
};
});

Expand Down Expand Up @@ -374,6 +378,8 @@ const tableData = computed(() => {
v-model:rounding-enabled="roundingEnabled"
v-model:rounding-type="roundingType"
v-model:rounding-minutes="roundingMinutes"
v-model:show-amounts="showAmounts"
:can-toggle-amounts="canSeeBillableRate"
v-model:start-date="startDate"
v-model:end-date="endDate" />
<MainContainer>
Expand Down
3 changes: 3 additions & 0 deletions resources/js/Pages/ReportingDetailed.vue
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ const billable = ref<'true' | 'false' | null>(null);
const roundingEnabled = ref<boolean>(false);
const roundingType = ref<TimeEntryRoundingType>('nearest');
const roundingMinutes = ref<number>(15);
const showAmounts = ref<boolean>(true);

const { members } = useMembersQuery();
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
Expand Down Expand Up @@ -121,6 +122,7 @@ function getFilterAttributes() {
billable: billable.value !== null ? billable.value : undefined,
rounding_type: roundingEnabled.value ? roundingType.value : undefined,
rounding_minutes: roundingEnabled.value ? roundingMinutes.value : undefined,
show_amounts: showAmounts.value,
};
return params;
}
Expand Down Expand Up @@ -345,6 +347,7 @@ async function downloadExport(format: ExportFormat) {
v-model:rounding-enabled="roundingEnabled"
v-model:rounding-type="roundingType"
v-model:rounding-minutes="roundingMinutes"
v-model:show-amounts="showAmounts"
v-model:start-date="startDate"
v-model:end-date="endDate"
@submit="updateFilteredTimeEntries" />
Expand Down
Loading