diff --git a/docker-compose.yml b/docker-compose.yml
index 6974efb1..53d73ed4 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -107,7 +107,7 @@ services:
- sail
- reverse-proxy
playwright:
- image: mcr.microsoft.com/playwright:v1.57.0-jammy
+ image: mcr.microsoft.com/playwright:v1.51.1-jammy
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
working_dir: /src
extra_hosts:
diff --git a/e2e/clients.spec.ts b/e2e/clients.spec.ts
index dab9c81f..dc6ed8cc 100644
--- a/e2e/clients.spec.ts
+++ b/e2e/clients.spec.ts
@@ -1,4 +1,5 @@
-import { expect, Page } from '@playwright/test';
+import { expect } from '@playwright/test';
+import type { Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
diff --git a/e2e/project-members.spec.ts b/e2e/project-members.spec.ts
index 36ee7f2b..958014ac 100644
--- a/e2e/project-members.spec.ts
+++ b/e2e/project-members.spec.ts
@@ -1,9 +1,8 @@
-import { expect, Page } from '@playwright/test';
+import { expect } from '@playwright/test';
+import type { Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
import { formatCentsWithOrganizationDefaults } from './utils/money';
-import type { CurrencyFormat } from '../resources/js/packages/ui/src/utils/money';
-import { NumberFormat } from '@/packages/ui/src/utils/number';
async function goToProjectsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
diff --git a/e2e/projects.spec.ts b/e2e/projects.spec.ts
index b052baed..1c953a95 100644
--- a/e2e/projects.spec.ts
+++ b/e2e/projects.spec.ts
@@ -1,4 +1,5 @@
-import { expect, Page } from '@playwright/test';
+import { expect } from '@playwright/test';
+import type { Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
import { formatCentsWithOrganizationDefaults } from './utils/money';
diff --git a/e2e/reporting.spec.ts b/e2e/reporting.spec.ts
index 4c3a777d..93815265 100644
--- a/e2e/reporting.spec.ts
+++ b/e2e/reporting.spec.ts
@@ -1,4 +1,5 @@
-import { expect, Page } from '@playwright/test';
+import { expect } from '@playwright/test';
+import type { Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
diff --git a/e2e/tags.spec.ts b/e2e/tags.spec.ts
index 8b89aa2b..36b8defd 100644
--- a/e2e/tags.spec.ts
+++ b/e2e/tags.spec.ts
@@ -1,4 +1,5 @@
-import { expect, Page } from '@playwright/test';
+import { expect } from '@playwright/test';
+import type { Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
diff --git a/e2e/tasks.spec.ts b/e2e/tasks.spec.ts
index 8e2073db..646f618c 100644
--- a/e2e/tasks.spec.ts
+++ b/e2e/tasks.spec.ts
@@ -1,4 +1,5 @@
-import { expect, Page } from '@playwright/test';
+import { expect } from '@playwright/test';
+import type { Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
diff --git a/e2e/time.spec.ts b/e2e/time.spec.ts
index 2b01ddfb..fbcb500d 100644
--- a/e2e/time.spec.ts
+++ b/e2e/time.spec.ts
@@ -1,6 +1,7 @@
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
-import { expect, Locator, Page } from '@playwright/test';
+import { expect } from '@playwright/test';
+import type { Locator, Page } from '@playwright/test';
import {
assertThatTimerHasStarted,
assertThatTimerIsStopped,
diff --git a/e2e/timetracker.spec.ts b/e2e/timetracker.spec.ts
index c7896ca1..2696acfc 100644
--- a/e2e/timetracker.spec.ts
+++ b/e2e/timetracker.spec.ts
@@ -7,7 +7,7 @@ import {
startOrStopTimerWithButton,
stoppedTimeEntryResponse,
} from './utils/currentTimeEntry';
-import { Page } from '@playwright/test';
+import type { Page } from '@playwright/test';
import { newTagResponse } from './utils/tags';
async function goToDashboard(page: Page) {
diff --git a/e2e/utils/currentTimeEntry.ts b/e2e/utils/currentTimeEntry.ts
index 162975ec..908da317 100644
--- a/e2e/utils/currentTimeEntry.ts
+++ b/e2e/utils/currentTimeEntry.ts
@@ -1,4 +1,5 @@
-import { expect, Page } from '@playwright/test';
+import { expect } from '@playwright/test';
+import type { Page } from '@playwright/test';
export async function startOrStopTimerWithButton(page: Page) {
await page.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]').click();
diff --git a/e2e/utils/money.ts b/e2e/utils/money.ts
index aed8176c..5a2f3c66 100644
--- a/e2e/utils/money.ts
+++ b/e2e/utils/money.ts
@@ -1,6 +1,6 @@
import { formatCents } from '../../resources/js/packages/ui/src/utils/money';
import type { CurrencyFormat } from '../../resources/js/packages/ui/src/utils/money';
-import { NumberFormat } from '../../resources/js/packages/ui/src/utils/number';
+import type { NumberFormat } from '../../resources/js/packages/ui/src/utils/number';
export function formatCentsWithOrganizationDefaults(
cents: number,
diff --git a/e2e/utils/tags.ts b/e2e/utils/tags.ts
index 888d6794..f3d0e86e 100644
--- a/e2e/utils/tags.ts
+++ b/e2e/utils/tags.ts
@@ -1,4 +1,4 @@
-import { Page } from '@playwright/test';
+import type { Page } from '@playwright/test';
export function newTagResponse(page: Page, { name = '' } = {}) {
return page.waitForResponse(async (response) => {
diff --git a/package-lock.json b/package-lock.json
index a481c98a..9d7b0f9f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -41,7 +41,7 @@
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
- "@inertiajs/vue3": "^1.0.0",
+ "@inertiajs/vue3": "^2.0.0",
"@playwright/test": "^1.41.1",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
@@ -1159,28 +1159,30 @@
}
},
"node_modules/@inertiajs/core": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-1.3.0.tgz",
- "integrity": "sha512-TJ8R1eUYY473m9DaKlCPRdHTdznFWTDuy5VvEzXg3t/hohbDQedLj46yn/uAqziJPEUZJrSftZzPI2NMzL9tQA==",
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.3.7.tgz",
+ "integrity": "sha512-gBu2m71KoAu3kyaBDeszYiInkPqR0lIe+mRpI1JQyOlX1CT+Hwn+Tk5O7Dsr3dRMphEkRj6H9oVmJ19jHSNTIA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "axios": "^1.6.0",
- "deepmerge": "^4.0.0",
- "nprogress": "^0.2.0",
- "qs": "^6.9.0"
+ "@types/lodash-es": "^4.17.12",
+ "axios": "^1.13.2",
+ "laravel-precognition": "^1.0.0",
+ "lodash-es": "^4.17.21",
+ "qs": "^6.14.1"
}
},
"node_modules/@inertiajs/vue3": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/@inertiajs/vue3/-/vue3-1.3.0.tgz",
- "integrity": "sha512-GizqdCM3u4JWunit3uUbW4fEmTLKQTi1W7VvPRdrNy8XDt4Qy2cCmfFjq+aH5tHBSS3fI/ngYuhN7XvwqNaKvw==",
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/@inertiajs/vue3/-/vue3-2.3.7.tgz",
+ "integrity": "sha512-jaJtOPjulmLy4uKHL8BlYRjXGKk5FMccjczCePJzKS1JJYaF0aixpY046HzlKDtbnU9xn2h2tZRwu4kQ1uoTTg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@inertiajs/core": "1.3.0",
- "lodash.clonedeep": "^4.5.0",
- "lodash.isequal": "^4.5.0"
+ "@inertiajs/core": "2.3.7",
+ "@types/lodash-es": "^4.17.12",
+ "laravel-precognition": "^1.0.0",
+ "lodash-es": "^4.17.21"
},
"peerDependencies": {
"vue": "^3.0.0"
@@ -1927,6 +1929,23 @@
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"license": "MIT"
},
+ "node_modules/@types/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/lodash-es": {
+ "version": "4.17.12",
+ "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
+ "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/lodash": "*"
+ }
+ },
"node_modules/@types/node": {
"version": "22.14.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
@@ -2710,14 +2729,14 @@
}
},
"node_modules/axios": {
- "version": "1.8.4",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz",
- "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==",
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
+ "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
- "form-data": "^4.0.0",
+ "form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
@@ -3089,16 +3108,6 @@
"license": "MIT",
"peer": true
},
- "node_modules/deepmerge": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
- "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
"node_modules/defu": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
@@ -3766,15 +3775,16 @@
}
},
"node_modules/form-data": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
- "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
@@ -4277,6 +4287,17 @@
"json-buffer": "3.0.1"
}
},
+ "node_modules/laravel-precognition": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/laravel-precognition/-/laravel-precognition-1.0.0.tgz",
+ "integrity": "sha512-hvXPT7dayCQAidxnsY0hab9Q+Y2rsh7xRpH9uiFtXN8Dekc3tIZt+NrxrOZ9N5SwHBmRBze/Bv+ElfXac0kD6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "axios": "^1.4.0",
+ "lodash-es": "^4.17.21"
+ }
+ },
"node_modules/laravel-vite-plugin": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-1.2.0.tgz",
@@ -4351,6 +4372,13 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
+ "node_modules/lodash-es": {
+ "version": "4.17.22",
+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz",
+ "integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/lodash.castarray": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz",
@@ -4358,21 +4386,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/lodash.clonedeep": {
- "version": "4.5.0",
- "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
- "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/lodash.isequal": {
- "version": "4.5.0",
- "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
- "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
- "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
- "dev": true,
- "license": "MIT"
- },
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
@@ -4604,13 +4617,6 @@
"node": ">=8"
}
},
- "node_modules/nprogress": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/nprogress/-/nprogress-0.2.0.tgz",
- "integrity": "sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==",
- "devOptional": true,
- "license": "MIT"
- },
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
@@ -5274,9 +5280,9 @@
}
},
"node_modules/qs": {
- "version": "6.14.0",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
- "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
+ "version": "6.14.1",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
+ "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
diff --git a/package.json b/package.json
index f133c166..d9bd3652 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,7 @@
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
- "@inertiajs/vue3": "^1.0.0",
+ "@inertiajs/vue3": "^2.0.0",
"@playwright/test": "^1.41.1",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
diff --git a/resources/js/Components/Common/Client/ClientMultiselectDropdown.vue b/resources/js/Components/Common/Client/ClientMultiselectDropdown.vue
index c5e381c3..29c37824 100644
--- a/resources/js/Components/Common/Client/ClientMultiselectDropdown.vue
+++ b/resources/js/Components/Common/Client/ClientMultiselectDropdown.vue
@@ -1,11 +1,9 @@
diff --git a/resources/js/Components/Common/Member/MemberTableRow.vue b/resources/js/Components/Common/Member/MemberTableRow.vue
index 827706a9..e19374e6 100644
--- a/resources/js/Components/Common/Member/MemberTableRow.vue
+++ b/resources/js/Components/Common/Member/MemberTableRow.vue
@@ -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>('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) {
diff --git a/resources/js/Components/Common/PageTitle.vue b/resources/js/Components/Common/PageTitle.vue
index 24a669fa..4d521bc0 100644
--- a/resources/js/Components/Common/PageTitle.vue
+++ b/resources/js/Components/Common/PageTitle.vue
@@ -10,7 +10,7 @@ defineProps<{
-
+
{{ title }}
diff --git a/resources/js/Components/Common/Project/ProjectEditModal.vue b/resources/js/Components/Common/Project/ProjectEditModal.vue
index 48f72b1e..e6b85de8 100644
--- a/resources/js/Components/Common/Project/ProjectEditModal.vue
+++ b/resources/js/Components/Common/Project/ProjectEditModal.vue
@@ -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);
diff --git a/resources/js/Components/Common/Project/ProjectTable.vue b/resources/js/Components/Common/Project/ProjectTable.vue
index 6c469cc1..0102a42f 100644
--- a/resources/js/Components/Common/Project/ProjectTable.vue
+++ b/resources/js/Components/Common/Project/ProjectTable.vue
@@ -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(() => {
diff --git a/resources/js/Components/Common/Project/ProjectTableRow.vue b/resources/js/Components/Common/Project/ProjectTableRow.vue
index c245a2ad..3d91c24d 100644
--- a/resources/js/Components/Common/Project/ProjectTableRow.vue
+++ b/resources/js/Components/Common/Project/ProjectTableRow.vue
@@ -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;
diff --git a/resources/js/Components/Common/ProjectMember/ProjectMemberMoreOptionsDropdown.vue b/resources/js/Components/Common/ProjectMember/ProjectMemberMoreOptionsDropdown.vue
index 7f0c9c6f..43496c6a 100644
--- a/resources/js/Components/Common/ProjectMember/ProjectMemberMoreOptionsDropdown.vue
+++ b/resources/js/Components/Common/ProjectMember/ProjectMemberMoreOptionsDropdown.vue
@@ -1,8 +1,7 @@
diff --git a/resources/js/Components/Common/Task/TaskMultiselectDropdown.vue b/resources/js/Components/Common/Task/TaskMultiselectDropdown.vue
index 61d60259..1c2b78cb 100644
--- a/resources/js/Components/Common/Task/TaskMultiselectDropdown.vue
+++ b/resources/js/Components/Common/Task/TaskMultiselectDropdown.vue
@@ -1,11 +1,9 @@
-
+
diff --git a/resources/js/Components/NavigationSidebarLink.vue b/resources/js/Components/NavigationSidebarLink.vue
index a71af159..4485f3fb 100644
--- a/resources/js/Components/NavigationSidebarLink.vue
+++ b/resources/js/Components/NavigationSidebarLink.vue
@@ -10,7 +10,7 @@ defineProps<{
-
+
+
diff --git a/resources/js/Components/TimeTracker.vue b/resources/js/Components/TimeTracker.vue
index 8861c8ea..63e7d147 100644
--- a/resources/js/Components/TimeTracker.vue
+++ b/resources/js/Components/TimeTracker.vue
@@ -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());
diff --git a/resources/js/Layouts/AppLayout.vue b/resources/js/Layouts/AppLayout.vue
index 4b7760c2..075949b4 100644
--- a/resources/js/Layouts/AppLayout.vue
+++ b/resources/js/Layouts/AppLayout.vue
@@ -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);
};
diff --git a/resources/js/Pages/Calendar.vue b/resources/js/Pages/Calendar.vue
index 3df088cc..21c05425 100644
--- a/resources/js/Pages/Calendar.vue
+++ b/resources/js/Pages/Calendar.vue
@@ -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
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();
diff --git a/resources/js/Pages/Clients.vue b/resources/js/Pages/Clients.vue
index 2ff0cdf9..43c0fcda 100644
--- a/resources/js/Pages/Clients.vue
+++ b/resources/js/Pages/Clients.vue
@@ -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') {
diff --git a/resources/js/Pages/ProjectShow.vue b/resources/js/Pages/ProjectShow.vue
index c31ee0b8..a9ca4c9d 100644
--- a/resources/js/Pages/ProjectShow.vue
+++ b/resources/js/Pages/ProjectShow.vue
@@ -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>('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) => {
diff --git a/resources/js/Pages/Projects.vue b/resources/js/Pages/Projects.vue
index 8ab7a2bf..e808dd18 100644
--- a/resources/js/Pages/Projects.vue
+++ b/resources/js/Pages/Projects.vue
@@ -1,7 +1,7 @@
diff --git a/resources/js/utils/init.ts b/resources/js/utils/init.ts
index 7eb4d6e4..bb6bd3a3 100644
--- a/resources/js/utils/init.ts
+++ b/resources/js/utils/init.ts
@@ -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();
- }
}
diff --git a/resources/js/utils/prefetch.ts b/resources/js/utils/prefetch.ts
new file mode 100644
index 00000000..5664af2e
--- /dev/null
+++ b/resources/js/utils/prefetch.ts
@@ -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 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);
+}
diff --git a/resources/js/utils/useClients.ts b/resources/js/utils/useClients.ts
index eee3f561..59506e8f 100644
--- a/resources/js/utils/useClients.ts
+++ b/resources/js/utils/useClients.ts
@@ -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(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 {
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(() => {
- return clientResponse.value?.data || [];
- });
-
- return { clients, fetchClients, createClient, deleteClient, updateClient };
+ return { createClient, deleteClient, updateClient };
});
diff --git a/resources/js/utils/useClientsQuery.ts b/resources/js/utils/useClientsQuery.ts
new file mode 100644
index 00000000..091bc933
--- /dev/null
+++ b/resources/js/utils/useClientsQuery.ts
@@ -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(() => query.data.value?.data ?? []);
+
+ const invalidateClients = () => {
+ queryClient.invalidateQueries({ queryKey: ['clients'] });
+ };
+
+ return {
+ ...query,
+ clients,
+ invalidateClients,
+ };
+}
diff --git a/resources/js/utils/useMembers.ts b/resources/js/utils/useMembers.ts
index 4445cdb8..f96c0c06 100644
--- a/resources/js/utils/useMembers.ts
+++ b/resources/js/utils/useMembers.ts
@@ -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(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(() => {
- return membersResponse.value?.data || [];
- });
-
- return { members, fetchMembers, removeMember, updateMember };
+ return { removeMember, updateMember };
});
diff --git a/resources/js/utils/useMembersQuery.ts b/resources/js/utils/useMembersQuery.ts
new file mode 100644
index 00000000..5a716f98
--- /dev/null
+++ b/resources/js/utils/useMembersQuery.ts
@@ -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(() => query.data.value?.data ?? []);
+
+ const invalidateMembers = () => {
+ queryClient.invalidateQueries({ queryKey: ['members'] });
+ };
+
+ return {
+ ...query,
+ members,
+ invalidateMembers,
+ };
+}
diff --git a/resources/js/utils/useProjectMembers.ts b/resources/js/utils/useProjectMembers.ts
index aa9e90ae..13104e3d 100644
--- a/resources/js/utils/useProjectMembers.ts
+++ b/resources/js/utils/useProjectMembers.ts
@@ -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(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(() => projectMemberResponse.value?.data || []);
-
return {
- projectMembers,
- fetchProjectMembers,
createProjectMember,
deleteProjectMember,
updateProjectMember,
diff --git a/resources/js/utils/useProjectMembersQuery.ts b/resources/js/utils/useProjectMembersQuery.ts
new file mode 100644
index 00000000..be0fdb00
--- /dev/null
+++ b/resources/js/utils/useProjectMembersQuery.ts
@@ -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) {
+ 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(() => query.data.value?.data ?? []);
+
+ const invalidateProjectMembers = () => {
+ queryClient.invalidateQueries({ queryKey: ['projectMembers', projectIdValue.value] });
+ };
+
+ return {
+ ...query,
+ projectMembers,
+ invalidateProjectMembers,
+ };
+}
diff --git a/resources/js/utils/useProjectsQuery.ts b/resources/js/utils/useProjectsQuery.ts
index 48eaabc8..454e23e9 100644
--- a/resources/js/utils/useProjectsQuery.ts
+++ b/resources/js/utils/useProjectsQuery.ts
@@ -18,6 +18,7 @@ export function useProjectsQuery() {
});
},
enabled: () => !!getCurrentOrganizationId(),
+ staleTime: 1000 * 30, // 30 seconds
});
const projects = computed(() => query.data.value?.data ?? []);
diff --git a/resources/js/utils/useReporting.ts b/resources/js/utils/useReporting.ts
index 48b74135..067d859b 100644
--- a/resources/js/utils/useReporting.ts
+++ b/resources/js/utils/useReporting.ts
@@ -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') {
diff --git a/resources/js/utils/useTags.ts b/resources/js/utils/useTags.ts
index 01c0f93b..436ed1f5 100644
--- a/resources/js/utils/useTags.ts
+++ b/resources/js/utils/useTags.ts
@@ -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([]);
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 {
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 };
});
diff --git a/resources/js/utils/useTagsQuery.ts b/resources/js/utils/useTagsQuery.ts
new file mode 100644
index 00000000..3af68702
--- /dev/null
+++ b/resources/js/utils/useTagsQuery.ts
@@ -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(() => query.data.value?.data ?? []);
+
+ const invalidateTags = () => {
+ queryClient.invalidateQueries({ queryKey: ['tags'] });
+ };
+
+ return {
+ ...query,
+ tags,
+ invalidateTags,
+ };
+}
diff --git a/resources/js/utils/useTasks.ts b/resources/js/utils/useTasks.ts
index f0c9267a..7c867618 100644
--- a/resources/js/utils/useTasks.ts
+++ b/resources/js/utils/useTasks.ts
@@ -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(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,
diff --git a/resources/js/utils/useTasksQuery.ts b/resources/js/utils/useTasksQuery.ts
new file mode 100644
index 00000000..c50c8fea
--- /dev/null
+++ b/resources/js/utils/useTasksQuery.ts
@@ -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(() => query.data.value?.data ?? []);
+
+ const invalidateTasks = () => {
+ queryClient.invalidateQueries({ queryKey: ['tasks'] });
+ };
+
+ return {
+ ...query,
+ tasks,
+ invalidateTasks,
+ };
+}