hide total billable amounts from employees when employees_can_see_billable_rates is disabled

This commit is contained in:
Gregor Vostrak
2025-03-09 14:12:42 +01:00
committed by Constantin Graf
parent 73ce5f793d
commit 50cc7053e4
17 changed files with 176 additions and 125 deletions

View File

@@ -73,6 +73,7 @@ class ReportController extends Controller
false, false,
$report->properties->start, $report->properties->start,
$report->properties->end, $report->properties->end,
true
); );
$historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions( $historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
$timeEntriesQuery->clone(), $timeEntriesQuery->clone(),
@@ -83,6 +84,7 @@ class ReportController extends Controller
true, true,
$report->properties->start, $report->properties->start,
$report->properties->end, $report->properties->end,
true
); );
return new DetailedWithDataReportResource($report, $data, $historyData); return new DetailedWithDataReportResource($report, $data, $historyData);

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Enums\ExportFormat; use App\Enums\ExportFormat;
use App\Enums\Role;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException; use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException; use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException; use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
@@ -180,6 +181,7 @@ class TimeEntryController extends Controller
} }
$user = $this->user(); $user = $this->user();
$timezone = $user->timezone; $timezone = $user->timezone;
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member); $timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
$timeEntriesQuery->with([ $timeEntriesQuery->with([
@@ -211,7 +213,8 @@ class TimeEntryController extends Controller
$user->week_start, $user->week_start,
false, false,
null, null,
null null,
$showBillableRate
); );
$html = Blade::render($viewFile, [ $html = Blade::render($viewFile, [
'timeEntries' => $timeEntriesQuery->get(), 'timeEntries' => $timeEntriesQuery->get(),
@@ -285,18 +288,18 @@ class TimeEntryController extends Controller
* grouped_data: null|array<array{ * grouped_data: null|array<array{
* key: string|null, * key: string|null,
* seconds: int, * seconds: int,
* cost: int, * cost: int|null,
* grouped_type: string|null, * grouped_type: string|null,
* grouped_data: null|array<array{ * grouped_data: null|array<array{
* key: string|null, * key: string|null,
* seconds: int, * seconds: int,
* cost: int, * cost: int|null,
* grouped_type: null, * grouped_type: null,
* grouped_data: null * grouped_data: null
* }> * }>
* }>, * }>,
* seconds: int, * seconds: int,
* cost: int * cost: int|null
* } * }
* } * }
* *
@@ -312,6 +315,7 @@ class TimeEntryController extends Controller
$this->checkPermission($organization, 'time-entries:view:all'); $this->checkPermission($organization, 'time-entries:view:all');
} }
$user = $this->user(); $user = $this->user();
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$group1Type = $request->getGroup(); $group1Type = $request->getGroup();
$group2Type = $request->getSubGroup(); $group2Type = $request->getSubGroup();
@@ -325,7 +329,8 @@ class TimeEntryController extends Controller
$user->week_start, $user->week_start,
$request->getFillGapsInTimeGroups(), $request->getFillGapsInTimeGroups(),
$request->getStart(), $request->getStart(),
$request->getEnd() $request->getEnd(),
$showBillableRate
); );
return [ return [
@@ -359,6 +364,7 @@ class TimeEntryController extends Controller
} }
$debug = $request->getDebug(); $debug = $request->getDebug();
$user = $this->user(); $user = $this->user();
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$group = $request->getGroup(); $group = $request->getGroup();
$subGroup = $request->getSubGroup(); $subGroup = $request->getSubGroup();
@@ -372,7 +378,8 @@ class TimeEntryController extends Controller
$user->week_start, $user->week_start,
false, false,
$request->getStart(), $request->getStart(),
$request->getEnd() $request->getEnd(),
$showBillableRate
); );
$dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries( $dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesAggregateQuery->clone(), $timeEntriesAggregateQuery->clone(),
@@ -382,7 +389,8 @@ class TimeEntryController extends Controller
$user->week_start, $user->week_start,
true, true,
$request->getStart(), $request->getStart(),
$request->getEnd() $request->getEnd(),
$showBillableRate
); );
$currency = $organization->currency; $currency = $organization->currency;
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user()); $timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Web; namespace App\Http\Controllers\Web;
use App\Enums\Role;
use App\Service\DashboardService; use App\Service\DashboardService;
use App\Service\PermissionStore; use App\Service\PermissionStore;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
@@ -33,6 +34,8 @@ class DashboardController extends Controller
$latestTeamActivity = $dashboardService->latestTeamActivity($organization); $latestTeamActivity = $dashboardService->latestTeamActivity($organization);
} }
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
return Inertia::render('Dashboard', [ return Inertia::render('Dashboard', [
'weeklyProjectOverview' => $weeklyProjectOverview, 'weeklyProjectOverview' => $weeklyProjectOverview,
'latestTasks' => $latestTasks, 'latestTasks' => $latestTasks,
@@ -41,7 +44,7 @@ class DashboardController extends Controller
'dailyTrackedHours' => $dailyTrackedHours, 'dailyTrackedHours' => $dailyTrackedHours,
'totalWeeklyTime' => $totalWeeklyTime, 'totalWeeklyTime' => $totalWeeklyTime,
'totalWeeklyBillableTime' => $totalWeeklyBillableTime, 'totalWeeklyBillableTime' => $totalWeeklyBillableTime,
'totalWeeklyBillableAmount' => $totalWeeklyBillableAmount, 'totalWeeklyBillableAmount' => $showBillableRate ? $totalWeeklyBillableAmount : null,
'weeklyHistory' => $weeklyHistory, 'weeklyHistory' => $weeklyHistory,
]); ]);
} }

View File

@@ -18,20 +18,20 @@ use Illuminate\Http\Request;
* description: string|null, * description: string|null,
* color: string|null, * color: string|null,
* seconds: int, * seconds: int,
* cost: int, * cost: int|null,
* grouped_type: string|null, * grouped_type: string|null,
* grouped_data: null|array<array{ * grouped_data: null|array<array{
* key: string|null, * key: string|null,
* description: string|null, * description: string|null,
* color: string|null, * color: string|null,
* seconds: int, * seconds: int,
* cost: int, * cost: int|null,
* grouped_type: null, * grouped_type: null,
* grouped_data: null * grouped_data: null
* }> * }>
* }>, * }>,
* seconds: int, * seconds: int,
* cost: int * cost: int|null
* } * }
*/ */
class DetailedWithDataReportResource extends BaseResource class DetailedWithDataReportResource extends BaseResource

View File

@@ -22,18 +22,18 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
* grouped_data: null|array<array{ * grouped_data: null|array<array{
* key: string|null, * key: string|null,
* seconds: int, * seconds: int,
* cost: int, * cost: int|null,
* grouped_type: string|null, * grouped_type: string|null,
* grouped_data: null|array<array{ * grouped_data: null|array<array{
* key: string|null, * key: string|null,
* seconds: int, * seconds: int,
* cost: int, * cost: int|null,
* grouped_type: null, * grouped_type: null,
* grouped_data: null * grouped_data: null
* }> * }>
* }>, * }>,
* seconds: int, * seconds: int,
* cost: int * cost: int|null
* } * }
*/ */
private array $data; private array $data;
@@ -52,18 +52,18 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
* grouped_data: null|array<array{ * grouped_data: null|array<array{
* key: string|null, * key: string|null,
* seconds: int, * seconds: int,
* cost: int, * cost: int|null,
* grouped_type: string|null, * grouped_type: string|null,
* grouped_data: null|array<array{ * grouped_data: null|array<array{
* key: string|null, * key: string|null,
* seconds: int, * seconds: int,
* cost: int, * cost: int|null,
* grouped_type: null, * grouped_type: null,
* grouped_data: null * grouped_data: null
* }> * }>
* }>, * }>,
* seconds: int, * seconds: int,
* cost: int * cost: int|null
* } $data * } $data
*/ */
public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup) public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup)

View File

@@ -27,21 +27,21 @@ class TimeEntryAggregationService
* grouped_data: null|array<array{ * grouped_data: null|array<array{
* key: string|null, * key: string|null,
* seconds: int, * seconds: int,
* cost: int, * cost: int|null,
* grouped_type: string|null, * grouped_type: string|null,
* grouped_data: null|array<array{ * grouped_data: null|array<array{
* key: string|null, * key: string|null,
* seconds: int, * seconds: int,
* cost: int, * cost: int|null,
* grouped_type: null, * grouped_type: null,
* grouped_data: null * grouped_data: null
* }> * }>
* }>, * }>,
* seconds: int, * seconds: int,
* cost: int * cost: int|null
* } * }
*/ */
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end): array public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array
{ {
$fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null; $fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null;
$group1Select = null; $group1Select = null;
@@ -96,7 +96,7 @@ class TimeEntryAggregationService
$group2Response[] = [ $group2Response[] = [
'key' => $group2 === '' ? null : (string) $group2, 'key' => $group2 === '' ? null : (string) $group2,
'seconds' => (int) $aggregate->get(0)->aggregate, 'seconds' => (int) $aggregate->get(0)->aggregate,
'cost' => (int) $aggregate->get(0)->cost, 'cost' => $showBillableRate ? (int) $aggregate->get(0)->cost : null,
'grouped_type' => null, 'grouped_type' => null,
'grouped_data' => null, 'grouped_data' => null,
]; ];
@@ -113,7 +113,7 @@ class TimeEntryAggregationService
$group1Response[] = [ $group1Response[] = [
'key' => $group1 === '' ? null : (string) $group1, 'key' => $group1 === '' ? null : (string) $group1,
'seconds' => $group2ResponseSum, 'seconds' => $group2ResponseSum,
'cost' => $group2ResponseCost, 'cost' => $showBillableRate ? $group2ResponseCost : null,
'grouped_type' => $group2Type?->value, 'grouped_type' => $group2Type?->value,
'grouped_data' => $group2Response, 'grouped_data' => $group2Response,
]; ];
@@ -133,7 +133,7 @@ class TimeEntryAggregationService
return [ return [
'seconds' => $group1ResponseSum, 'seconds' => $group1ResponseSum,
'cost' => $group1ResponseCost, 'cost' => $showBillableRate ? $group1ResponseCost : null,
'grouped_type' => $group1Type?->value, 'grouped_type' => $group1Type?->value,
'grouped_data' => $group1Response, 'grouped_data' => $group1Response,
]; ];
@@ -148,25 +148,25 @@ class TimeEntryAggregationService
* description: string|null, * description: string|null,
* color: string|null, * color: string|null,
* seconds: int, * seconds: int,
* cost: int, * cost: int|null,
* grouped_type: string|null, * grouped_type: string|null,
* grouped_data: null|array<array{ * grouped_data: null|array<array{
* key: string|null, * key: string|null,
* description: string|null, * description: string|null,
* color: string|null, * color: string|null,
* seconds: int, * seconds: int,
* cost: int, * cost: int|null,
* grouped_type: null, * grouped_type: null,
* grouped_data: null * grouped_data: null
* }> * }>
* }>, * }>,
* seconds: int, * seconds: int,
* cost: int * cost: int|null
* } * }
*/ */
public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end): array public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array
{ {
$aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end); $aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end, $showBillableRate);
$keysGroup1 = []; $keysGroup1 = [];
$keysGroup2 = []; $keysGroup2 = [];
@@ -289,12 +289,12 @@ class TimeEntryAggregationService
* @param array<array{ * @param array<array{
* key: string|null, * key: string|null,
* seconds: int, * seconds: int,
* cost: int, * cost: int|null,
* grouped_type: string|null, * grouped_type: string|null,
* grouped_data: null|array<array{ * grouped_data: null|array<array{
* key: string|null, * key: string|null,
* seconds: int, * seconds: int,
* cost: int, * cost: int|null,
* grouped_type: null|mixed, * grouped_type: null|mixed,
* grouped_data: null|mixed * grouped_data: null|mixed
* }> * }>
@@ -302,12 +302,12 @@ class TimeEntryAggregationService
* @return array<array{ * @return array<array{
* key: string|null, * key: string|null,
* seconds: int, * seconds: int,
* cost: int, * cost: int|null,
* grouped_type: string|null, * grouped_type: string|null,
* grouped_data: null|array<array{ * grouped_data: null|array<array{
* key: string|null, * key: string|null,
* seconds: int, * seconds: int,
* cost: int, * cost: int|null,
* grouped_type: null|mixed, * grouped_type: null|mixed,
* grouped_data: null|mixed * grouped_data: null|mixed
* }> * }>

View File

@@ -12,7 +12,7 @@ type AggregatedGroupedData = GroupedData & {
type GroupedData = { type GroupedData = {
seconds: number; seconds: number;
cost: number; cost: number | null;
description: string | null | undefined; description: string | null | undefined;
}; };
@@ -48,7 +48,7 @@ const expanded = ref(false);
{{ formatHumanReadableDuration(entry.seconds) }} {{ formatHumanReadableDuration(entry.seconds) }}
</div> </div>
<div class="justify-end pr-6 flex items-center"> <div class="justify-end pr-6 flex items-center">
{{ formatCents(entry.cost, getOrganizationCurrencyString()) }} {{entry.cost ? formatCents(entry.cost, getOrganizationCurrencyString()) : '--' }}
</div> </div>
</div> </div>
<div <div

View File

@@ -2,9 +2,13 @@
import { router } from '@inertiajs/vue3'; import { router } from '@inertiajs/vue3';
import TabBar from '@/Components/Common/TabBar/TabBar.vue'; import TabBar from '@/Components/Common/TabBar/TabBar.vue';
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue'; import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
import {canViewReport} from "@/utils/permissions";
import {computed} from "vue";
defineProps<{ defineProps<{
active: 'reporting' | 'detailed' | 'shared'; active: 'reporting' | 'detailed' | 'shared';
}>(); }>();
const showSharedReports = computed(() => canViewReport());
</script> </script>
<template> <template>
@@ -20,6 +24,7 @@ defineProps<{
>Detailed</TabBarItem >Detailed</TabBarItem
> >
<TabBarItem <TabBarItem
v-if="showSharedReports"
:active="active === 'shared'" :active="active === 'shared'"
@click="router.visit(route('reporting.shared'))" @click="router.visit(route('reporting.shared'))"
>Shared</TabBarItem >Shared</TabBarItem

View File

@@ -43,7 +43,7 @@ const props = defineProps<{
totalWeeklyBillableAmount: { totalWeeklyBillableAmount: {
value: number; value: number;
currency: string; currency: string;
}; } | null;
weeklyHistory: { weeklyHistory: {
date: string; date: string;
duration: number; duration: number;
@@ -199,10 +199,11 @@ const option = ref({
<StatCard <StatCard
title="Billable Amount" title="Billable Amount"
:value=" :value="
props.totalWeeklyBillableAmount ?
formatCents( formatCents(
props.totalWeeklyBillableAmount.value, props.totalWeeklyBillableAmount.value,
getOrganizationCurrencyString() getOrganizationCurrencyString()
) ) : '--'
" /> " />
<ProjectsChartCard <ProjectsChartCard
:weekly-project-overview=" :weekly-project-overview="

View File

@@ -14,7 +14,7 @@ const props = defineProps<{
icon?: Component; icon?: Component;
current?: boolean; current?: boolean;
href: string; href: string;
subItems?: { title: string; route: string }[]; subItems?: { title: string; route: string, show: boolean }[];
}>(); }>();
const open = useSessionStorage('nav-collapse-state-' + props.title, true); const open = useSessionStorage('nav-collapse-state-' + props.title, true);
@@ -66,6 +66,7 @@ const open = useSessionStorage('nav-collapse-state-' + props.title, true);
:key="subItem.title" :key="subItem.title"
class="w-full relative"> class="w-full relative">
<NavigationSidebarLink <NavigationSidebarLink
v-if="subItem.show"
:title="subItem.title" :title="subItem.title"
:current="route().current(subItem.route)" :current="route().current(subItem.route)"
:href=" :href="

View File

@@ -27,7 +27,7 @@ import {
canUpdateOrganization, canUpdateOrganization,
canViewClients, canViewClients,
canViewMembers, canViewMembers,
canViewProjects, canViewProjects, canViewReport,
canViewTags, canViewTags,
} from '@/utils/permissions'; } from '@/utils/permissions';
import { isBillingActivated } from '@/utils/billing'; import { isBillingActivated } from '@/utils/billing';
@@ -118,14 +118,17 @@ const page = usePage<{
{ {
title: 'Overview', title: 'Overview',
route: 'reporting', route: 'reporting',
show: true
}, },
{ {
title: 'Detailed', title: 'Detailed',
route: 'reporting.detailed', route: 'reporting.detailed',
show: true
}, },
{ {
title: 'Shared', title: 'Shared',
route: 'reporting.shared', route: 'reporting.shared',
show: canViewReport()
}, },
]" ]"
:current=" :current="

View File

@@ -464,10 +464,11 @@ const tableData = computed(() => {
<div <div
class="justify-end pr-6 flex items-center font-medium"> class="justify-end pr-6 flex items-center font-medium">
{{ {{
aggregatedTableTimeEntries.cost ?
formatCents( formatCents(
aggregatedTableTimeEntries.cost, aggregatedTableTimeEntries.cost,
getOrganizationCurrencyString() getOrganizationCurrencyString()
) ) : '--'
}} }}
</div> </div>
</div> </div>

View File

@@ -58,7 +58,7 @@ import {
PaginationRoot, PaginationRoot,
} from 'radix-vue'; } from 'radix-vue';
import { useQuery, useQueryClient } from '@tanstack/vue-query'; import { useQuery, useQueryClient } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
import { useTimeEntriesStore } from '@/utils/useTimeEntries'; import { useTimeEntriesStore } from '@/utils/useTimeEntries';
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue'; import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
import ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.vue'; import ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.vue';
@@ -66,7 +66,7 @@ import type { ExportFormat } from '@/types/reporting';
import { useNotificationsStore } from '@/utils/notification'; import { useNotificationsStore } from '@/utils/notification';
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue'; import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
import { isAllowedToPerformPremiumAction } from '@/utils/billing'; import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { canCreateProjects } from '@/utils/permissions'; import {canCreateProjects, canViewAllTimeEntries} from '@/utils/permissions';
import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue'; import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue';
const startDate = useSessionStorage<string>( const startDate = useSessionStorage<string>(
@@ -98,6 +98,7 @@ function getFilterAttributes() {
}; };
const params = { const params = {
...defaultParams, ...defaultParams,
member_id: !canViewAllTimeEntries() ? getCurrentMembershipId() : undefined,
member_ids: member_ids:
selectedMembers.value.length > 0 selectedMembers.value.length > 0
? selectedMembers.value ? selectedMembers.value

View File

@@ -94,28 +94,6 @@ const OrganizationUpdateRequest = z
employees_can_see_billable_rates: z.boolean().optional(), employees_can_see_billable_rates: z.boolean().optional(),
}) })
.passthrough(); .passthrough();
const VersionRequest = z
.object({
version: z.string().max(255),
build: z.string().max(255),
url: z.string().max(255),
})
.passthrough();
const TelemetryRequest = z
.object({
version: z.string().max(255),
build: z.string().max(255),
url: z.string().max(255).url(),
user_count: z.number().int(),
organization_count: z.number().int(),
audit_count: z.number().int(),
project_count: z.number().int(),
project_member_count: z.number().int(),
client_count: z.number().int(),
task_count: z.number().int(),
time_entry_count: z.number().int(),
})
.passthrough();
const ProjectResource = z const ProjectResource = z
.object({ .object({
id: z.string(), id: z.string(),
@@ -525,8 +503,6 @@ export const schemas = {
MemberMergeIntoRequest, MemberMergeIntoRequest,
OrganizationResource, OrganizationResource,
OrganizationUpdateRequest, OrganizationUpdateRequest,
VersionRequest,
TelemetryRequest,
ProjectResource, ProjectResource,
ProjectStoreRequest, ProjectStoreRequest,
ProjectUpdateRequest, ProjectUpdateRequest,
@@ -3149,7 +3125,7 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
.object({ .object({
key: z.union([z.string(), z.null()]), key: z.union([z.string(), z.null()]),
seconds: z.number().int(), seconds: z.number().int(),
cost: z.number().int(), cost: z.union([z.number(), z.null()]),
grouped_type: z.union([ grouped_type: z.union([
z.string(), z.string(),
z.null(), z.null(),
@@ -3165,7 +3141,10 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
seconds: z seconds: z
.number() .number()
.int(), .int(),
cost: z.number().int(), cost: z.union([
z.number(),
z.null(),
]),
grouped_type: z.null(), grouped_type: z.null(),
grouped_data: z.null(), grouped_data: z.null(),
}) })
@@ -3179,7 +3158,7 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
z.null(), z.null(),
]), ]),
seconds: z.number().int(), seconds: z.number().int(),
cost: z.number().int(), cost: z.union([z.number(), z.null()]),
}) })
.passthrough(), .passthrough(),
}) })
@@ -3498,58 +3477,6 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
}, },
], ],
}, },
{
method: 'post',
path: '/v1/ping/telemetry',
alias: 'v1.ping.telemetry',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: TelemetryRequest,
},
],
response: z.object({ success: z.boolean() }).passthrough(),
errors: [
{
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
},
{
method: 'post',
path: '/v1/ping/version',
alias: 'v1.ping.version',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: VersionRequest,
},
],
response: z.object({ version: z.string() }).passthrough(),
errors: [
{
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
},
{ {
method: 'get', method: 'get',
path: '/v1/public/reports', path: '/v1/public/reports',

View File

@@ -105,9 +105,16 @@ export function canManageBilling() {
return currentUserHasPermission('billing'); return currentUserHasPermission('billing');
} }
export function canViewReport() {
return currentUserHasPermission('reports:view');
}
export function canUpdateReport() { export function canUpdateReport() {
return currentUserHasPermission('reports:update'); return currentUserHasPermission('reports:update');
} }
export function canDeleteReport() { export function canDeleteReport() {
return currentUserHasPermission('reports:delete'); return currentUserHasPermission('reports:delete');
} }
export function canViewAllTimeEntries() {
return currentUserHasPermission('time-entries:view:all');
}

View File

@@ -80,7 +80,7 @@ class DashboardEndpointTest extends EndpointTestAbstract
->whereNot('dailyTrackedHours', null) ->whereNot('dailyTrackedHours', null)
->whereNot('totalWeeklyTime', null) ->whereNot('totalWeeklyTime', null)
->whereNot('totalWeeklyBillableTime', null) ->whereNot('totalWeeklyBillableTime', null)
->whereNot('totalWeeklyBillableAmount', null) ->where('totalWeeklyBillableAmount', null)
->whereNot('weeklyHistory', null) ->whereNot('weeklyHistory', null)
); );
} }

View File

@@ -41,7 +41,8 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
Weekday::Monday, Weekday::Monday,
false, false,
null, null,
null null,
true
); );
// Assert // Assert
@@ -88,6 +89,7 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
false, false,
Carbon::now()->subDays(2)->utc(), Carbon::now()->subDays(2)->utc(),
Carbon::now()->subDay()->utc(), Carbon::now()->subDay()->utc(),
true
); );
// Assert // Assert
@@ -137,6 +139,91 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
], $result); ], $result);
} }
public function test_aggregate_time_entries_without_billable_amounts(): void
{
// Arrange
$project1 = Project::factory()->create([
// Note: To ensure deterministic order
'id' => '5de4e6df-9560-4675-95be-18d42c441bfc',
]);
$project2 = Project::factory()->create([
// Note: To ensure deterministic order
'id' => '130bdf66-d370-4564-aec7-7171e9b415f7',
]);
TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project1)->create([
'description' => 'Test',
]);
TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project2)->create([
'description' => '',
]);
TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project1)->create([
'description' => 'Test',
]);
TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project2)->create([
'description' => 'Test',
]);
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntries(
$query,
TimeEntryAggregationType::Project,
TimeEntryAggregationType::Description,
'Europe/Vienna',
Weekday::Monday,
false,
Carbon::now()->subDays(2)->utc(),
Carbon::now()->subDay()->utc(),
false
);
// Assert
$this->assertSame([
'seconds' => 40,
'cost' => null,
'grouped_type' => 'project',
'grouped_data' => [
[
'key' => $project2->getKey(),
'seconds' => 20,
'cost' => null,
'grouped_type' => 'description',
'grouped_data' => [
[
'key' => null,
'seconds' => 10,
'cost' => null,
'grouped_type' => null,
'grouped_data' => null,
],
[
'key' => 'Test',
'seconds' => 10,
'cost' => null,
'grouped_type' => null,
'grouped_data' => null,
],
],
],
[
'key' => $project1->getKey(),
'seconds' => 20,
'cost' => null,
'grouped_type' => 'description',
'grouped_data' => [
[
'key' => 'Test',
'seconds' => 20,
'cost' => null,
'grouped_type' => null,
'grouped_data' => null,
],
],
],
],
], $result);
}
public function test_aggregate_time_entries_empty_state_by_day_and_project_with_filled_gaps(): void public function test_aggregate_time_entries_empty_state_by_day_and_project_with_filled_gaps(): void
{ {
// Arrange // Arrange
@@ -153,6 +240,7 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
true, true,
Carbon::now()->subDays(2)->utc(), Carbon::now()->subDays(2)->utc(),
Carbon::now()->subDay()->utc(), Carbon::now()->subDay()->utc(),
true
); );
// Assert // Assert
@@ -194,6 +282,7 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
true, true,
Carbon::now()->subDays(2), Carbon::now()->subDays(2),
Carbon::now()->subDay(), Carbon::now()->subDay(),
true
); );
// Assert // Assert
@@ -220,6 +309,7 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
true, true,
Carbon::now()->subDays(2), Carbon::now()->subDays(2),
Carbon::now()->subDay(), Carbon::now()->subDay(),
true
); );
// Assert // Assert
@@ -254,7 +344,8 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
Weekday::Monday, Weekday::Monday,
false, false,
null, null,
null null,
true
); );
// Assert // Assert
@@ -342,7 +433,8 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
Weekday::Monday, Weekday::Monday,
true, true,
null, null,
null null,
true
); );
// Assert // Assert