mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-17 20:52:14 +01:00
refactor timeentries queries and mutations, improve activitygraph, add dashboard reporting table
This commit is contained in:
@@ -102,7 +102,7 @@ class ChartController extends Controller
|
|||||||
$this->checkPermission($organization, 'charts:view:own');
|
$this->checkPermission($organization, 'charts:view:own');
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
|
|
||||||
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 60);
|
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 100);
|
||||||
|
|
||||||
return response()->json($dailyTrackedHours);
|
return response()->json($dailyTrackedHours);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ test('test that creating and deleting a new client via the modal works', async (
|
|||||||
|
|
||||||
await expect(page.getByTestId('client_table')).toContainText(newClientName);
|
await expect(page.getByTestId('client_table')).toContainText(newClientName);
|
||||||
const moreButton = page.locator("[aria-label='Actions for Client " + newClientName + "']");
|
const moreButton = page.locator("[aria-label='Actions for Client " + newClientName + "']");
|
||||||
moreButton.click();
|
await moreButton.click();
|
||||||
const deleteButton = page.locator("[aria-label='Delete Client " + newClientName + "']");
|
const deleteButton = page.locator("[aria-label='Delete Client " + newClientName + "']");
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
|||||||
@@ -17,8 +17,16 @@ test('test that updating project member billable rate works for existing time en
|
|||||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||||
await page.getByLabel('Project Name').fill(newProjectName);
|
await page.getByLabel('Project Name').fill(newProjectName);
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
await Promise.all([
|
||||||
await expect(page.getByText(newProjectName)).toBeVisible();
|
page.getByRole('button', { name: 'Create Project' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/projects') &&
|
||||||
|
response.request().method() === 'POST' &&
|
||||||
|
response.status() === 201
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
await page.getByText(newProjectName).click();
|
await page.getByText(newProjectName).click();
|
||||||
await page.getByRole('button', { name: 'Add Member' }).click();
|
await page.getByRole('button', { name: 'Add Member' }).click();
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ test('test that creating and deleting a new project via the modal works', async
|
|||||||
|
|
||||||
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
|
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
|
||||||
const moreButton = page.locator("[aria-label='Actions for Project " + newProjectName + "']");
|
const moreButton = page.locator("[aria-label='Actions for Project " + newProjectName + "']");
|
||||||
moreButton.click();
|
await moreButton.click();
|
||||||
const deleteButton = page.locator("[aria-label='Delete Project " + newProjectName + "']");
|
const deleteButton = page.locator("[aria-label='Delete Project " + newProjectName + "']");
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -79,8 +79,16 @@ test('test that archiving and unarchiving projects works', async ({ page }) => {
|
|||||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||||
await page.getByLabel('Project Name').fill(newProjectName);
|
await page.getByLabel('Project Name').fill(newProjectName);
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
await Promise.all([
|
||||||
await expect(page.getByText(newProjectName)).toBeVisible();
|
page.getByRole('button', { name: 'Create Project' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/projects') &&
|
||||||
|
response.request().method() === 'POST' &&
|
||||||
|
response.status() === 201
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Archive the project
|
// Archive the project
|
||||||
await page.getByRole('row').first().getByRole('button').click();
|
await page.getByRole('row').first().getByRole('button').click();
|
||||||
@@ -118,8 +126,16 @@ test('test that updating billable rate works with existing time entries', async
|
|||||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||||
await page.getByLabel('Project Name').fill(newProjectName);
|
await page.getByLabel('Project Name').fill(newProjectName);
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
await Promise.all([
|
||||||
await expect(page.getByText(newProjectName)).toBeVisible();
|
page.getByRole('button', { name: 'Create Project' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/projects') &&
|
||||||
|
response.request().method() === 'POST' &&
|
||||||
|
response.status() === 201
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
await page.getByRole('row').first().getByRole('button').click();
|
await page.getByRole('row').first().getByRole('button').click();
|
||||||
await page.getByRole('menuitem').getByText('Edit').first().click();
|
await page.getByRole('menuitem').getByText('Edit').first().click();
|
||||||
@@ -224,8 +240,16 @@ test('test that filtering projects by status works', async ({ page }) => {
|
|||||||
// Create a new project
|
// Create a new project
|
||||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||||
await page.getByLabel('Project Name').fill(newProjectName);
|
await page.getByLabel('Project Name').fill(newProjectName);
|
||||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
await Promise.all([
|
||||||
await expect(page.getByText(newProjectName)).toBeVisible();
|
page.getByRole('button', { name: 'Create Project' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/projects') &&
|
||||||
|
response.request().method() === 'POST' &&
|
||||||
|
response.status() === 201
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Archive the project
|
// Archive the project
|
||||||
await page.getByRole('row').first().getByRole('button').click();
|
await page.getByRole('row').first().getByRole('button').click();
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import VChart, { THEME_KEY } from 'vue-echarts';
|
import VChart, { THEME_KEY } from 'vue-echarts';
|
||||||
import { provide, computed, inject, type ComputedRef } from 'vue';
|
import { provide, computed, inject, ref, type ComputedRef } from 'vue';
|
||||||
import { use } from 'echarts/core';
|
import { use } from 'echarts/core';
|
||||||
|
import { useElementSize } from '@vueuse/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';
|
||||||
@@ -12,13 +13,13 @@ import {
|
|||||||
VisualMapComponent,
|
VisualMapComponent,
|
||||||
} from 'echarts/components';
|
} from 'echarts/components';
|
||||||
import { CanvasRenderer } from 'echarts/renderers';
|
import { CanvasRenderer } from 'echarts/renderers';
|
||||||
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 chroma from 'chroma-js';
|
||||||
import { useCssVariable } from '@/utils/useCssVariable';
|
import { useCssVariable } from '@/utils/useCssVariable';
|
||||||
import { useQuery } from '@tanstack/vue-query';
|
import { useQuery } from '@tanstack/vue-query';
|
||||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
@@ -62,8 +63,44 @@ const max = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const backgroundColor = useCssVariable('--theme-color-card-background');
|
const backgroundColor = useCssVariable('--theme-color-card-background');
|
||||||
const itemBackgroundColor = useCssVariable('--color-bg-tertiary');
|
|
||||||
const borderColor = useCssVariable('--color-border');
|
const borderColor = useCssVariable('--color-border');
|
||||||
|
const labelColor = useCssVariable('--color-text-secondary');
|
||||||
|
const chartColorRaw = useCssVariable('--theme-color-chart');
|
||||||
|
|
||||||
|
const chartEmptyColorRaw = useCssVariable('--color-bg-tertiary');
|
||||||
|
const chartEmptyColor = computed(() => {
|
||||||
|
if (!chartEmptyColorRaw.value) return '#2a2c32';
|
||||||
|
return chroma(chartEmptyColorRaw.value).hex();
|
||||||
|
});
|
||||||
|
const chartColor = computed(() => {
|
||||||
|
if (!chartColorRaw.value) return '#bae6fd';
|
||||||
|
return `rgb(${chartColorRaw.value})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track chart container size
|
||||||
|
const chartContainer = ref<HTMLElement | null>(null);
|
||||||
|
const { width: containerWidth } = useElementSize(chartContainer);
|
||||||
|
|
||||||
|
// Calculate number of weeks based on available width
|
||||||
|
// Rough estimate: 40px per cell + 80px for labels = ~360px for 7 weeks
|
||||||
|
const numberOfWeeks = computed(() => {
|
||||||
|
const availableWidth = containerWidth.value || 400;
|
||||||
|
const minCellSize = 25; // Minimum cell size in pixels
|
||||||
|
const labelSpace = 80; // Space for day labels
|
||||||
|
const usableWidth = availableWidth - labelSpace;
|
||||||
|
const maxWeeks = Math.floor(usableWidth / minCellSize);
|
||||||
|
// Clamp between 4 and 12 weeks for reasonable display
|
||||||
|
return Math.max(4, Math.min(12, maxWeeks));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate date range based on dynamic number of weeks
|
||||||
|
const dateRange = computed(() => {
|
||||||
|
const today = getDayJsInstance()();
|
||||||
|
const startOfWeek = today.startOf('week');
|
||||||
|
// Go back (numberOfWeeks - 1) weeks from the start of current week
|
||||||
|
const rangeStart = startOfWeek.subtract(numberOfWeeks.value - 1, 'week');
|
||||||
|
return [today.format('YYYY-MM-DD'), rangeStart.format('YYYY-MM-DD')];
|
||||||
|
});
|
||||||
|
|
||||||
const option = computed(() => {
|
const option = computed(() => {
|
||||||
return {
|
return {
|
||||||
@@ -76,26 +113,30 @@ const option = computed(() => {
|
|||||||
left: 'center',
|
left: 'center',
|
||||||
top: 'center',
|
top: 'center',
|
||||||
inRange: {
|
inRange: {
|
||||||
color: [itemBackgroundColor.value, '#2DBE45'],
|
color: [chartEmptyColor.value, chartColor.value],
|
||||||
},
|
},
|
||||||
show: false,
|
show: false,
|
||||||
},
|
},
|
||||||
calendar: {
|
calendar: {
|
||||||
top: 40,
|
top: 35,
|
||||||
bottom: 20,
|
bottom: 20,
|
||||||
left: 40,
|
left: 35,
|
||||||
right: 10,
|
right: 5,
|
||||||
cellSize: [40, 40],
|
cellSize: 'auto',
|
||||||
|
orient: 'horizontal',
|
||||||
dayLabel: {
|
dayLabel: {
|
||||||
firstDay: firstDayIndex.value,
|
firstDay: firstDayIndex.value,
|
||||||
|
color: labelColor.value,
|
||||||
|
fontFamily: 'Inter, sans-serif',
|
||||||
|
},
|
||||||
|
monthLabel: {
|
||||||
|
color: labelColor.value,
|
||||||
|
fontFamily: 'Inter, sans-serif',
|
||||||
},
|
},
|
||||||
splitLine: {
|
splitLine: {
|
||||||
show: false,
|
show: false,
|
||||||
},
|
},
|
||||||
range: [
|
range: dateRange.value,
|
||||||
dayjs().format('YYYY-MM-DD'),
|
|
||||||
getDayJsInstance()().subtract(50, 'day').startOf('week').format('YYYY-MM-DD'),
|
|
||||||
],
|
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
color: 'transparent',
|
color: 'transparent',
|
||||||
borderWidth: 8,
|
borderWidth: 8,
|
||||||
@@ -144,7 +185,7 @@ const option = computed(() => {
|
|||||||
<div v-if="isLoading" class="flex justify-center items-center h-40">
|
<div v-if="isLoading" class="flex justify-center items-center h-40">
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="dailyHoursTracked">
|
<div v-else-if="dailyHoursTracked" ref="chartContainer">
|
||||||
<v-chart
|
<v-chart
|
||||||
class="chart"
|
class="chart"
|
||||||
:autoresize="true"
|
:autoresize="true"
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="flex flex-col">
|
<section class="flex overflow-hidden flex-col gap-1.5">
|
||||||
<CardTitle :title="title" :icon="icon"></CardTitle>
|
<CardTitle :title="title" :icon="icon"></CardTitle>
|
||||||
<div class="rounded-lg border border-card-border flex-1 flex items-stretch">
|
|
||||||
|
<div
|
||||||
|
class="flex-1 flex items-stretch rounded-lg bg-card-background border border-card-border">
|
||||||
<div class="w-full flex flex-col">
|
<div class="w-full flex flex-col">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,17 +14,16 @@ defineProps<{
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="px-3.5 py-2 flex justify-between @container border-b border-b-background-separator">
|
<div class="px-3.5 py-2 flex justify-between @container">
|
||||||
<div class="flex items-center min-w-[70px]">
|
<div class="flex items-center min-w-[70px]">
|
||||||
<p class="font-medium text-sm text-text-primary">
|
<p class="text-sm text-text-primary">
|
||||||
{{ formatHumanReadableDate(date) }}
|
{{ formatHumanReadableDate(date) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="items-center justify-center flex-1 hidden @2xs:flex">
|
<div class="items-center justify-center flex-1 hidden @2xs:flex">
|
||||||
<DayOverviewCardChart :history="history"></DayOverviewCardChart>
|
<DayOverviewCardChart :history="history"></DayOverviewCardChart>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="flex text-sm items-center justify-center text-text-secondary min-w-[65px]">
|
||||||
class="flex text-sm items-center justify-center text-text-secondary min-w-[65px] font-medium">
|
|
||||||
{{
|
{{
|
||||||
formatHumanReadableDuration(
|
formatHumanReadableDuration(
|
||||||
duration,
|
duration,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/vue-query';
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import RecentlyTrackedTasksCardEntry from '@/Components/Dashboard/RecentlyTrackedTasksCardEntry.vue';
|
import RecentlyTrackedTasksCardEntry from '@/Components/Dashboard/RecentlyTrackedTasksCardEntry.vue';
|
||||||
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
|
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
|
||||||
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
|
import { CheckCircleIcon } from '@heroicons/vue/24/solid';
|
||||||
import { PlusCircleIcon } from '@heroicons/vue/24/solid';
|
import { PlusCircleIcon } from '@heroicons/vue/24/solid';
|
||||||
import { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser';
|
import { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
import { api } from '@/packages/api/src';
|
import { api } from '@/packages/api/src';
|
||||||
|
|||||||
@@ -45,11 +45,11 @@ async function startTaskTimer() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="px-3.5 py-2 grid grid-cols-5 border-b border-b-background-separator">
|
<div class="px-3.5 py-2 grid grid-cols-5">
|
||||||
<div class="col-span-4">
|
<div class="col-span-4">
|
||||||
<p class="font-medium text-text-primary text-sm pb-1 truncate">
|
<p class="text-text-secondary text-sm pb-1.5 truncate">
|
||||||
<span v-if="timeEntry.description"> {{ timeEntry.description }}</span>
|
<span v-if="timeEntry.description"> {{ timeEntry.description }}</span>
|
||||||
<span v-else class="text-text-tertiary">No description</span>
|
<span v-else>No description</span>
|
||||||
</p>
|
</p>
|
||||||
<ProjectBadge size="base" class="min-w-0 max-w-full" :color="project?.color">
|
<ProjectBadge size="base" class="min-w-0 max-w-full" :color="project?.color">
|
||||||
<div class="flex items-center lg:space-x-0.5 min-w-0">
|
<div class="flex items-center lg:space-x-0.5 min-w-0">
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ defineProps<{
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="px-4 py-2 2xl:py-3 border-b border-b-background-separator">
|
<div class="px-3.5 py-2 2xl:py-3">
|
||||||
<div class="col-span-2">
|
<div class="col-span-2">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<p
|
<p
|
||||||
class="font-semibold text-sm min-w-0 overflow-ellipsis overflow-hidden flex-1 text-text-primary">
|
class="text-xs min-w-0 overflow-ellipsis overflow-hidden flex-1 text-text-secondary">
|
||||||
{{ name }}
|
{{ name }}
|
||||||
</p>
|
</p>
|
||||||
<div v-if="working" class="flex space-x-1.5 items-center justify-end">
|
<div v-if="working" class="flex space-x-1.5 items-center justify-end">
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ 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 ThisWeekReportingTable from '@/Components/Dashboard/ThisWeekReportingTable.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';
|
||||||
@@ -71,6 +72,7 @@ const { data: weeklyProjectOverview } = useQuery({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
enabled: computed(() => !!organizationId.value),
|
enabled: computed(() => !!organizationId.value),
|
||||||
|
staleTime: 1000 * 30, // 30 seconds
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: totalWeeklyTime } = useQuery({
|
const { data: totalWeeklyTime } = useQuery({
|
||||||
@@ -83,6 +85,7 @@ const { data: totalWeeklyTime } = useQuery({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
enabled: computed(() => !!organizationId.value),
|
enabled: computed(() => !!organizationId.value),
|
||||||
|
staleTime: 1000 * 30, // 30 seconds
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: totalWeeklyBillableTime } = useQuery({
|
const { data: totalWeeklyBillableTime } = useQuery({
|
||||||
@@ -95,6 +98,7 @@ const { data: totalWeeklyBillableTime } = useQuery({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
enabled: computed(() => !!organizationId.value),
|
enabled: computed(() => !!organizationId.value),
|
||||||
|
staleTime: 1000 * 30, // 30 seconds
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: totalWeeklyBillableAmount } = useQuery({
|
const { data: totalWeeklyBillableAmount } = useQuery({
|
||||||
@@ -107,6 +111,7 @@ const { data: totalWeeklyBillableAmount } = useQuery({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
enabled: computed(() => !!organizationId.value),
|
enabled: computed(() => !!organizationId.value),
|
||||||
|
staleTime: 1000 * 30, // 30 seconds
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: weeklyHistory } = useQuery({
|
const { data: weeklyHistory } = useQuery({
|
||||||
@@ -119,6 +124,7 @@ const { data: weeklyHistory } = useQuery({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
enabled: computed(() => !!organizationId.value),
|
enabled: computed(() => !!organizationId.value),
|
||||||
|
staleTime: 1000 * 30, // 30 seconds
|
||||||
});
|
});
|
||||||
|
|
||||||
const seriesData = computed(() => {
|
const seriesData = computed(() => {
|
||||||
@@ -241,6 +247,10 @@ const option = computed(() => {
|
|||||||
<div class="col-span-2 xl:col-span-3">
|
<div class="col-span-2 xl:col-span-3">
|
||||||
<CardTitle title="This Week" class="pb-8" :icon="ClockIcon"></CardTitle>
|
<CardTitle title="This Week" class="pb-8" :icon="ClockIcon"></CardTitle>
|
||||||
<v-chart v-if="weeklyHistory" :autoresize="true" class="chart" :option="option" />
|
<v-chart v-if="weeklyHistory" :autoresize="true" class="chart" :option="option" />
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<ThisWeekReportingTable></ThisWeekReportingTable>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<StatCard
|
<StatCard
|
||||||
|
|||||||
194
resources/js/Components/Dashboard/ThisWeekReportingTable.vue
Normal file
194
resources/js/Components/Dashboard/ThisWeekReportingTable.vue
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
|
||||||
|
import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';
|
||||||
|
import {
|
||||||
|
formatHumanReadableDuration,
|
||||||
|
getDayJsInstance,
|
||||||
|
getLocalizedDayJs,
|
||||||
|
} from '@/packages/ui/src/utils/time';
|
||||||
|
import { formatCents } from '@/packages/ui/src/utils/money';
|
||||||
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
|
import { type GroupingOption, useReportingStore } from '@/utils/useReporting';
|
||||||
|
import { getCurrentMembershipId, getCurrentOrganizationId, getCurrentRole } from '@/utils/useUser';
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
type AggregatedTimeEntries,
|
||||||
|
type AggregatedTimeEntriesQueryParams,
|
||||||
|
type Organization,
|
||||||
|
} from '@/packages/api/src';
|
||||||
|
import { useQuery } from '@tanstack/vue-query';
|
||||||
|
import { useStorage } from '@vueuse/core';
|
||||||
|
import { computed, inject, type ComputedRef, watch } from 'vue';
|
||||||
|
|
||||||
|
const organization = inject<ComputedRef<Organization>>('organization');
|
||||||
|
|
||||||
|
const group = useStorage<GroupingOption>('dashboard-reporting-group', 'project');
|
||||||
|
const subGroup = useStorage<GroupingOption>('dashboard-reporting-sub-group', 'task');
|
||||||
|
|
||||||
|
const reportingStore = useReportingStore();
|
||||||
|
const { groupByOptions, getNameForReportingRowEntry } = reportingStore;
|
||||||
|
|
||||||
|
watch(
|
||||||
|
group,
|
||||||
|
() => {
|
||||||
|
if (group.value === subGroup.value) {
|
||||||
|
const fallbackOption = groupByOptions.find((el) => el.value !== group.value);
|
||||||
|
if (fallbackOption?.value) {
|
||||||
|
subGroup.value = fallbackOption.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const organizationId = computed(() => getCurrentOrganizationId());
|
||||||
|
|
||||||
|
const weekStartUtc = computed(() => {
|
||||||
|
return getLocalizedDayJs(getDayJsInstance()().format())
|
||||||
|
.startOf('week')
|
||||||
|
.startOf('day')
|
||||||
|
.utc()
|
||||||
|
.format();
|
||||||
|
});
|
||||||
|
|
||||||
|
const weekEndUtc = computed(() => {
|
||||||
|
return getLocalizedDayJs(getDayJsInstance()().format()).endOf('day').utc().format();
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryParams = computed<AggregatedTimeEntriesQueryParams>(() => {
|
||||||
|
return {
|
||||||
|
start: weekStartUtc.value,
|
||||||
|
end: weekEndUtc.value,
|
||||||
|
group: group.value,
|
||||||
|
sub_group: subGroup.value,
|
||||||
|
member_id: getCurrentRole() === 'employee' ? getCurrentMembershipId() : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: reportingResponse, isLoading } = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
'dashboardThisWeekReporting',
|
||||||
|
organizationId,
|
||||||
|
weekStartUtc,
|
||||||
|
weekEndUtc,
|
||||||
|
group,
|
||||||
|
subGroup,
|
||||||
|
],
|
||||||
|
queryFn: () => {
|
||||||
|
return api.getAggregatedTimeEntries({
|
||||||
|
params: {
|
||||||
|
organization: organizationId.value!,
|
||||||
|
},
|
||||||
|
queries: queryParams.value,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
enabled: computed(() => !!organizationId.value),
|
||||||
|
});
|
||||||
|
|
||||||
|
const aggregatedTableTimeEntries = computed<AggregatedTimeEntries | null>(() => {
|
||||||
|
return (reportingResponse.value?.data as AggregatedTimeEntries | undefined) ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const tableData = computed(() => {
|
||||||
|
return (
|
||||||
|
aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
|
||||||
|
return {
|
||||||
|
seconds: entry.seconds,
|
||||||
|
cost: entry.cost,
|
||||||
|
description: getNameForReportingRowEntry(
|
||||||
|
entry.key,
|
||||||
|
aggregatedTableTimeEntries.value?.grouped_type ?? null
|
||||||
|
),
|
||||||
|
grouped_data:
|
||||||
|
entry.grouped_data?.map((el) => {
|
||||||
|
return {
|
||||||
|
seconds: el.seconds,
|
||||||
|
cost: el.cost,
|
||||||
|
description: getNameForReportingRowEntry(
|
||||||
|
el.key,
|
||||||
|
entry.grouped_type ?? null
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}) ?? [],
|
||||||
|
};
|
||||||
|
}) ?? []
|
||||||
|
);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="rounded-lg bg-card-background border border-card-border">
|
||||||
|
<div
|
||||||
|
class="text-sm flex text-text-primary pt-3 items-center space-x-3 font-medium px-6 border-b border-card-background-separator pb-3">
|
||||||
|
<span>Group by</span>
|
||||||
|
<ReportingGroupBySelect
|
||||||
|
v-model="group"
|
||||||
|
:group-by-options="groupByOptions"></ReportingGroupBySelect>
|
||||||
|
<span>and</span>
|
||||||
|
<ReportingGroupBySelect
|
||||||
|
v-model="subGroup"
|
||||||
|
:group-by-options="
|
||||||
|
groupByOptions.filter((el) => el.value !== group)
|
||||||
|
"></ReportingGroupBySelect>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid items-center" style="grid-template-columns: 1fr 100px 150px">
|
||||||
|
<div
|
||||||
|
class="contents [&>*]:border-card-background-separator [&>*]:border-b [&>*]:pb-1.5 [&>*]:pt-1 text-text-tertiary text-sm">
|
||||||
|
<div class="pl-6">Name</div>
|
||||||
|
<div class="text-right">Duration</div>
|
||||||
|
<div class="text-right pr-6">Cost</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isLoading" class="flex justify-center py-10 col-span-3 text-text-tertiary">
|
||||||
|
Loading reporting data…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template
|
||||||
|
v-else-if="
|
||||||
|
aggregatedTableTimeEntries?.grouped_data &&
|
||||||
|
aggregatedTableTimeEntries.grouped_data?.length > 0
|
||||||
|
">
|
||||||
|
<ReportingRow
|
||||||
|
v-for="entry in tableData"
|
||||||
|
:key="entry.description ?? 'none'"
|
||||||
|
:currency="getOrganizationCurrencyString()"
|
||||||
|
:entry="entry"></ReportingRow>
|
||||||
|
<div class="contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]">
|
||||||
|
<div class="flex items-center pl-6 font-medium">
|
||||||
|
<span>Total</span>
|
||||||
|
</div>
|
||||||
|
<div class="justify-end flex items-center font-medium">
|
||||||
|
{{
|
||||||
|
formatHumanReadableDuration(
|
||||||
|
aggregatedTableTimeEntries.seconds,
|
||||||
|
organization?.interval_format,
|
||||||
|
organization?.number_format
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div class="justify-end pr-6 flex items-center font-medium">
|
||||||
|
{{
|
||||||
|
aggregatedTableTimeEntries.cost
|
||||||
|
? formatCents(
|
||||||
|
aggregatedTableTimeEntries.cost,
|
||||||
|
getOrganizationCurrencyString(),
|
||||||
|
organization?.currency_format,
|
||||||
|
organization?.currency_symbol,
|
||||||
|
organization?.number_format
|
||||||
|
)
|
||||||
|
: '--'
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else class="chart flex flex-col items-center justify-center py-12 col-span-3">
|
||||||
|
<p class="text-lg text-text-primary font-medium">No time entries found</p>
|
||||||
|
<p>Try to track some time entries this week</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -15,7 +15,7 @@ const classes = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Link :href="href ?? ''" :class="classes" prefetch>
|
<Link :href="href ?? ''" :class="classes" prefetch="mount">
|
||||||
<slot />
|
<slot />
|
||||||
</Link>
|
</Link>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -34,10 +34,9 @@ import { getOrganizationCurrencyString } from '@/utils/money';
|
|||||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||||
import { canCreateProjects } from '@/utils/permissions';
|
import { canCreateProjects } from '@/utils/permissions';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
|
|
||||||
import { useMutation, useQueryClient } from '@tanstack/vue-query';
|
|
||||||
import { api } from '@/packages/api/src';
|
|
||||||
import { useNotificationsStore } from '@/utils/notification';
|
import { useNotificationsStore } from '@/utils/notification';
|
||||||
|
import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
|
||||||
|
import { useTimeEntriesInfiniteQuery } from '@/utils/useTimeEntriesInfiniteQuery';
|
||||||
|
|
||||||
const page = usePage<{
|
const page = usePage<{
|
||||||
auth: {
|
auth: {
|
||||||
@@ -62,6 +61,10 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const showManualTimeEntryModal = ref(false);
|
const showManualTimeEntryModal = ref(false);
|
||||||
|
|
||||||
|
const { createTimeEntry: createTimeEntryMutation, deleteTimeEntry } = useTimeEntriesMutations();
|
||||||
|
const { data: timeEntriesData } = useTimeEntriesInfiniteQuery();
|
||||||
|
const timeEntries = computed(() => timeEntriesData.value?.pages.flatMap((page) => page.data) || []);
|
||||||
|
|
||||||
watch(isActive, () => {
|
watch(isActive, () => {
|
||||||
if (isActive.value) {
|
if (isActive.value) {
|
||||||
startLiveTimer();
|
startLiveTimer();
|
||||||
@@ -113,7 +116,7 @@ async function createTag(tag: string): Promise<Tag | undefined> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createTimeEntry(timeEntry: Omit<CreateTimeEntryBody, 'member_id'>) {
|
async function createTimeEntry(timeEntry: Omit<CreateTimeEntryBody, 'member_id'>) {
|
||||||
await useTimeEntriesStore().createTimeEntry(timeEntry);
|
await createTimeEntryMutation(timeEntry);
|
||||||
showManualTimeEntryModal.value = false;
|
showManualTimeEntryModal.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,41 +127,19 @@ async function createTimeEntryFromCurrentEntry() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { handleApiRequestNotifications } = useNotificationsStore();
|
const { handleApiRequestNotifications } = useNotificationsStore();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const deleteTimeEntryMutation = useMutation({
|
|
||||||
mutationFn: async (timeEntryId: string) => {
|
|
||||||
const organizationId = getCurrentOrganizationId();
|
|
||||||
if (!organizationId) {
|
|
||||||
throw new Error('No organization selected');
|
|
||||||
}
|
|
||||||
return await api.deleteTimeEntry(undefined, {
|
|
||||||
params: {
|
|
||||||
organization: organizationId,
|
|
||||||
timeEntry: timeEntryId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: async () => {
|
|
||||||
await currentTimeEntryStore.fetchCurrentTimeEntry();
|
|
||||||
await useTimeEntriesStore().fetchTimeEntries();
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['timeEntry'] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
async function discardCurrentTimeEntry() {
|
async function discardCurrentTimeEntry() {
|
||||||
if (currentTimeEntry.value.id) {
|
if (currentTimeEntry.value.id) {
|
||||||
await handleApiRequestNotifications(
|
await handleApiRequestNotifications(
|
||||||
() => deleteTimeEntryMutation.mutateAsync(currentTimeEntry.value.id),
|
() => deleteTimeEntry(currentTimeEntry.value.id),
|
||||||
'Time entry discarded successfully',
|
'Time entry discarded successfully',
|
||||||
'Failed to discard time entry'
|
'Failed to discard time entry'
|
||||||
);
|
);
|
||||||
|
await currentTimeEntryStore.fetchCurrentTimeEntry();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { tags } = useTagsQuery();
|
const { tags } = useTagsQuery();
|
||||||
const { timeEntries } = storeToRefs(useTimeEntriesStore());
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -176,7 +157,7 @@ const { timeEntries } = storeToRefs(useTimeEntriesStore());
|
|||||||
:tags
|
:tags
|
||||||
:clients></TimeEntryCreateModal>
|
:clients></TimeEntryCreateModal>
|
||||||
<CardTitle title="Time Tracker" :icon="ClockIcon"></CardTitle>
|
<CardTitle title="Time Tracker" :icon="ClockIcon"></CardTitle>
|
||||||
<div class="relative">
|
<div class="relative pt-1">
|
||||||
<TimeTrackerRunningInDifferentOrganizationOverlay
|
<TimeTrackerRunningInDifferentOrganizationOverlay
|
||||||
v-if="isRunningInDifferentOrganization"
|
v-if="isRunningInDifferentOrganization"
|
||||||
@switch-organization="
|
@switch-organization="
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import { computed, onMounted, provide, ref } from 'vue';
|
|||||||
import NotificationContainer from '@/Components/NotificationContainer.vue';
|
import NotificationContainer from '@/Components/NotificationContainer.vue';
|
||||||
import { initializeStores } from '@/utils/init';
|
import { initializeStores } from '@/utils/init';
|
||||||
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
||||||
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
|
|
||||||
import {
|
import {
|
||||||
canManageBilling,
|
canManageBilling,
|
||||||
canUpdateOrganization,
|
canUpdateOrganization,
|
||||||
@@ -95,7 +94,6 @@ onMounted(async () => {
|
|||||||
// Only refresh non-migrated stores
|
// Only refresh non-migrated stores
|
||||||
if (isUnloading.value === false) {
|
if (isUnloading.value === false) {
|
||||||
useCurrentTimeEntryStore().fetchCurrentTimeEntry();
|
useCurrentTimeEntryStore().fetchCurrentTimeEntry();
|
||||||
useTimeEntriesStore().patchTimeEntries();
|
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/vue-query';
|
import { useTimeEntriesCalendarQuery } from '@/utils/useTimeEntriesCalendarQuery';
|
||||||
|
import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useQueryClient } from '@tanstack/vue-query';
|
||||||
import {
|
import {
|
||||||
api,
|
|
||||||
type Client,
|
type Client,
|
||||||
type CreateClientBody,
|
type CreateClientBody,
|
||||||
type CreateProjectBody,
|
type CreateProjectBody,
|
||||||
type Project,
|
type Project,
|
||||||
type TimeEntryResponse,
|
|
||||||
} from '@/packages/api/src';
|
} from '@/packages/api/src';
|
||||||
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
|
|
||||||
import { computed, ref } from 'vue';
|
|
||||||
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
|
|
||||||
import { TimeEntryCalendar } from '@/packages/ui/src';
|
import { TimeEntryCalendar } from '@/packages/ui/src';
|
||||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||||
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
|
|
||||||
import { useTagsStore } from '@/utils/useTags';
|
import { useTagsStore } from '@/utils/useTags';
|
||||||
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
||||||
import { useClientsQuery } from '@/utils/useClientsQuery';
|
import { useClientsQuery } from '@/utils/useClientsQuery';
|
||||||
@@ -22,71 +19,41 @@ import { useTasksQuery } from '@/utils/useTasksQuery';
|
|||||||
import { useTagsQuery } from '@/utils/useTagsQuery';
|
import { useTagsQuery } from '@/utils/useTagsQuery';
|
||||||
import { useProjectsStore } from '@/utils/useProjects';
|
import { useProjectsStore } from '@/utils/useProjects';
|
||||||
import { useClientsStore } from '@/utils/useClients';
|
import { useClientsStore } from '@/utils/useClients';
|
||||||
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
|
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
import { canCreateProjects } from '@/utils/permissions';
|
import { canCreateProjects } from '@/utils/permissions';
|
||||||
|
|
||||||
const calendarStart = ref<Date | undefined>(undefined);
|
const calendarStart = ref<Date | undefined>(undefined);
|
||||||
const calendarEnd = ref<Date | undefined>(undefined);
|
const calendarEnd = ref<Date | undefined>(undefined);
|
||||||
|
|
||||||
const enableCalendarQuery = computed(() => {
|
const { data: timeEntryResponse, isLoading: timeEntriesLoading } = useTimeEntriesCalendarQuery(
|
||||||
return !!getCurrentOrganizationId() && !!calendarStart.value && !!calendarEnd.value;
|
calendarStart,
|
||||||
});
|
calendarEnd
|
||||||
|
);
|
||||||
// Calculate expanded date range to include previous and next periods with timezone transformations
|
|
||||||
const expandedDateRange = computed(() => {
|
|
||||||
if (!calendarStart.value || !calendarEnd.value) {
|
|
||||||
return { start: null, end: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const dayjs = getDayJsInstance();
|
|
||||||
const duration = dayjs(calendarEnd.value).diff(dayjs(calendarStart.value), 'milliseconds');
|
|
||||||
|
|
||||||
// Calculate previous period
|
|
||||||
const previousStart = dayjs(calendarStart.value).subtract(duration, 'milliseconds');
|
|
||||||
// Calculate next period
|
|
||||||
const nextEnd = dayjs(calendarEnd.value).add(duration, 'milliseconds');
|
|
||||||
|
|
||||||
// Apply timezone transformations
|
|
||||||
const formattedStart = previousStart.utc().tz(getUserTimezone(), true).utc().format();
|
|
||||||
const formattedEnd = nextEnd.utc().tz(getUserTimezone(), true).utc().format();
|
|
||||||
|
|
||||||
return {
|
|
||||||
start: formattedStart,
|
|
||||||
end: formattedEnd,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: timeEntryResponse, isLoading: timeEntriesLoading } = useQuery<TimeEntryResponse>({
|
|
||||||
queryKey: computed(() => [
|
|
||||||
'timeEntry',
|
|
||||||
'calendar',
|
|
||||||
{
|
|
||||||
start: expandedDateRange.value.start,
|
|
||||||
end: expandedDateRange.value.end,
|
|
||||||
organization: getCurrentOrganizationId(),
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
enabled: enableCalendarQuery,
|
|
||||||
placeholderData: (previousData) => previousData,
|
|
||||||
queryFn: () =>
|
|
||||||
api.getTimeEntries({
|
|
||||||
params: {
|
|
||||||
organization: getCurrentOrganizationId() || '',
|
|
||||||
},
|
|
||||||
queries: {
|
|
||||||
start: expandedDateRange.value.start!,
|
|
||||||
end: expandedDateRange.value.end!,
|
|
||||||
member_id: getCurrentMembershipId(),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentTimeEntries = computed(() => {
|
const currentTimeEntries = computed(() => {
|
||||||
return timeEntryResponse?.value?.data || [];
|
return timeEntryResponse?.value?.data || [];
|
||||||
});
|
});
|
||||||
|
|
||||||
const { createTimeEntry, updateTimeEntry, deleteTimeEntry } = useTimeEntriesStore();
|
const {
|
||||||
|
createTimeEntry: createTimeEntryMutation,
|
||||||
|
updateTimeEntry: updateTimeEntryMutation,
|
||||||
|
deleteTimeEntry: deleteTimeEntryMutation,
|
||||||
|
} = useTimeEntriesMutations();
|
||||||
|
|
||||||
|
// Wrap mutations to match expected Promise<void> return type
|
||||||
|
async function createTimeEntry(
|
||||||
|
entry: Omit<import('@/packages/api/src').TimeEntry, 'id' | 'organization_id' | 'user_id'>
|
||||||
|
): Promise<void> {
|
||||||
|
await createTimeEntryMutation(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateTimeEntry(entry: import('@/packages/api/src').TimeEntry): Promise<void> {
|
||||||
|
await updateTimeEntryMutation(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTimeEntry(timeEntryId: string): Promise<void> {
|
||||||
|
await deleteTimeEntryMutation(timeEntryId);
|
||||||
|
}
|
||||||
|
|
||||||
async function createTag(name: string) {
|
async function createTag(name: string) {
|
||||||
return await useTagsStore().createTag(name);
|
return await useTagsStore().createTag(name);
|
||||||
@@ -114,7 +81,7 @@ function onDatesChange({ start, end }: { start: Date; end: Date }) {
|
|||||||
|
|
||||||
function onRefresh() {
|
function onRefresh() {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ['timeEntry', 'calendar'],
|
queryKey: ['timeEntries', 'calendar'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import {
|
|||||||
type CreateProjectBody,
|
type CreateProjectBody,
|
||||||
type Project,
|
type Project,
|
||||||
type TimeEntry,
|
type TimeEntry,
|
||||||
type TimeEntryResponse,
|
|
||||||
} from '@/packages/api/src';
|
} from '@/packages/api/src';
|
||||||
import ReportingFilterBadge from '@/Components/Common/Reporting/ReportingFilterBadge.vue';
|
import ReportingFilterBadge from '@/Components/Common/Reporting/ReportingFilterBadge.vue';
|
||||||
import ProjectMultiselectDropdown from '@/Components/Common/Project/ProjectMultiselectDropdown.vue';
|
import ProjectMultiselectDropdown from '@/Components/Common/Project/ProjectMultiselectDropdown.vue';
|
||||||
@@ -58,9 +57,8 @@ import {
|
|||||||
PaginationPrev,
|
PaginationPrev,
|
||||||
PaginationRoot,
|
PaginationRoot,
|
||||||
} from 'radix-vue';
|
} from 'radix-vue';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/vue-query';
|
import { useQueryClient } from '@tanstack/vue-query';
|
||||||
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
|
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
|
||||||
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
|
|
||||||
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
|
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
|
||||||
import ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.vue';
|
import ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.vue';
|
||||||
import type { ExportFormat } from '@/types/reporting';
|
import type { ExportFormat } from '@/types/reporting';
|
||||||
@@ -69,6 +67,8 @@ import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassAct
|
|||||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||||
import { canCreateProjects, canViewAllTimeEntries } from '@/utils/permissions';
|
import { canCreateProjects, canViewAllTimeEntries } from '@/utils/permissions';
|
||||||
import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue';
|
import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue';
|
||||||
|
import { useTimeEntriesReportQuery } from '@/utils/useTimeEntriesReportQuery';
|
||||||
|
import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
|
||||||
|
|
||||||
// TimeEntryRoundingType is now defined in ReportingRoundingControls component
|
// TimeEntryRoundingType is now defined in ReportingRoundingControls component
|
||||||
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
|
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
|
||||||
@@ -127,30 +127,32 @@ const currentTimeEntryStore = useCurrentTimeEntryStore();
|
|||||||
const { currentTimeEntry } = storeToRefs(currentTimeEntryStore);
|
const { currentTimeEntry } = storeToRefs(currentTimeEntryStore);
|
||||||
const { setActiveState, startLiveTimer } = currentTimeEntryStore;
|
const { setActiveState, startLiveTimer } = currentTimeEntryStore;
|
||||||
const { handleApiRequestNotifications } = useNotificationsStore();
|
const { handleApiRequestNotifications } = useNotificationsStore();
|
||||||
const { createTimeEntry, updateTimeEntry, updateTimeEntries } = useTimeEntriesStore();
|
|
||||||
|
const {
|
||||||
|
createTimeEntry,
|
||||||
|
updateTimeEntry,
|
||||||
|
updateTimeEntries: updateTimeEntriesMutation,
|
||||||
|
deleteTimeEntries: deleteTimeEntriesMutation,
|
||||||
|
} = useTimeEntriesMutations();
|
||||||
|
|
||||||
|
async function updateTimeEntries(
|
||||||
|
ids: string[],
|
||||||
|
changes: Parameters<typeof updateTimeEntriesMutation>[0]['changes']
|
||||||
|
) {
|
||||||
|
await updateTimeEntriesMutation({ ids, changes });
|
||||||
|
}
|
||||||
|
|
||||||
const { tags } = useTagsQuery();
|
const { tags } = useTagsQuery();
|
||||||
|
|
||||||
const { data: timeEntryResponse } = useQuery<TimeEntryResponse>({
|
const filterParams = computed(() => getFilterAttributes());
|
||||||
queryKey: ['timeEntry', 'detailed-report'],
|
const { data: timeEntryResponse } = useTimeEntriesReportQuery(filterParams);
|
||||||
enabled: !!getCurrentOrganizationId(),
|
|
||||||
queryFn: () =>
|
|
||||||
api.getTimeEntries({
|
|
||||||
params: {
|
|
||||||
organization: getCurrentOrganizationId() || '',
|
|
||||||
},
|
|
||||||
queries: { ...getFilterAttributes() },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalPages = computed(() => {
|
const totalPages = computed(() => {
|
||||||
return timeEntryResponse?.value?.meta?.total ?? 1;
|
return timeEntryResponse?.value?.meta?.total ?? 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
const timeEntriesStore = useTimeEntriesStore();
|
|
||||||
|
|
||||||
async function deleteTimeEntries(timeEntries: TimeEntry[]) {
|
async function deleteTimeEntries(timeEntries: TimeEntry[]) {
|
||||||
await timeEntriesStore.deleteTimeEntries(timeEntries);
|
await deleteTimeEntriesMutation(timeEntries);
|
||||||
selectedTimeEntries.value = [];
|
selectedTimeEntries.value = [];
|
||||||
await updateFilteredTimeEntries();
|
await updateFilteredTimeEntries();
|
||||||
}
|
}
|
||||||
@@ -203,7 +205,7 @@ async function startTimeEntryFromExisting(entry: TimeEntry) {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
async function updateFilteredTimeEntries() {
|
async function updateFilteredTimeEntries() {
|
||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: ['timeEntry', 'detailed-report'],
|
queryKey: ['timeEntries', 'detailed-report'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
watch(currentPage, () => {
|
watch(currentPage, () => {
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
<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 { onMounted, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||||
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
|
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import type {
|
import type {
|
||||||
CreateClientBody,
|
CreateClientBody,
|
||||||
@@ -30,17 +29,23 @@ import { canCreateProjects } from '@/utils/permissions';
|
|||||||
import { useTagsStore } from '@/utils/useTags';
|
import { useTagsStore } from '@/utils/useTags';
|
||||||
import { useProjectsStore } from '@/utils/useProjects';
|
import { useProjectsStore } from '@/utils/useProjects';
|
||||||
import { useClientsStore } from '@/utils/useClients';
|
import { useClientsStore } from '@/utils/useClients';
|
||||||
|
import { useTimeEntriesInfiniteQuery } from '@/utils/useTimeEntriesInfiniteQuery';
|
||||||
|
import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
|
||||||
|
|
||||||
const timeEntriesStore = useTimeEntriesStore();
|
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useTimeEntriesInfiniteQuery();
|
||||||
const { timeEntries, allTimeEntriesLoaded } = storeToRefs(timeEntriesStore);
|
const {
|
||||||
const { updateTimeEntry, fetchTimeEntries, createTimeEntry } = useTimeEntriesStore();
|
createTimeEntry: createTimeEntryMutation,
|
||||||
|
updateTimeEntry,
|
||||||
|
updateTimeEntries: updateTimeEntriesMutation,
|
||||||
|
deleteTimeEntries: deleteTimeEntriesMutation,
|
||||||
|
} = useTimeEntriesMutations();
|
||||||
|
|
||||||
|
const timeEntries = computed(() => data.value?.pages.flatMap((page) => page.data) || []);
|
||||||
|
|
||||||
async function updateTimeEntries(ids: string[], changes: UpdateMultipleTimeEntriesChangeset) {
|
async function updateTimeEntries(ids: string[], changes: UpdateMultipleTimeEntriesChangeset) {
|
||||||
await useTimeEntriesStore().updateTimeEntries(ids, changes);
|
await updateTimeEntriesMutation({ ids, changes });
|
||||||
fetchTimeEntries();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const loading = ref(false);
|
|
||||||
const loadMoreContainer = ref<HTMLDivElement | null>(null);
|
const loadMoreContainer = ref<HTMLDivElement | null>(null);
|
||||||
const isLoadMoreVisible = useElementVisibility(loadMoreContainer);
|
const isLoadMoreVisible = useElementVisibility(loadMoreContainer);
|
||||||
const currentTimeEntryStore = useCurrentTimeEntryStore();
|
const currentTimeEntryStore = useCurrentTimeEntryStore();
|
||||||
@@ -51,27 +56,20 @@ async function startTimeEntry(timeEntry: Omit<CreateTimeEntryBody, 'member_id'>)
|
|||||||
if (currentTimeEntry.value.id) {
|
if (currentTimeEntry.value.id) {
|
||||||
await setActiveState(false);
|
await setActiveState(false);
|
||||||
}
|
}
|
||||||
await createTimeEntry(timeEntry);
|
await createTimeEntryMutation(timeEntry);
|
||||||
fetchTimeEntries();
|
|
||||||
useCurrentTimeEntryStore().fetchCurrentTimeEntry();
|
useCurrentTimeEntryStore().fetchCurrentTimeEntry();
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteTimeEntries(timeEntries: TimeEntry[]) {
|
async function deleteTimeEntries(timeEntries: TimeEntry[]) {
|
||||||
useTimeEntriesStore().deleteTimeEntries(timeEntries);
|
await deleteTimeEntriesMutation(timeEntries);
|
||||||
fetchTimeEntries();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(isLoadMoreVisible, async (isVisible) => {
|
watch(isLoadMoreVisible, async (isVisible) => {
|
||||||
if (isVisible && timeEntries.value.length > 0 && !allTimeEntriesLoaded.value) {
|
if (isVisible && hasNextPage.value) {
|
||||||
loading.value = true;
|
await fetchNextPage();
|
||||||
await timeEntriesStore.fetchMoreTimeEntries();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await timeEntriesStore.fetchTimeEntries();
|
|
||||||
});
|
|
||||||
|
|
||||||
const { projects } = useProjectsQuery();
|
const { projects } = useProjectsQuery();
|
||||||
const { tasks } = useTasksQuery();
|
const { tasks } = useTasksQuery();
|
||||||
const { clients } = useClientsQuery();
|
const { clients } = useClientsQuery();
|
||||||
@@ -92,7 +90,6 @@ const selectedTimeEntries = ref([] as TimeEntry[]);
|
|||||||
|
|
||||||
async function clearSelectionAndState() {
|
async function clearSelectionAndState() {
|
||||||
selectedTimeEntries.value = [];
|
selectedTimeEntries.value = [];
|
||||||
await fetchTimeEntries();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteSelected() {
|
function deleteSelected() {
|
||||||
@@ -155,13 +152,13 @@ function deleteSelected() {
|
|||||||
</div>
|
</div>
|
||||||
<div ref="loadMoreContainer">
|
<div ref="loadMoreContainer">
|
||||||
<div
|
<div
|
||||||
v-if="loading && !allTimeEntriesLoaded"
|
v-if="isFetchingNextPage"
|
||||||
class="flex justify-center items-center py-5 text-text-primary font-medium">
|
class="flex justify-center items-center py-5 text-text-primary font-medium">
|
||||||
<LoadingSpinner></LoadingSpinner>
|
<LoadingSpinner></LoadingSpinner>
|
||||||
<span> Loading more time entries... </span>
|
<span> Loading more time entries... </span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="allTimeEntriesLoaded"
|
v-else-if="!hasNextPage"
|
||||||
class="flex justify-center items-center py-5 text-text-secondary font-medium">
|
class="flex justify-center items-center py-5 text-text-secondary font-medium">
|
||||||
All time entries are loaded!
|
All time entries are loaded!
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,10 +8,9 @@ defineProps<{
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex w-full items-center justify-between pb-2.5 lg:pb-3">
|
<div class="flex w-full items-center justify-between py-1.5">
|
||||||
<h3
|
<h3 class="text-text-primary font-medium text-sm flex items-center space-x-2">
|
||||||
class="text-text-primary font-medium text-sm lg:text-base flex items-center space-x-1.5 lg:space-x-2">
|
<component :is="icon" v-if="icon" class="w-4 lg:w-4 text-text-tertiary"></component>
|
||||||
<component :is="icon" v-if="icon" class="w-4 lg:w-4 text-icon-default"></component>
|
|
||||||
<span>
|
<span>
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ function selectUnselectAll(value: boolean) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="bg-background dark:bg-quaternary border-b border-border-primary py-1 text-xs @sm:text-sm">
|
class="bg-background dark:bg-secondary border-b border-border-primary py-1 text-xs @sm:text-sm">
|
||||||
<MainContainer>
|
<MainContainer>
|
||||||
<div class="flex group justify-between items-center">
|
<div class="flex group justify-between items-center">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ useSelectEvents(
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center relative @container" data-testid="dashboard_timer">
|
<div class="flex items-center relative @container" data-testid="dashboard_timer">
|
||||||
<div
|
<div
|
||||||
class="flex flex-col @2xl:flex-row w-full justify-between rounded-lg bg-secondary border-card-border border transition shadow-card">
|
class="flex flex-col @2xl:flex-row w-full justify-between rounded-lg bg-card-background border-card-border border transition shadow-card">
|
||||||
<div class="flex flex-1 items-center pr-6 relative">
|
<div class="flex flex-1 items-center pr-6 relative">
|
||||||
<input
|
<input
|
||||||
ref="currentTimeEntryDescriptionInput"
|
ref="currentTimeEntryDescriptionInput"
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ function closeAndFocusInput() {
|
|||||||
v-model="currentTime"
|
v-model="currentTime"
|
||||||
placeholder="00:00:00"
|
placeholder="00:00:00"
|
||||||
data-testid="time_entry_time"
|
data-testid="time_entry_time"
|
||||||
class="w-[110px] lg:w-[130px] h-full text-text-primary py-2.5 rounded-lg border-border-secondary border text-center px-4 text-base lg:text-lg font-semibold bg-secondary border-none placeholder-text-tertiary focus:ring-0 transition"
|
class="w-[110px] lg:w-[130px] h-full text-text-primary py-2.5 rounded-lg border-border-secondary border text-center px-4 text-base lg:text-lg font-semibold bg-card-background border-none placeholder-text-tertiary focus:ring-0 transition"
|
||||||
type="text"
|
type="text"
|
||||||
@focusin="openModalOnTab"
|
@focusin="openModalOnTab"
|
||||||
@click="openModalOnClick"
|
@click="openModalOnClick"
|
||||||
|
|||||||
@@ -14,18 +14,18 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
:root.dark {
|
:root.dark {
|
||||||
--color-bg-primary: #101012;
|
--color-bg-primary: oklch(0.14 0.0041 285.97);
|
||||||
--color-bg-secondary: #17181b;
|
--color-bg-secondary: oklch(0.18 0.005 285.97);
|
||||||
--color-bg-tertiary: #2a2c32;
|
--color-bg-tertiary: oklch(0.22 0.0112 285.97);
|
||||||
--color-bg-quaternary: #141518;
|
--color-bg-quaternary: oklch(0.175 0.005 285.97);
|
||||||
--color-bg-background: #090909;
|
--color-bg-background: oklch(0.1 0 0);
|
||||||
--color-text-primary: #ffffff;
|
--color-text-primary: #ffffff;
|
||||||
--color-text-secondary: #e3e4e6;
|
--color-text-secondary: #e3e4e6;
|
||||||
--color-text-tertiary: #969799;
|
--color-text-tertiary: #969799;
|
||||||
--color-text-quaternary: #595a5c;
|
--color-text-quaternary: #595a5c;
|
||||||
|
|
||||||
--color-border-primary: #191b1f;
|
--color-border-primary: #191b1f;
|
||||||
--color-border-secondary: #23252a;
|
--color-border-secondary: oklch(0.25 0.0098 268.31);
|
||||||
--color-border-tertiary: #2c2e33;
|
--color-border-tertiary: #2c2e33;
|
||||||
--color-border-quaternary: #393b42;
|
--color-border-quaternary: #393b42;
|
||||||
--color-input-border-active: rgba(255, 255, 255, 0.3);
|
--color-input-border-active: rgba(255, 255, 255, 0.3);
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
--theme-color-chart: var(--color-accent-200);
|
--theme-color-chart: var(--color-accent-200);
|
||||||
|
|
||||||
--theme-color-menu-active: var(--color-bg-secondary);
|
--theme-color-menu-active: var(--color-bg-secondary);
|
||||||
--theme-color-card-background: var(--color-bg-primary);
|
--theme-color-card-background: var(--color-bg-secondary);
|
||||||
--theme-shadow-card: 0 4px 7px 0px rgb(0 0 0 / 15%);
|
--theme-shadow-card: 0 4px 7px 0px rgb(0 0 0 / 15%);
|
||||||
--theme-shadow-dropdown: 0 4px 7px 0px rgb(0 0 0 / 40%);
|
--theme-shadow-dropdown: 0 4px 7px 0px rgb(0 0 0 / 40%);
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
||||||
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
|
|
||||||
|
|
||||||
export function initializeStores() {
|
export function initializeStores() {
|
||||||
// TanStack Query now handles projects, tasks, tags, clients, and members fetching automatically
|
// TanStack Query now handles projects, tasks, tags, clients, and members fetching automatically
|
||||||
// Only initialize stores that aren't migrated to TanStack Query yet
|
// Only initialize stores that aren't migrated to TanStack Query yet
|
||||||
useCurrentTimeEntryStore().fetchCurrentTimeEntry();
|
useCurrentTimeEntryStore().fetchCurrentTimeEntry();
|
||||||
useTimeEntriesStore().patchTimeEntries();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { QueryClient } from '@tanstack/vue-query';
|
import type { QueryClient } from '@tanstack/vue-query';
|
||||||
import { api } from '@/packages/api/src';
|
import { api } from '@/packages/api/src';
|
||||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
|
||||||
import { canViewClients, canViewMembers } from '@/utils/permissions';
|
import { canViewClients, canViewMembers } from '@/utils/permissions';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,6 +21,7 @@ const routePrefetchers: Record<string, (queryClient: QueryClient) => void> = {
|
|||||||
prefetchTasks(queryClient);
|
prefetchTasks(queryClient);
|
||||||
prefetchTags(queryClient);
|
prefetchTags(queryClient);
|
||||||
prefetchClients(queryClient);
|
prefetchClients(queryClient);
|
||||||
|
prefetchTimeEntries(queryClient);
|
||||||
},
|
},
|
||||||
|
|
||||||
'/calendar': (queryClient) => {
|
'/calendar': (queryClient) => {
|
||||||
@@ -221,6 +222,28 @@ function prefetchReports(queryClient: QueryClient) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function prefetchTimeEntries(queryClient: QueryClient) {
|
||||||
|
const organizationId = getCurrentOrganizationId();
|
||||||
|
const memberId = getCurrentMembershipId();
|
||||||
|
if (!organizationId) return;
|
||||||
|
|
||||||
|
queryClient.prefetchInfiniteQuery({
|
||||||
|
queryKey: ['timeEntries', 'infinite', { organizationId, memberId }],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await api.getTimeEntries({
|
||||||
|
params: { organization: organizationId },
|
||||||
|
queries: {
|
||||||
|
only_full_dates: 'true',
|
||||||
|
member_id: memberId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
initialPageParam: undefined,
|
||||||
|
staleTime: 30000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function prefetchProjectMembers(queryClient: QueryClient, projectId: string) {
|
function prefetchProjectMembers(queryClient: QueryClient, projectId: string) {
|
||||||
const organizationId = getCurrentOrganizationId();
|
const organizationId = getCurrentOrganizationId();
|
||||||
if (!organizationId || !canViewMembers()) return;
|
if (!organizationId || !canViewMembers()) return;
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import {
|
|||||||
getCurrentUserId,
|
getCurrentUserId,
|
||||||
} from '@/utils/useUser';
|
} from '@/utils/useUser';
|
||||||
import { useLocalStorage } from '@vueuse/core';
|
import { useLocalStorage } from '@vueuse/core';
|
||||||
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
|
|
||||||
import { useNotificationsStore } from '@/utils/notification';
|
import { useNotificationsStore } from '@/utils/notification';
|
||||||
|
import { useQueryClient } from '@tanstack/vue-query';
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
|
|
||||||
@@ -32,6 +32,7 @@ const emptyTimeEntry = {
|
|||||||
export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
||||||
const currentTimeEntry = ref<TimeEntry>(reactive(emptyTimeEntry));
|
const currentTimeEntry = ref<TimeEntry>(reactive(emptyTimeEntry));
|
||||||
const { handleApiRequestNotifications } = useNotificationsStore();
|
const { handleApiRequestNotifications } = useNotificationsStore();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
useLocalStorage('solidtime/current-time-entry', currentTimeEntry, {
|
useLocalStorage('solidtime/current-time-entry', currentTimeEntry, {
|
||||||
deep: true,
|
deep: true,
|
||||||
@@ -208,7 +209,7 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
|||||||
stopLiveTimer();
|
stopLiveTimer();
|
||||||
await stopTimer();
|
await stopTimer();
|
||||||
}
|
}
|
||||||
useTimeEntriesStore().fetchTimeEntries();
|
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,246 +0,0 @@
|
|||||||
import { defineStore } from 'pinia';
|
|
||||||
import { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser';
|
|
||||||
|
|
||||||
import { reactive, ref, type Ref } from 'vue';
|
|
||||||
import { api } from '@/packages/api/src';
|
|
||||||
import type {
|
|
||||||
CreateTimeEntryBody,
|
|
||||||
TimeEntriesQueryParams,
|
|
||||||
TimeEntry,
|
|
||||||
UpdateMultipleTimeEntriesChangeset,
|
|
||||||
} from '@/packages/api/src';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import { useNotificationsStore } from '@/utils/notification';
|
|
||||||
import type {} from '@/packages/api/src';
|
|
||||||
import { useQueryClient } from '@tanstack/vue-query';
|
|
||||||
|
|
||||||
export const useTimeEntriesStore = defineStore(
|
|
||||||
'timeEntries',
|
|
||||||
(): {
|
|
||||||
timeEntries: Ref<TimeEntry[]>;
|
|
||||||
fetchTimeEntries: (queryParams?: TimeEntriesQueryParams) => Promise<void>;
|
|
||||||
updateTimeEntry: (timeEntry: TimeEntry) => Promise<void>;
|
|
||||||
createTimeEntry: (timeEntry: Omit<CreateTimeEntryBody, 'member_id'>) => Promise<void>;
|
|
||||||
deleteTimeEntry: (timeEntryId: string) => Promise<void>;
|
|
||||||
fetchMoreTimeEntries: () => Promise<void>;
|
|
||||||
allTimeEntriesLoaded: Ref<boolean>;
|
|
||||||
updateTimeEntries: (
|
|
||||||
ids: string[],
|
|
||||||
changes: UpdateMultipleTimeEntriesChangeset
|
|
||||||
) => Promise<void>;
|
|
||||||
deleteTimeEntries: (timeEntries: TimeEntry[]) => Promise<void>;
|
|
||||||
patchTimeEntries: (queryParams?: TimeEntriesQueryParams) => Promise<void>;
|
|
||||||
} => {
|
|
||||||
const timeEntries = ref<TimeEntry[]>(reactive([]));
|
|
||||||
|
|
||||||
const allTimeEntriesLoaded = ref(false);
|
|
||||||
const { handleApiRequestNotifications } = useNotificationsStore();
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
async function patchTimeEntries(
|
|
||||||
queryParams: TimeEntriesQueryParams = {
|
|
||||||
only_full_dates: 'true',
|
|
||||||
member_id: getCurrentMembershipId(),
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
const organizationId = getCurrentOrganizationId();
|
|
||||||
|
|
||||||
if (organizationId) {
|
|
||||||
const timeEntriesResponse = await handleApiRequestNotifications(
|
|
||||||
() =>
|
|
||||||
api.getTimeEntries({
|
|
||||||
params: {
|
|
||||||
organization: organizationId,
|
|
||||||
},
|
|
||||||
queries: queryParams,
|
|
||||||
}),
|
|
||||||
undefined,
|
|
||||||
'Failed to fetch time entries'
|
|
||||||
);
|
|
||||||
if (timeEntriesResponse?.data) {
|
|
||||||
// insert missing time entries
|
|
||||||
const missingTimeEntries = timeEntriesResponse.data.filter(
|
|
||||||
(entry) => !timeEntries.value.find((e) => e.id === entry.id)
|
|
||||||
);
|
|
||||||
timeEntries.value = [...missingTimeEntries, ...timeEntries.value];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchTimeEntries(
|
|
||||||
queryParams: TimeEntriesQueryParams = {
|
|
||||||
only_full_dates: 'true',
|
|
||||||
member_id: getCurrentMembershipId(),
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
const organizationId = getCurrentOrganizationId();
|
|
||||||
|
|
||||||
if (organizationId) {
|
|
||||||
const timeEntriesResponse = await handleApiRequestNotifications(
|
|
||||||
() =>
|
|
||||||
api.getTimeEntries({
|
|
||||||
params: {
|
|
||||||
organization: organizationId,
|
|
||||||
},
|
|
||||||
queries: queryParams,
|
|
||||||
}),
|
|
||||||
undefined,
|
|
||||||
'Failed to fetch time entries'
|
|
||||||
);
|
|
||||||
if (timeEntriesResponse?.data) {
|
|
||||||
timeEntries.value = timeEntriesResponse.data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchMoreTimeEntries() {
|
|
||||||
const organizationId = getCurrentOrganizationId();
|
|
||||||
if (organizationId) {
|
|
||||||
const latestTimeEntry = timeEntries.value[timeEntries.value.length - 1];
|
|
||||||
dayjs(latestTimeEntry.start).utc().format('YYYY-MM-DD');
|
|
||||||
|
|
||||||
const timeEntriesResponse = await handleApiRequestNotifications(
|
|
||||||
() =>
|
|
||||||
api.getTimeEntries({
|
|
||||||
params: {
|
|
||||||
organization: organizationId,
|
|
||||||
},
|
|
||||||
queries: {
|
|
||||||
only_full_dates: 'true',
|
|
||||||
member_id: getCurrentMembershipId(),
|
|
||||||
end: dayjs(latestTimeEntry.start).utc().format(),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
undefined,
|
|
||||||
'Failed to fetch time entries'
|
|
||||||
);
|
|
||||||
if (timeEntriesResponse?.data && timeEntriesResponse.data.length > 0) {
|
|
||||||
timeEntries.value = timeEntries.value.concat(timeEntriesResponse.data);
|
|
||||||
} else {
|
|
||||||
allTimeEntriesLoaded.value = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateTimeEntries(
|
|
||||||
ids: string[],
|
|
||||||
changes: UpdateMultipleTimeEntriesChangeset
|
|
||||||
) {
|
|
||||||
const organizationId = getCurrentOrganizationId();
|
|
||||||
if (organizationId) {
|
|
||||||
await handleApiRequestNotifications(
|
|
||||||
() =>
|
|
||||||
api.updateMultipleTimeEntries(
|
|
||||||
{
|
|
||||||
ids: ids,
|
|
||||||
changes: changes,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
params: {
|
|
||||||
organization: organizationId,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
),
|
|
||||||
'Time entries updated successfully',
|
|
||||||
'Failed to update time entries'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateTimeEntry(timeEntry: TimeEntry) {
|
|
||||||
const organizationId = getCurrentOrganizationId();
|
|
||||||
if (organizationId) {
|
|
||||||
const response = await handleApiRequestNotifications(
|
|
||||||
() =>
|
|
||||||
api.updateTimeEntry(timeEntry, {
|
|
||||||
params: {
|
|
||||||
organization: organizationId,
|
|
||||||
timeEntry: timeEntry.id,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
'Time entry updated successfully',
|
|
||||||
'Failed to update time entry'
|
|
||||||
);
|
|
||||||
timeEntries.value = timeEntries.value.map((entry) =>
|
|
||||||
entry.id === timeEntry.id ? response.data : entry
|
|
||||||
);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['timeEntry'] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createTimeEntry(timeEntry: Omit<CreateTimeEntryBody, 'member_id'>) {
|
|
||||||
const organizationId = getCurrentOrganizationId();
|
|
||||||
const memberId = getCurrentMembershipId();
|
|
||||||
if (organizationId && memberId !== undefined) {
|
|
||||||
const newTimeEntry = {
|
|
||||||
...timeEntry,
|
|
||||||
member_id: memberId,
|
|
||||||
} as CreateTimeEntryBody;
|
|
||||||
await handleApiRequestNotifications(
|
|
||||||
() =>
|
|
||||||
api.createTimeEntry(newTimeEntry, {
|
|
||||||
params: {
|
|
||||||
organization: organizationId,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
'Time entry created successfully',
|
|
||||||
'Failed to create time entry'
|
|
||||||
);
|
|
||||||
await fetchTimeEntries();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteTimeEntry(timeEntryId: string) {
|
|
||||||
const organizationId = getCurrentOrganizationId();
|
|
||||||
if (organizationId) {
|
|
||||||
await handleApiRequestNotifications(
|
|
||||||
() =>
|
|
||||||
api.deleteTimeEntry(undefined, {
|
|
||||||
params: {
|
|
||||||
organization: organizationId,
|
|
||||||
timeEntry: timeEntryId,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
'Time entry deleted successfully',
|
|
||||||
'Failed to delete time entry'
|
|
||||||
);
|
|
||||||
await fetchTimeEntries();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteTimeEntries(timeEntries: TimeEntry[]) {
|
|
||||||
const organizationId = getCurrentOrganizationId();
|
|
||||||
const timeEntryIds = timeEntries.map((entry) => entry.id);
|
|
||||||
if (organizationId) {
|
|
||||||
await handleApiRequestNotifications(
|
|
||||||
() =>
|
|
||||||
api.deleteTimeEntries(undefined, {
|
|
||||||
queries: {
|
|
||||||
ids: timeEntryIds,
|
|
||||||
},
|
|
||||||
params: {
|
|
||||||
organization: organizationId,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
'Time entries deleted successfully',
|
|
||||||
'Failed to delete time entries'
|
|
||||||
);
|
|
||||||
await fetchTimeEntries();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
timeEntries,
|
|
||||||
fetchTimeEntries,
|
|
||||||
updateTimeEntry,
|
|
||||||
createTimeEntry,
|
|
||||||
deleteTimeEntry,
|
|
||||||
fetchMoreTimeEntries,
|
|
||||||
allTimeEntriesLoaded,
|
|
||||||
updateTimeEntries,
|
|
||||||
deleteTimeEntries,
|
|
||||||
patchTimeEntries,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
65
resources/js/utils/useTimeEntriesCalendarQuery.ts
Normal file
65
resources/js/utils/useTimeEntriesCalendarQuery.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { useQuery } from '@tanstack/vue-query';
|
||||||
|
import { api, type TimeEntryResponse } from '@/packages/api/src';
|
||||||
|
import { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
|
import { computed, type Ref } from 'vue';
|
||||||
|
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
|
||||||
|
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
|
||||||
|
|
||||||
|
export function useTimeEntriesCalendarQuery(
|
||||||
|
calendarStart: Ref<Date | undefined>,
|
||||||
|
calendarEnd: Ref<Date | undefined>
|
||||||
|
) {
|
||||||
|
const enableCalendarQuery = computed(() => {
|
||||||
|
return !!getCurrentOrganizationId() && !!calendarStart.value && !!calendarEnd.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate expanded date range to include previous and next periods with timezone transformations
|
||||||
|
const expandedDateRange = computed(() => {
|
||||||
|
if (!calendarStart.value || !calendarEnd.value) {
|
||||||
|
return { start: null, end: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dayjs = getDayJsInstance();
|
||||||
|
const duration = dayjs(calendarEnd.value).diff(dayjs(calendarStart.value), 'milliseconds');
|
||||||
|
|
||||||
|
// Calculate previous period
|
||||||
|
const previousStart = dayjs(calendarStart.value).subtract(duration, 'milliseconds');
|
||||||
|
// Calculate next period
|
||||||
|
const nextEnd = dayjs(calendarEnd.value).add(duration, 'milliseconds');
|
||||||
|
|
||||||
|
// Apply timezone transformations
|
||||||
|
const formattedStart = previousStart.utc().tz(getUserTimezone(), true).utc().format();
|
||||||
|
const formattedEnd = nextEnd.utc().tz(getUserTimezone(), true).utc().format();
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: formattedStart,
|
||||||
|
end: formattedEnd,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return useQuery<TimeEntryResponse>({
|
||||||
|
queryKey: computed(() => [
|
||||||
|
'timeEntries',
|
||||||
|
'calendar',
|
||||||
|
{
|
||||||
|
start: expandedDateRange.value.start,
|
||||||
|
end: expandedDateRange.value.end,
|
||||||
|
organization: getCurrentOrganizationId(),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
enabled: enableCalendarQuery,
|
||||||
|
placeholderData: (previousData) => previousData,
|
||||||
|
queryFn: () =>
|
||||||
|
api.getTimeEntries({
|
||||||
|
params: {
|
||||||
|
organization: getCurrentOrganizationId() || '',
|
||||||
|
},
|
||||||
|
queries: {
|
||||||
|
start: expandedDateRange.value.start!,
|
||||||
|
end: expandedDateRange.value.end!,
|
||||||
|
member_id: getCurrentMembershipId(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
staleTime: 1000 * 30, // 30 seconds
|
||||||
|
});
|
||||||
|
}
|
||||||
51
resources/js/utils/useTimeEntriesInfiniteQuery.ts
Normal file
51
resources/js/utils/useTimeEntriesInfiniteQuery.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { useInfiniteQuery } from '@tanstack/vue-query';
|
||||||
|
import { api } from '@/packages/api/src';
|
||||||
|
import { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
export function useTimeEntriesInfiniteQuery() {
|
||||||
|
const organizationId = computed(() => getCurrentOrganizationId());
|
||||||
|
const memberId = computed(() => getCurrentMembershipId());
|
||||||
|
|
||||||
|
return useInfiniteQuery({
|
||||||
|
queryKey: computed(() => [
|
||||||
|
'timeEntries',
|
||||||
|
'infinite',
|
||||||
|
{ organizationId: organizationId.value, memberId: memberId.value },
|
||||||
|
]),
|
||||||
|
queryFn: async ({ pageParam }) => {
|
||||||
|
const orgId = organizationId.value;
|
||||||
|
if (!orgId) return { data: [] };
|
||||||
|
|
||||||
|
const queries: Record<string, string | undefined> = {
|
||||||
|
only_full_dates: 'true',
|
||||||
|
member_id: memberId.value,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (pageParam) {
|
||||||
|
queries.end = pageParam;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await api.getTimeEntries({
|
||||||
|
params: {
|
||||||
|
organization: orgId,
|
||||||
|
},
|
||||||
|
queries: queries,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
getNextPageParam: (lastPage) => {
|
||||||
|
if (!lastPage?.data || lastPage.data.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const latestTimeEntry = lastPage.data[lastPage.data.length - 1];
|
||||||
|
return dayjs(latestTimeEntry.start).utc().format();
|
||||||
|
},
|
||||||
|
enabled: computed(() => !!organizationId.value),
|
||||||
|
staleTime: 1000 * 30, // 30 seconds
|
||||||
|
gcTime: 1000 * 60 * 10, // 10 minutes
|
||||||
|
});
|
||||||
|
}
|
||||||
151
resources/js/utils/useTimeEntriesMutations.ts
Normal file
151
resources/js/utils/useTimeEntriesMutations.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/vue-query';
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
type CreateTimeEntryBody,
|
||||||
|
type TimeEntry,
|
||||||
|
type UpdateMultipleTimeEntriesChangeset,
|
||||||
|
} from '@/packages/api/src';
|
||||||
|
import { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
|
import { useNotificationsStore } from '@/utils/notification';
|
||||||
|
|
||||||
|
export function useTimeEntriesMutations() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { handleApiRequestNotifications } = useNotificationsStore();
|
||||||
|
|
||||||
|
const { mutateAsync: createTimeEntry } = useMutation({
|
||||||
|
mutationFn: async (timeEntry: Omit<CreateTimeEntryBody, 'member_id'>) => {
|
||||||
|
const organizationId = getCurrentOrganizationId();
|
||||||
|
const memberId = getCurrentMembershipId();
|
||||||
|
if (organizationId && memberId !== undefined) {
|
||||||
|
const newTimeEntry = {
|
||||||
|
...timeEntry,
|
||||||
|
member_id: memberId,
|
||||||
|
} as CreateTimeEntryBody;
|
||||||
|
|
||||||
|
return await handleApiRequestNotifications(
|
||||||
|
() =>
|
||||||
|
api.createTimeEntry(newTimeEntry, {
|
||||||
|
params: {
|
||||||
|
organization: organizationId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'Time entry created successfully',
|
||||||
|
'Failed to create time entry'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: updateTimeEntry } = useMutation({
|
||||||
|
mutationFn: async (timeEntry: TimeEntry) => {
|
||||||
|
const organizationId = getCurrentOrganizationId();
|
||||||
|
if (organizationId) {
|
||||||
|
return await handleApiRequestNotifications(
|
||||||
|
() =>
|
||||||
|
api.updateTimeEntry(timeEntry, {
|
||||||
|
params: {
|
||||||
|
organization: organizationId,
|
||||||
|
timeEntry: timeEntry.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'Time entry updated successfully',
|
||||||
|
'Failed to update time entry'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: updateTimeEntries } = useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
ids,
|
||||||
|
changes,
|
||||||
|
}: {
|
||||||
|
ids: string[];
|
||||||
|
changes: UpdateMultipleTimeEntriesChangeset;
|
||||||
|
}) => {
|
||||||
|
const organizationId = getCurrentOrganizationId();
|
||||||
|
if (organizationId) {
|
||||||
|
return await handleApiRequestNotifications(
|
||||||
|
() =>
|
||||||
|
api.updateMultipleTimeEntries(
|
||||||
|
{
|
||||||
|
ids: ids,
|
||||||
|
changes: changes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
organization: organizationId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
'Time entries updated successfully',
|
||||||
|
'Failed to update time entries'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: deleteTimeEntry } = useMutation({
|
||||||
|
mutationFn: async (timeEntryId: string) => {
|
||||||
|
const organizationId = getCurrentOrganizationId();
|
||||||
|
if (organizationId) {
|
||||||
|
return await handleApiRequestNotifications(
|
||||||
|
() =>
|
||||||
|
api.deleteTimeEntry(undefined, {
|
||||||
|
params: {
|
||||||
|
organization: organizationId,
|
||||||
|
timeEntry: timeEntryId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'Time entry deleted successfully',
|
||||||
|
'Failed to delete time entry'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: deleteTimeEntries } = useMutation({
|
||||||
|
mutationFn: async (timeEntries: TimeEntry[]) => {
|
||||||
|
const organizationId = getCurrentOrganizationId();
|
||||||
|
const timeEntryIds = timeEntries.map((entry) => entry.id);
|
||||||
|
if (organizationId) {
|
||||||
|
return await handleApiRequestNotifications(
|
||||||
|
() =>
|
||||||
|
api.deleteTimeEntries(undefined, {
|
||||||
|
queries: {
|
||||||
|
ids: timeEntryIds,
|
||||||
|
},
|
||||||
|
params: {
|
||||||
|
organization: organizationId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'Time entries deleted successfully',
|
||||||
|
'Failed to delete time entries'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
createTimeEntry,
|
||||||
|
updateTimeEntry,
|
||||||
|
updateTimeEntries,
|
||||||
|
deleteTimeEntry,
|
||||||
|
deleteTimeEntries,
|
||||||
|
};
|
||||||
|
}
|
||||||
21
resources/js/utils/useTimeEntriesReportQuery.ts
Normal file
21
resources/js/utils/useTimeEntriesReportQuery.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { useQuery } from '@tanstack/vue-query';
|
||||||
|
import { api, type TimeEntryResponse } from '@/packages/api/src';
|
||||||
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
|
import { computed, type Ref, type ComputedRef, unref } from 'vue';
|
||||||
|
|
||||||
|
export function useTimeEntriesReportQuery(
|
||||||
|
filterParams: Ref<Record<string, unknown>> | ComputedRef<Record<string, unknown>>
|
||||||
|
) {
|
||||||
|
return useQuery<TimeEntryResponse>({
|
||||||
|
queryKey: computed(() => ['timeEntries', 'detailed-report', unref(filterParams)]),
|
||||||
|
enabled: computed(() => !!getCurrentOrganizationId()),
|
||||||
|
queryFn: () =>
|
||||||
|
api.getTimeEntries({
|
||||||
|
params: {
|
||||||
|
organization: getCurrentOrganizationId() || '',
|
||||||
|
},
|
||||||
|
queries: { ...unref(filterParams) },
|
||||||
|
}),
|
||||||
|
staleTime: 1000 * 30, // 30 seconds
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user