From 99400ca655a1ec81d89a764773ec21a1e7b86eae Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Tue, 27 Jan 2026 20:11:35 +0100 Subject: [PATCH] fix: display custom billable rate correctly on project detail page --- e2e/projects.spec.ts | 49 ++++++++++++++++++++++ resources/js/Layouts/AppLayout.vue | 21 +++------- resources/js/Pages/ProjectShow.vue | 30 +++++++------ resources/js/utils/useOrganizationQuery.ts | 22 ++++++++++ 4 files changed, 94 insertions(+), 28 deletions(-) create mode 100644 resources/js/utils/useOrganizationQuery.ts diff --git a/e2e/projects.spec.ts b/e2e/projects.spec.ts index 7b5e1bfc..5fe115c9 100644 --- a/e2e/projects.spec.ts +++ b/e2e/projects.spec.ts @@ -317,6 +317,55 @@ test('test that sort state persists after page reload', async ({ page }) => { await expect(page.getByTestId('project_table')).toBeVisible(); }); +test('test that custom billable rate is displayed correctly on project detail page', async ({ + page, +}) => { + const newProjectName = 'Billable Rate Project ' + Math.floor(1 + Math.random() * 10000); + const newBillableRate = Math.round(10 + Math.random() * 1000); + await goToProjectsOverview(page); + await page.getByRole('button', { name: 'Create Project' }).click(); + await page.getByLabel('Project Name').fill(newProjectName); + + await Promise.all([ + page.getByRole('button', { name: 'Create Project' }).click(), + page.waitForResponse( + (response) => + response.url().includes('/projects') && + response.request().method() === 'POST' && + response.status() === 201 + ), + ]); + await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 }); + + // Edit the project to set a custom billable rate + await page.getByRole('row').first().getByRole('button').click(); + await page.getByRole('menuitem').getByText('Edit').first().click(); + await page.getByText('Non-Billable').click(); + await page.getByText('Custom Rate').click(); + await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString()); + await page.getByRole('button', { name: 'Update Project' }).click(); + + await Promise.all([ + page.locator('button').filter({ hasText: 'Yes, update existing time' }).click(), + page.waitForResponse( + async (response) => + response.url().includes('/projects/') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + ]); + + // Navigate to the project detail page by clicking the project name + await page.getByText(newProjectName).first().click(); + await page.waitForURL(/\/projects\/[a-f0-9-]+/); + + // Verify the badge displays the correctly formatted billable rate + const expectedFormattedRate = formatCentsWithOrganizationDefaults(newBillableRate * 100); + await expect(page.locator('nav[aria-label="Breadcrumb"]').locator('..')).toContainText( + expectedFormattedRate + ); +}); + // Create new project with new Client // Create new project with existing Client diff --git a/resources/js/Layouts/AppLayout.vue b/resources/js/Layouts/AppLayout.vue index 8d1e4de1..5de76566 100644 --- a/resources/js/Layouts/AppLayout.vue +++ b/resources/js/Layouts/AppLayout.vue @@ -44,8 +44,7 @@ import UpdateSidebarNotification from '@/Components/UpdateSidebarNotification.vu import BillingBanner from '@/Components/Billing/BillingBanner.vue'; import UserTimezoneMismatchModal from '@/Components/Common/User/UserTimezoneMismatchModal.vue'; import { useTheme } from '@/utils/theme'; -import { useQuery } from '@tanstack/vue-query'; -import { api } from '@/packages/api/src'; +import { useOrganizationQuery } from '@/utils/useOrganizationQuery'; import { getCurrentOrganizationId } from '@/utils/useUser'; import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue'; import { twMerge } from 'tailwind-merge'; @@ -65,22 +64,12 @@ defineProps({ const showSidebarMenu = ref(false); const isUnloading = ref(false); -const { data: organization, isLoading: isOrganizationLoading } = useQuery({ - queryKey: ['organization', getCurrentOrganizationId()], - queryFn: () => - api.getOrganization({ - params: { - organization: getCurrentOrganizationId()!, - }, - }), - enabled: !!getCurrentOrganizationId(), -}); - -provide( - 'organization', - computed(() => organization.value?.data) +const { organization, isLoading: isOrganizationLoading } = useOrganizationQuery( + getCurrentOrganizationId()! ); +provide('organization', organization); + onMounted(async () => { useTheme(); // make sure that the initial requests are only loaded once, this can be removed once we move away from inertia diff --git a/resources/js/Pages/ProjectShow.vue b/resources/js/Pages/ProjectShow.vue index a9ca4c9d..503ac550 100644 --- a/resources/js/Pages/ProjectShow.vue +++ b/resources/js/Pages/ProjectShow.vue @@ -3,7 +3,7 @@ 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, ref, inject, type ComputedRef } from 'vue'; +import { computed, ref } from 'vue'; import { useProjectsQuery } from '@/utils/useProjectsQuery'; import { ChevronRightIcon, @@ -28,11 +28,12 @@ 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'; +import { useOrganizationQuery } from '@/utils/useOrganizationQuery'; +import { getCurrentOrganizationId } from '@/utils/useUser'; const { projects } = useProjectsQuery(); -const organization = inject>('organization'); +const { organization } = useOrganizationQuery(getCurrentOrganizationId()!); const project = computed(() => { return projects.value.find((project) => project.id === route().params.project) ?? null; @@ -48,6 +49,19 @@ const { projectMembers } = canViewProjectMembers() const showEditProjectModal = ref(false); +const billableRateFormatted = computed(() => { + if (project.value?.billable_rate) { + return formatCents( + project.value.billable_rate, + getOrganizationCurrencyString(), + organization.value?.currency_format, + organization.value?.currency_symbol, + organization.value?.number_format + ); + } + return null; +}); + const activeTab = ref<'active' | 'done'>('active'); const { tasks } = useTasksQuery(); @@ -96,15 +110,7 @@ const shownTasks = computed(() => {
- {{ - formatCents( - project?.billable_rate ?? 0, - getOrganizationCurrencyString(), - organization?.currency_format, - organization?.currency_symbol, - organization?.number_format - ) - }} + {{ billableRateFormatted }} / h diff --git a/resources/js/utils/useOrganizationQuery.ts b/resources/js/utils/useOrganizationQuery.ts new file mode 100644 index 00000000..bee5a693 --- /dev/null +++ b/resources/js/utils/useOrganizationQuery.ts @@ -0,0 +1,22 @@ +import { useQuery } from '@tanstack/vue-query'; +import { api } from '@/packages/api/src'; +import { computed } from 'vue'; + +export function useOrganizationQuery(organizationId: string) { + const query = useQuery({ + queryKey: ['organization', organizationId], + queryFn: () => + api.getOrganization({ + params: { + organization: organizationId, + }, + }), + }); + + const organization = computed(() => query.data.value?.data); + + return { + ...query, + organization, + }; +}