mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-16 04:02:15 +01:00
hide total billable amounts from employees when employees_can_see_billable_rates is disabled
This commit is contained in:
committed by
Constantin Graf
parent
73ce5f793d
commit
50cc7053e4
@@ -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);
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
* }>
|
* }>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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="
|
||||||
|
|||||||
@@ -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="
|
||||||
|
|||||||
@@ -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="
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 `null` 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 `null` 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 `null` 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 `null` 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',
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user