Skip to content

Commit 99400ca

Browse files
committed
fix: display custom billable rate correctly on project detail page
1 parent 672c243 commit 99400ca

File tree

4 files changed

+94
-28
lines changed

4 files changed

+94
-28
lines changed

e2e/projects.spec.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,55 @@ test('test that sort state persists after page reload', async ({ page }) => {
317317
await expect(page.getByTestId('project_table')).toBeVisible();
318318
});
319319

320+
test('test that custom billable rate is displayed correctly on project detail page', async ({
321+
page,
322+
}) => {
323+
const newProjectName = 'Billable Rate Project ' + Math.floor(1 + Math.random() * 10000);
324+
const newBillableRate = Math.round(10 + Math.random() * 1000);
325+
await goToProjectsOverview(page);
326+
await page.getByRole('button', { name: 'Create Project' }).click();
327+
await page.getByLabel('Project Name').fill(newProjectName);
328+
329+
await Promise.all([
330+
page.getByRole('button', { name: 'Create Project' }).click(),
331+
page.waitForResponse(
332+
(response) =>
333+
response.url().includes('/projects') &&
334+
response.request().method() === 'POST' &&
335+
response.status() === 201
336+
),
337+
]);
338+
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
339+
340+
// Edit the project to set a custom billable rate
341+
await page.getByRole('row').first().getByRole('button').click();
342+
await page.getByRole('menuitem').getByText('Edit').first().click();
343+
await page.getByText('Non-Billable').click();
344+
await page.getByText('Custom Rate').click();
345+
await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString());
346+
await page.getByRole('button', { name: 'Update Project' }).click();
347+
348+
await Promise.all([
349+
page.locator('button').filter({ hasText: 'Yes, update existing time' }).click(),
350+
page.waitForResponse(
351+
async (response) =>
352+
response.url().includes('/projects/') &&
353+
response.request().method() === 'PUT' &&
354+
response.status() === 200
355+
),
356+
]);
357+
358+
// Navigate to the project detail page by clicking the project name
359+
await page.getByText(newProjectName).first().click();
360+
await page.waitForURL(/\/projects\/[a-f0-9-]+/);
361+
362+
// Verify the badge displays the correctly formatted billable rate
363+
const expectedFormattedRate = formatCentsWithOrganizationDefaults(newBillableRate * 100);
364+
await expect(page.locator('nav[aria-label="Breadcrumb"]').locator('..')).toContainText(
365+
expectedFormattedRate
366+
);
367+
});
368+
320369
// Create new project with new Client
321370

322371
// Create new project with existing Client

resources/js/Layouts/AppLayout.vue

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,7 @@ import UpdateSidebarNotification from '@/Components/UpdateSidebarNotification.vu
4444
import BillingBanner from '@/Components/Billing/BillingBanner.vue';
4545
import UserTimezoneMismatchModal from '@/Components/Common/User/UserTimezoneMismatchModal.vue';
4646
import { useTheme } from '@/utils/theme';
47-
import { useQuery } from '@tanstack/vue-query';
48-
import { api } from '@/packages/api/src';
47+
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
4948
import { getCurrentOrganizationId } from '@/utils/useUser';
5049
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
5150
import { twMerge } from 'tailwind-merge';
@@ -65,22 +64,12 @@ defineProps({
6564
const showSidebarMenu = ref(false);
6665
const isUnloading = ref(false);
6766
68-
const { data: organization, isLoading: isOrganizationLoading } = useQuery({
69-
queryKey: ['organization', getCurrentOrganizationId()],
70-
queryFn: () =>
71-
api.getOrganization({
72-
params: {
73-
organization: getCurrentOrganizationId()!,
74-
},
75-
}),
76-
enabled: !!getCurrentOrganizationId(),
77-
});
78-
79-
provide(
80-
'organization',
81-
computed(() => organization.value?.data)
67+
const { organization, isLoading: isOrganizationLoading } = useOrganizationQuery(
68+
getCurrentOrganizationId()!
8269
);
8370
71+
provide('organization', organization);
72+
8473
onMounted(async () => {
8574
useTheme();
8675
// make sure that the initial requests are only loaded once, this can be removed once we move away from inertia

resources/js/Pages/ProjectShow.vue

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import MainContainer from '@/packages/ui/src/MainContainer.vue';
33
import AppLayout from '@/Layouts/AppLayout.vue';
44
import { FolderIcon, PlusIcon } from '@heroicons/vue/16/solid';
55
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
6-
import { computed, ref, inject, type ComputedRef } from 'vue';
6+
import { computed, ref } from 'vue';
77
import { useProjectsQuery } from '@/utils/useProjectsQuery';
88
import {
99
ChevronRightIcon,
@@ -28,11 +28,12 @@ import ProjectEditModal from '@/Components/Common/Project/ProjectEditModal.vue';
2828
import { Badge } from '@/packages/ui/src';
2929
import { formatCents } from '../packages/ui/src/utils/money';
3030
import { getOrganizationCurrencyString } from '../utils/money';
31-
import type { Organization } from '@/packages/api/src';
31+
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
32+
import { getCurrentOrganizationId } from '@/utils/useUser';
3233
3334
const { projects } = useProjectsQuery();
3435
35-
const organization = inject<ComputedRef<Organization>>('organization');
36+
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
3637
3738
const project = computed(() => {
3839
return projects.value.find((project) => project.id === route().params.project) ?? null;
@@ -48,6 +49,19 @@ const { projectMembers } = canViewProjectMembers()
4849
4950
const showEditProjectModal = ref(false);
5051
52+
const billableRateFormatted = computed(() => {
53+
if (project.value?.billable_rate) {
54+
return formatCents(
55+
project.value.billable_rate,
56+
getOrganizationCurrencyString(),
57+
organization.value?.currency_format,
58+
organization.value?.currency_symbol,
59+
organization.value?.number_format
60+
);
61+
}
62+
return null;
63+
});
64+
5165
const activeTab = ref<'active' | 'done'>('active');
5266
5367
const { tasks } = useTasksQuery();
@@ -96,15 +110,7 @@ const shownTasks = computed(() => {
96110
</ol>
97111
<div class="px-4">
98112
<Badge v-if="project?.billable_rate">
99-
{{
100-
formatCents(
101-
project?.billable_rate ?? 0,
102-
getOrganizationCurrencyString(),
103-
organization?.currency_format,
104-
organization?.currency_symbol,
105-
organization?.number_format
106-
)
107-
}}
113+
{{ billableRateFormatted }}
108114
/ h
109115
</Badge>
110116
<Badge v-if="project?.is_billable && !project?.billable_rate">
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { useQuery } from '@tanstack/vue-query';
2+
import { api } from '@/packages/api/src';
3+
import { computed } from 'vue';
4+
5+
export function useOrganizationQuery(organizationId: string) {
6+
const query = useQuery({
7+
queryKey: ['organization', organizationId],
8+
queryFn: () =>
9+
api.getOrganization({
10+
params: {
11+
organization: organizationId,
12+
},
13+
}),
14+
});
15+
16+
const organization = computed(() => query.data.value?.data);
17+
18+
return {
19+
...query,
20+
organization,
21+
};
22+
}

0 commit comments

Comments
 (0)