upgrade inertia v2; add prefetching; migrate queries to tanstack query

vue
This commit is contained in:
Gregor Vostrak
2026-01-09 03:15:32 +01:00
parent 5a05ee35e0
commit 47c2d8e6de
59 changed files with 712 additions and 392 deletions

View File

@@ -1,11 +1,9 @@
<script setup lang="ts">
import MultiselectDropdown from '@/packages/ui/src/Input/MultiselectDropdown.vue';
import { storeToRefs } from 'pinia';
import type { Client } from '@/packages/api/src';
import { useClientsStore } from '@/utils/useClients';
import { useClientsQuery } from '@/utils/useClientsQuery';
const clientsStore = useClientsStore();
const { clients } = storeToRefs(clientsStore);
const { clients } = useClientsQuery();
function getKeyFromItem(item: Client) {
return item.id;

View File

@@ -1,15 +1,13 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { storeToRefs } from 'pinia';
import { useMembersStore } from '@/utils/useMembers';
import { useMembersQuery } from '@/utils/useMembersQuery';
import { UserIcon, ChevronDownIcon } from '@heroicons/vue/24/solid';
import { useFocus } from '@vueuse/core';
import type { ProjectMember } from '@/packages/api/src';
import { Badge, SelectDropdown } from '@/packages/ui/src';
import type { Member } from '@/packages/api/src';
const membersStore = useMembersStore();
const { members } = storeToRefs(membersStore);
const { members } = useMembersQuery();
const model = defineModel<string>({
default: '',

View File

@@ -2,7 +2,7 @@
import type { Member } from '@/packages/api/src';
import { api } from '@/packages/api/src';
import { useForm } from '@tanstack/vue-form';
import { useMutation } from '@tanstack/vue-query';
import { useMutation, useQueryClient } from '@tanstack/vue-query';
import Modal from '@/packages/ui/src/Modal.vue';
import DangerButton from '@/packages/ui/src/Buttons/DangerButton.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
@@ -11,7 +11,6 @@ import { useNotificationsStore } from '@/utils/notification';
import { getCurrentOrganizationId } from '@/utils/useUser';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import InputError from '@/packages/ui/src/Input/InputError.vue';
import { useMembersStore } from '@/utils/useMembers';
const props = defineProps<{
show: boolean;
@@ -23,6 +22,7 @@ const emit = defineEmits<{
}>();
const { handleApiRequestNotifications } = useNotificationsStore();
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: async () => {
@@ -43,7 +43,7 @@ const deleteMutation = useMutation({
},
onSuccess: () => {
close();
useMembersStore().fetchMembers();
queryClient.invalidateQueries({ queryKey: ['members'] });
},
});

View File

@@ -4,12 +4,12 @@ import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { ref } from 'vue';
import { api, type Member } from '@/packages/api/src';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { useMutation } from '@tanstack/vue-query';
import { useMutation, useQueryClient } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
import { useMembersStore } from '@/utils/useMembers';
const { handleApiRequestNotifications } = useNotificationsStore();
const queryClient = useQueryClient();
const show = defineModel('show', { default: false });
const saving = ref(false);
@@ -41,7 +41,7 @@ async function submit() {
'There was an error deactivating the user.',
() => {
show.value = false;
useMembersStore().fetchMembers();
queryClient.invalidateQueries({ queryKey: ['members'] });
}
);
}

View File

@@ -1,11 +1,9 @@
<script setup lang="ts">
import MultiselectDropdown from '@/packages/ui/src/Input/MultiselectDropdown.vue';
import { useMembersStore } from '@/utils/useMembers';
import { storeToRefs } from 'pinia';
import { useMembersQuery } from '@/utils/useMembersQuery';
import type { Member } from '@/packages/api/src';
const membersStore = useMembersStore();
const { members } = storeToRefs(membersStore);
const { members } = useMembersQuery();
function getKeyFromItem(item: Member) {
return item.id;

View File

@@ -1,15 +1,9 @@
<script setup lang="ts">
import { onMounted } from 'vue';
import { storeToRefs } from 'pinia';
import MemberTableHeading from '@/Components/Common/Member/MemberTableHeading.vue';
import MemberTableRow from '@/Components/Common/Member/MemberTableRow.vue';
import { useMembersStore } from '@/utils/useMembers';
import { useMembersQuery } from '@/utils/useMembersQuery';
const { members } = storeToRefs(useMembersStore());
onMounted(async () => {
await useMembersStore().fetchMembers();
});
const { members } = useMembersQuery();
</script>
<template>

View File

@@ -15,14 +15,12 @@ import MemberMakePlaceholderModal from '@/Components/Common/Member/MemberMakePla
import MemberDeleteModal from '@/Components/Common/Member/MemberDeleteModal.vue';
import { capitalizeFirstLetter } from '../../../utils/format';
import { formatCents } from '../../../packages/ui/src/utils/money';
import { useMembersStore } from '@/utils/useMembers';
const props = defineProps<{
member: Member;
}>();
const organization = inject<ComputedRef<Organization>>('organization');
const memberStore = useMembersStore();
const showEditMemberModal = ref(false);
const showMergeMemberModal = ref(false);
@@ -31,7 +29,6 @@ const showDeleteMemberModal = ref(false);
function removeMember() {
showDeleteMemberModal.value = true;
memberStore.fetchMembers();
}
async function invitePlaceholder(id: string) {

View File

@@ -10,7 +10,7 @@ defineProps<{
<template>
<h3
class="text-text-primary font-semibold text-sm sm:text-base flex items-center space-x-2 sm:space-x-2.5">
<component :is="icon" class="w-5 sm:w-6 text-icon-default"></component>
<component :is="icon" class="w-5 text-icon-default"></component>
<span> {{ title }} </span>
</h3>
</template>

View File

@@ -6,11 +6,11 @@ import { computed, ref } from 'vue';
import type { CreateClientBody, CreateProjectBody, Project } from '@/packages/api/src';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { useProjectsStore } from '@/utils/useProjects';
import { useClientsStore } from '@/utils/useClients';
import { useFocus } from '@vueuse/core';
import ClientDropdown from '@/packages/ui/src/Client/ClientDropdown.vue';
import Badge from '@/packages/ui/src/Badge.vue';
import { useClientsStore } from '@/utils/useClients';
import { storeToRefs } from 'pinia';
import { useClientsQuery } from '@/utils/useClientsQuery';
import ProjectColorSelector from '@/packages/ui/src/Project/ProjectColorSelector.vue';
import { UserCircleIcon } from '@heroicons/vue/20/solid';
import EstimatedTimeSection from '@/packages/ui/src/EstimatedTimeSection.vue';
@@ -21,7 +21,7 @@ import ProjectEditBillableSection from '@/packages/ui/src/Project/ProjectEditBil
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
const { updateProject } = useProjectsStore();
const { clients } = storeToRefs(useClientsStore());
const { clients } = useClientsQuery();
const show = defineModel('show', { default: false });
const saving = ref(false);
const showBillableRateModal = ref(false);

View File

@@ -13,7 +13,7 @@ import { canCreateProjects } from '@/utils/permissions';
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
import { useProjectsStore } from '@/utils/useProjects';
import { useClientsStore } from '@/utils/useClients';
import { storeToRefs } from 'pinia';
import { useClientsQuery } from '@/utils/useClientsQuery';
import { getOrganizationCurrencyString } from '@/utils/money';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import {
@@ -34,7 +34,7 @@ const emit = defineEmits<{
sort: [column: SortColumn];
}>();
const { clients } = storeToRefs(useClientsStore());
const { clients } = useClientsQuery();
// Create a map of client names for sorting
const clientNameMap = computed(() => {

View File

@@ -3,9 +3,8 @@ import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreO
import type { Project } from '@/packages/api/src';
import { computed, ref, inject, type ComputedRef } from 'vue';
import { CheckCircleIcon, ArchiveBoxIcon } from '@heroicons/vue/24/outline';
import { useClientsStore } from '@/utils/useClients';
import { storeToRefs } from 'pinia';
import { useTasksStore } from '@/utils/useTasks';
import { useClientsQuery } from '@/utils/useClientsQuery';
import { useTasksQuery } from '@/utils/useTasksQuery';
import { useProjectsStore } from '@/utils/useProjects';
import TableRow from '@/Components/TableRow.vue';
import ProjectEditModal from '@/Components/Common/Project/ProjectEditModal.vue';
@@ -17,8 +16,8 @@ import { formatHumanReadableDuration } from '../../../packages/ui/src/utils/time
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import type { Organization } from '@/packages/api/src';
const { clients } = storeToRefs(useClientsStore());
const { tasks } = storeToRefs(useTasksStore());
const { clients } = useClientsQuery();
const { tasks } = useTasksQuery();
const props = defineProps<{
project: Project;

View File

@@ -1,8 +1,7 @@
<script setup lang="ts">
import { TrashIcon, PencilSquareIcon } from '@heroicons/vue/20/solid';
import type { ProjectMember } from '@/packages/api/src';
import { useMembersStore } from '@/utils/useMembers';
import { storeToRefs } from 'pinia';
import { useMembersQuery } from '@/utils/useMembersQuery';
import { computed } from 'vue';
import {
DropdownMenu,
@@ -19,7 +18,7 @@ const props = defineProps<{
projectMember: ProjectMember;
}>();
const { members } = storeToRefs(useMembersStore());
const { members } = useMembersQuery();
const currentMember = computed(() => {
return members.value.find((member) => member.id === props.projectMember.user_id);

View File

@@ -1,9 +1,8 @@
<script setup lang="ts">
import type { ProjectMember } from '@/packages/api/src';
import { computed, ref, inject, type ComputedRef } from 'vue';
import { storeToRefs } from 'pinia';
import TableRow from '@/Components/TableRow.vue';
import { useMembersStore } from '@/utils/useMembers';
import { useMembersQuery } from '@/utils/useMembersQuery';
import { useProjectMembersStore } from '@/utils/useProjectMembers';
import ProjectMemberMoreOptionsDropdown from '@/Components/Common/ProjectMember/ProjectMemberMoreOptionsDropdown.vue';
import { formatCents } from '@/packages/ui/src/utils/money';
@@ -29,7 +28,7 @@ function editProjectMember() {
showEditModal.value = true;
}
const { members } = storeToRefs(useMembersStore());
const { members } = useMembersQuery();
const member = computed(() => {
return members.value.find((member) => member.id === props.projectMember.member_id);
});

View File

@@ -39,12 +39,13 @@ import {
type Organization,
} from '@/packages/api/src';
import { getCurrentMembershipId, getCurrentOrganizationId, getCurrentRole } from '@/utils/useUser';
import { useTagsQuery } from '@/utils/useTagsQuery';
import { useTagsStore } from '@/utils/useTags';
import { useSessionStorage, useStorage } from '@vueuse/core';
import { useNotificationsStore } from '@/utils/notification';
import type { ExportFormat } from '@/types/reporting';
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
import { useProjectsStore } from '@/utils/useProjects';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
// TimeEntryRoundingType is now defined in ReportingRoundingControls component
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
@@ -154,7 +155,7 @@ onMounted(() => {
updateTableReporting();
});
const { tags } = storeToRefs(useTagsStore());
const { tags } = useTagsQuery();
async function createTag(tag: string) {
return await useTagsStore().createTag(tag);
@@ -199,8 +200,7 @@ async function downloadExport(format: ExportFormat) {
const { getNameForReportingRowEntry, emptyPlaceholder } = useReportingStore();
const projectsStore = useProjectsStore();
const { projects } = storeToRefs(projectsStore);
const { projects } = useProjectsQuery();
const showExportModal = ref(false);
const exportUrl = ref<string | null>(null);
@@ -375,7 +375,7 @@ const tableData = computed(() => {
</MainContainer>
<MainContainer>
<div class="sm:grid grid-cols-4 pt-6 items-start">
<div class="col-span-3 bg-card-background rounded-lg border border-card-border pt-3">
<div class="col-span-3 bg-secondary rounded-lg border border-card-border pt-3">
<div
class="text-sm flex text-text-primary items-center space-x-3 font-medium px-6 border-b border-card-background-separator pb-3">
<span>Group by</span>
@@ -391,7 +391,7 @@ const tableData = computed(() => {
</div>
<div class="grid items-center" style="grid-template-columns: 1fr 100px 150px">
<div
class="contents [&>*]:border-card-background-separator [&>*]:border-b [&>*]:bg-tertiary [&>*]:pb-1.5 [&>*]:pt-1 text-text-secondary text-sm">
class="contents [&>*]:border-card-background-separator [&>*]:border-b [&>*]:bg-secondary [&>*]: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>

View File

@@ -3,8 +3,7 @@ import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { FolderPlusIcon } from '@heroicons/vue/24/solid';
import { PlusIcon } from '@heroicons/vue/16/solid';
import { ref } from 'vue';
import { storeToRefs } from 'pinia';
import { useTagsStore } from '@/utils/useTags';
import { useTagsQuery } from '@/utils/useTagsQuery';
import TagTableRow from '@/Components/Common/Tag/TagTableRow.vue';
import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
import TagTableHeading from '@/Components/Common/Tag/TagTableHeading.vue';
@@ -13,7 +12,7 @@ import type { Tag } from '@/packages/api/src';
defineProps<{
createTag: (name: string) => Promise<Tag | undefined>;
}>();
const { tags } = storeToRefs(useTagsStore());
const { tags } = useTagsQuery();
const showCreateTagModal = ref(false);
</script>

View File

@@ -1,11 +1,9 @@
<script setup lang="ts">
import MultiselectDropdown from '@/packages/ui/src/Input/MultiselectDropdown.vue';
import { storeToRefs } from 'pinia';
import type { Task } from '@/packages/api/src';
import { useTasksStore } from '@/utils/useTasks';
import { useTasksQuery } from '@/utils/useTasksQuery';
const tasksStore = useTasksStore();
const { tasks } = storeToRefs(tasksStore);
const { tasks } = useTasksQuery();
function getKeyFromItem(item: Task) {
return item.id;

View File

@@ -1,26 +1,26 @@
<script setup lang="ts">
import ProjectBadge from '@/packages/ui/src/Project/ProjectBadge.vue';
import TimeTrackerStartStop from '@/packages/ui/src/TimeTrackerStartStop.vue';
import { useProjectsStore } from '@/utils/useProjects';
import { storeToRefs } from 'pinia';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
import { computed } from 'vue';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { storeToRefs } from 'pinia';
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
import type { TimeEntry } from '@/packages/api/src';
import { useTasksStore } from '@/utils/useTasks';
import { useTasksQuery } from '@/utils/useTasksQuery';
import { ChevronRightIcon } from '@heroicons/vue/16/solid';
const props = defineProps<{
timeEntry: TimeEntry;
}>();
const { projects } = storeToRefs(useProjectsStore());
const { projects } = useProjectsQuery();
const project = computed(() => {
return projects.value.find((project) => project.id === props.timeEntry.project_id);
});
const { tasks } = storeToRefs(useTasksStore());
const { tasks } = useTasksQuery();
const task = computed(() => {
return tasks.value.find((task) => task.id === props.timeEntry.task_id);

View File

@@ -26,6 +26,7 @@ defineProps<{
<Link
v-else
:href="href ?? ''"
prefetch
class="block px-4 py-2 text-sm leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<slot />
</Link>

View File

@@ -15,7 +15,7 @@ const classes = computed(() => {
</script>
<template>
<Link :href="href ?? ''" :class="classes">
<Link :href="href ?? ''" :class="classes" prefetch>
<slot />
</Link>
</template>

View File

@@ -10,7 +10,7 @@ defineProps<{
</script>
<template>
<Link :href="href" class="block group">
<Link :href="href" class="block group" prefetch>
<div
:class="[
current

View File

@@ -21,7 +21,7 @@ const classes = computed(() => {
<slot />
</button>
<Link v-else :href="href ?? ''" :class="classes">
<Link v-else :href="href ?? ''" :class="classes" prefetch>
<slot />
</Link>
</div>

View File

@@ -12,9 +12,12 @@ import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { storeToRefs } from 'pinia';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { switchOrganization } from '@/utils/useOrganization';
import { useProjectsStore } from '@/utils/useProjects';
import { useTasksStore } from '@/utils/useTasks';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
import { useTasksQuery } from '@/utils/useTasksQuery';
import { useTagsQuery } from '@/utils/useTagsQuery';
import { useClientsQuery } from '@/utils/useClientsQuery';
import { useTagsStore } from '@/utils/useTags';
import { useProjectsStore } from '@/utils/useProjects';
import TimeTrackerControls from '@/packages/ui/src/TimeTracker/TimeTrackerControls.vue';
import type {
CreateClientBody,
@@ -49,12 +52,9 @@ const currentTimeEntryStore = useCurrentTimeEntryStore();
const { currentTimeEntry, isActive, now } = storeToRefs(currentTimeEntryStore);
const { startLiveTimer, stopLiveTimer, setActiveState } = currentTimeEntryStore;
const projectStore = useProjectsStore();
const { projects } = storeToRefs(projectStore);
const taskStore = useTasksStore();
const { tasks } = storeToRefs(taskStore);
const clientStore = useClientsStore();
const { clients } = storeToRefs(clientStore);
const { projects } = useProjectsQuery();
const { tasks } = useTasksQuery();
const { clients } = useClientsQuery();
const emit = defineEmits<{
change: [];
@@ -157,7 +157,7 @@ async function discardCurrentTimeEntry() {
}
}
const { tags } = storeToRefs(useTagsStore());
const { tags } = useTagsQuery();
const { timeEntries } = storeToRefs(useTimeEntriesStore());
</script>

View File

@@ -23,7 +23,9 @@ import UserSettingsIcon from '@/Components/UserSettingsIcon.vue';
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import { computed, onMounted, provide, ref } from 'vue';
import NotificationContainer from '@/Components/NotificationContainer.vue';
import { initializeStores, refreshStores } from '@/utils/init';
import { initializeStores } from '@/utils/init';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
import {
canManageBilling,
canUpdateOrganization,
@@ -89,9 +91,11 @@ onMounted(async () => {
await fetchToken();
}
setTimeout(() => {
// prevent store refreshing on navigation
// TanStack Query automatically refetches on window focus
// Only refresh non-migrated stores
if (isUnloading.value === false) {
refreshStores();
useCurrentTimeEntryStore().fetchCurrentTimeEntry();
useTimeEntriesStore().patchTimeEntries();
}
}, 100);
};

View File

@@ -16,10 +16,12 @@ import { TimeEntryCalendar } from '@/packages/ui/src';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
import { useTagsStore } from '@/utils/useTags';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
import { useClientsQuery } from '@/utils/useClientsQuery';
import { useTasksQuery } from '@/utils/useTasksQuery';
import { useTagsQuery } from '@/utils/useTagsQuery';
import { useProjectsStore } from '@/utils/useProjects';
import { useClientsStore } from '@/utils/useClients';
import { storeToRefs } from 'pinia';
import { useTasksStore } from '@/utils/useTasks';
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
import { getOrganizationCurrencyString } from '@/utils/money';
import { canCreateProjects } from '@/utils/permissions';
@@ -98,14 +100,10 @@ async function createClient(body: CreateClientBody): Promise<Client | undefined>
return await useClientsStore().createClient(body);
}
const projectStore = useProjectsStore();
const { projects } = storeToRefs(projectStore);
const taskStore = useTasksStore();
const { tasks } = storeToRefs(taskStore);
const clientStore = useClientsStore();
const { clients } = storeToRefs(clientStore);
const tagsStore = useTagsStore();
const { tags } = storeToRefs(tagsStore);
const { projects } = useProjectsQuery();
const { tasks } = useTasksQuery();
const { clients } = useClientsQuery();
const { tags } = useTagsQuery();
const queryClient = useQueryClient();

View File

@@ -4,26 +4,21 @@ import AppLayout from '@/Layouts/AppLayout.vue';
import { PlusIcon } from '@heroicons/vue/16/solid';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { UserCircleIcon } from '@heroicons/vue/20/solid';
import { computed, onMounted, ref } from 'vue';
import { useClientsStore } from '@/utils/useClients';
import { computed, ref } from 'vue';
import { useClientsQuery } from '@/utils/useClientsQuery';
import ClientTable from '@/Components/Common/Client/ClientTable.vue';
import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';
import PageTitle from '@/Components/Common/PageTitle.vue';
import { canCreateClients } from '@/utils/permissions';
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
import { storeToRefs } from 'pinia';
onMounted(() => {
useClientsStore().fetchClients();
});
const { clients } = useClientsQuery();
const activeTab = ref<'active' | 'archived'>('active');
const createClient = ref(false);
const { clients } = storeToRefs(useClientsStore());
const shownClients = computed(() => {
return clients.value.filter((client) => {
if (activeTab.value === 'active') {

View File

@@ -3,9 +3,8 @@ import MainContainer from '@/packages/ui/src/MainContainer.vue';
import AppLayout from '@/Layouts/AppLayout.vue';
import { FolderIcon, PlusIcon } from '@heroicons/vue/16/solid';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { computed, onMounted, ref, inject, type ComputedRef } from 'vue';
import { useProjectsStore } from '@/utils/useProjects';
import { storeToRefs } from 'pinia';
import { computed, ref, inject, type ComputedRef } from 'vue';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
import {
ChevronRightIcon,
CheckCircleIcon,
@@ -20,18 +19,18 @@ import CardTitle from '@/packages/ui/src/CardTitle.vue';
import Card from '@/Components/Common/Card.vue';
import ProjectMemberTable from '@/Components/Common/ProjectMember/ProjectMemberTable.vue';
import ProjectMemberCreateModal from '@/Components/Common/ProjectMember/ProjectMemberCreateModal.vue';
import { useProjectMembersStore } from '@/utils/useProjectMembers';
import { useProjectMembersQuery } from '@/utils/useProjectMembersQuery';
import { canCreateProjects, canCreateTasks, canViewProjectMembers } from '@/utils/permissions';
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
import { useTasksStore } from '@/utils/useTasks';
import { useTasksQuery } from '@/utils/useTasksQuery';
import ProjectEditModal from '@/Components/Common/Project/ProjectEditModal.vue';
import { Badge } from '@/packages/ui/src';
import { formatCents } from '../packages/ui/src/utils/money';
import { getOrganizationCurrencyString } from '../utils/money';
import type { Organization } from '@/packages/api/src';
const { projects } = storeToRefs(useProjectsStore());
const { projects } = useProjectsQuery();
const organization = inject<ComputedRef<Organization>>('organization');
@@ -42,20 +41,16 @@ const createTask = ref(false);
const createProjectMember = ref(false);
const projectId = route()?.params?.project as string;
const { projectMembers } = storeToRefs(useProjectMembersStore());
onMounted(() => {
if (canViewProjectMembers()) {
useProjectMembersStore().fetchProjectMembers(projectId);
}
useTasksStore().fetchTasks();
});
// TanStack Query automatically fetches project members when component mounts
const { projectMembers } = canViewProjectMembers()
? useProjectMembersQuery(projectId)
: { projectMembers: computed(() => []) };
const showEditProjectModal = ref(false);
const activeTab = ref<'active' | 'done'>('active');
const { tasks } = storeToRefs(useTasksStore());
const { tasks } = useTasksQuery();
const shownTasks = computed(() => {
return tasks.value.filter((task) => {

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import AppLayout from '@/Layouts/AppLayout.vue';
import { FolderIcon, PlusIcon } from '@heroicons/vue/16/solid';
import { FolderIcon, PlusIcon } from '@heroicons/vue/24/outline';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import ProjectTable from '@/Components/Common/Project/ProjectTable.vue';
import type {
@@ -15,6 +15,7 @@ import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue
import PageTitle from '@/Components/Common/PageTitle.vue';
import { canCreateProjects } from '@/utils/permissions';
import { storeToRefs } from 'pinia';
import { useClientsQuery } from '@/utils/useClientsQuery';
import { useClientsStore } from '@/utils/useClients';
import type { CreateClientBody, Client, CreateProjectBody, Project } from '@/packages/api/src';
import { getOrganizationCurrencyString } from '@/utils/money';
@@ -29,8 +30,7 @@ import { NO_CLIENT_ID } from '@/Components/Common/Project/constants';
// Fetch data using TanStack Query
const { projects } = useProjectsQuery();
const { clients } = storeToRefs(useClientsStore());
const { clients } = useClientsQuery();
const { organization } = storeToRefs(useOrganizationStore());
// Table state persisted in localStorage

View File

@@ -36,15 +36,18 @@ import MemberMultiselectDropdown from '@/Components/Common/Member/MemberMultisel
import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';
import SelectDropdown from '@/packages/ui/src/Input/SelectDropdown.vue';
import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue';
import { useTagsQuery } from '@/utils/useTagsQuery';
import { useTagsStore } from '@/utils/useTags';
import { useSessionStorage } from '@vueuse/core';
import TimeEntryRow from '@/packages/ui/src/TimeEntry/TimeEntryRow.vue';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
import { useProjectsStore } from '@/utils/useProjects';
import { useTasksStore } from '@/utils/useTasks';
import { useTasksQuery } from '@/utils/useTasksQuery';
import { useClientsQuery } from '@/utils/useClientsQuery';
import { useClientsStore } from '@/utils/useClients';
import { getOrganizationCurrencyString } from '@/utils/money';
import { useMembersStore } from '@/utils/useMembers';
import { useMembersQuery } from '@/utils/useMembersQuery';
import {
PaginationEllipsis,
PaginationFirst,
@@ -88,7 +91,7 @@ const roundingEnabled = ref<boolean>(false);
const roundingType = ref<TimeEntryRoundingType>('nearest');
const roundingMinutes = ref<number>(15);
const { members } = storeToRefs(useMembersStore());
const { members } = useMembersQuery();
const pageLimit = 15;
// Watch rounding enabled state to trigger updates
@@ -126,7 +129,7 @@ const { setActiveState, startLiveTimer } = currentTimeEntryStore;
const { handleApiRequestNotifications } = useNotificationsStore();
const { createTimeEntry, updateTimeEntry, updateTimeEntries } = useTimeEntriesStore();
const { tags } = storeToRefs(useTagsStore());
const { tags } = useTagsQuery();
const { data: timeEntryResponse } = useQuery<TimeEntryResponse>({
queryKey: ['timeEntry', 'detailed-report'],
@@ -160,12 +163,9 @@ onMounted(async () => {
await updateFilteredTimeEntries();
});
const projectStore = useProjectsStore();
const { projects } = storeToRefs(projectStore);
const taskStore = useTasksStore();
const { tasks } = storeToRefs(taskStore);
const clientStore = useClientsStore();
const { clients } = storeToRefs(clientStore);
const { projects } = useProjectsQuery();
const { tasks } = useTasksQuery();
const { clients } = useClientsQuery();
const selectedTimeEntries = ref<TimeEntry[]>([]);

View File

@@ -17,16 +17,19 @@ import { useElementVisibility } from '@vueuse/core';
import { ClockIcon } from '@heroicons/vue/20/solid';
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { useTasksStore } from '@/utils/useTasks';
import { useProjectsStore } from '@/utils/useProjects';
import { useTasksQuery } from '@/utils/useTasksQuery';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
import TimeEntryGroupedTable from '@/packages/ui/src/TimeEntry/TimeEntryGroupedTable.vue';
import { useTagsStore } from '@/utils/useTags';
import { useClientsStore } from '@/utils/useClients';
import { useTagsQuery } from '@/utils/useTagsQuery';
import { useClientsQuery } from '@/utils/useClientsQuery';
import { getOrganizationCurrencyString } from '@/utils/money';
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
import type { UpdateMultipleTimeEntriesChangeset } from '@/packages/api/src';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { canCreateProjects } from '@/utils/permissions';
import { useTagsStore } from '@/utils/useTags';
import { useProjectsStore } from '@/utils/useProjects';
import { useClientsStore } from '@/utils/useClients';
const timeEntriesStore = useTimeEntriesStore();
const { timeEntries, allTimeEntriesLoaded } = storeToRefs(timeEntriesStore);
@@ -43,7 +46,6 @@ const isLoadMoreVisible = useElementVisibility(loadMoreContainer);
const currentTimeEntryStore = useCurrentTimeEntryStore();
const { currentTimeEntry } = storeToRefs(currentTimeEntryStore);
const { setActiveState } = currentTimeEntryStore;
const { tags } = storeToRefs(useTagsStore());
async function startTimeEntry(timeEntry: Omit<CreateTimeEntryBody, 'member_id'>) {
if (currentTimeEntry.value.id) {
@@ -70,12 +72,11 @@ onMounted(async () => {
await timeEntriesStore.fetchTimeEntries();
});
const projectStore = useProjectsStore();
const { projects } = storeToRefs(projectStore);
const taskStore = useTasksStore();
const { tasks } = storeToRefs(taskStore);
const clientStore = useClientsStore();
const { clients } = storeToRefs(clientStore);
const { projects } = useProjectsQuery();
const { tasks } = useTasksQuery();
const { clients } = useClientsQuery();
const { tags } = useTagsQuery();
async function createTag(name: string) {
return await useTagsStore().createTag(name);

View File

@@ -6,11 +6,13 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import { ZiggyVue } from '../../vendor/tightenco/ziggy';
import { createPinia } from 'pinia';
import type { User } from '@/types/models';
import { VueQueryPlugin } from '@tanstack/vue-query';
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query';
import { type DefineComponent } from 'vue';
import { setupPrefetching } from '@/utils/prefetch';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
const pinia = createPinia();
const queryClient = new QueryClient();
createInertiaApp({
title: (title) => `${title} - ${appName}`,
@@ -62,7 +64,10 @@ createInertiaApp({
return page.props.auth.user.timezone;
};
app.use(plugin).use(pinia).use(ZiggyVue).use(VueQueryPlugin).mount(el);
app.use(plugin).use(pinia).use(ZiggyVue).use(VueQueryPlugin, { queryClient }).mount(el);
// Setup Inertia prefetching to warm TanStack Query cache
setupPrefetching(queryClient);
},
progress: {

View File

@@ -15,9 +15,9 @@ const props = withDefaults(
const expandedStatusClasses = computed(() => {
if (props.expanded) {
return 'border-card-border border bg-card-background-active text-text-primary';
return 'border-card-border border bg-quaternary text-text-primary';
}
return 'border-card-border border bg-card-background hover:bg-card-background-active hover:text-text-primary transition text-text-secondary';
return 'border-card-border border hover:bg-tertiary hover:text-text-primary transition text-text-secondary';
});
</script>

View File

@@ -1,26 +1,9 @@
import { useProjectsStore } from '@/utils/useProjects';
import { useTasksStore } from '@/utils/useTasks';
import { useTagsStore } from '@/utils/useTags';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { useClientsStore } from '@/utils/useClients';
import { useMembersStore } from '@/utils/useMembers';
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
import { canViewClients, canViewMembers } from '@/utils/permissions';
export function initializeStores() {
refreshStores();
}
export function refreshStores() {
useProjectsStore().fetchProjects();
useTasksStore().fetchTasks();
useTagsStore().fetchTags();
// TanStack Query now handles projects, tasks, tags, clients, and members fetching automatically
// Only initialize stores that aren't migrated to TanStack Query yet
useCurrentTimeEntryStore().fetchCurrentTimeEntry();
useTimeEntriesStore().patchTimeEntries();
if (canViewMembers()) {
useMembersStore().fetchMembers();
}
if (canViewClients()) {
useClientsStore().fetchClients();
}
}

View File

@@ -0,0 +1,283 @@
import type { QueryClient } from '@tanstack/vue-query';
import { api } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { canViewClients, canViewMembers } from '@/utils/permissions';
/**
* Route patterns mapped to their prefetch functions.
* Each function receives the QueryClient and prefetches relevant data.
*/
const routePrefetchers: Record<string, (queryClient: QueryClient) => void> = {
'/': (queryClient) => {
prefetchDashboard(queryClient);
},
'/dashboard': (queryClient) => {
prefetchDashboard(queryClient);
},
'/time': (queryClient) => {
prefetchProjects(queryClient);
prefetchTasks(queryClient);
prefetchTags(queryClient);
prefetchClients(queryClient);
},
'/calendar': (queryClient) => {
prefetchProjects(queryClient);
prefetchTasks(queryClient);
prefetchTags(queryClient);
prefetchClients(queryClient);
},
'/projects': (queryClient) => {
prefetchProjects(queryClient);
prefetchClients(queryClient);
},
'/clients': (queryClient) => {
prefetchClients(queryClient);
},
'/tags': (queryClient) => {
prefetchTags(queryClient);
},
'/members': (queryClient) => {
prefetchMembers(queryClient);
},
'/reporting': (queryClient) => {
prefetchProjects(queryClient);
prefetchTags(queryClient);
prefetchClients(queryClient);
prefetchMembers(queryClient);
},
'/reporting/detailed': (queryClient) => {
prefetchProjects(queryClient);
prefetchTasks(queryClient);
prefetchTags(queryClient);
prefetchClients(queryClient);
prefetchMembers(queryClient);
},
'/reporting/shared': (queryClient) => {
prefetchReports(queryClient);
},
};
function prefetchDashboard(queryClient: QueryClient) {
const organizationId = getCurrentOrganizationId();
if (!organizationId) return;
// Prefetch all dashboard card data
queryClient.prefetchQuery({
queryKey: ['timeEntries', organizationId],
queryFn: () =>
api.getTimeEntries({
params: { organization: organizationId },
queries: { limit: 10, offset: 0, only_full_dates: 'true' },
}),
staleTime: 30000,
});
queryClient.prefetchQuery({
queryKey: ['lastSevenDays', organizationId],
queryFn: () => api.lastSevenDays({ params: { organization: organizationId } }),
staleTime: 30000,
});
queryClient.prefetchQuery({
queryKey: ['dailyTrackedHours', organizationId],
queryFn: () => api.dailyTrackedHours({ params: { organization: organizationId } }),
staleTime: 30000,
});
queryClient.prefetchQuery({
queryKey: ['weeklyProjectOverview', organizationId],
queryFn: () => api.weeklyProjectOverview({ params: { organization: organizationId } }),
staleTime: 30000,
});
queryClient.prefetchQuery({
queryKey: ['totalWeeklyTime', organizationId],
queryFn: () => api.totalWeeklyTime({ params: { organization: organizationId } }),
staleTime: 30000,
});
queryClient.prefetchQuery({
queryKey: ['totalWeeklyBillableTime', organizationId],
queryFn: () => api.totalWeeklyBillableTime({ params: { organization: organizationId } }),
staleTime: 30000,
});
queryClient.prefetchQuery({
queryKey: ['totalWeeklyBillableAmount', organizationId],
queryFn: () => api.totalWeeklyBillableAmount({ params: { organization: organizationId } }),
staleTime: 30000,
});
queryClient.prefetchQuery({
queryKey: ['weeklyHistory', organizationId],
queryFn: () => api.weeklyHistory({ params: { organization: organizationId } }),
staleTime: 30000,
});
// Prefetch team activity only if user has permission
if (canViewMembers()) {
queryClient.prefetchQuery({
queryKey: ['latestTeamActivity', organizationId],
queryFn: () => api.latestTeamActivity({ params: { organization: organizationId } }),
staleTime: 30000,
});
}
}
function prefetchProjects(queryClient: QueryClient) {
const organizationId = getCurrentOrganizationId();
if (!organizationId) return;
queryClient.prefetchQuery({
queryKey: ['projects'],
queryFn: () =>
api.getProjects({
params: { organization: organizationId },
queries: { archived: 'all' },
}),
staleTime: 30000, // Consider fresh for 30 seconds
});
}
function prefetchTasks(queryClient: QueryClient) {
const organizationId = getCurrentOrganizationId();
if (!organizationId) return;
queryClient.prefetchQuery({
queryKey: ['tasks'],
queryFn: () =>
api.getTasks({
params: { organization: organizationId },
queries: { done: 'all' },
}),
staleTime: 30000,
});
}
function prefetchTags(queryClient: QueryClient) {
const organizationId = getCurrentOrganizationId();
if (!organizationId) return;
queryClient.prefetchQuery({
queryKey: ['tags'],
queryFn: () =>
api.getTags({
params: { organization: organizationId },
}),
staleTime: 30000,
});
}
function prefetchClients(queryClient: QueryClient) {
const organizationId = getCurrentOrganizationId();
if (!organizationId || !canViewClients()) return;
queryClient.prefetchQuery({
queryKey: ['clients'],
queryFn: () =>
api.getClients({
params: { organization: organizationId },
queries: { archived: 'all' },
}),
staleTime: 30000,
});
}
function prefetchMembers(queryClient: QueryClient) {
const organizationId = getCurrentOrganizationId();
if (!organizationId || !canViewMembers()) return;
queryClient.prefetchQuery({
queryKey: ['members'],
queryFn: () =>
api.getMembers({
params: { organization: organizationId },
}),
staleTime: 30000,
});
}
function prefetchReports(queryClient: QueryClient) {
const organizationId = getCurrentOrganizationId();
if (!organizationId) return;
queryClient.prefetchQuery({
queryKey: ['reports', 1],
queryFn: () =>
api.getReports({
params: { organization: organizationId },
}),
staleTime: 30000,
});
}
function prefetchProjectMembers(queryClient: QueryClient, projectId: string) {
const organizationId = getCurrentOrganizationId();
if (!organizationId || !canViewMembers()) return;
queryClient.prefetchQuery({
queryKey: ['projectMembers', projectId],
queryFn: () =>
api.getProjectMembers({
params: { organization: organizationId, project: projectId },
}),
staleTime: 30000,
});
}
/**
* Matches a URL to find the appropriate prefetcher.
* Handles both exact matches and pattern matching for dynamic routes.
*/
function findPrefetcher(url: string): ((queryClient: QueryClient) => void) | undefined {
// Extract pathname from URL
const pathname = url.startsWith('http') ? new URL(url).pathname : url.split('?')[0];
// Try exact match first
if (routePrefetchers[pathname]) {
return routePrefetchers[pathname];
}
// Try pattern matching for dynamic routes like /projects/{id}
const projectMatch = pathname.match(/^\/projects\/([^/]+)$/);
if (projectMatch) {
const projectId = projectMatch[1];
return (queryClient) => {
prefetchProjects(queryClient);
prefetchTasks(queryClient);
prefetchProjectMembers(queryClient, projectId);
};
}
return undefined;
}
/**
* Sets up Inertia prefetch event listener to warm TanStack Query cache.
* Call this once during app initialization.
*/
export function setupPrefetching(queryClient: QueryClient) {
// Listen for the 'prefetching' event which fires when Inertia starts prefetching a page
// The event detail contains the visit object with the URL being prefetched
document.addEventListener('inertia:prefetching', ((event: CustomEvent) => {
const visit = event.detail?.visit;
if (!visit?.url) return;
const url = visit.url.href || visit.url.toString();
const prefetcher = findPrefetcher(url);
if (prefetcher) {
prefetcher(queryClient);
}
}) as EventListener);
}

View File

@@ -1,37 +1,13 @@
import { defineStore } from 'pinia';
import { api } from '@/packages/api/src';
import { computed, ref } from 'vue';
import type {
CreateClientBody,
ClientIndexResponse,
Client,
UpdateClientBody,
} from '@/packages/api/src';
import type { CreateClientBody, Client, UpdateClientBody } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
import { useQueryClient } from '@tanstack/vue-query';
export const useClientsStore = defineStore('clients', () => {
const clientResponse = ref<ClientIndexResponse | null>(null);
const { handleApiRequestNotifications } = useNotificationsStore();
async function fetchClients() {
const organization = getCurrentOrganizationId();
if (organization) {
clientResponse.value = await handleApiRequestNotifications(
() =>
api.getClients({
queries: {
archived: 'all',
},
params: {
organization: organization,
},
}),
undefined,
'Failed to fetch clients'
);
}
}
const queryClient = useQueryClient();
async function createClient(clientBody: CreateClientBody): Promise<Client | undefined> {
const organization = getCurrentOrganizationId();
@@ -46,7 +22,7 @@ export const useClientsStore = defineStore('clients', () => {
'Client created successfully',
'Failed to create client'
);
await fetchClients();
queryClient.invalidateQueries({ queryKey: ['clients'] });
return response?.data;
}
}
@@ -65,7 +41,7 @@ export const useClientsStore = defineStore('clients', () => {
'Client updated successfully',
'Failed to update client'
);
await fetchClients();
queryClient.invalidateQueries({ queryKey: ['clients'] });
}
}
@@ -83,13 +59,9 @@ export const useClientsStore = defineStore('clients', () => {
'Client deleted successfully',
'Failed to delete client'
);
await fetchClients();
queryClient.invalidateQueries({ queryKey: ['clients'] });
}
}
const clients = computed<Client[]>(() => {
return clientResponse.value?.data || [];
});
return { clients, fetchClients, createClient, deleteClient, updateClient };
return { createClient, deleteClient, updateClient };
});

View File

@@ -0,0 +1,35 @@
import { useQuery, useQueryClient } from '@tanstack/vue-query';
import { api } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import type { Client } from '@/packages/api/src';
import { computed } from 'vue';
export function useClientsQuery() {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ['clients'],
queryFn: async () => {
const organizationId = getCurrentOrganizationId();
if (!organizationId) throw new Error('No organization');
return api.getClients({
params: { organization: organizationId },
queries: { archived: 'all' },
});
},
enabled: () => !!getCurrentOrganizationId(),
staleTime: 1000 * 30, // 30 seconds
});
const clients = computed<Client[]>(() => query.data.value?.data ?? []);
const invalidateClients = () => {
queryClient.invalidateQueries({ queryKey: ['clients'] });
};
return {
...query,
clients,
invalidateClients,
};
}

View File

@@ -1,31 +1,15 @@
import { defineStore } from 'pinia';
import { api } from '@/packages/api/src';
import { computed, ref } from 'vue';
import type { Member, MemberIndexResponse, UpdateMemberBody } from '@/packages/api/src';
import type { UpdateMemberBody } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
import { useQueryClient } from '@tanstack/vue-query';
export type MemberBillableKey = 'default-rate' | 'custom-rate';
export const useMembersStore = defineStore('members', () => {
const membersResponse = ref<MemberIndexResponse | null>(null);
const { handleApiRequestNotifications } = useNotificationsStore();
async function fetchMembers() {
const organization = getCurrentOrganizationId();
if (organization) {
membersResponse.value = await handleApiRequestNotifications(
() =>
api.getMembers({
params: {
organization: organization,
},
}),
undefined,
'Failed to fetch members'
);
}
}
const queryClient = useQueryClient();
async function removeMember(membershipId: string) {
const organization = getCurrentOrganizationId();
@@ -41,7 +25,7 @@ export const useMembersStore = defineStore('members', () => {
'Member deleted successfully',
'Failed to delete member'
);
await fetchMembers();
queryClient.invalidateQueries({ queryKey: ['members'] });
}
}
@@ -59,13 +43,9 @@ export const useMembersStore = defineStore('members', () => {
'Member updated successfully',
'Failed to update member'
);
await fetchMembers();
queryClient.invalidateQueries({ queryKey: ['members'] });
}
}
const members = computed<Member[]>(() => {
return membersResponse.value?.data || [];
});
return { members, fetchMembers, removeMember, updateMember };
return { removeMember, updateMember };
});

View File

@@ -0,0 +1,34 @@
import { useQuery, useQueryClient } from '@tanstack/vue-query';
import { api } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import type { Member } from '@/packages/api/src';
import { computed } from 'vue';
export function useMembersQuery() {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ['members'],
queryFn: async () => {
const organizationId = getCurrentOrganizationId();
if (!organizationId) throw new Error('No organization');
return api.getMembers({
params: { organization: organizationId },
});
},
enabled: () => !!getCurrentOrganizationId(),
staleTime: 1000 * 30, // 30 seconds
});
const members = computed<Member[]>(() => query.data.value?.data ?? []);
const invalidateMembers = () => {
queryClient.invalidateQueries({ queryKey: ['members'] });
};
return {
...query,
members,
invalidateMembers,
};
}

View File

@@ -1,35 +1,13 @@
import { defineStore } from 'pinia';
import { api } from '@/packages/api/src';
import { computed, ref } from 'vue';
import type {
CreateProjectMemberBody,
ProjectMember,
ProjectMemberResponse,
UpdateProjectMemberBody,
} from '@/packages/api/src';
import type { CreateProjectMemberBody, UpdateProjectMemberBody } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
import { useQueryClient } from '@tanstack/vue-query';
export const useProjectMembersStore = defineStore('project-members', () => {
const projectMemberResponse = ref<ProjectMemberResponse | null>(null);
const { handleApiRequestNotifications } = useNotificationsStore();
async function fetchProjectMembers(projectId: string) {
const organization = getCurrentOrganizationId();
if (organization) {
projectMemberResponse.value = await handleApiRequestNotifications(
() =>
api.getProjectMembers({
params: {
organization: organization,
project: projectId,
},
}),
undefined,
'Failed to fetch project members'
);
}
}
const queryClient = useQueryClient();
async function createProjectMember(
projectId: string,
@@ -48,7 +26,7 @@ export const useProjectMembersStore = defineStore('project-members', () => {
'Project member added successfully',
'Failed to add project member'
);
await fetchProjectMembers(projectId);
queryClient.invalidateQueries({ queryKey: ['projectMembers', projectId] });
}
}
@@ -69,7 +47,11 @@ export const useProjectMembersStore = defineStore('project-members', () => {
'Project member updated successfully',
'Failed to update project member'
);
await fetchProjectMembers(response.data.project_id);
if (response?.data?.project_id) {
queryClient.invalidateQueries({
queryKey: ['projectMembers', response.data.project_id],
});
}
}
}
@@ -87,15 +69,11 @@ export const useProjectMembersStore = defineStore('project-members', () => {
'Project member removed successfully',
'Failed to remove project member'
);
await fetchProjectMembers(projectId);
queryClient.invalidateQueries({ queryKey: ['projectMembers', projectId] });
}
}
const projectMembers = computed<ProjectMember[]>(() => projectMemberResponse.value?.data || []);
return {
projectMembers,
fetchProjectMembers,
createProjectMember,
deleteProjectMember,
updateProjectMember,

View File

@@ -0,0 +1,39 @@
import { useQuery, useQueryClient } from '@tanstack/vue-query';
import { api } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import type { ProjectMember } from '@/packages/api/src';
import { computed, type Ref } from 'vue';
export function useProjectMembersQuery(projectId: Ref<string | null> | string) {
const queryClient = useQueryClient();
const projectIdValue = computed(() => {
return typeof projectId === 'string' ? projectId : projectId.value;
});
const query = useQuery({
queryKey: ['projectMembers', projectIdValue],
queryFn: async () => {
const organizationId = getCurrentOrganizationId();
const pid = projectIdValue.value;
if (!organizationId || !pid) throw new Error('No organization or project');
return api.getProjectMembers({
params: { organization: organizationId, project: pid },
});
},
enabled: () => !!getCurrentOrganizationId() && !!projectIdValue.value,
staleTime: 1000 * 30, // 30 seconds
});
const projectMembers = computed<ProjectMember[]>(() => query.data.value?.data ?? []);
const invalidateProjectMembers = () => {
queryClient.invalidateQueries({ queryKey: ['projectMembers', projectIdValue.value] });
};
return {
...query,
projectMembers,
invalidateProjectMembers,
};
}

View File

@@ -18,6 +18,7 @@ export function useProjectsQuery() {
});
},
enabled: () => !!getCurrentOrganizationId(),
staleTime: 1000 * 30, // 30 seconds
});
const projects = computed<Project[]>(() => query.data.value?.data ?? []);

View File

@@ -1,4 +1,4 @@
import { defineStore, storeToRefs } from 'pinia';
import { defineStore } from 'pinia';
import { api } from '@/packages/api/src';
import { type Component, computed, ref } from 'vue';
import type {
@@ -8,11 +8,11 @@ import type {
} from '@/packages/api/src';
import { getCurrentOrganizationId, getCurrentRole, getCurrentUser } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
import { useProjectsStore } from '@/utils/useProjects';
import { useMembersStore } from '@/utils/useMembers';
import { useTasksStore } from '@/utils/useTasks';
import { useClientsStore } from '@/utils/useClients';
import { useTagsStore } from '@/utils/useTags';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
import { useMembersQuery } from '@/utils/useMembersQuery';
import { useTasksQuery } from '@/utils/useTasksQuery';
import { useClientsQuery } from '@/utils/useClientsQuery';
import { useTagsQuery } from '@/utils/useTagsQuery';
import { CheckCircleIcon, UserCircleIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
import { DocumentTextIcon, FolderIcon } from '@heroicons/vue/16/solid';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
@@ -32,6 +32,13 @@ export const useReportingStore = defineStore('reporting', () => {
const { handleApiRequestNotifications } = useNotificationsStore();
// Cache query composables to avoid creating new subscriptions on every call
const { projects } = useProjectsQuery();
const { members } = useMembersQuery();
const { tasks } = useTasksQuery();
const { clients } = useClientsQuery();
const { tags } = useTagsQuery();
async function fetchGraphReporting(params: AggregatedTimeEntriesQueryParams) {
const organization = getCurrentOrganizationId();
if (organization) {
@@ -93,31 +100,21 @@ export const useReportingStore = defineStore('reporting', () => {
}
if (type === 'project') {
const projectsStore = useProjectsStore();
const { projects } = storeToRefs(projectsStore);
return projects.value.find((project) => project.id === key)?.name;
}
if (type === 'user') {
if (getCurrentRole() === 'employee') {
return getCurrentUser().name;
}
const memberStore = useMembersStore();
const { members } = storeToRefs(memberStore);
return members.value.find((member) => member.user_id === key)?.name;
}
if (type === 'task') {
const taskStore = useTasksStore();
const { tasks } = storeToRefs(taskStore);
return tasks.value.find((task) => task.id === key)?.name;
}
if (type === 'client') {
const clientsStore = useClientsStore();
const { clients } = storeToRefs(clientsStore);
return clients.value.find((client) => client.id === key)?.name;
}
if (type === 'tag') {
const tagsStore = useTagsStore();
const { tags } = storeToRefs(tagsStore);
return tags.value.find((tag) => tag.id === key)?.name;
}
if (type === 'billable') {

View File

@@ -1,33 +1,13 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import type { Tag } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { api } from '@/packages/api/src';
import { useNotificationsStore } from '@/utils/notification';
import { useQueryClient } from '@tanstack/vue-query';
export const useTagsStore = defineStore('tags', () => {
const tags = ref<Tag[]>([]);
const { handleApiRequestNotifications } = useNotificationsStore();
async function fetchTags() {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
const response = await handleApiRequestNotifications(
() =>
api.getTags({
params: {
organization: organizationId,
},
}),
undefined,
'Failed to fetch tags'
);
if (response?.data) {
tags.value = response.data;
}
} else {
throw new Error('Failed to fetch current tags because organization ID is missing.');
}
}
const queryClient = useQueryClient();
async function deleteTag(tagId: string) {
const organizationId = getCurrentOrganizationId();
@@ -43,11 +23,11 @@ export const useTagsStore = defineStore('tags', () => {
'Tag deleted successfully',
'Failed to delete tag'
);
await fetchTags();
queryClient.invalidateQueries({ queryKey: ['tags'] });
}
}
async function createTag(name: string) {
async function createTag(name: string): Promise<Tag | undefined> {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
const response = await handleApiRequestNotifications(
@@ -66,7 +46,7 @@ export const useTagsStore = defineStore('tags', () => {
'Failed to create tag'
);
if (response?.data) {
tags.value.unshift(response.data);
queryClient.invalidateQueries({ queryKey: ['tags'] });
return response.data;
}
} else {
@@ -74,5 +54,5 @@ export const useTagsStore = defineStore('tags', () => {
}
}
return { tags, fetchTags, createTag, deleteTag };
return { createTag, deleteTag };
});

View File

@@ -0,0 +1,34 @@
import { useQuery, useQueryClient } from '@tanstack/vue-query';
import { api } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import type { Tag } from '@/packages/api/src';
import { computed } from 'vue';
export function useTagsQuery() {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ['tags'],
queryFn: async () => {
const organizationId = getCurrentOrganizationId();
if (!organizationId) throw new Error('No organization');
return api.getTags({
params: { organization: organizationId },
});
},
enabled: () => !!getCurrentOrganizationId(),
staleTime: 1000 * 30, // 30 seconds
});
const tags = computed<Tag[]>(() => query.data.value?.data ?? []);
const invalidateTags = () => {
queryClient.invalidateQueries({ queryKey: ['tags'] });
};
return {
...query,
tags,
invalidateTags,
};
}

View File

@@ -1,32 +1,13 @@
import { defineStore } from 'pinia';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { api } from '@/packages/api/src';
import { reactive, ref } from 'vue';
import type { CreateTaskBody, Task, UpdateTaskBody } from '@/packages/api/src';
import type { CreateTaskBody, UpdateTaskBody } from '@/packages/api/src';
import { useNotificationsStore } from '@/utils/notification';
import { useQueryClient } from '@tanstack/vue-query';
export const useTasksStore = defineStore('tasks', () => {
const tasks = ref<Task[]>(reactive([]));
const { handleApiRequestNotifications } = useNotificationsStore();
async function fetchTasks() {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
const tasksResponse = await handleApiRequestNotifications(() =>
api.getTasks({
params: {
organization: organizationId,
},
queries: {
done: 'all',
},
})
);
if (tasksResponse?.data) {
tasks.value = tasksResponse.data;
}
}
}
const queryClient = useQueryClient();
async function updateTask(taskId: string, taskBody: UpdateTaskBody) {
const organizationId = getCurrentOrganizationId();
@@ -42,7 +23,7 @@ export const useTasksStore = defineStore('tasks', () => {
'Task updated successfully',
'Failed to update task'
);
await fetchTasks();
queryClient.invalidateQueries({ queryKey: ['tasks'] });
}
}
@@ -59,7 +40,7 @@ export const useTasksStore = defineStore('tasks', () => {
'Task created successfully',
'Failed to create task'
);
await fetchTasks();
queryClient.invalidateQueries({ queryKey: ['tasks'] });
}
}
@@ -77,13 +58,11 @@ export const useTasksStore = defineStore('tasks', () => {
'Task deleted successfully',
'Failed to delete task'
);
await fetchTasks();
queryClient.invalidateQueries({ queryKey: ['tasks'] });
}
}
return {
tasks,
fetchTasks,
updateTask,
createTask,
deleteTask,

View File

@@ -0,0 +1,35 @@
import { useQuery, useQueryClient } from '@tanstack/vue-query';
import { api } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import type { Task } from '@/packages/api/src';
import { computed } from 'vue';
export function useTasksQuery() {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ['tasks'],
queryFn: async () => {
const organizationId = getCurrentOrganizationId();
if (!organizationId) throw new Error('No organization');
return api.getTasks({
params: { organization: organizationId },
queries: { done: 'all' },
});
},
enabled: () => !!getCurrentOrganizationId(),
staleTime: 1000 * 30, // 30 seconds
});
const tasks = computed<Task[]>(() => query.data.value?.data ?? []);
const invalidateTasks = () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
};
return {
...query,
tasks,
invalidateTasks,
};
}