mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-16 20:22:15 +01:00
add billable rate updates for time entries in the past to projects and project members, fixes ST-304
This commit is contained in:
126
e2e/project-members.spec.ts
Normal file
126
e2e/project-members.spec.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { expect, Page } from '@playwright/test';
|
import { expect, Page } from '@playwright/test';
|
||||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||||
import { test } from '../playwright/fixtures';
|
import { test } from '../playwright/fixtures';
|
||||||
|
import { formatCents } from '../resources/js/utils/money';
|
||||||
|
|
||||||
async function goToProjectsOverview(page: Page) {
|
async function goToProjectsOverview(page: Page) {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
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 new Client
|
||||||
|
|
||||||
// Create new project with existing Client
|
// Create new project with existing Client
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ function updateRate(value: string) {
|
|||||||
}
|
}
|
||||||
inputValue.value = formatValue(model.value);
|
inputValue.value = formatValue(model.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatValue(modelValue: number | null) {
|
function formatValue(modelValue: number | null) {
|
||||||
const formattedValue = formatCents(modelValue ?? 0);
|
const formattedValue = formatCents(modelValue ?? 0);
|
||||||
return formattedValue.replace(getOrganizationCurrencySymbol(), '').trim();
|
return formattedValue.replace(getOrganizationCurrencySymbol(), '').trim();
|
||||||
@@ -81,14 +82,12 @@ const inputValue = ref(formatValue(model.value));
|
|||||||
placeholder="Billable Rate"
|
placeholder="Billable Rate"
|
||||||
class="mt-2 block w-full"
|
class="mt-2 block w-full"
|
||||||
autocomplete="teamMemberRate" />
|
autocomplete="teamMemberRate" />
|
||||||
<span>
|
<div
|
||||||
<div
|
class="absolute top-0 right-0 h-full flex items-center px-4 font-medium pointer-events-none">
|
||||||
class="absolute top-0 right-0 h-full flex items-center px-4 font-medium">
|
<span>
|
||||||
<span>
|
{{ getOrganizationCurrencyString() }}
|
||||||
{{ getOrganizationCurrencyString() }}
|
</span>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import SecondaryButton from '@/Components/SecondaryButton.vue';
|
|||||||
import DialogModal from '@/Components/DialogModal.vue';
|
import DialogModal from '@/Components/DialogModal.vue';
|
||||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||||
import { formatCents } from '../../../utils/money';
|
import { formatCents } from '../../../utils/money';
|
||||||
|
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid';
|
||||||
|
|
||||||
const show = defineModel('show', { default: false });
|
const show = defineModel('show', { default: false });
|
||||||
const saving = defineModel('saving', { default: false });
|
const saving = defineModel('saving', { default: false });
|
||||||
@@ -36,7 +37,8 @@ const emit = defineEmits<{
|
|||||||
>.
|
>.
|
||||||
</p>
|
</p>
|
||||||
<p class="py-0.5 text-center font-semibold">
|
<p class="py-0.5 text-center font-semibold">
|
||||||
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?
|
||||||
</p>
|
</p>
|
||||||
<div class="space-x-3 pt-5 pb-2 flex justify-center">
|
<div class="space-x-3 pt-5 pb-2 flex justify-center">
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
@@ -52,6 +54,17 @@ const emit = defineEmits<{
|
|||||||
No, only for new time entries
|
No, only for new time entries
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-center pt-3 pb-1">
|
||||||
|
Learn more about the
|
||||||
|
<a
|
||||||
|
target="_blank"
|
||||||
|
href="https://docs.solidtime.io/user-guide/billable-rates"
|
||||||
|
class="text-blue-400 hover:text-blue-500 transition"
|
||||||
|
>billable rate logic
|
||||||
|
<ArrowTopRightOnSquareIcon
|
||||||
|
class="w-4 -mt-0.5 inline-block"></ArrowTopRightOnSquareIcon
|
||||||
|
></a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
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 });
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
newBillableRate?: number | null;
|
||||||
|
projectName: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submit: [billable_rate_update_time_entries: boolean];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogModal closeable :show="show" @close="show = false">
|
||||||
|
<template #title>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<span> Update Project Billable Rate </span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #content>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<div class="col-span-6 sm:col-span-4 flex-1">
|
||||||
|
<p class="py-1 text-center">
|
||||||
|
The billable rate of {{ projectName }} will be updated
|
||||||
|
to
|
||||||
|
<strong>{{
|
||||||
|
newBillableRate
|
||||||
|
? formatCents(newBillableRate)
|
||||||
|
: ' the default rate of the organization'
|
||||||
|
}}</strong
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
<p class="py-1 text-center font-semibold max-w-md mx-auto">
|
||||||
|
Do you want to update all existing time entries, where
|
||||||
|
the project billable rate applies as well?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="space-x-3 pt-5 pb-2 flex justify-center">
|
||||||
|
<PrimaryButton
|
||||||
|
:class="{ 'opacity-25': saving }"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="emit('submit', true)">
|
||||||
|
Yes, update existing time entries
|
||||||
|
</PrimaryButton>
|
||||||
|
<PrimaryButton
|
||||||
|
:class="{ 'opacity-25': saving }"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="emit('submit', false)">
|
||||||
|
No, only for new time entries
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
<p class="text-center pt-3 pb-1">
|
||||||
|
Learn more about the
|
||||||
|
<a
|
||||||
|
target="_blank"
|
||||||
|
href="https://docs.solidtime.io/user-guide/billable-rates"
|
||||||
|
class="text-blue-400 hover:text-blue-500 transition"
|
||||||
|
>billable rate logic
|
||||||
|
<ArrowTopRightOnSquareIcon
|
||||||
|
class="w-4 -mt-0.5 inline-block"></ArrowTopRightOnSquareIcon
|
||||||
|
></a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<SecondaryButton @click="show = false"> Cancel </SecondaryButton>
|
||||||
|
</template>
|
||||||
|
</DialogModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -15,7 +15,7 @@ const model = defineModel<string>({ default: '' });
|
|||||||
:style="{
|
:style="{
|
||||||
backgroundColor: model,
|
backgroundColor: model,
|
||||||
}"
|
}"
|
||||||
class="w-5 h-5 rounded-full cursor-pointer"></div>
|
class="w-6 h-6 rounded-full cursor-pointer"></div>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
|
|||||||
@@ -15,12 +15,13 @@ import ProjectColorSelector from '@/Components/Common/Project/ProjectColorSelect
|
|||||||
import ProjectEditBillableSection from '@/Components/Common/Project/ProjectEditBillableSection.vue';
|
import ProjectEditBillableSection from '@/Components/Common/Project/ProjectEditBillableSection.vue';
|
||||||
import { UserCircleIcon } from '@heroicons/vue/20/solid';
|
import { UserCircleIcon } from '@heroicons/vue/20/solid';
|
||||||
import InputLabel from '@/Components/InputLabel.vue';
|
import InputLabel from '@/Components/InputLabel.vue';
|
||||||
|
import ProjectBillableRateModal from '@/Components/Common/Project/ProjectBillableRateModal.vue';
|
||||||
|
|
||||||
const { updateProject } = useProjectsStore();
|
const { updateProject } = useProjectsStore();
|
||||||
const { clients } = storeToRefs(useClientsStore());
|
const { clients } = storeToRefs(useClientsStore());
|
||||||
const show = defineModel('show', { default: false });
|
const show = defineModel('show', { default: false });
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
|
const showBillableRateModal = ref(false);
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
originalProject: Project;
|
originalProject: Project;
|
||||||
}>();
|
}>();
|
||||||
@@ -34,6 +35,10 @@ const project = ref<CreateProjectBody>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
|
if (props.originalProject.billable_rate !== project.value.billable_rate) {
|
||||||
|
showBillableRateModal.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
await updateProject(props.originalProject.id, project.value);
|
await updateProject(props.originalProject.id, project.value);
|
||||||
show.value = false;
|
show.value = false;
|
||||||
}
|
}
|
||||||
@@ -50,6 +55,14 @@ const currentClientName = computed(() => {
|
|||||||
}
|
}
|
||||||
return 'No Client';
|
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;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -62,11 +75,12 @@ const currentClientName = computed(() => {
|
|||||||
|
|
||||||
<template #content>
|
<template #content>
|
||||||
<div
|
<div
|
||||||
class="sm:flex items-center space-y-2 sm:space-y-0 sm:space-x-4">
|
class="sm:flex items-center space-y-2 sm:space-y-0 sm:space-x-5">
|
||||||
<div class="flex-1 flex items-center">
|
<div class="flex-1 flex items-center">
|
||||||
<div class="text-center pr-5">
|
<div class="text-center">
|
||||||
<InputLabel for="color" value="Color" />
|
<InputLabel for="color" value="Color" />
|
||||||
<ProjectColorSelector
|
<ProjectColorSelector
|
||||||
|
class="mt-1"
|
||||||
v-model="project.color"></ProjectColorSelector>
|
v-model="project.color"></ProjectColorSelector>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,7 +99,7 @@ const currentClientName = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="">
|
<div class="">
|
||||||
<InputLabel for="client" value="Client" />
|
<InputLabel for="client" value="Client" />
|
||||||
<ClientDropdown class="mt-2" v-model="project.client_id">
|
<ClientDropdown class="mt-1" v-model="project.client_id">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<Badge
|
<Badge
|
||||||
class="bg-input-background cursor-pointer hover:bg-tertiary"
|
class="bg-input-background cursor-pointer hover:bg-tertiary"
|
||||||
@@ -93,7 +107,7 @@ const currentClientName = computed(() => {
|
|||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<UserCircleIcon
|
<UserCircleIcon
|
||||||
class="w-5 text-icon-default"></UserCircleIcon>
|
class="w-5 text-icon-default"></UserCircleIcon>
|
||||||
<span>
|
<span class="whitespace-nowrap">
|
||||||
{{ currentClientName }}
|
{{ currentClientName }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,6 +135,11 @@ const currentClientName = computed(() => {
|
|||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
</template>
|
</template>
|
||||||
</DialogModal>
|
</DialogModal>
|
||||||
|
<ProjectBillableRateModal
|
||||||
|
v-model:show="showBillableRateModal"
|
||||||
|
@submit="submitBillableRate"
|
||||||
|
:new-billable-rate="project.billable_rate"
|
||||||
|
:project-name="project.name"></ProjectBillableRateModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
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 });
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
newBillableRate?: number | null;
|
||||||
|
memberName: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submit: [billable_rate_update_time_entries: boolean];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogModal closeable :show="show" @close="show = false">
|
||||||
|
<template #title>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<span> Update Project Member Billable Rate </span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #content>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<div class="col-span-6 sm:col-span-4 flex-1">
|
||||||
|
<p class="py-1 text-center">
|
||||||
|
The billable rate of {{ memberName }} will be updated to
|
||||||
|
<strong>{{
|
||||||
|
newBillableRate
|
||||||
|
? formatCents(newBillableRate)
|
||||||
|
: ' the default rate of the organization'
|
||||||
|
}}</strong
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
<p class="py-1 text-center font-semibold max-w-md mx-auto">
|
||||||
|
Do you want to update all existing time entries, where
|
||||||
|
the project member billable rate applies as well?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="space-x-3 pt-5 pb-2 flex justify-center">
|
||||||
|
<PrimaryButton
|
||||||
|
:class="{ 'opacity-25': saving }"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="emit('submit', true)">
|
||||||
|
Yes, update existing time entries
|
||||||
|
</PrimaryButton>
|
||||||
|
<PrimaryButton
|
||||||
|
:class="{ 'opacity-25': saving }"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="emit('submit', false)">
|
||||||
|
No, only for new time entries
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
<p class="text-center pt-3 pb-1">
|
||||||
|
Learn more about the
|
||||||
|
<a
|
||||||
|
target="_blank"
|
||||||
|
href="https://docs.solidtime.io/user-guide/billable-rates"
|
||||||
|
class="text-blue-400 hover:text-blue-500 transition"
|
||||||
|
>billable rate logic
|
||||||
|
<ArrowTopRightOnSquareIcon
|
||||||
|
class="w-4 -mt-0.5 inline-block"></ArrowTopRightOnSquareIcon
|
||||||
|
></a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<SecondaryButton @click="show = false"> Cancel </SecondaryButton>
|
||||||
|
</template>
|
||||||
|
</DialogModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -8,6 +8,8 @@ import { useFocus } from '@vueuse/core';
|
|||||||
import { useProjectMembersStore } from '@/utils/useProjectMembers';
|
import { useProjectMembersStore } from '@/utils/useProjectMembers';
|
||||||
import BillableRateInput from '@/Components/Common/BillableRateInput.vue';
|
import BillableRateInput from '@/Components/Common/BillableRateInput.vue';
|
||||||
import { UserIcon } from '@heroicons/vue/24/solid';
|
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 { updateProjectMember } = useProjectMembersStore();
|
||||||
|
|
||||||
const show = defineModel('show', { default: false });
|
const show = defineModel('show', { default: false });
|
||||||
@@ -21,8 +23,15 @@ const props = defineProps<{
|
|||||||
const projectMemberBody = ref<UpdateProjectMemberBody>({
|
const projectMemberBody = ref<UpdateProjectMemberBody>({
|
||||||
billable_rate: props.projectMember.billable_rate,
|
billable_rate: props.projectMember.billable_rate,
|
||||||
});
|
});
|
||||||
|
const showBillableRateModal = ref(false);
|
||||||
async function submit() {
|
async function submit() {
|
||||||
|
if (
|
||||||
|
props.projectMember.billable_rate !==
|
||||||
|
projectMemberBody.value.billable_rate
|
||||||
|
) {
|
||||||
|
showBillableRateModal.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
await updateProjectMember(props.projectMember.id, projectMemberBody.value);
|
await updateProjectMember(props.projectMember.id, projectMemberBody.value);
|
||||||
show.value = false;
|
show.value = false;
|
||||||
projectMemberBody.value = {
|
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(
|
watch(
|
||||||
() => show.value,
|
() => show.value,
|
||||||
(value) => {
|
(value) => {
|
||||||
@@ -55,6 +72,11 @@ useFocus(projectNameInput, { initialValue: true });
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #content>
|
<template #content>
|
||||||
|
<ProjectMemberBillableRateModal
|
||||||
|
member-name="props.name"
|
||||||
|
v-model:show="showBillableRateModal"
|
||||||
|
@close="showBillableRateModal = false"
|
||||||
|
@submit="submitBillableRate"></ProjectMemberBillableRateModal>
|
||||||
<div class="grid grid-cols-3 items-center space-x-4">
|
<div class="grid grid-cols-3 items-center space-x-4">
|
||||||
<div
|
<div
|
||||||
class="col-span-3 sm:col-span-2 space-x-2 flex items-center">
|
class="col-span-3 sm:col-span-2 space-x-2 flex items-center">
|
||||||
@@ -62,6 +84,9 @@ useFocus(projectNameInput, { initialValue: true });
|
|||||||
<span>{{ props.name }}</span>
|
<span>{{ props.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-3 sm:col-span-1 flex-1">
|
<div class="col-span-3 sm:col-span-1 flex-1">
|
||||||
|
<InputLabel
|
||||||
|
for="billable_rate"
|
||||||
|
value="Billable Rate"></InputLabel>
|
||||||
<BillableRateInput
|
<BillableRateInput
|
||||||
@keydown.enter="submit"
|
@keydown.enter="submit"
|
||||||
name="billable_rate"
|
name="billable_rate"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { ProjectMember } from '@/utils/api';
|
|||||||
import { useMembersStore } from '@/utils/useMembers';
|
import { useMembersStore } from '@/utils/useMembers';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
delete: [];
|
delete: [];
|
||||||
edit: [];
|
edit: [];
|
||||||
@@ -25,22 +26,24 @@ const currentMember = computed(() => {
|
|||||||
<template>
|
<template>
|
||||||
<Dropdown>
|
<Dropdown>
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<svg
|
<button
|
||||||
data-testid="project_actions"
|
class="focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-1 focus-visible:ring-input-border-active focus-visible:opacity-100 hover:bg-card-background group-hover:opacity-100 opacity-20 transition-opacity"
|
||||||
:aria-label="
|
:aria-label="
|
||||||
'Actions for Project Member ' + currentMember?.name
|
'Actions for Project Member ' + currentMember?.name
|
||||||
"
|
">
|
||||||
class="h-10 w-10 p-2 rounded-full hover:bg-card-background opacity-20 group-hover:opacity-100 transition"
|
<svg
|
||||||
viewBox="0 0 24 24"
|
class="h-10 w-10 p-2 rounded-full"
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
viewBox="0 0 24 24"
|
||||||
<path
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
fill="none"
|
<path
|
||||||
stroke="currentColor"
|
fill="none"
|
||||||
stroke-linecap="round"
|
stroke="currentColor"
|
||||||
stroke-linejoin="round"
|
stroke-linecap="round"
|
||||||
stroke-width="1.5"
|
stroke-linejoin="round"
|
||||||
d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" />
|
stroke-width="1.5"
|
||||||
</svg>
|
d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const createProjectMember = ref(false);
|
|||||||
<div class="flow-root">
|
<div class="flow-root">
|
||||||
<div class="inline-block min-w-full align-middle">
|
<div class="inline-block min-w-full align-middle">
|
||||||
<div
|
<div
|
||||||
data-testid="project_table"
|
data-testid="project_member_table"
|
||||||
class="grid min-w-full"
|
class="grid min-w-full"
|
||||||
style="grid-template-columns: 1fr 150px 150px 80px">
|
style="grid-template-columns: 1fr 150px 150px 80px">
|
||||||
<ProjectMemberTableHeading></ProjectMemberTableHeading>
|
<ProjectMemberTableHeading></ProjectMemberTableHeading>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const close = () => {
|
|||||||
:closeable="closeable"
|
:closeable="closeable"
|
||||||
@close="close">
|
@close="close">
|
||||||
<div class="px-6 py-4">
|
<div class="px-6 py-4">
|
||||||
<div class="text-lg font-medium text-white">
|
<div class="text-lg font-medium text-white" role="heading">
|
||||||
<slot name="title" />
|
<slot name="title" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ const maxWidthClass = computed(() => {
|
|||||||
leave-to-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
|
leave-to-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
|
||||||
<div
|
<div
|
||||||
v-show="show"
|
v-show="show"
|
||||||
|
role="dialog"
|
||||||
class="mb-6 bg-default-background border border-card-border rounded-lg shadow-xl transform transition-all sm:w-full sm:mx-auto"
|
class="mb-6 bg-default-background border border-card-border rounded-lg shadow-xl transform transition-all sm:w-full sm:mx-auto"
|
||||||
:class="maxWidthClass">
|
:class="maxWidthClass">
|
||||||
<slot v-if="show" />
|
<slot v-if="show" />
|
||||||
|
|||||||
Reference in New Issue
Block a user