From e3b4cfd881e0d0d9eefa2ad6780a1dd13fd54b2c Mon Sep 17 00:00:00 2001
From: Gregor Vostrak
Date: Thu, 4 Jul 2024 18:30:47 +0200
Subject: [PATCH] add billable rate updates for time entries in the past to
projects and project members, fixes ST-304
---
e2e/project-members.spec.ts | 126 ++++++++++++++++++
e2e/projects.spec.ts | 103 ++++++++++++++
.../Components/Common/BillableRateInput.vue | 15 +--
.../OrganizationBillableRateModal.vue | 15 ++-
.../Project/ProjectBillableRateModal.vue | 80 +++++++++++
.../Common/Project/ProjectColorSelector.vue | 2 +-
.../Common/Project/ProjectEditModal.vue | 29 +++-
.../ProjectMemberBillableRateModal.vue | 79 +++++++++++
.../ProjectMember/ProjectMemberEditModal.vue | 27 +++-
.../ProjectMemberMoreOptionsDropdown.vue | 31 +++--
.../ProjectMember/ProjectMemberTable.vue | 2 +-
resources/js/Components/DialogModal.vue | 2 +-
resources/js/Components/Modal.vue | 1 +
13 files changed, 480 insertions(+), 32 deletions(-)
create mode 100644 e2e/project-members.spec.ts
create mode 100644 resources/js/Components/Common/Project/ProjectBillableRateModal.vue
create mode 100644 resources/js/Components/Common/ProjectMember/ProjectMemberBillableRateModal.vue
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 @@
+
+
+
+
+
+
+ Update Project Billable Rate
+
+
+
+
+
+
+ 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?
+
+
+
+
+ Yes, update existing time entries
+
+
+ No, only for new time entries
+
+
+
+ Learn more about the
+ billable rate logic
+
+
+
+
+
+
+ Cancel
+
+
+
+
+
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">
diff --git a/resources/js/Components/Common/Project/ProjectEditModal.vue b/resources/js/Components/Common/Project/ProjectEditModal.vue
index a5ab7fbc..61f09e03 100644
--- a/resources/js/Components/Common/Project/ProjectEditModal.vue
+++ b/resources/js/Components/Common/Project/ProjectEditModal.vue
@@ -15,12 +15,13 @@ import ProjectColorSelector from '@/Components/Common/Project/ProjectColorSelect
import ProjectEditBillableSection from '@/Components/Common/Project/ProjectEditBillableSection.vue';
import { UserCircleIcon } from '@heroicons/vue/20/solid';
import InputLabel from '@/Components/InputLabel.vue';
+import ProjectBillableRateModal from '@/Components/Common/Project/ProjectBillableRateModal.vue';
const { updateProject } = useProjectsStore();
const { clients } = storeToRefs(useClientsStore());
const show = defineModel('show', { default: false });
const saving = ref(false);
-
+const showBillableRateModal = ref(false);
const props = defineProps<{
originalProject: Project;
}>();
@@ -34,6 +35,10 @@ const project = ref({
});
async function submit() {
+ if (props.originalProject.billable_rate !== project.value.billable_rate) {
+ showBillableRateModal.value = true;
+ return;
+ }
await updateProject(props.originalProject.id, project.value);
show.value = false;
}
@@ -50,6 +55,14 @@ const currentClientName = computed(() => {
}
return 'No Client';
});
+
+async function submitBillableRate(billableRateUpdateTimeEntries: boolean) {
+ project.value.billable_rate_update_time_entries =
+ billableRateUpdateTimeEntries;
+ await updateProject(props.originalProject.id, project.value);
+ show.value = false;
+ showBillableRateModal.value = false;
+}
@@ -62,11 +75,12 @@ const currentClientName = computed(() => {
+ class="sm:flex items-center space-y-2 sm:space-y-0 sm:space-x-5">
-
@@ -85,7 +99,7 @@ const currentClientName = computed(() => {
-
+
{
-
+
{{ currentClientName }}
@@ -121,6 +135,11 @@ const currentClientName = computed(() => {
+
diff --git a/resources/js/Components/Common/ProjectMember/ProjectMemberBillableRateModal.vue b/resources/js/Components/Common/ProjectMember/ProjectMemberBillableRateModal.vue
new file mode 100644
index 00000000..f346a417
--- /dev/null
+++ b/resources/js/Components/Common/ProjectMember/ProjectMemberBillableRateModal.vue
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+ Update Project Member Billable Rate
+
+
+
+
+
+
+ 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?
+
+
+
+
+ Yes, update existing time entries
+
+
+ No, only for new time entries
+
+
+
+ Learn more about the
+ billable rate logic
+
+
+
+
+
+
+ Cancel
+
+
+
+
+
diff --git a/resources/js/Components/Common/ProjectMember/ProjectMemberEditModal.vue b/resources/js/Components/Common/ProjectMember/ProjectMemberEditModal.vue
index 58781a86..14b6c747 100644
--- a/resources/js/Components/Common/ProjectMember/ProjectMemberEditModal.vue
+++ b/resources/js/Components/Common/ProjectMember/ProjectMemberEditModal.vue
@@ -8,6 +8,8 @@ import { useFocus } from '@vueuse/core';
import { useProjectMembersStore } from '@/utils/useProjectMembers';
import BillableRateInput from '@/Components/Common/BillableRateInput.vue';
import { UserIcon } from '@heroicons/vue/24/solid';
+import ProjectMemberBillableRateModal from '@/Components/Common/ProjectMember/ProjectMemberBillableRateModal.vue';
+import InputLabel from '@/Components/InputLabel.vue';
const { updateProjectMember } = useProjectMembersStore();
const show = defineModel('show', { default: false });
@@ -21,8 +23,15 @@ const props = defineProps<{
const projectMemberBody = ref({
billable_rate: props.projectMember.billable_rate,
});
-
+const showBillableRateModal = ref(false);
async function submit() {
+ if (
+ props.projectMember.billable_rate !==
+ projectMemberBody.value.billable_rate
+ ) {
+ showBillableRateModal.value = true;
+ return;
+ }
await updateProjectMember(props.projectMember.id, projectMemberBody.value);
show.value = false;
projectMemberBody.value = {
@@ -30,6 +39,14 @@ async function submit() {
};
}
+async function submitBillableRate(billableRateUpdateTimeEntries: boolean) {
+ projectMemberBody.value.billable_rate_update_time_entries =
+ billableRateUpdateTimeEntries;
+ await updateProjectMember(props.projectMember.id, projectMemberBody.value);
+ show.value = false;
+ showBillableRateModal.value = false;
+}
+
watch(
() => show.value,
(value) => {
@@ -55,6 +72,11 @@ useFocus(projectNameInput, { initialValue: true });
+
@@ -62,6 +84,9 @@ useFocus(projectNameInput, { initialValue: true });
{{ props.name }}
+
{
-
-
-
+ ">
+
+
+
+
diff --git a/resources/js/Components/DialogModal.vue b/resources/js/Components/DialogModal.vue
index 9c60cc4e..bebab7a6 100644
--- a/resources/js/Components/DialogModal.vue
+++ b/resources/js/Components/DialogModal.vue
@@ -30,7 +30,7 @@ const close = () => {
:closeable="closeable"
@close="close">
-
+
diff --git a/resources/js/Components/Modal.vue b/resources/js/Components/Modal.vue
index f3141cce..a21f7ed7 100644
--- a/resources/js/Components/Modal.vue
+++ b/resources/js/Components/Modal.vue
@@ -91,6 +91,7 @@ const maxWidthClass = computed(() => {
leave-to-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">