diff --git a/e2e/project-members.spec.ts b/e2e/project-members.spec.ts new file mode 100644 index 00000000..243fdc72 --- /dev/null +++ b/e2e/project-members.spec.ts @@ -0,0 +1,126 @@ +import { expect, Page } from '@playwright/test'; +import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; +import { test } from '../playwright/fixtures'; +import { formatCents } from '../resources/js/utils/money'; + +async function goToProjectsOverview(page: Page) { + await page.goto(PLAYWRIGHT_BASE_URL + '/projects'); +} + +test('test that updating project member billable rate works', async ({ + page, +}) => { + const newProjectName = + 'New Project ' + Math.floor(1 + Math.random() * 10000); + const newBillableRate = Math.round(Math.random() * 10000); + await goToProjectsOverview(page); + await page.getByRole('button', { name: 'Create Project' }).click(); + await page.getByLabel('Project Name').fill(newProjectName); + + await page.getByRole('button', { name: 'Create Project' }).nth(1).click(); + await expect(page.getByText(newProjectName)).toBeVisible(); + + await page.getByText(newProjectName).click(); + await page.getByRole('button', { name: 'Add Member' }).click(); + + await expect(page.getByText('Add Project Member').first()).toBeVisible(); + await page.keyboard.press('Enter'); + await page.getByRole('button', { name: 'Add Project Member' }).click(); + + await page + .getByTestId('project_member_table') + .getByRole('row') + .first() + .getByRole('button') + .click(); + await page.getByRole('button', { name: 'Edit' }).first().click(); + await page.getByLabel('Billable Rate').fill(newBillableRate.toString()); + await page.getByRole('button', { name: 'Update Project Member' }).click(); + + await Promise.all([ + page + .getByRole('button', { name: 'No, only for new time entries' }) + .click(), + page.waitForRequest( + async (request) => + request.url().includes('/project-members/') && + request.method() === 'PUT' && + request.postDataJSON().billable_rate === + newBillableRate * 100 && + request.postDataJSON().billable_rate_update_time_entries === + false + ), + page.waitForResponse( + async (response) => + response.url().includes('/project-members/') && + response.request().method() === 'PUT' && + response.status() === 200 && + (await response.json()).data.billable_rate === + newBillableRate * 100 + ), + ]); + await expect( + page + .getByRole('row') + .first() + .getByText(formatCents(newBillableRate * 100)) + ).toBeVisible(); +}); + +test('test that updating project member billable rate works for existing time entries', async ({ + page, +}) => { + const newProjectName = + 'New Project ' + Math.floor(1 + Math.random() * 10000); + const newBillableRate = Math.round(Math.random() * 10000); + await goToProjectsOverview(page); + await page.getByRole('button', { name: 'Create Project' }).click(); + await page.getByLabel('Project Name').fill(newProjectName); + + await page.getByRole('button', { name: 'Create Project' }).nth(1).click(); + await expect(page.getByText(newProjectName)).toBeVisible(); + + await page.getByText(newProjectName).click(); + await page.getByRole('button', { name: 'Add Member' }).click(); + + await expect(page.getByText('Add Project Member').first()).toBeVisible(); + await page.keyboard.press('Enter'); + await page.getByRole('button', { name: 'Add Project Member' }).click(); + + await page + .getByTestId('project_member_table') + .getByRole('row') + .first() + .getByRole('button') + .click(); + await page.getByRole('button', { name: 'Edit' }).first().click(); + await page.getByLabel('Billable Rate').fill(newBillableRate.toString()); + await page.getByRole('button', { name: 'Update Project Member' }).click(); + + await Promise.all([ + page.getByRole('button', { name: 'Yes, update existing time' }).click(), + page.waitForRequest( + async (request) => + request.url().includes('/project-members/') && + request.method() === 'PUT' && + request.postDataJSON().billable_rate === + newBillableRate * 100 && + request.postDataJSON().billable_rate_update_time_entries === + true + ), + page.waitForResponse( + async (response) => + response.url().includes('/project-members/') && + response.request().method() === 'PUT' && + response.status() === 200 && + (await response.json()).data.billable_rate === + newBillableRate * 100 + ), + ]); + await expect( + page + .getByRole('row') + .first() + .getByText(formatCents(newBillableRate * 100)) + ).toBeVisible(); +}); diff --git a/e2e/projects.spec.ts b/e2e/projects.spec.ts index 90e72c51..6b5ad959 100644 --- a/e2e/projects.spec.ts +++ b/e2e/projects.spec.ts @@ -1,6 +1,7 @@ import { expect, Page } from '@playwright/test'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import { test } from '../playwright/fixtures'; +import { formatCents } from '../resources/js/utils/money'; async function goToProjectsOverview(page: Page) { await page.goto(PLAYWRIGHT_BASE_URL + '/projects'); @@ -85,6 +86,108 @@ test('test that archiving and unarchiving projects works', async ({ page }) => { ]); }); +test('test that updating billable rate works', async ({ page }) => { + const newProjectName = + 'New Project ' + Math.floor(1 + Math.random() * 10000); + const newBillableRate = Math.round(Math.random() * 10000); + await goToProjectsOverview(page); + await page.getByRole('button', { name: 'Create Project' }).click(); + await page.getByLabel('Project Name').fill(newProjectName); + + await page.getByRole('button', { name: 'Create Project' }).nth(1).click(); + await expect(page.getByText(newProjectName)).toBeVisible(); + + await page.getByRole('row').first().getByRole('button').click(); + await page.getByRole('button').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 + .getByRole('button', { name: 'No, only for new time entries' }) + .click(), + page.waitForRequest( + async (request) => + request.url().includes('/projects/') && + request.method() === 'PUT' && + request.postDataJSON().billable_rate === + newBillableRate * 100 && + request.postDataJSON().billable_rate_update_time_entries === + false + ), + page.waitForResponse( + async (response) => + response.url().includes('/projects/') && + response.request().method() === 'PUT' && + response.status() === 200 && + (await response.json()).data.billable_rate === + newBillableRate * 100 + ), + ]); + await expect( + page + .getByRole('row') + .first() + .getByText(formatCents(newBillableRate * 100)) + ).toBeVisible(); +}); + +test('test that updating billable rate works with existing time entries', async ({ + page, +}) => { + const newProjectName = + 'New Project ' + Math.floor(1 + Math.random() * 10000); + const newBillableRate = Math.round(Math.random() * 10000); + await goToProjectsOverview(page); + await page.getByRole('button', { name: 'Create Project' }).click(); + await page.getByLabel('Project Name').fill(newProjectName); + + await page.getByRole('button', { name: 'Create Project' }).nth(1).click(); + await expect(page.getByText(newProjectName)).toBeVisible(); + + await page.getByRole('row').first().getByRole('button').click(); + await page.getByRole('button').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 + .getByRole('button', { name: 'Yes, update existing time entries' }) + .click(), + page.waitForRequest( + async (request) => + request.url().includes('/projects/') && + request.method() === 'PUT' && + request.postDataJSON().billable_rate === + newBillableRate * 100 && + request.postDataJSON().billable_rate_update_time_entries === + true + ), + page.waitForResponse( + async (response) => + response.url().includes('/projects/') && + response.request().method() === 'PUT' && + response.status() === 200 && + (await response.json()).data.billable_rate === + newBillableRate * 100 + ), + ]); + await expect( + page + .getByRole('row') + .first() + .getByText(formatCents(newBillableRate * 100)) + ).toBeVisible(); +}); + // Create new project with new Client // Create new project with existing Client diff --git a/resources/js/Components/Common/BillableRateInput.vue b/resources/js/Components/Common/BillableRateInput.vue index aec7e8c6..ef29a55d 100644 --- a/resources/js/Components/Common/BillableRateInput.vue +++ b/resources/js/Components/Common/BillableRateInput.vue @@ -56,6 +56,7 @@ function updateRate(value: string) { } inputValue.value = formatValue(model.value); } + function formatValue(modelValue: number | null) { const formattedValue = formatCents(modelValue ?? 0); return formattedValue.replace(getOrganizationCurrencySymbol(), '').trim(); @@ -81,14 +82,12 @@ const inputValue = ref(formatValue(model.value)); placeholder="Billable Rate" class="mt-2 block w-full" autocomplete="teamMemberRate" /> - -
- - {{ getOrganizationCurrencyString() }} - -
-
+
+ + {{ getOrganizationCurrencyString() }} + +
diff --git a/resources/js/Components/Common/Organization/OrganizationBillableRateModal.vue b/resources/js/Components/Common/Organization/OrganizationBillableRateModal.vue index 8e18da1b..ab1b3b20 100644 --- a/resources/js/Components/Common/Organization/OrganizationBillableRateModal.vue +++ b/resources/js/Components/Common/Organization/OrganizationBillableRateModal.vue @@ -3,6 +3,7 @@ import SecondaryButton from '@/Components/SecondaryButton.vue'; import DialogModal from '@/Components/DialogModal.vue'; import PrimaryButton from '@/Components/PrimaryButton.vue'; import { formatCents } from '../../../utils/money'; +import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid'; const show = defineModel('show', { default: false }); const saving = defineModel('saving', { default: false }); @@ -36,7 +37,8 @@ const emit = defineEmits<{ >.

- Do you want to update all existing time entries as well? + Do you want to update all existing time entries, where + the organization billable rate applies as well?

+

+ Learn more about the + billable rate logic + +

diff --git a/resources/js/Components/Common/Project/ProjectBillableRateModal.vue b/resources/js/Components/Common/Project/ProjectBillableRateModal.vue new file mode 100644 index 00000000..68872eba --- /dev/null +++ b/resources/js/Components/Common/Project/ProjectBillableRateModal.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/resources/js/Components/Common/Project/ProjectColorSelector.vue b/resources/js/Components/Common/Project/ProjectColorSelector.vue index 4b8fc1de..24c7d346 100644 --- a/resources/js/Components/Common/Project/ProjectColorSelector.vue +++ b/resources/js/Components/Common/Project/ProjectColorSelector.vue @@ -15,7 +15,7 @@ const model = defineModel({ default: '' }); :style="{ backgroundColor: model, }" - class="w-5 h-5 rounded-full cursor-pointer"> + class="w-6 h-6 rounded-full cursor-pointer">