+ class="flex flex-col @2xl:flex-row w-full justify-between rounded-lg bg-card-background border-card-border border transition shadow-card">
void> = {
prefetchTasks(queryClient);
prefetchTags(queryClient);
prefetchClients(queryClient);
+ prefetchTimeEntries(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) {
const organizationId = getCurrentOrganizationId();
if (!organizationId || !canViewMembers()) return;
diff --git a/resources/js/utils/useCurrentTimeEntry.ts b/resources/js/utils/useCurrentTimeEntry.ts
index d8ac699c..be732751 100644
--- a/resources/js/utils/useCurrentTimeEntry.ts
+++ b/resources/js/utils/useCurrentTimeEntry.ts
@@ -10,8 +10,8 @@ import {
getCurrentUserId,
} from '@/utils/useUser';
import { useLocalStorage } from '@vueuse/core';
-import { useTimeEntriesStore } from '@/utils/useTimeEntries';
import { useNotificationsStore } from '@/utils/notification';
+import { useQueryClient } from '@tanstack/vue-query';
dayjs.extend(utc);
@@ -32,6 +32,7 @@ const emptyTimeEntry = {
export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
const currentTimeEntry = ref(reactive(emptyTimeEntry));
const { handleApiRequestNotifications } = useNotificationsStore();
+ const queryClient = useQueryClient();
useLocalStorage('solidtime/current-time-entry', currentTimeEntry, {
deep: true,
@@ -208,7 +209,7 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
stopLiveTimer();
await stopTimer();
}
- useTimeEntriesStore().fetchTimeEntries();
+ queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
}
return {
diff --git a/resources/js/utils/useTimeEntries.ts b/resources/js/utils/useTimeEntries.ts
deleted file mode 100644
index 1f903980..00000000
--- a/resources/js/utils/useTimeEntries.ts
+++ /dev/null
@@ -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;
- fetchTimeEntries: (queryParams?: TimeEntriesQueryParams) => Promise;
- updateTimeEntry: (timeEntry: TimeEntry) => Promise;
- createTimeEntry: (timeEntry: Omit) => Promise;
- deleteTimeEntry: (timeEntryId: string) => Promise;
- fetchMoreTimeEntries: () => Promise;
- allTimeEntriesLoaded: Ref;
- updateTimeEntries: (
- ids: string[],
- changes: UpdateMultipleTimeEntriesChangeset
- ) => Promise;
- deleteTimeEntries: (timeEntries: TimeEntry[]) => Promise;
- patchTimeEntries: (queryParams?: TimeEntriesQueryParams) => Promise;
- } => {
- const timeEntries = ref(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) {
- 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,
- };
- }
-);
diff --git a/resources/js/utils/useTimeEntriesCalendarQuery.ts b/resources/js/utils/useTimeEntriesCalendarQuery.ts
new file mode 100644
index 00000000..fec1be99
--- /dev/null
+++ b/resources/js/utils/useTimeEntriesCalendarQuery.ts
@@ -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,
+ calendarEnd: Ref
+) {
+ 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({
+ 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
+ });
+}
diff --git a/resources/js/utils/useTimeEntriesInfiniteQuery.ts b/resources/js/utils/useTimeEntriesInfiniteQuery.ts
new file mode 100644
index 00000000..1808da61
--- /dev/null
+++ b/resources/js/utils/useTimeEntriesInfiniteQuery.ts
@@ -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 = {
+ 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
+ });
+}
diff --git a/resources/js/utils/useTimeEntriesMutations.ts b/resources/js/utils/useTimeEntriesMutations.ts
new file mode 100644
index 00000000..a42746ce
--- /dev/null
+++ b/resources/js/utils/useTimeEntriesMutations.ts
@@ -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) => {
+ 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,
+ };
+}
diff --git a/resources/js/utils/useTimeEntriesReportQuery.ts b/resources/js/utils/useTimeEntriesReportQuery.ts
new file mode 100644
index 00000000..208c56e8
--- /dev/null
+++ b/resources/js/utils/useTimeEntriesReportQuery.ts
@@ -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> | ComputedRef>
+) {
+ return useQuery({
+ queryKey: computed(() => ['timeEntries', 'detailed-report', unref(filterParams)]),
+ enabled: computed(() => !!getCurrentOrganizationId()),
+ queryFn: () =>
+ api.getTimeEntries({
+ params: {
+ organization: getCurrentOrganizationId() || '',
+ },
+ queries: { ...unref(filterParams) },
+ }),
+ staleTime: 1000 * 30, // 30 seconds
+ });
+}