mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 16:22:16 +01:00
Compare commits
2 Commits
feature/fr
...
feature/ch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9070f6cd7e | ||
|
|
919399e828 |
172
app/Http/Controllers/Api/V1/ChartController.php
Normal file
172
app/Http/Controllers/Api/V1/ChartController.php
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\Role;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Service\DashboardService;
|
||||||
|
use App\Service\PermissionStore;
|
||||||
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class ChartController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*
|
||||||
|
* @operationId weeklyProjectOverview
|
||||||
|
*
|
||||||
|
* @response array<int, array{value: int, name: string, color: string}>
|
||||||
|
*/
|
||||||
|
public function weeklyProjectOverview(Organization $organization, DashboardService $dashboardService): JsonResponse
|
||||||
|
{
|
||||||
|
$this->checkPermission($organization, 'charts:view:own');
|
||||||
|
$user = $this->user();
|
||||||
|
|
||||||
|
$weeklyProjectOverview = $dashboardService->weeklyProjectOverview($user, $organization);
|
||||||
|
|
||||||
|
return response()->json($weeklyProjectOverview);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*
|
||||||
|
* @operationId latestTasks
|
||||||
|
*
|
||||||
|
* @response array<int, array{task_id: string, name: string, description: string|null, status: bool, time_entry_id: string|null}>
|
||||||
|
*/
|
||||||
|
public function latestTasks(Organization $organization, DashboardService $dashboardService): JsonResponse
|
||||||
|
{
|
||||||
|
$this->checkPermission($organization, 'charts:view:own');
|
||||||
|
$user = $this->user();
|
||||||
|
|
||||||
|
$latestTasks = $dashboardService->latestTasks($user, $organization);
|
||||||
|
|
||||||
|
return response()->json($latestTasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*
|
||||||
|
* @operationId lastSevenDays
|
||||||
|
*
|
||||||
|
* @response array<int, array{ date: string, duration: int, history: array<int> }>
|
||||||
|
*/
|
||||||
|
public function lastSevenDays(Organization $organization, DashboardService $dashboardService): JsonResponse
|
||||||
|
{
|
||||||
|
$this->checkPermission($organization, 'charts:view:own');
|
||||||
|
$user = $this->user();
|
||||||
|
|
||||||
|
$lastSevenDays = $dashboardService->lastSevenDays($user, $organization);
|
||||||
|
|
||||||
|
return response()->json($lastSevenDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*
|
||||||
|
* @operationId latestTeamActivity
|
||||||
|
*
|
||||||
|
* @response array<int, array{member_id: string, name: string, description: string|null, time_entry_id: string, task_id: string|null, status: bool }>
|
||||||
|
*/
|
||||||
|
public function latestTeamActivity(Organization $organization, DashboardService $dashboardService, PermissionStore $permissionStore): JsonResponse
|
||||||
|
{
|
||||||
|
$this->checkPermission($organization, 'charts:view:all');
|
||||||
|
|
||||||
|
$latestTeamActivity = $dashboardService->latestTeamActivity($organization);
|
||||||
|
|
||||||
|
return response()->json($latestTeamActivity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*
|
||||||
|
* @operationId dailyTrackedHours
|
||||||
|
*
|
||||||
|
* @response array<int, array{date: string, duration: int}>
|
||||||
|
*/
|
||||||
|
public function dailyTrackedHours(Organization $organization, DashboardService $dashboardService): JsonResponse
|
||||||
|
{
|
||||||
|
$this->checkPermission($organization, 'charts:view:own');
|
||||||
|
$user = $this->user();
|
||||||
|
|
||||||
|
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 60);
|
||||||
|
|
||||||
|
return response()->json($dailyTrackedHours);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*
|
||||||
|
* @operationId totalWeeklyTime
|
||||||
|
*
|
||||||
|
* @response int
|
||||||
|
*/
|
||||||
|
public function totalWeeklyTime(Organization $organization, DashboardService $dashboardService): JsonResponse
|
||||||
|
{
|
||||||
|
$this->checkPermission($organization, 'charts:view:own');
|
||||||
|
$user = $this->user();
|
||||||
|
|
||||||
|
$totalWeeklyTime = $dashboardService->totalWeeklyTime($user, $organization);
|
||||||
|
|
||||||
|
return response()->json($totalWeeklyTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*
|
||||||
|
* @operationId totalWeeklyBillableTime
|
||||||
|
*
|
||||||
|
* @response int
|
||||||
|
*/
|
||||||
|
public function totalWeeklyBillableTime(Organization $organization, DashboardService $dashboardService): JsonResponse
|
||||||
|
{
|
||||||
|
$this->checkPermission($organization, 'charts:view:own');
|
||||||
|
$user = $this->user();
|
||||||
|
|
||||||
|
$totalWeeklyBillableTime = $dashboardService->totalWeeklyBillableTime($user, $organization);
|
||||||
|
|
||||||
|
return response()->json($totalWeeklyBillableTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*
|
||||||
|
* @operationId totalWeeklyBillableAmount
|
||||||
|
*
|
||||||
|
* @response array{value: int, currency: string}
|
||||||
|
*/
|
||||||
|
public function totalWeeklyBillableAmount(Organization $organization, DashboardService $dashboardService): JsonResponse
|
||||||
|
{
|
||||||
|
$this->checkPermission($organization, 'charts:view:own');
|
||||||
|
$user = $this->user();
|
||||||
|
|
||||||
|
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||||
|
if (! $showBillableRate) {
|
||||||
|
throw new AuthorizationException('You do not have permission to view billable rates.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalWeeklyBillableAmount = $dashboardService->totalWeeklyBillableAmount($user, $organization);
|
||||||
|
|
||||||
|
return response()->json($totalWeeklyBillableAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*
|
||||||
|
* @operationId weeklyHistory
|
||||||
|
*
|
||||||
|
* @response array<int, array{date: string, duration: int}>
|
||||||
|
*/
|
||||||
|
public function weeklyHistory(Organization $organization, DashboardService $dashboardService): JsonResponse
|
||||||
|
{
|
||||||
|
$this->checkPermission($organization, 'charts:view:own');
|
||||||
|
$user = $this->user();
|
||||||
|
|
||||||
|
$weeklyHistory = $dashboardService->getWeeklyHistory($user, $organization);
|
||||||
|
|
||||||
|
return response()->json($weeklyHistory);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,14 +20,6 @@ class DashboardController extends Controller
|
|||||||
{
|
{
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
$organization = $this->currentOrganization();
|
$organization = $this->currentOrganization();
|
||||||
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 60);
|
|
||||||
$weeklyHistory = $dashboardService->getWeeklyHistory($user, $organization);
|
|
||||||
$totalWeeklyTime = $dashboardService->totalWeeklyTime($user, $organization);
|
|
||||||
$totalWeeklyBillableTime = $dashboardService->totalWeeklyBillableTime($user, $organization);
|
|
||||||
$totalWeeklyBillableAmount = $dashboardService->totalWeeklyBillableAmount($user, $organization);
|
|
||||||
$weeklyProjectOverview = $dashboardService->weeklyProjectOverview($user, $organization);
|
|
||||||
$latestTasks = $dashboardService->latestTasks($user, $organization);
|
|
||||||
$lastSevenDays = $dashboardService->lastSevenDays($user, $organization);
|
|
||||||
|
|
||||||
$latestTeamActivity = null;
|
$latestTeamActivity = null;
|
||||||
if ($permissionStore->has($organization, 'time-entries:view:all')) {
|
if ($permissionStore->has($organization, 'time-entries:view:all')) {
|
||||||
@@ -36,16 +28,6 @@ class DashboardController extends Controller
|
|||||||
|
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||||
|
|
||||||
return Inertia::render('Dashboard', [
|
return Inertia::render('Dashboard');
|
||||||
'weeklyProjectOverview' => $weeklyProjectOverview,
|
|
||||||
'latestTasks' => $latestTasks,
|
|
||||||
'lastSevenDays' => $lastSevenDays,
|
|
||||||
'latestTeamActivity' => $latestTeamActivity,
|
|
||||||
'dailyTrackedHours' => $dailyTrackedHours,
|
|
||||||
'totalWeeklyTime' => $totalWeeklyTime,
|
|
||||||
'totalWeeklyBillableTime' => $totalWeeklyBillableTime,
|
|
||||||
'totalWeeklyBillableAmount' => $showBillableRate ? $totalWeeklyBillableAmount : null,
|
|
||||||
'weeklyHistory' => $weeklyHistory,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
Jetstream::defaultApiTokenPermissions([]);
|
Jetstream::defaultApiTokenPermissions([]);
|
||||||
|
|
||||||
Jetstream::role(Role::Owner->value, 'Owner', [
|
Jetstream::role(Role::Owner->value, 'Owner', [
|
||||||
|
'charts:view:own',
|
||||||
|
'charts:view:all',
|
||||||
'projects:view',
|
'projects:view',
|
||||||
'projects:view:all',
|
'projects:view:all',
|
||||||
'projects:create',
|
'projects:create',
|
||||||
@@ -134,6 +136,8 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
])->description('Owner users can perform any action. There is only one owner per organization.');
|
])->description('Owner users can perform any action. There is only one owner per organization.');
|
||||||
|
|
||||||
Jetstream::role(Role::Admin->value, 'Administrator', [
|
Jetstream::role(Role::Admin->value, 'Administrator', [
|
||||||
|
'charts:view:own',
|
||||||
|
'charts:view:all',
|
||||||
'projects:view',
|
'projects:view',
|
||||||
'projects:view:all',
|
'projects:view:all',
|
||||||
'projects:create',
|
'projects:create',
|
||||||
@@ -184,6 +188,8 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
])->description('Administrator users can perform any action, except accessing the billing dashboard.');
|
])->description('Administrator users can perform any action, except accessing the billing dashboard.');
|
||||||
|
|
||||||
Jetstream::role(Role::Manager->value, 'Manager', [
|
Jetstream::role(Role::Manager->value, 'Manager', [
|
||||||
|
'charts:view:own',
|
||||||
|
'charts:view:all',
|
||||||
'projects:view',
|
'projects:view',
|
||||||
'projects:view:all',
|
'projects:view:all',
|
||||||
'projects:create',
|
'projects:create',
|
||||||
@@ -224,6 +230,7 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
])->description('Managers have full access to all projects, time entries, ect. but cannot manage the organization (add/remove member, edit the organization, ect.).');
|
])->description('Managers have full access to all projects, time entries, ect. but cannot manage the organization (add/remove member, edit the organization, ect.).');
|
||||||
|
|
||||||
Jetstream::role(Role::Employee->value, 'Employee', [
|
Jetstream::role(Role::Employee->value, 'Employee', [
|
||||||
|
'charts:view:own',
|
||||||
'projects:view',
|
'projects:view',
|
||||||
'tags:view',
|
'tags:view',
|
||||||
'tasks:view',
|
'tasks:view',
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ test('test that starting and updating the time while running works', async ({
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
page.waitForResponse(async (response) => {
|
page.waitForResponse(async (response) => {
|
||||||
return (
|
return (
|
||||||
|
response.url().includes('/time-entries') &&
|
||||||
response.status() === 200 &&
|
response.status() === 200 &&
|
||||||
(await response.headerValue('Content-Type')) ===
|
(await response.headerValue('Content-Type')) ===
|
||||||
'application/json' &&
|
'application/json' &&
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export function newTimeEntryResponse(
|
|||||||
) {
|
) {
|
||||||
return page.waitForResponse(async (response) => {
|
return page.waitForResponse(async (response) => {
|
||||||
return (
|
return (
|
||||||
|
response.url().includes('/time-entries') &&
|
||||||
response.status() === status &&
|
response.status() === status &&
|
||||||
(await response.headerValue('Content-Type')) ===
|
(await response.headerValue('Content-Type')) ===
|
||||||
'application/json' &&
|
'application/json' &&
|
||||||
|
|||||||
@@ -1,29 +1,45 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import VChart, { THEME_KEY } from 'vue-echarts';
|
import VChart, { THEME_KEY } from "vue-echarts";
|
||||||
import { provide, ref } from 'vue';
|
import { provide, computed } from "vue";
|
||||||
import { use } from 'echarts/core';
|
import { use } from "echarts/core";
|
||||||
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
|
import DashboardCard from "@/Components/Dashboard/DashboardCard.vue";
|
||||||
import { BoltIcon } from '@heroicons/vue/20/solid';
|
import { BoltIcon } from "@heroicons/vue/20/solid";
|
||||||
import { HeatmapChart } from 'echarts/charts';
|
import { HeatmapChart } from "echarts/charts";
|
||||||
import {
|
import {
|
||||||
CalendarComponent,
|
CalendarComponent,
|
||||||
TitleComponent,
|
TitleComponent,
|
||||||
TooltipComponent,
|
TooltipComponent,
|
||||||
VisualMapComponent,
|
VisualMapComponent
|
||||||
} from 'echarts/components';
|
} from "echarts/components";
|
||||||
import { CanvasRenderer } from 'echarts/renderers';
|
import { CanvasRenderer } from "echarts/renderers";
|
||||||
import dayjs from 'dayjs';
|
import dayjs from "dayjs";
|
||||||
import {
|
import {
|
||||||
firstDayIndex,
|
firstDayIndex,
|
||||||
formatDate,
|
formatDate,
|
||||||
formatHumanReadableDuration,
|
formatHumanReadableDuration,
|
||||||
getDayJsInstance,
|
getDayJsInstance
|
||||||
} from '@/packages/ui/src/utils/time';
|
} from "@/packages/ui/src/utils/time";
|
||||||
import { useCssVar } from '@vueuse/core';
|
import { useCssVar } from "@vueuse/core";
|
||||||
|
import { useQuery } from "@tanstack/vue-query";
|
||||||
|
import { getCurrentOrganizationId } from "@/utils/useUser";
|
||||||
|
import { api } from "@/packages/api/src";
|
||||||
|
import { LoadingSpinner } from "@/packages/ui/src";
|
||||||
|
|
||||||
const props = defineProps<{
|
// Get the organization ID using the utility function
|
||||||
dailyHoursTracked: { duration: number; date: string }[];
|
const organizationId = computed(() => getCurrentOrganizationId());
|
||||||
}>();
|
|
||||||
|
|
||||||
|
const { data: dailyHoursTracked, isLoading } = useQuery({
|
||||||
|
queryKey: ["dailyTrackedHours", organizationId],
|
||||||
|
queryFn: () => {
|
||||||
|
return api.dailyTrackedHours({
|
||||||
|
params: {
|
||||||
|
organization: organizationId.value!
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
enabled: computed(() => !!organizationId.value)
|
||||||
|
});
|
||||||
|
|
||||||
use([
|
use([
|
||||||
TitleComponent,
|
TitleComponent,
|
||||||
@@ -31,31 +47,39 @@ use([
|
|||||||
VisualMapComponent,
|
VisualMapComponent,
|
||||||
CalendarComponent,
|
CalendarComponent,
|
||||||
HeatmapChart,
|
HeatmapChart,
|
||||||
CanvasRenderer,
|
CanvasRenderer
|
||||||
]);
|
]);
|
||||||
|
|
||||||
provide(THEME_KEY, 'dark');
|
provide(THEME_KEY, "dark");
|
||||||
|
|
||||||
const max = Math.max(
|
const max = computed(() => {
|
||||||
Math.max(...props.dailyHoursTracked.map((el) => el.duration)),
|
if (!isLoading.value && dailyHoursTracked.value) {
|
||||||
|
return Math.max(
|
||||||
|
Math.max(...dailyHoursTracked.value.map((el) => el.duration)),
|
||||||
1
|
1
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const backgroundColor = useCssVar('--color-bg-secondary');
|
const backgroundColor = useCssVar("--color-bg-secondary");
|
||||||
const itemBackgroundColor = useCssVar('--color-bg-tertiary');
|
const itemBackgroundColor = useCssVar("--color-bg-tertiary");
|
||||||
const option = ref({
|
const option = computed(() => {
|
||||||
|
return {
|
||||||
tooltip: {},
|
tooltip: {},
|
||||||
visualMap: {
|
visualMap: {
|
||||||
min: 0,
|
min: 0,
|
||||||
max: max,
|
max: max.value,
|
||||||
type: 'piecewise',
|
type: "piecewise",
|
||||||
orient: 'horizontal',
|
orient: "horizontal",
|
||||||
left: 'center',
|
left: "center",
|
||||||
top: 'center',
|
top: "center",
|
||||||
inRange: {
|
inRange: {
|
||||||
color: [itemBackgroundColor.value, '#2DBE45'],
|
color: [itemBackgroundColor.value, "#2DBE45"]
|
||||||
},
|
},
|
||||||
show: false,
|
show: false
|
||||||
},
|
},
|
||||||
calendar: {
|
calendar: {
|
||||||
top: 40,
|
top: 40,
|
||||||
@@ -64,57 +88,73 @@ const option = ref({
|
|||||||
right: 10,
|
right: 10,
|
||||||
cellSize: [40, 40],
|
cellSize: [40, 40],
|
||||||
dayLabel: {
|
dayLabel: {
|
||||||
firstDay: firstDayIndex.value,
|
firstDay: firstDayIndex.value
|
||||||
},
|
},
|
||||||
splitLine: {
|
splitLine: {
|
||||||
show: false,
|
show: false
|
||||||
},
|
},
|
||||||
range: [
|
range: [
|
||||||
dayjs().format('YYYY-MM-DD'),
|
dayjs().format("YYYY-MM-DD"),
|
||||||
getDayJsInstance()()
|
getDayJsInstance()()
|
||||||
.subtract(50, 'day')
|
.subtract(50, "day")
|
||||||
.startOf('week')
|
.startOf("week")
|
||||||
.format('YYYY-MM-DD'),
|
.format("YYYY-MM-DD")
|
||||||
],
|
],
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
color: 'transparent',
|
color: "transparent",
|
||||||
borderWidth: 8,
|
borderWidth: 8,
|
||||||
borderColor: backgroundColor.value,
|
borderColor: backgroundColor.value
|
||||||
},
|
},
|
||||||
yearLabel: { show: false },
|
yearLabel: { show: false }
|
||||||
},
|
},
|
||||||
series: {
|
series: {
|
||||||
type: 'heatmap',
|
type: "heatmap",
|
||||||
coordinateSystem: 'calendar',
|
coordinateSystem: "calendar",
|
||||||
data: props.dailyHoursTracked.map((el) => [el.date, el.duration]),
|
data: dailyHoursTracked?.value?.map((el) => [el.date, el.duration]) ?? [],
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
borderRadius: 5,
|
borderRadius: 5,
|
||||||
borderColor: 'rgba(255,255,255,0.05)',
|
borderColor: "rgba(255,255,255,0.05)",
|
||||||
borderWidth: 1,
|
borderWidth: 1
|
||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
valueFormatter: (value: number, dataIndex: number) => {
|
valueFormatter: (value: number, dataIndex: number) => {
|
||||||
|
if(dailyHoursTracked?.value){
|
||||||
return (
|
return (
|
||||||
formatDate(props.dailyHoursTracked[dataIndex].date) +
|
formatDate(dailyHoursTracked?.value[dataIndex].date) +
|
||||||
': ' +
|
": " +
|
||||||
formatHumanReadableDuration(value)
|
formatHumanReadableDuration(value)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
backgroundColor: "transparent"
|
||||||
},
|
};
|
||||||
backgroundColor: 'transparent',
|
});
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DashboardCard title="Activity Graph" :icon="BoltIcon">
|
<DashboardCard title="Activity Graph" :icon="BoltIcon">
|
||||||
<div class="px-2">
|
<div class="px-2">
|
||||||
|
<div v-if="isLoading" class="flex justify-center items-center h-40">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="dailyHoursTracked">
|
||||||
<v-chart
|
<v-chart
|
||||||
class="chart"
|
class="chart"
|
||||||
:autoresize="true"
|
:autoresize="true"
|
||||||
:option="option"
|
:option="option"
|
||||||
style="height: 260px; background-color: transparent" />
|
style="height: 260px; background-color: transparent" />
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="text-center text-gray-500 py-8">
|
||||||
|
No activity data available
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</DashboardCard>
|
</DashboardCard>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,43 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
|
import { useQuery } from "@tanstack/vue-query";
|
||||||
import DayOverviewCardEntry from '@/Components/Dashboard/DayOverviewCardEntry.vue';
|
import { computed } from "vue";
|
||||||
import { CalendarIcon } from '@heroicons/vue/20/solid';
|
import DashboardCard from "@/Components/Dashboard/DashboardCard.vue";
|
||||||
defineProps<{
|
import DayOverviewCardEntry from "@/Components/Dashboard/DayOverviewCardEntry.vue";
|
||||||
last7Days: {
|
import { CalendarIcon } from "@heroicons/vue/20/solid";
|
||||||
date: string;
|
import { getCurrentOrganizationId } from "@/utils/useUser";
|
||||||
duration: number; // Total duration in seconds
|
import { api } from "@/packages/api/src";
|
||||||
history: number[]; // Array representing the duration in seconds of the 3h windows for the day
|
import { LoadingSpinner } from "@/packages/ui/src";
|
||||||
}[];
|
|
||||||
}>();
|
// Get the organization ID using the utility function
|
||||||
|
const organizationId = computed(() => getCurrentOrganizationId());
|
||||||
|
|
||||||
|
|
||||||
|
// Set up the query
|
||||||
|
const { data: last7Days, isLoading } = useQuery({
|
||||||
|
queryKey: ["lastSevenDays", organizationId],
|
||||||
|
queryFn: () => {
|
||||||
|
return api.lastSevenDays({
|
||||||
|
params: {
|
||||||
|
organization: organizationId.value!
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
enabled: computed(() => !!organizationId.value),
|
||||||
|
placeholderData: Array.from({ length: 7 }, (_, i) => ({
|
||||||
|
date: new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString().split("T")[0],
|
||||||
|
duration: 0,
|
||||||
|
history: Array(8).fill(0)
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DashboardCard title="Last 7 Days" :icon="CalendarIcon">
|
<DashboardCard title="Last 7 Days" :icon="CalendarIcon">
|
||||||
|
<div v-if="isLoading" class="flex justify-center items-center h-40">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="last7Days">
|
||||||
<DayOverviewCardEntry
|
<DayOverviewCardEntry
|
||||||
v-for="day in last7Days"
|
v-for="day in last7Days"
|
||||||
:key="day.date"
|
:key="day.date"
|
||||||
@@ -20,5 +45,9 @@ defineProps<{
|
|||||||
:date="day.date"
|
:date="day.date"
|
||||||
:history="day.history"
|
:history="day.history"
|
||||||
:duration="day.duration"></DayOverviewCardEntry>
|
:duration="day.duration"></DayOverviewCardEntry>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-center text-gray-500 py-8">
|
||||||
|
No data available
|
||||||
|
</div>
|
||||||
</DashboardCard>
|
</DashboardCard>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,32 +1,83 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import RecentlyTrackedTasksCardEntry from '@/Components/Dashboard/RecentlyTrackedTasksCardEntry.vue';
|
import { useQuery } from "@tanstack/vue-query";
|
||||||
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
|
import { computed } from "vue";
|
||||||
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
|
import RecentlyTrackedTasksCardEntry from "@/Components/Dashboard/RecentlyTrackedTasksCardEntry.vue";
|
||||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
import DashboardCard from "@/Components/Dashboard/DashboardCard.vue";
|
||||||
import { PlusCircleIcon } from '@heroicons/vue/24/solid';
|
import { CheckCircleIcon } from "@heroicons/vue/20/solid";
|
||||||
import { router } from '@inertiajs/vue3';
|
import SecondaryButton from "@/packages/ui/src/Buttons/SecondaryButton.vue";
|
||||||
|
import { PlusCircleIcon } from "@heroicons/vue/24/solid";
|
||||||
|
import { router } from "@inertiajs/vue3";
|
||||||
|
import { getCurrentMembershipId, getCurrentOrganizationId } from "@/utils/useUser";
|
||||||
|
import { api } from "@/packages/api/src";
|
||||||
|
import { LoadingSpinner } from "@/packages/ui/src";
|
||||||
|
|
||||||
const props = defineProps<{
|
// Get the organization ID using the utility function
|
||||||
latestTasks: {
|
const organizationId = computed(() => getCurrentOrganizationId());
|
||||||
id: string;
|
|
||||||
name: string;
|
// Function to fetch latest tasks using the API client
|
||||||
project_name: string;
|
|
||||||
project_id: string;
|
// Set up the query
|
||||||
}[];
|
const { data: timeEntriesResponse, isLoading, refetch } = useQuery({
|
||||||
}>();
|
queryKey: ["timeEntries", organizationId],
|
||||||
|
queryFn: () => {
|
||||||
|
return api.getTimeEntries({
|
||||||
|
params: {
|
||||||
|
organization: organizationId.value!
|
||||||
|
},
|
||||||
|
queries: {
|
||||||
|
member_id: getCurrentMembershipId()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
enabled: computed(() => !!organizationId.value)
|
||||||
|
});
|
||||||
|
|
||||||
|
const latestTasks = computed(() => {
|
||||||
|
if (!timeEntriesResponse.value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return timeEntriesResponse.value.data;
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredLatestTasks = computed(() => {
|
||||||
|
// do not include running time entries
|
||||||
|
const finishedTimeEntries = latestTasks.value.filter((item) => item.end !== null);
|
||||||
|
|
||||||
|
// filter out duplicates based on description, task, project, tags and billable
|
||||||
|
return finishedTimeEntries.filter((item, index, self) => {
|
||||||
|
return index === self.findIndex((t) => (
|
||||||
|
t.description === item.description &&
|
||||||
|
t.task_id === item.task_id &&
|
||||||
|
t.project_id === item.project_id &&
|
||||||
|
t.tags.length === item.tags.length &&
|
||||||
|
t.tags.every((tag) => item.tags.includes(tag)) &&
|
||||||
|
t.billable === item.billable
|
||||||
|
));
|
||||||
|
}).slice(0, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Listen for dashboard refresh events
|
||||||
|
window.addEventListener("dashboard:refresh", () => {
|
||||||
|
refetch();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DashboardCard title="Recently Tracked Tasks" :icon="CheckCircleIcon">
|
<DashboardCard title="Recent Time Entries" :icon="CheckCircleIcon">
|
||||||
|
<div v-if="isLoading" class="flex justify-center items-center h-40">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="filteredLatestTasks && filteredLatestTasks.length > 0">
|
||||||
<RecentlyTrackedTasksCardEntry
|
<RecentlyTrackedTasksCardEntry
|
||||||
v-for="lastTask in props.latestTasks"
|
v-for="lastTask in filteredLatestTasks"
|
||||||
:key="lastTask.id"
|
:key="lastTask.id"
|
||||||
:class="props.latestTasks.length === 4 ? 'last:border-0' : ''"
|
:time-entry="lastTask"
|
||||||
:project_id="lastTask.project_id"
|
:class="filteredLatestTasks.length === 4 ? 'last:border-0' : ''"></RecentlyTrackedTasksCardEntry>
|
||||||
:task_id="lastTask.id"
|
</div>
|
||||||
:title="lastTask.name"></RecentlyTrackedTasksCardEntry>
|
|
||||||
<div
|
<div
|
||||||
v-if="props.latestTasks.length === 0"
|
v-else
|
||||||
class="text-center flex flex-1 justify-center items-center">
|
class="text-center flex flex-1 justify-center items-center">
|
||||||
<div>
|
<div>
|
||||||
<PlusCircleIcon
|
<PlusCircleIcon
|
||||||
@@ -41,7 +92,7 @@ const props = defineProps<{
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="props.latestTasks.length === 1"
|
v-if="latestTasks && latestTasks.length === 1"
|
||||||
class="text-center flex flex-1 justify-center items-center text-sm">
|
class="text-center flex flex-1 justify-center items-center text-sm">
|
||||||
<div>
|
<div>
|
||||||
<PlusCircleIcon
|
<PlusCircleIcon
|
||||||
|
|||||||
@@ -6,17 +6,16 @@ import { storeToRefs } from 'pinia';
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
||||||
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
|
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
|
||||||
|
import type { TimeEntry } from "@/packages/api/src";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
title: string;
|
timeEntry: TimeEntry
|
||||||
project_id: string;
|
|
||||||
task_id: string;
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { projects } = storeToRefs(useProjectsStore());
|
const { projects } = storeToRefs(useProjectsStore());
|
||||||
|
|
||||||
const project = computed(() => {
|
const project = computed(() => {
|
||||||
return projects.value.find((project) => project.id === props.project_id);
|
return projects.value.find((project) => project.id === props.timeEntry.project_id);
|
||||||
});
|
});
|
||||||
|
|
||||||
const { currentTimeEntry } = storeToRefs(useCurrentTimeEntryStore());
|
const { currentTimeEntry } = storeToRefs(useCurrentTimeEntryStore());
|
||||||
@@ -26,23 +25,28 @@ async function startTaskTimer() {
|
|||||||
if (currentTimeEntry.value.id) {
|
if (currentTimeEntry.value.id) {
|
||||||
await setActiveState(false);
|
await setActiveState(false);
|
||||||
}
|
}
|
||||||
currentTimeEntry.value.project_id = props.project_id;
|
currentTimeEntry.value.description = props.timeEntry.description;
|
||||||
currentTimeEntry.value.task_id = props.task_id;
|
currentTimeEntry.value.project_id = props.timeEntry.project_id;
|
||||||
|
currentTimeEntry.value.task_id = props.timeEntry.task_id;
|
||||||
|
currentTimeEntry.value.tags = props.timeEntry.tags;
|
||||||
|
currentTimeEntry.value.billable = props.timeEntry.billable;
|
||||||
currentTimeEntry.value.start = getDayJsInstance().utc().format();
|
currentTimeEntry.value.start = getDayJsInstance().utc().format();
|
||||||
await setActiveState(true);
|
await setActiveState(true);
|
||||||
useCurrentTimeEntryStore().fetchCurrentTimeEntry();
|
useCurrentTimeEntryStore().fetchCurrentTimeEntry();
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="px-3.5 py-2 grid grid-cols-5 border-b border-b-card-background-separator">
|
class="px-3.5 py-2 grid grid-cols-5 border-b border-b-card-background-separator">
|
||||||
<div class="col-span-4">
|
<div class="col-span-4">
|
||||||
<p class="font-semibold text-white text-sm pb-1 overflow-ellipsis">
|
<p class="font-medium text-white text-sm pb-1 truncate">
|
||||||
{{ title }}
|
<span v-if="timeEntry.description"> {{ timeEntry.description }}</span>
|
||||||
|
<span v-else class="text-text-tertiary">No description</span>
|
||||||
</p>
|
</p>
|
||||||
<ProjectBadge
|
<ProjectBadge
|
||||||
:name="project?.name"
|
:name="project?.name ?? 'No Project'"
|
||||||
:color="project?.color"></ProjectBadge>
|
:color="project?.color"></ProjectBadge>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-center">
|
<div class="flex items-center justify-center">
|
||||||
|
|||||||
@@ -1,33 +1,52 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { useQuery } from '@tanstack/vue-query';
|
||||||
|
import { computed } from 'vue';
|
||||||
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
|
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
|
||||||
import TeamActivityCardEntry from '@/Components/Dashboard/TeamActivityCardEntry.vue';
|
import TeamActivityCardEntry from '@/Components/Dashboard/TeamActivityCardEntry.vue';
|
||||||
import { UserGroupIcon } from '@heroicons/vue/20/solid';
|
import { UserGroupIcon } from '@heroicons/vue/20/solid';
|
||||||
import { router } from '@inertiajs/vue3';
|
|
||||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
|
import { api } from '@/packages/api/src';
|
||||||
|
import { LoadingSpinner } from "@/packages/ui/src";
|
||||||
|
import { router } from '@inertiajs/vue3';
|
||||||
|
|
||||||
|
// Get the organization ID using the utility function
|
||||||
|
const organizationId = computed(() => getCurrentOrganizationId());
|
||||||
|
|
||||||
|
// Set up the query
|
||||||
|
const { data: latestTeamActivity, isLoading } = useQuery({
|
||||||
|
queryKey: ['latestTeamActivity', organizationId],
|
||||||
|
queryFn: () => {
|
||||||
|
return api.latestTeamActivity({
|
||||||
|
params: {
|
||||||
|
organization: organizationId.value!
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
enabled: computed(() => !!organizationId.value),
|
||||||
|
});
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
latestTeamActivity: {
|
|
||||||
user_id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
time_entry_id: string;
|
|
||||||
task_id: string;
|
|
||||||
status: boolean;
|
|
||||||
}[];
|
|
||||||
}>();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DashboardCard title="Team Activity" :icon="UserGroupIcon">
|
<DashboardCard title="Team Activity" :icon="UserGroupIcon">
|
||||||
|
<div v-if="isLoading" class="flex justify-center items-center h-40">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="latestTeamActivity">
|
||||||
<TeamActivityCardEntry
|
<TeamActivityCardEntry
|
||||||
v-for="activity in latestTeamActivity"
|
v-for="activity in latestTeamActivity"
|
||||||
:key="activity.user_id"
|
:key="activity.time_entry_id"
|
||||||
:class="latestTeamActivity.length === 4 ? 'last:border-0' : ''"
|
:class="latestTeamActivity.length === 4 ? 'last:border-0' : ''"
|
||||||
:name="activity.name"
|
:name="activity.name"
|
||||||
:description="activity.description"
|
:description="activity.description"
|
||||||
:working="activity.status"></TeamActivityCardEntry>
|
:working="activity.status"></TeamActivityCardEntry>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-center text-gray-500 py-8">
|
||||||
|
No team activity found
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="latestTeamActivity.length <= 1"
|
v-if="latestTeamActivity && latestTeamActivity.length <= 1"
|
||||||
class="text-center flex flex-1 justify-center items-center">
|
class="text-center flex flex-1 justify-center items-center">
|
||||||
<div>
|
<div>
|
||||||
<UserGroupIcon
|
<UserGroupIcon
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
defineProps<{
|
defineProps<{
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string | null;
|
||||||
working?: boolean;
|
working?: boolean;
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,25 +1,28 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { use } from 'echarts/core';
|
import { use } from "echarts/core";
|
||||||
import { CanvasRenderer } from 'echarts/renderers';
|
import { CanvasRenderer } from "echarts/renderers";
|
||||||
import { BarChart } from 'echarts/charts';
|
import { BarChart } from "echarts/charts";
|
||||||
import {
|
import {
|
||||||
GridComponent,
|
GridComponent,
|
||||||
LegendComponent,
|
LegendComponent,
|
||||||
TitleComponent,
|
TitleComponent,
|
||||||
TooltipComponent,
|
TooltipComponent
|
||||||
} from 'echarts/components';
|
} from "echarts/components";
|
||||||
import VChart, { THEME_KEY } from 'vue-echarts';
|
import VChart, { THEME_KEY } from "vue-echarts";
|
||||||
import { computed, provide, ref } from 'vue';
|
import { computed, provide } from "vue";
|
||||||
import StatCard from '@/Components/Common/StatCard.vue';
|
import StatCard from "@/Components/Common/StatCard.vue";
|
||||||
import { ClockIcon } from '@heroicons/vue/20/solid';
|
import { ClockIcon } from "@heroicons/vue/20/solid";
|
||||||
import CardTitle from '@/packages/ui/src/CardTitle.vue';
|
import CardTitle from "@/packages/ui/src/CardTitle.vue";
|
||||||
import LinearGradient from 'zrender/lib/graphic/LinearGradient';
|
import LinearGradient from "zrender/lib/graphic/LinearGradient";
|
||||||
import ProjectsChartCard from '@/Components/Dashboard/ProjectsChartCard.vue';
|
import ProjectsChartCard from "@/Components/Dashboard/ProjectsChartCard.vue";
|
||||||
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
|
import { formatHumanReadableDuration } from "@/packages/ui/src/utils/time";
|
||||||
import { formatCents } from '@/packages/ui/src/utils/money';
|
import { formatCents } from "@/packages/ui/src/utils/money";
|
||||||
import { getWeekStart } from '@/packages/ui/src/utils/settings';
|
import { getWeekStart } from "@/packages/ui/src/utils/settings";
|
||||||
import { useCssVar } from '@vueuse/core';
|
import { useCssVar } from "@vueuse/core";
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
import { getOrganizationCurrencyString } from "@/utils/money";
|
||||||
|
import { useQuery } from "@tanstack/vue-query";
|
||||||
|
import { getCurrentOrganizationId } from "@/utils/useUser";
|
||||||
|
import { api } from "@/packages/api/src";
|
||||||
|
|
||||||
use([
|
use([
|
||||||
CanvasRenderer,
|
CanvasRenderer,
|
||||||
@@ -27,85 +30,22 @@ use([
|
|||||||
TitleComponent,
|
TitleComponent,
|
||||||
GridComponent,
|
GridComponent,
|
||||||
TooltipComponent,
|
TooltipComponent,
|
||||||
LegendComponent,
|
LegendComponent
|
||||||
]);
|
]);
|
||||||
|
|
||||||
provide(THEME_KEY, 'dark');
|
provide(THEME_KEY, "dark");
|
||||||
|
const accentColor = useCssVar("--color-accent-quaternary");
|
||||||
const props = defineProps<{
|
|
||||||
weeklyProjectOverview: {
|
|
||||||
value: number;
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
}[];
|
|
||||||
totalWeeklyTime: number;
|
|
||||||
totalWeeklyBillableTime: number;
|
|
||||||
totalWeeklyBillableAmount: {
|
|
||||||
value: number;
|
|
||||||
currency: string;
|
|
||||||
} | null;
|
|
||||||
weeklyHistory: {
|
|
||||||
date: string;
|
|
||||||
duration: number;
|
|
||||||
}[];
|
|
||||||
}>();
|
|
||||||
const accentColor = useCssVar('--color-accent-quaternary');
|
|
||||||
|
|
||||||
const seriesData = computed(() => {
|
|
||||||
return props.weeklyHistory.map((el) => {
|
|
||||||
return {
|
|
||||||
value: el.duration,
|
|
||||||
...{
|
|
||||||
itemStyle: {
|
|
||||||
borderColor: new LinearGradient(0, 0, 0, 1, [
|
|
||||||
{
|
|
||||||
offset: 0,
|
|
||||||
color: 'rgba(' + accentColor.value + ',0.7)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
offset: 1,
|
|
||||||
color: 'rgba(' + accentColor.value + ',0.5)',
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
emphasis: {
|
|
||||||
color: new LinearGradient(0, 0, 0, 1, [
|
|
||||||
{
|
|
||||||
offset: 0,
|
|
||||||
color: 'rgba(' + accentColor.value + ',0.9)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
offset: 1,
|
|
||||||
color: 'rgba(' + accentColor.value + ',0.7)',
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
borderRadius: [12, 12, 0, 0],
|
|
||||||
color: new LinearGradient(0, 0, 0, 1, [
|
|
||||||
{
|
|
||||||
offset: 0,
|
|
||||||
color: 'rgba(' + accentColor.value + ',0.7)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
offset: 1,
|
|
||||||
color: 'rgba(' + accentColor.value + ',0.5)',
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const weekdays = computed(() => {
|
const weekdays = computed(() => {
|
||||||
const daysOrder = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
const daysOrder = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||||
const dayMapping: Record<string, string> = {
|
const dayMapping: Record<string, string> = {
|
||||||
monday: 'Mon',
|
monday: "Mon",
|
||||||
tuesday: 'Tue',
|
tuesday: "Tue",
|
||||||
wednesday: 'Wed',
|
wednesday: "Wed",
|
||||||
thursday: 'Thu',
|
thursday: "Thu",
|
||||||
friday: 'Fri',
|
friday: "Fri",
|
||||||
saturday: 'Sat',
|
saturday: "Sat",
|
||||||
sunday: 'Sun',
|
sunday: "Sun"
|
||||||
};
|
};
|
||||||
|
|
||||||
if (dayMapping[getWeekStart()]) {
|
if (dayMapping[getWeekStart()]) {
|
||||||
@@ -122,59 +62,179 @@ const weekdays = computed(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const markLineColor = useCssVar('--color-border-secondary');
|
const markLineColor = useCssVar("--color-border-secondary");
|
||||||
|
|
||||||
const option = ref({
|
|
||||||
|
// Get the organization ID using the utility function
|
||||||
|
const organizationId = computed(() => getCurrentOrganizationId());
|
||||||
|
|
||||||
|
|
||||||
|
// Set up the queries
|
||||||
|
const { data: weeklyProjectOverview } = useQuery({
|
||||||
|
queryKey: ["weeklyProjectOverview", organizationId],
|
||||||
|
queryFn: () => {
|
||||||
|
return api.weeklyProjectOverview({
|
||||||
|
params: {
|
||||||
|
organization: organizationId.value!
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
enabled: computed(() => !!organizationId.value)
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: totalWeeklyTime } = useQuery({
|
||||||
|
queryKey: ["totalWeeklyTime", organizationId],
|
||||||
|
queryFn: () => {
|
||||||
|
return api.totalWeeklyTime({
|
||||||
|
params: {
|
||||||
|
organization: organizationId.value!
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
enabled: computed(() => !!organizationId.value)
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: totalWeeklyBillableTime } = useQuery({
|
||||||
|
queryKey: ["totalWeeklyBillableTime", organizationId],
|
||||||
|
queryFn: () => {
|
||||||
|
return api.totalWeeklyBillableTime({
|
||||||
|
params: {
|
||||||
|
organization: organizationId.value!
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
enabled: computed(() => !!organizationId.value)
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: totalWeeklyBillableAmount } = useQuery({
|
||||||
|
queryKey: ["totalWeeklyBillableAmount", organizationId],
|
||||||
|
queryFn: () => {
|
||||||
|
return api.totalWeeklyBillableAmount({
|
||||||
|
params: {
|
||||||
|
organization: organizationId.value!
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
enabled: computed(() => !!organizationId.value)
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: weeklyHistory } = useQuery({
|
||||||
|
queryKey: ["weeklyHistory", organizationId],
|
||||||
|
queryFn: () => {
|
||||||
|
return api.weeklyHistory({
|
||||||
|
params: {
|
||||||
|
organization: organizationId.value!
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
enabled: computed(() => !!organizationId.value)
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const seriesData = computed(() => {
|
||||||
|
if (!weeklyHistory.value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return weeklyHistory.value?.map((el) => {
|
||||||
|
return {
|
||||||
|
value: el.duration,
|
||||||
|
...{
|
||||||
|
itemStyle: {
|
||||||
|
borderColor: new LinearGradient(0, 0, 0, 1, [
|
||||||
|
{
|
||||||
|
offset: 0,
|
||||||
|
color: "rgba(" + accentColor.value + ",0.7)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
offset: 1,
|
||||||
|
color: "rgba(" + accentColor.value + ",0.5)"
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
emphasis: {
|
||||||
|
color: new LinearGradient(0, 0, 0, 1, [
|
||||||
|
{
|
||||||
|
offset: 0,
|
||||||
|
color: "rgba(" + accentColor.value + ",0.9)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
offset: 1,
|
||||||
|
color: "rgba(" + accentColor.value + ",0.7)"
|
||||||
|
}
|
||||||
|
])
|
||||||
|
},
|
||||||
|
borderRadius: [12, 12, 0, 0],
|
||||||
|
color: new LinearGradient(0, 0, 0, 1, [
|
||||||
|
{
|
||||||
|
offset: 0,
|
||||||
|
color: "rgba(" + accentColor.value + ",0.7)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
offset: 1,
|
||||||
|
color: "rgba(" + accentColor.value + ",0.5)"
|
||||||
|
}
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const option = computed(() => {
|
||||||
|
return {
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'item',
|
trigger: "item"
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
top: 0,
|
top: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 50,
|
bottom: 50,
|
||||||
left: 0,
|
left: 0
|
||||||
},
|
},
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: "transparent",
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: "category",
|
||||||
data: weekdays.value,
|
data: weekdays.value,
|
||||||
axisLine: {
|
axisLine: {
|
||||||
lineStyle: {
|
lineStyle: {
|
||||||
color: 'transparent', // Set desired color here
|
color: "transparent" // Set desired color here
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
margin: 24,
|
margin: 24,
|
||||||
fontFamily: 'Outfit, sans-serif',
|
fontFamily: "Outfit, sans-serif"
|
||||||
},
|
},
|
||||||
axisTick: {
|
axisTick: {
|
||||||
lineStyle: {
|
lineStyle: {
|
||||||
color: 'transparent', // Set desired color here
|
color: "transparent" // Set desired color here
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: 'value',
|
type: "value",
|
||||||
splitLine: {
|
splitLine: {
|
||||||
lineStyle: {
|
lineStyle: {
|
||||||
color: markLineColor.value,
|
color: markLineColor.value
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
data: seriesData,
|
data: seriesData.value,
|
||||||
type: 'bar',
|
type: "bar",
|
||||||
tooltip: {
|
tooltip: {
|
||||||
valueFormatter: (value: number) => {
|
valueFormatter: (value: number) => {
|
||||||
return formatHumanReadableDuration(value);
|
return formatHumanReadableDuration(value);
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
],
|
]
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -185,29 +245,35 @@ const option = ref({
|
|||||||
title="This Week"
|
title="This Week"
|
||||||
class="pb-8"
|
class="pb-8"
|
||||||
:icon="ClockIcon"></CardTitle>
|
:icon="ClockIcon"></CardTitle>
|
||||||
<v-chart :autoresize="true" class="chart" :option="option" />
|
<v-chart
|
||||||
|
v-if="weeklyHistory"
|
||||||
|
:autoresize="true" class="chart" :option="option" />
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Spent Time"
|
title="Spent Time"
|
||||||
:value="formatHumanReadableDuration(props.totalWeeklyTime)" />
|
:value="
|
||||||
|
totalWeeklyTime ?
|
||||||
|
formatHumanReadableDuration(totalWeeklyTime) : '--'" />
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Billable Time"
|
title="Billable Time"
|
||||||
:value="
|
:value="
|
||||||
formatHumanReadableDuration(props.totalWeeklyBillableTime)
|
totalWeeklyBillableTime ?
|
||||||
|
formatHumanReadableDuration(totalWeeklyBillableTime) : '--'
|
||||||
" />
|
" />
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Billable Amount"
|
title="Billable Amount"
|
||||||
:value="
|
:value="
|
||||||
props.totalWeeklyBillableAmount ?
|
totalWeeklyBillableAmount ?
|
||||||
formatCents(
|
formatCents(
|
||||||
props.totalWeeklyBillableAmount.value,
|
totalWeeklyBillableAmount.value,
|
||||||
getOrganizationCurrencyString()
|
getOrganizationCurrencyString()
|
||||||
) : '--'
|
) : '--'
|
||||||
" />
|
" />
|
||||||
<ProjectsChartCard
|
<ProjectsChartCard
|
||||||
|
v-if="weeklyProjectOverview"
|
||||||
:weekly-project-overview="
|
:weekly-project-overview="
|
||||||
props.weeklyProjectOverview
|
weeklyProjectOverview
|
||||||
"></ProjectsChartCard>
|
"></ProjectsChartCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,68 +1,30 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||||
import TimeTracker from '@/Components/TimeTracker.vue';
|
import TimeTracker from "@/Components/TimeTracker.vue";
|
||||||
import RecentlyTrackedTasksCard from '@/Components/Dashboard/RecentlyTrackedTasksCard.vue';
|
import RecentlyTrackedTasksCard from "@/Components/Dashboard/RecentlyTrackedTasksCard.vue";
|
||||||
import LastSevenDaysCard from '@/Components/Dashboard/LastSevenDaysCard.vue';
|
import LastSevenDaysCard from "@/Components/Dashboard/LastSevenDaysCard.vue";
|
||||||
import TeamActivityCard from '@/Components/Dashboard/TeamActivityCard.vue';
|
import TeamActivityCard from "@/Components/Dashboard/TeamActivityCard.vue";
|
||||||
import ThisWeekOverview from '@/Components/Dashboard/ThisWeekOverview.vue';
|
import ThisWeekOverview from "@/Components/Dashboard/ThisWeekOverview.vue";
|
||||||
import ActivityGraphCard from '@/Components/Dashboard/ActivityGraphCard.vue';
|
import ActivityGraphCard from "@/Components/Dashboard/ActivityGraphCard.vue";
|
||||||
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
import MainContainer from "@/packages/ui/src/MainContainer.vue";
|
||||||
import { canViewMembers } from '@/utils/permissions';
|
import { canViewMembers } from "@/utils/permissions";
|
||||||
import { router } from '@inertiajs/vue3';
|
import { useQueryClient } from "@tanstack/vue-query";
|
||||||
|
|
||||||
const props = defineProps<{
|
const queryClient = useQueryClient();
|
||||||
latestTasks: {
|
|
||||||
id: string;
|
const refreshDashboardData = () => {
|
||||||
name: string;
|
// Invalidate all dashboard queries to trigger refetching
|
||||||
project_name: string;
|
queryClient.invalidateQueries({ queryKey: ["latestTasks"] });
|
||||||
project_id: string;
|
queryClient.invalidateQueries({ queryKey: ["lastSevenDays"] });
|
||||||
}[];
|
queryClient.invalidateQueries({ queryKey: ["dailyTrackedHours"] });
|
||||||
latestTeamActivity: {
|
queryClient.invalidateQueries({ queryKey: ["latestTeamActivity"] });
|
||||||
user_id: string;
|
queryClient.invalidateQueries({ queryKey: ["weeklyProjectOverview"] });
|
||||||
name: string;
|
queryClient.invalidateQueries({ queryKey: ["totalWeeklyTime"] });
|
||||||
description: string;
|
queryClient.invalidateQueries({ queryKey: ["totalWeeklyBillableTime"] });
|
||||||
time_entry_id: string;
|
queryClient.invalidateQueries({ queryKey: ["totalWeeklyBillableAmount"] });
|
||||||
task_id: string;
|
queryClient.invalidateQueries({ queryKey: ["weeklyHistory"] });
|
||||||
status: boolean;
|
};
|
||||||
}[];
|
|
||||||
lastSevenDays: {
|
|
||||||
date: string;
|
|
||||||
duration: number; // Total duration in seconds
|
|
||||||
history: number[]; // Array representing the duration in seconds of the 3h windows for the day
|
|
||||||
}[];
|
|
||||||
dailyTrackedHours: { duration: number; date: string }[];
|
|
||||||
weeklyProjectOverview: {
|
|
||||||
value: number;
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
}[];
|
|
||||||
totalWeeklyTime: number;
|
|
||||||
totalWeeklyBillableTime: number;
|
|
||||||
totalWeeklyBillableAmount: {
|
|
||||||
value: number;
|
|
||||||
currency: string;
|
|
||||||
};
|
|
||||||
weeklyHistory: {
|
|
||||||
date: string;
|
|
||||||
duration: number;
|
|
||||||
}[];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
function refreshDashboardData() {
|
|
||||||
router.reload({
|
|
||||||
only: [
|
|
||||||
'latestTasks',
|
|
||||||
'latestTeamActivity',
|
|
||||||
'lastSevenDays',
|
|
||||||
'dailyTrackedHours',
|
|
||||||
'weeklyProjectOverview',
|
|
||||||
'totalWeeklyTime',
|
|
||||||
'totalWeeklyBillableTime',
|
|
||||||
'totalWeeklyBillableAmount',
|
|
||||||
'weeklyHistory',
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -71,30 +33,19 @@ function refreshDashboardData() {
|
|||||||
class="pt-5 sm:pt-8 pb-4 sm:pb-6 border-b border-default-background-separator">
|
class="pt-5 sm:pt-8 pb-4 sm:pb-6 border-b border-default-background-separator">
|
||||||
<TimeTracker @change="refreshDashboardData"></TimeTracker>
|
<TimeTracker @change="refreshDashboardData"></TimeTracker>
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
|
|
||||||
<MainContainer
|
<MainContainer
|
||||||
class="grid gap-5 sm:gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 pt-3 sm:pt-5 pb-4 sm:pb-6 border-b border-default-background-separator items-stretch">
|
class="grid gap-5 sm:gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 pt-3 sm:pt-5 pb-4 sm:pb-6 border-b border-default-background-separator items-stretch">
|
||||||
<RecentlyTrackedTasksCard
|
<RecentlyTrackedTasksCard></RecentlyTrackedTasksCard>
|
||||||
:latest-tasks="props.latestTasks"></RecentlyTrackedTasksCard>
|
<LastSevenDaysCard></LastSevenDaysCard>
|
||||||
<LastSevenDaysCard
|
<ActivityGraphCard></ActivityGraphCard>
|
||||||
:last7-days="props.lastSevenDays"></LastSevenDaysCard>
|
|
||||||
<ActivityGraphCard
|
|
||||||
:daily-hours-tracked="
|
|
||||||
props.dailyTrackedHours
|
|
||||||
"></ActivityGraphCard>
|
|
||||||
<TeamActivityCard
|
<TeamActivityCard
|
||||||
v-if="canViewMembers()"
|
v-if="canViewMembers()"
|
||||||
class="flex lg:hidden xl:flex"
|
class="flex lg:hidden xl:flex">
|
||||||
:latest-team-activity="
|
</TeamActivityCard>
|
||||||
props.latestTeamActivity
|
|
||||||
"></TeamActivityCard>
|
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
<MainContainer class="py-5">
|
<MainContainer class="py-5">
|
||||||
<ThisWeekOverview
|
<ThisWeekOverview></ThisWeekOverview>
|
||||||
:weekly-project-overview="props.weeklyProjectOverview"
|
|
||||||
:total-weekly-billable-amount="props.totalWeeklyBillableAmount"
|
|
||||||
:total-weekly-billable-time="props.totalWeeklyBillableTime"
|
|
||||||
:total-weekly-time="props.totalWeeklyTime"
|
|
||||||
:weekly-history="props.weeklyHistory"></ThisWeekOverview>
|
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -611,6 +611,332 @@ const endpoints = makeApi([
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'get',
|
||||||
|
path: '/v1/organizations/:organization/charts/daily-tracked-hours',
|
||||||
|
alias: 'dailyTrackedHours',
|
||||||
|
requestFormat: 'json',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'organization',
|
||||||
|
type: 'Path',
|
||||||
|
schema: z.string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
response: z.array(
|
||||||
|
z
|
||||||
|
.object({ date: z.string(), duration: z.number().int() })
|
||||||
|
.passthrough()
|
||||||
|
),
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
description: `Unauthenticated`,
|
||||||
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
description: `Authorization error`,
|
||||||
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 404,
|
||||||
|
description: `Not found`,
|
||||||
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'get',
|
||||||
|
path: '/v1/organizations/:organization/charts/last-seven-days',
|
||||||
|
alias: 'lastSevenDays',
|
||||||
|
requestFormat: 'json',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'organization',
|
||||||
|
type: 'Path',
|
||||||
|
schema: z.string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
response: z.array(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
date: z.string(),
|
||||||
|
duration: z.number().int(),
|
||||||
|
history: z.array(z.number().int()),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
),
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
description: `Unauthenticated`,
|
||||||
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
description: `Authorization error`,
|
||||||
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 404,
|
||||||
|
description: `Not found`,
|
||||||
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'get',
|
||||||
|
path: '/v1/organizations/:organization/charts/latest-tasks',
|
||||||
|
alias: 'latestTasks',
|
||||||
|
requestFormat: 'json',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'organization',
|
||||||
|
type: 'Path',
|
||||||
|
schema: z.string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
response: z.array(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
task_id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
description: z.union([z.string(), z.null()]),
|
||||||
|
status: z.boolean(),
|
||||||
|
time_entry_id: z.union([z.string(), z.null()]),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
),
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
description: `Unauthenticated`,
|
||||||
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
description: `Authorization error`,
|
||||||
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 404,
|
||||||
|
description: `Not found`,
|
||||||
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'get',
|
||||||
|
path: '/v1/organizations/:organization/charts/latest-team-activity',
|
||||||
|
alias: 'latestTeamActivity',
|
||||||
|
requestFormat: 'json',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'organization',
|
||||||
|
type: 'Path',
|
||||||
|
schema: z.string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
response: z.array(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
member_id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
description: z.union([z.string(), z.null()]),
|
||||||
|
time_entry_id: z.string(),
|
||||||
|
task_id: z.union([z.string(), z.null()]),
|
||||||
|
status: z.boolean(),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
),
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
description: `Unauthenticated`,
|
||||||
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
description: `Authorization error`,
|
||||||
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 404,
|
||||||
|
description: `Not found`,
|
||||||
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'get',
|
||||||
|
path: '/v1/organizations/:organization/charts/total-weekly-billable-amount',
|
||||||
|
alias: 'totalWeeklyBillableAmount',
|
||||||
|
requestFormat: 'json',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'organization',
|
||||||
|
type: 'Path',
|
||||||
|
schema: z.string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
response: z
|
||||||
|
.object({ value: z.number().int(), currency: z.string() })
|
||||||
|
.passthrough(),
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
description: `Unauthenticated`,
|
||||||
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
description: `Authorization error`,
|
||||||
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 404,
|
||||||
|
description: `Not found`,
|
||||||
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'get',
|
||||||
|
path: '/v1/organizations/:organization/charts/total-weekly-billable-time',
|
||||||
|
alias: 'totalWeeklyBillableTime',
|
||||||
|
requestFormat: 'json',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'organization',
|
||||||
|
type: 'Path',
|
||||||
|
schema: z.string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
response: z.number().int(),
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
description: `Unauthenticated`,
|
||||||
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
description: `Authorization error`,
|
||||||
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 404,
|
||||||
|
description: `Not found`,
|
||||||
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'get',
|
||||||
|
path: '/v1/organizations/:organization/charts/total-weekly-time',
|
||||||
|
alias: 'totalWeeklyTime',
|
||||||
|
requestFormat: 'json',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'organization',
|
||||||
|
type: 'Path',
|
||||||
|
schema: z.string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
response: z.number().int(),
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
description: `Unauthenticated`,
|
||||||
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
description: `Authorization error`,
|
||||||
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 404,
|
||||||
|
description: `Not found`,
|
||||||
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'get',
|
||||||
|
path: '/v1/organizations/:organization/charts/weekly-history',
|
||||||
|
alias: 'weeklyHistory',
|
||||||
|
requestFormat: 'json',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'organization',
|
||||||
|
type: 'Path',
|
||||||
|
schema: z.string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
response: z.array(
|
||||||
|
z
|
||||||
|
.object({ date: z.string(), duration: z.number().int() })
|
||||||
|
.passthrough()
|
||||||
|
),
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
description: `Unauthenticated`,
|
||||||
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
description: `Authorization error`,
|
||||||
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 404,
|
||||||
|
description: `Not found`,
|
||||||
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'get',
|
||||||
|
path: '/v1/organizations/:organization/charts/weekly-project-overview',
|
||||||
|
alias: 'weeklyProjectOverview',
|
||||||
|
requestFormat: 'json',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'organization',
|
||||||
|
type: 'Path',
|
||||||
|
schema: z.string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
response: z.array(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
value: z.number().int(),
|
||||||
|
name: z.string(),
|
||||||
|
color: z.string(),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
),
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
description: `Unauthenticated`,
|
||||||
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
description: `Authorization error`,
|
||||||
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 404,
|
||||||
|
description: `Not found`,
|
||||||
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: 'get',
|
method: 'get',
|
||||||
path: '/v1/organizations/:organization/clients',
|
path: '/v1/organizations/:organization/clients',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Http\Controllers\Api\V1\ApiTokenController;
|
use App\Http\Controllers\Api\V1\ApiTokenController;
|
||||||
|
use App\Http\Controllers\Api\V1\ChartController;
|
||||||
use App\Http\Controllers\Api\V1\ClientController;
|
use App\Http\Controllers\Api\V1\ClientController;
|
||||||
use App\Http\Controllers\Api\V1\ExportController;
|
use App\Http\Controllers\Api\V1\ExportController;
|
||||||
use App\Http\Controllers\Api\V1\ImportController;
|
use App\Http\Controllers\Api\V1\ImportController;
|
||||||
@@ -123,6 +124,19 @@ Route::prefix('v1')->name('v1.')->group(static function (): void {
|
|||||||
Route::delete('/reports/{report}', [ReportController::class, 'destroy'])->name('destroy');
|
Route::delete('/reports/{report}', [ReportController::class, 'destroy'])->name('destroy');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Chart routes
|
||||||
|
Route::name('charts.')->prefix('/organizations/{organization}/charts')->group(static function (): void {
|
||||||
|
Route::get('/weekly-project-overview', [ChartController::class, 'weeklyProjectOverview'])->name('weekly-project-overview');
|
||||||
|
Route::get('/latest-tasks', [ChartController::class, 'latestTasks'])->name('latest-tasks');
|
||||||
|
Route::get('/last-seven-days', [ChartController::class, 'lastSevenDays'])->name('last-seven-days');
|
||||||
|
Route::get('/latest-team-activity', [ChartController::class, 'latestTeamActivity'])->name('latest-team-activity');
|
||||||
|
Route::get('/daily-tracked-hours', [ChartController::class, 'dailyTrackedHours'])->name('daily-tracked-hours');
|
||||||
|
Route::get('/total-weekly-time', [ChartController::class, 'totalWeeklyTime'])->name('total-weekly-time');
|
||||||
|
Route::get('/total-weekly-billable-time', [ChartController::class, 'totalWeeklyBillableTime'])->name('total-weekly-billable-time');
|
||||||
|
Route::get('/total-weekly-billable-amount', [ChartController::class, 'totalWeeklyBillableAmount'])->name('total-weekly-billable-amount');
|
||||||
|
Route::get('/weekly-history', [ChartController::class, 'weeklyHistory'])->name('weekly-history');
|
||||||
|
});
|
||||||
|
|
||||||
// Tag routes
|
// Tag routes
|
||||||
Route::name('tags.')->prefix('/organizations/{organization}')->group(static function (): void {
|
Route::name('tags.')->prefix('/organizations/{organization}')->group(static function (): void {
|
||||||
Route::get('/tags', [TagController::class, 'index'])->name('index');
|
Route::get('/tags', [TagController::class, 'index'])->name('index');
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ abstract class TestCaseWithDatabase extends TestCase
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return object{user: User, organization: Organization, member: Member, owner: User, ownerMember: Member}
|
||||||
|
*/
|
||||||
public function createUserWithRole(Role $role): object
|
public function createUserWithRole(Role $role): object
|
||||||
{
|
{
|
||||||
$owner = User::factory()->create();
|
$owner = User::factory()->create();
|
||||||
|
|||||||
303
tests/Unit/Endpoint/Api/V1/ChartEndpointTest.php
Normal file
303
tests/Unit/Endpoint/Api/V1/ChartEndpointTest.php
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Unit\Endpoint\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\Role;
|
||||||
|
use Laravel\Passport\Passport;
|
||||||
|
use Tests\Unit\Endpoint\Web\EndpointTestAbstract;
|
||||||
|
|
||||||
|
class ChartEndpointTest extends EndpointTestAbstract
|
||||||
|
{
|
||||||
|
public function test_weekly_project_overview_endpoint_fails_if_user_has_no_permission_to_view_chart(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = $this->createUserWithPermission();
|
||||||
|
Passport::actingAs($user->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.charts.weekly-project-overview', [
|
||||||
|
'organization' => $user->organization,
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_weekly_project_overview_endpoint_returns_chart_data(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = $this->createUserWithPermission(['charts:view:own']);
|
||||||
|
Passport::actingAs($user->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.charts.weekly-project-overview', [
|
||||||
|
'organization' => $user->organization,
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_latest_tasks_endpoint_fails_if_user_has_no_permission_to_view_chart(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = $this->createUserWithPermission();
|
||||||
|
Passport::actingAs($user->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.charts.latest-tasks', [
|
||||||
|
'organization' => $user->organization,
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_latest_tasks_endpoint_returns_chart_data(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = $this->createUserWithPermission(['charts:view:own']);
|
||||||
|
Passport::actingAs($user->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.charts.latest-tasks', [
|
||||||
|
'organization' => $user->organization,
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_last_seven_days_endpoint_fails_if_user_has_no_permission_to_view_chart(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = $this->createUserWithPermission();
|
||||||
|
Passport::actingAs($user->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.charts.last-seven-days', [
|
||||||
|
'organization' => $user->organization,
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_last_seven_days_endpoint_returns_chart_data(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = $this->createUserWithPermission(['charts:view:own']);
|
||||||
|
Passport::actingAs($user->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.charts.last-seven-days', [
|
||||||
|
'organization' => $user->organization,
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_latest_team_activity_endpoint_fails_if_user_has_no_permission_to_view_chart_for_the_whole_orgnaization(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = $this->createUserWithPermission();
|
||||||
|
Passport::actingAs($user->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.charts.latest-team-activity', [
|
||||||
|
'organization' => $user->organization,
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_latest_team_activity_endpoint_returns_chart_data(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = $this->createUserWithPermission(['charts:view:all']);
|
||||||
|
Passport::actingAs($user->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.charts.latest-team-activity', [
|
||||||
|
'organization' => $user->organization,
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_daily_tracked_hours_endpoint_fails_if_user_has_no_permission_to_view_chart(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = $this->createUserWithPermission();
|
||||||
|
Passport::actingAs($user->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.charts.daily-tracked-hours', [
|
||||||
|
'organization' => $user->organization,
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_daily_tracked_hours_endpoint_returns_chart_data(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = $this->createUserWithPermission(['charts:view:own']);
|
||||||
|
Passport::actingAs($user->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.charts.daily-tracked-hours', [
|
||||||
|
'organization' => $user->organization,
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_total_weekly_time_endpoint_fails_if_user_has_no_permission_to_view_chart(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = $this->createUserWithPermission();
|
||||||
|
Passport::actingAs($user->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.charts.total-weekly-time', [
|
||||||
|
'organization' => $user->organization,
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_total_weekly_time_endpoint_returns_chart_data(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = $this->createUserWithPermission(['charts:view:own']);
|
||||||
|
Passport::actingAs($user->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.charts.total-weekly-time', [
|
||||||
|
'organization' => $user->organization,
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_total_weekly_billable_time_endpoint_fails_if_user_has_no_permission_to_view_chart(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = $this->createUserWithPermission();
|
||||||
|
Passport::actingAs($user->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.charts.total-weekly-billable-time', [
|
||||||
|
'organization' => $user->organization,
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_total_weekly_billable_time_endpoint_returns_chart_data(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = $this->createUserWithPermission(['charts:view:own']);
|
||||||
|
Passport::actingAs($user->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.charts.total-weekly-billable-time', [
|
||||||
|
'organization' => $user->organization,
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_total_weekly_billable_amount_endpoint_fails_if_user_has_no_permission_to_view_chart(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = $this->createUserWithPermission();
|
||||||
|
Passport::actingAs($user->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.charts.total-weekly-billable-amount', [
|
||||||
|
'organization' => $user->organization,
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_total_weekly_billable_amount_endpoint_fails_if_the_user_is_an_employee_but_the_organization_does_not_allow_employees_to_view_billable_rates(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = $this->createUserWithRole(Role::Employee);
|
||||||
|
$organization = $user->organization;
|
||||||
|
$organization->employees_can_see_billable_rates = false;
|
||||||
|
$organization->save();
|
||||||
|
Passport::actingAs($user->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.charts.total-weekly-billable-amount', [
|
||||||
|
'organization' => $organization,
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_total_weekly_billable_amount_endpoint_returns_chart_data(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = $this->createUserWithRole(Role::Employee);
|
||||||
|
$organization = $user->organization;
|
||||||
|
$organization->employees_can_see_billable_rates = true;
|
||||||
|
$organization->save();
|
||||||
|
Passport::actingAs($user->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.charts.total-weekly-billable-amount', [
|
||||||
|
'organization' => $user->organization,
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_weekly_history_endpoint_fails_if_user_has_no_permission_to_view_chart(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = $this->createUserWithPermission();
|
||||||
|
Passport::actingAs($user->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.charts.weekly-history', [
|
||||||
|
'organization' => $user->organization,
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_weekly_history_endpoint_returns_chart_data(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = $this->createUserWithPermission(['charts:view:own']);
|
||||||
|
Passport::actingAs($user->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.charts.weekly-history', [
|
||||||
|
'organization' => $user->organization,
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertOk();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ use PHPUnit\Framework\Attributes\UsesClass;
|
|||||||
#[UsesClass(DashboardController::class)]
|
#[UsesClass(DashboardController::class)]
|
||||||
class DashboardEndpointTest extends EndpointTestAbstract
|
class DashboardEndpointTest extends EndpointTestAbstract
|
||||||
{
|
{
|
||||||
public function test_showing_dashboard_succeeds_for_empty_user_with_no_data_entries(): void
|
public function test_showing_dashboard_succeeds_for_empty_user(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
$user = User::factory()->withPersonalOrganization()->create();
|
$user = User::factory()->withPersonalOrganization()->create();
|
||||||
@@ -27,30 +27,9 @@ class DashboardEndpointTest extends EndpointTestAbstract
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertSuccessful();
|
$response->assertSuccessful();
|
||||||
$response->assertInertia(fn (Assert $page) => $page
|
|
||||||
->has('weeklyProjectOverview')
|
|
||||||
->has('latestTasks')
|
|
||||||
->has('lastSevenDays')
|
|
||||||
->has('latestTeamActivity')
|
|
||||||
->has('dailyTrackedHours')
|
|
||||||
->has('totalWeeklyTime')
|
|
||||||
->has('totalWeeklyBillableTime')
|
|
||||||
->has('totalWeeklyBillableAmount')
|
|
||||||
->has('weeklyHistory')
|
|
||||||
->whereNot('weeklyProjectOverview', null)
|
|
||||||
->whereNot('latestTasks', null)
|
|
||||||
->whereNot('lastSevenDays', null)
|
|
||||||
->whereNot('latestTeamActivity', null)
|
|
||||||
->whereNot('dailyTrackedHours', null)
|
|
||||||
->whereNot('totalWeeklyTime', null)
|
|
||||||
->whereNot('totalWeeklyBillableTime', null)
|
|
||||||
->whereNot('totalWeeklyBillableAmount', null)
|
|
||||||
->whereNot('weeklyHistory', null)
|
|
||||||
->whereNot('latestTeamActivity', null)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_showing_dashboard_succeeds_with_less_data_for_user_with_employee_role(): void
|
public function test_showing_dashboard_succeeds_for_user_with_employee_role(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
$organization = Organization::factory()->create();
|
$organization = Organization::factory()->create();
|
||||||
@@ -63,25 +42,5 @@ class DashboardEndpointTest extends EndpointTestAbstract
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertSuccessful();
|
$response->assertSuccessful();
|
||||||
$response->assertInertia(fn (Assert $page) => $page
|
|
||||||
->has('weeklyProjectOverview')
|
|
||||||
->has('latestTasks')
|
|
||||||
->has('lastSevenDays')
|
|
||||||
->has('latestTeamActivity')
|
|
||||||
->has('dailyTrackedHours')
|
|
||||||
->has('totalWeeklyTime')
|
|
||||||
->has('totalWeeklyBillableTime')
|
|
||||||
->has('totalWeeklyBillableAmount')
|
|
||||||
->has('weeklyHistory')
|
|
||||||
->whereNot('weeklyProjectOverview', null)
|
|
||||||
->whereNot('latestTasks', null)
|
|
||||||
->whereNot('lastSevenDays', null)
|
|
||||||
->where('latestTeamActivity', null)
|
|
||||||
->whereNot('dailyTrackedHours', null)
|
|
||||||
->whereNot('totalWeeklyTime', null)
|
|
||||||
->whereNot('totalWeeklyBillableTime', null)
|
|
||||||
->where('totalWeeklyBillableAmount', null)
|
|
||||||
->whereNot('weeklyHistory', null)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user