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" />
-
-
- 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
+
+ The billable rate of {{ projectName }} will be updated + to + {{ + newBillableRate + ? formatCents(newBillableRate) + : ' the default rate of the organization' + }}. +
++ Do you want to update all existing time entries, where + the project billable rate applies as well? +
+ +
+ Learn more about the
+ billable rate logic
+
+ The billable rate of {{ memberName }} will be updated to + {{ + newBillableRate + ? formatCents(newBillableRate) + : ' the default rate of the organization' + }}. +
++ Do you want to update all existing time entries, where + the project member billable rate applies as well? +
+ +
+ Learn more about the
+ billable rate logic
+