mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 05:22:44 +01:00
Compare commits
3 Commits
8a1a187f7f
...
feature/hi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee09afc4e8 | ||
|
|
ab095f4a39 | ||
|
|
09384d3517 |
@@ -115,6 +115,8 @@ class MemberController extends Controller
|
||||
* Make a member a placeholder member
|
||||
*
|
||||
* @throws AuthorizationException|CanNotRemoveOwnerFromOrganization|ChangingRoleOfPlaceholderIsNotAllowed
|
||||
*
|
||||
* @operationId makePlaceholder
|
||||
*/
|
||||
public function makePlaceholder(Organization $organization, Member $member, MemberService $memberService): JsonResponse
|
||||
{
|
||||
|
||||
@@ -73,6 +73,7 @@ class ReportController extends Controller
|
||||
false,
|
||||
$report->properties->start,
|
||||
$report->properties->end,
|
||||
true
|
||||
);
|
||||
$historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
|
||||
$timeEntriesQuery->clone(),
|
||||
@@ -83,6 +84,7 @@ class ReportController extends Controller
|
||||
true,
|
||||
$report->properties->start,
|
||||
$report->properties->end,
|
||||
true
|
||||
);
|
||||
|
||||
return new DetailedWithDataReportResource($report, $data, $historyData);
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\ExportFormat;
|
||||
use App\Enums\Role;
|
||||
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
||||
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
||||
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
||||
@@ -180,6 +181,7 @@ class TimeEntryController extends Controller
|
||||
}
|
||||
$user = $this->user();
|
||||
$timezone = $user->timezone;
|
||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||
|
||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
|
||||
$timeEntriesQuery->with([
|
||||
@@ -211,7 +213,8 @@ class TimeEntryController extends Controller
|
||||
$user->week_start,
|
||||
false,
|
||||
null,
|
||||
null
|
||||
null,
|
||||
$showBillableRate
|
||||
);
|
||||
$html = Blade::render($viewFile, [
|
||||
'timeEntries' => $timeEntriesQuery->get(),
|
||||
@@ -285,18 +288,18 @@ class TimeEntryController extends Controller
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* cost: int|null,
|
||||
* grouped_type: string|null,
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* cost: int|null,
|
||||
* grouped_type: null,
|
||||
* grouped_data: null
|
||||
* }>
|
||||
* }>,
|
||||
* seconds: int,
|
||||
* cost: int
|
||||
* cost: int|null
|
||||
* }
|
||||
* }
|
||||
*
|
||||
@@ -312,6 +315,7 @@ class TimeEntryController extends Controller
|
||||
$this->checkPermission($organization, 'time-entries:view:all');
|
||||
}
|
||||
$user = $this->user();
|
||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||
|
||||
$group1Type = $request->getGroup();
|
||||
$group2Type = $request->getSubGroup();
|
||||
@@ -325,7 +329,8 @@ class TimeEntryController extends Controller
|
||||
$user->week_start,
|
||||
$request->getFillGapsInTimeGroups(),
|
||||
$request->getStart(),
|
||||
$request->getEnd()
|
||||
$request->getEnd(),
|
||||
$showBillableRate
|
||||
);
|
||||
|
||||
return [
|
||||
@@ -359,6 +364,7 @@ class TimeEntryController extends Controller
|
||||
}
|
||||
$debug = $request->getDebug();
|
||||
$user = $this->user();
|
||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||
|
||||
$group = $request->getGroup();
|
||||
$subGroup = $request->getSubGroup();
|
||||
@@ -372,7 +378,8 @@ class TimeEntryController extends Controller
|
||||
$user->week_start,
|
||||
false,
|
||||
$request->getStart(),
|
||||
$request->getEnd()
|
||||
$request->getEnd(),
|
||||
$showBillableRate
|
||||
);
|
||||
$dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||
$timeEntriesAggregateQuery->clone(),
|
||||
@@ -382,7 +389,8 @@ class TimeEntryController extends Controller
|
||||
$user->week_start,
|
||||
true,
|
||||
$request->getStart(),
|
||||
$request->getEnd()
|
||||
$request->getEnd(),
|
||||
$showBillableRate
|
||||
);
|
||||
$currency = $organization->currency;
|
||||
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Service\DashboardService;
|
||||
use App\Service\PermissionStore;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
@@ -33,6 +34,8 @@ class DashboardController extends Controller
|
||||
$latestTeamActivity = $dashboardService->latestTeamActivity($organization);
|
||||
}
|
||||
|
||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||
|
||||
return Inertia::render('Dashboard', [
|
||||
'weeklyProjectOverview' => $weeklyProjectOverview,
|
||||
'latestTasks' => $latestTasks,
|
||||
@@ -41,7 +44,7 @@ class DashboardController extends Controller
|
||||
'dailyTrackedHours' => $dailyTrackedHours,
|
||||
'totalWeeklyTime' => $totalWeeklyTime,
|
||||
'totalWeeklyBillableTime' => $totalWeeklyBillableTime,
|
||||
'totalWeeklyBillableAmount' => $totalWeeklyBillableAmount,
|
||||
'totalWeeklyBillableAmount' => $showBillableRate ? $totalWeeklyBillableAmount : null,
|
||||
'weeklyHistory' => $weeklyHistory,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -18,20 +18,20 @@ use Illuminate\Http\Request;
|
||||
* description: string|null,
|
||||
* color: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* cost: int|null,
|
||||
* grouped_type: string|null,
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* description: string|null,
|
||||
* color: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* cost: int|null,
|
||||
* grouped_type: null,
|
||||
* grouped_data: null
|
||||
* }>
|
||||
* }>,
|
||||
* seconds: int,
|
||||
* cost: int
|
||||
* cost: int|null
|
||||
* }
|
||||
*/
|
||||
class DetailedWithDataReportResource extends BaseResource
|
||||
|
||||
@@ -22,18 +22,18 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* cost: int|null,
|
||||
* grouped_type: string|null,
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* cost: int|null,
|
||||
* grouped_type: null,
|
||||
* grouped_data: null
|
||||
* }>
|
||||
* }>,
|
||||
* seconds: int,
|
||||
* cost: int
|
||||
* cost: int|null
|
||||
* }
|
||||
*/
|
||||
private array $data;
|
||||
@@ -52,18 +52,18 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* cost: int|null,
|
||||
* grouped_type: string|null,
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* cost: int|null,
|
||||
* grouped_type: null,
|
||||
* grouped_data: null
|
||||
* }>
|
||||
* }>,
|
||||
* seconds: int,
|
||||
* cost: int
|
||||
* cost: int|null
|
||||
* } $data
|
||||
*/
|
||||
public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup)
|
||||
|
||||
@@ -27,21 +27,21 @@ class TimeEntryAggregationService
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* cost: int|null,
|
||||
* grouped_type: string|null,
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* cost: int|null,
|
||||
* grouped_type: null,
|
||||
* grouped_data: null
|
||||
* }>
|
||||
* }>,
|
||||
* 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;
|
||||
$group1Select = null;
|
||||
@@ -96,7 +96,7 @@ class TimeEntryAggregationService
|
||||
$group2Response[] = [
|
||||
'key' => $group2 === '' ? null : (string) $group2,
|
||||
'seconds' => (int) $aggregate->get(0)->aggregate,
|
||||
'cost' => (int) $aggregate->get(0)->cost,
|
||||
'cost' => $showBillableRate ? (int) $aggregate->get(0)->cost : null,
|
||||
'grouped_type' => null,
|
||||
'grouped_data' => null,
|
||||
];
|
||||
@@ -113,7 +113,7 @@ class TimeEntryAggregationService
|
||||
$group1Response[] = [
|
||||
'key' => $group1 === '' ? null : (string) $group1,
|
||||
'seconds' => $group2ResponseSum,
|
||||
'cost' => $group2ResponseCost,
|
||||
'cost' => $showBillableRate ? $group2ResponseCost : null,
|
||||
'grouped_type' => $group2Type?->value,
|
||||
'grouped_data' => $group2Response,
|
||||
];
|
||||
@@ -133,7 +133,7 @@ class TimeEntryAggregationService
|
||||
|
||||
return [
|
||||
'seconds' => $group1ResponseSum,
|
||||
'cost' => $group1ResponseCost,
|
||||
'cost' => $showBillableRate ? $group1ResponseCost : null,
|
||||
'grouped_type' => $group1Type?->value,
|
||||
'grouped_data' => $group1Response,
|
||||
];
|
||||
@@ -148,25 +148,25 @@ class TimeEntryAggregationService
|
||||
* description: string|null,
|
||||
* color: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* cost: int|null,
|
||||
* grouped_type: string|null,
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* description: string|null,
|
||||
* color: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* cost: int|null,
|
||||
* grouped_type: null,
|
||||
* grouped_data: null
|
||||
* }>
|
||||
* }>,
|
||||
* 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 = [];
|
||||
$keysGroup2 = [];
|
||||
@@ -289,12 +289,12 @@ class TimeEntryAggregationService
|
||||
* @param array<array{
|
||||
* key: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* cost: int|null,
|
||||
* grouped_type: string|null,
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* cost: int|null,
|
||||
* grouped_type: null|mixed,
|
||||
* grouped_data: null|mixed
|
||||
* }>
|
||||
@@ -302,12 +302,12 @@ class TimeEntryAggregationService
|
||||
* @return array<array{
|
||||
* key: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* cost: int|null,
|
||||
* grouped_type: string|null,
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* cost: int|null,
|
||||
* grouped_type: null|mixed,
|
||||
* grouped_data: null|mixed
|
||||
* }>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
||||
import {ref} from 'vue';
|
||||
import {api, type Member} from '@/packages/api/src';
|
||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import {useMutation} from '@tanstack/vue-query';
|
||||
import {getCurrentOrganizationId} from "@/utils/useUser";
|
||||
import {useNotificationsStore} from "@/utils/notification";
|
||||
import {useMembersStore} from "@/utils/useMembers";
|
||||
|
||||
const {handleApiRequestNotifications} = useNotificationsStore();
|
||||
|
||||
const show = defineModel('show', {default: false});
|
||||
const saving = ref(false);
|
||||
|
||||
const props = defineProps<{
|
||||
member: Member;
|
||||
}>();
|
||||
|
||||
const turnToPlaceholderMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
if (organizationId === null) {
|
||||
throw new Error('No current organization id - create report');
|
||||
}
|
||||
return await api.makePlaceholder(undefined, {
|
||||
params: {
|
||||
organization: organizationId,
|
||||
member: props.member.id
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
saving.value = true;
|
||||
await handleApiRequestNotifications(
|
||||
() =>
|
||||
turnToPlaceholderMutation.mutateAsync(),
|
||||
'Deactivating the member was successful!',
|
||||
'There was an error deactivating the user.',
|
||||
() => {
|
||||
show.value = false;
|
||||
useMembersStore().fetchMembers()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal closeable :show="show" @close="show = false">
|
||||
<template #title>
|
||||
<div class="flex space-x-2">
|
||||
<span> Deactivate User </span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<p>
|
||||
Deactivating the user <strong>{{ member.name }} </strong> will remove the user's access to
|
||||
the organization. You will not be billed for inactive users and all time entries will be preserved.
|
||||
</p>
|
||||
</template>
|
||||
<template #footer>
|
||||
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
|
||||
|
||||
<PrimaryButton
|
||||
class="ms-3"
|
||||
:class="{ 'opacity-25': saving }"
|
||||
:disabled="saving"
|
||||
@click="submit()">
|
||||
Deactivate
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,13 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { TrashIcon, PencilSquareIcon, ArrowDownOnSquareStackIcon } from '@heroicons/vue/20/solid';
|
||||
import { TrashIcon, UserCircleIcon, PencilSquareIcon, ArrowDownOnSquareStackIcon } from '@heroicons/vue/20/solid';
|
||||
import type { Member } from '@/packages/api/src';
|
||||
import {canDeleteMembers, canMergeMembers, canUpdateMembers} from '@/utils/permissions';
|
||||
import {canDeleteMembers, canMakeMembersPlaceholders, canMergeMembers, canUpdateMembers} from '@/utils/permissions';
|
||||
import MoreOptionsDropdown from '@/packages/ui/src/MoreOptionsDropdown.vue';
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [];
|
||||
edit: [];
|
||||
merge: [];
|
||||
makePlaceholder: [];
|
||||
}>();
|
||||
const props = defineProps<{
|
||||
member: Member;
|
||||
@@ -47,6 +48,14 @@ const props = defineProps<{
|
||||
<ArrowDownOnSquareStackIcon class="w-5 text-icon-active"></ArrowDownOnSquareStackIcon>
|
||||
<span>Merge</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="props.member.role !== 'placeholder' && canMakeMembersPlaceholders()"
|
||||
:aria-label="'Make Member ' + props.member.name + ' a placeholder'"
|
||||
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
|
||||
@click="emit('makePlaceholder')">
|
||||
<UserCircleIcon class="w-5 text-icon-active"></UserCircleIcon>
|
||||
<span>Deactivate</span>
|
||||
</button>
|
||||
</div>
|
||||
</MoreOptionsDropdown>
|
||||
</template>
|
||||
|
||||
@@ -15,6 +15,7 @@ import MemberEditModal from '@/Components/Common/Member/MemberEditModal.vue';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import { formatCents } from '@/packages/ui/src/utils/money';
|
||||
import MemberMergeModal from "@/Components/Common/Member/MemberMergeModal.vue";
|
||||
import MemberMakePlaceholderModal from "@/Components/Common/Member/MemberMakePlaceholderModal.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
member: Member;
|
||||
@@ -22,6 +23,7 @@ const props = defineProps<{
|
||||
|
||||
const showEditMemberModal = ref(false);
|
||||
const showMergeMemberModal = ref(false);
|
||||
const showMakeMemberPlaceholderModal = ref(false);
|
||||
|
||||
function removeMember() {
|
||||
useMembersStore().removeMember(props.member.id);
|
||||
@@ -106,12 +108,14 @@ const userHasValidMailAddress = computed(() => {
|
||||
@edit="showEditMemberModal = true"
|
||||
@delete="removeMember"
|
||||
@merge="showMergeMemberModal = true"
|
||||
@make-placeholder="showMakeMemberPlaceholderModal = true"
|
||||
></MemberMoreOptionsDropdown>
|
||||
</div>
|
||||
<MemberEditModal
|
||||
v-model:show="showEditMemberModal"
|
||||
:member="member"></MemberEditModal>
|
||||
<MemberMergeModal v-model:show="showMergeMemberModal" :member="member"></MemberMergeModal>
|
||||
<MemberMakePlaceholderModal v-model:show="showMakeMemberPlaceholderModal" :member="member"></MemberMakePlaceholderModal>
|
||||
</TableRow>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ type AggregatedGroupedData = GroupedData & {
|
||||
|
||||
type GroupedData = {
|
||||
seconds: number;
|
||||
cost: number;
|
||||
cost: number | null;
|
||||
description: string | null | undefined;
|
||||
};
|
||||
|
||||
@@ -48,7 +48,7 @@ const expanded = ref(false);
|
||||
{{ formatHumanReadableDuration(entry.seconds) }}
|
||||
</div>
|
||||
<div class="justify-end pr-6 flex items-center">
|
||||
{{ formatCents(entry.cost, getOrganizationCurrencyString()) }}
|
||||
{{entry.cost ? formatCents(entry.cost, getOrganizationCurrencyString()) : '--' }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
|
||||
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
|
||||
import {canViewReport} from "@/utils/permissions";
|
||||
import {computed} from "vue";
|
||||
defineProps<{
|
||||
active: 'reporting' | 'detailed' | 'shared';
|
||||
}>();
|
||||
|
||||
const showSharedReports = computed(() => canViewReport());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -20,6 +24,7 @@ defineProps<{
|
||||
>Detailed</TabBarItem
|
||||
>
|
||||
<TabBarItem
|
||||
v-if="showSharedReports"
|
||||
:active="active === 'shared'"
|
||||
@click="router.visit(route('reporting.shared'))"
|
||||
>Shared</TabBarItem
|
||||
|
||||
@@ -43,7 +43,7 @@ const props = defineProps<{
|
||||
totalWeeklyBillableAmount: {
|
||||
value: number;
|
||||
currency: string;
|
||||
};
|
||||
} | null;
|
||||
weeklyHistory: {
|
||||
date: string;
|
||||
duration: number;
|
||||
@@ -199,10 +199,11 @@ const option = ref({
|
||||
<StatCard
|
||||
title="Billable Amount"
|
||||
:value="
|
||||
props.totalWeeklyBillableAmount ?
|
||||
formatCents(
|
||||
props.totalWeeklyBillableAmount.value,
|
||||
getOrganizationCurrencyString()
|
||||
)
|
||||
) : '--'
|
||||
" />
|
||||
<ProjectsChartCard
|
||||
:weekly-project-overview="
|
||||
|
||||
@@ -14,7 +14,7 @@ const props = defineProps<{
|
||||
icon?: Component;
|
||||
current?: boolean;
|
||||
href: string;
|
||||
subItems?: { title: string; route: string }[];
|
||||
subItems?: { title: string; route: string, show: boolean }[];
|
||||
}>();
|
||||
|
||||
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"
|
||||
class="w-full relative">
|
||||
<NavigationSidebarLink
|
||||
v-if="subItem.show"
|
||||
:title="subItem.title"
|
||||
:current="route().current(subItem.route)"
|
||||
:href="
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
canUpdateOrganization,
|
||||
canViewClients,
|
||||
canViewMembers,
|
||||
canViewProjects,
|
||||
canViewProjects, canViewReport,
|
||||
canViewTags,
|
||||
} from '@/utils/permissions';
|
||||
import { isBillingActivated } from '@/utils/billing';
|
||||
@@ -118,14 +118,17 @@ const page = usePage<{
|
||||
{
|
||||
title: 'Overview',
|
||||
route: 'reporting',
|
||||
show: true
|
||||
},
|
||||
{
|
||||
title: 'Detailed',
|
||||
route: 'reporting.detailed',
|
||||
show: true
|
||||
},
|
||||
{
|
||||
title: 'Shared',
|
||||
route: 'reporting.shared',
|
||||
show: canViewReport()
|
||||
},
|
||||
]"
|
||||
:current="
|
||||
|
||||
@@ -53,6 +53,7 @@ onMounted(() => {
|
||||
if (canViewProjectMembers()) {
|
||||
useProjectMembersStore().fetchProjectMembers(projectId);
|
||||
}
|
||||
useTasksStore().fetchTasks();
|
||||
});
|
||||
|
||||
const showEditProjectModal = ref(false);
|
||||
|
||||
@@ -464,10 +464,11 @@ const tableData = computed(() => {
|
||||
<div
|
||||
class="justify-end pr-6 flex items-center font-medium">
|
||||
{{
|
||||
aggregatedTableTimeEntries.cost ?
|
||||
formatCents(
|
||||
aggregatedTableTimeEntries.cost,
|
||||
getOrganizationCurrencyString()
|
||||
)
|
||||
) : '--'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -58,7 +58,7 @@ import {
|
||||
PaginationRoot,
|
||||
} from 'radix-vue';
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
|
||||
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
|
||||
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.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 TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
|
||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
import { canCreateProjects } from '@/utils/permissions';
|
||||
import {canCreateProjects, canViewAllTimeEntries} from '@/utils/permissions';
|
||||
import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue';
|
||||
|
||||
const startDate = useSessionStorage<string>(
|
||||
@@ -98,6 +98,7 @@ function getFilterAttributes() {
|
||||
};
|
||||
const params = {
|
||||
...defaultParams,
|
||||
member_id: !canViewAllTimeEntries() ? getCurrentMembershipId() : undefined,
|
||||
member_ids:
|
||||
selectedMembers.value.length > 0
|
||||
? selectedMembers.value
|
||||
|
||||
@@ -94,28 +94,6 @@ const OrganizationUpdateRequest = z
|
||||
employees_can_see_billable_rates: z.boolean().optional(),
|
||||
})
|
||||
.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
|
||||
.object({
|
||||
id: z.string(),
|
||||
@@ -525,8 +503,6 @@ export const schemas = {
|
||||
MemberMergeIntoRequest,
|
||||
OrganizationResource,
|
||||
OrganizationUpdateRequest,
|
||||
VersionRequest,
|
||||
TelemetryRequest,
|
||||
ProjectResource,
|
||||
ProjectStoreRequest,
|
||||
ProjectUpdateRequest,
|
||||
@@ -1496,7 +1472,7 @@ const endpoints = makeApi([
|
||||
{
|
||||
method: 'post',
|
||||
path: '/v1/organizations/:organization/members/:member/make-placeholder',
|
||||
alias: 'v1.members.make-placeholder',
|
||||
alias: 'makePlaceholder',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
@@ -3149,7 +3125,7 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
.object({
|
||||
key: z.union([z.string(), z.null()]),
|
||||
seconds: z.number().int(),
|
||||
cost: z.number().int(),
|
||||
cost: z.union([z.number(), z.null()]),
|
||||
grouped_type: z.union([
|
||||
z.string(),
|
||||
z.null(),
|
||||
@@ -3165,7 +3141,10 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
seconds: z
|
||||
.number()
|
||||
.int(),
|
||||
cost: z.number().int(),
|
||||
cost: z.union([
|
||||
z.number(),
|
||||
z.null(),
|
||||
]),
|
||||
grouped_type: 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(),
|
||||
]),
|
||||
seconds: z.number().int(),
|
||||
cost: z.number().int(),
|
||||
cost: z.union([z.number(), z.null()]),
|
||||
})
|
||||
.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',
|
||||
path: '/v1/public/reports',
|
||||
|
||||
@@ -81,6 +81,10 @@ export function canMergeMembers() {
|
||||
return currentUserHasPermission('members:merge-into');
|
||||
}
|
||||
|
||||
export function canMakeMembersPlaceholders() {
|
||||
return currentUserHasPermission('members:make-placeholder');
|
||||
}
|
||||
|
||||
export function canInvitePlaceholderMembers() {
|
||||
return currentUserHasPermission('members:invite-placeholder');
|
||||
}
|
||||
@@ -105,9 +109,16 @@ export function canManageBilling() {
|
||||
return currentUserHasPermission('billing');
|
||||
}
|
||||
|
||||
export function canViewReport() {
|
||||
return currentUserHasPermission('reports:view');
|
||||
}
|
||||
export function canUpdateReport() {
|
||||
return currentUserHasPermission('reports:update');
|
||||
}
|
||||
export function canDeleteReport() {
|
||||
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('totalWeeklyTime', null)
|
||||
->whereNot('totalWeeklyBillableTime', null)
|
||||
->whereNot('totalWeeklyBillableAmount', null)
|
||||
->where('totalWeeklyBillableAmount', null)
|
||||
->whereNot('weeklyHistory', null)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,8 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
||||
Weekday::Monday,
|
||||
false,
|
||||
null,
|
||||
null
|
||||
null,
|
||||
true
|
||||
);
|
||||
|
||||
// Assert
|
||||
@@ -88,6 +89,7 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
||||
false,
|
||||
Carbon::now()->subDays(2)->utc(),
|
||||
Carbon::now()->subDay()->utc(),
|
||||
true
|
||||
);
|
||||
|
||||
// Assert
|
||||
@@ -137,6 +139,91 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
||||
], $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
|
||||
{
|
||||
// Arrange
|
||||
@@ -153,6 +240,7 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
||||
true,
|
||||
Carbon::now()->subDays(2)->utc(),
|
||||
Carbon::now()->subDay()->utc(),
|
||||
true
|
||||
);
|
||||
|
||||
// Assert
|
||||
@@ -194,6 +282,7 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
||||
true,
|
||||
Carbon::now()->subDays(2),
|
||||
Carbon::now()->subDay(),
|
||||
true
|
||||
);
|
||||
|
||||
// Assert
|
||||
@@ -220,6 +309,7 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
||||
true,
|
||||
Carbon::now()->subDays(2),
|
||||
Carbon::now()->subDay(),
|
||||
true
|
||||
);
|
||||
|
||||
// Assert
|
||||
@@ -254,7 +344,8 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
||||
Weekday::Monday,
|
||||
false,
|
||||
null,
|
||||
null
|
||||
null,
|
||||
true
|
||||
);
|
||||
|
||||
// Assert
|
||||
@@ -342,7 +433,8 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
||||
Weekday::Monday,
|
||||
true,
|
||||
null,
|
||||
null
|
||||
null,
|
||||
true
|
||||
);
|
||||
|
||||
// Assert
|
||||
|
||||
Reference in New Issue
Block a user