add billable rate time entries update support for existing time entries (member & organization)

This commit is contained in:
Gregor Vostrak
2024-07-01 13:44:05 +02:00
parent c3a7ef7585
commit 264b7c9b8d
12 changed files with 514 additions and 38 deletions

View File

@@ -1 +1,78 @@
// TODO: Edit Billable Rate
import { test } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
async function goToMembersSection(page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
}
test('test that member billable rate can be updated', async ({ page }) => {
await goToMembersSection(page);
const newBillableRate = Math.round(Math.random() * 10000);
await page.getByRole('row').first().getByRole('button').click();
await page.getByRole('button').getByText('Edit').first().click();
await page.getByText('Organization Default Rate').click();
await page.getByText('Custom Rate').click();
await page
.getByPlaceholder('Billable Rate')
.fill(newBillableRate.toString());
await page.getByRole('button', { name: 'Update Member' }).click();
await Promise.all([
page
.getByRole('button', { name: 'No, only for new time entries' })
.click(),
page.waitForRequest(
async (request) =>
request.url().includes('/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("/organizations/") &&
response.request().method() === "PUT" &&
response.status() === 200 &&
(await response.json()).data.billable_rate === (newBillableRate * 100)
)*/
]);
});
test('test that organization billable rate can be updated with all existing time entries', async ({
page,
}) => {
await goToMembersSection(page);
const newBillableRate = Math.round(Math.random() * 10000);
await page.getByRole('row').first().getByRole('button').click();
await page.getByRole('button').getByText('Edit').first().click();
await page.getByText('Organization Default Rate').click();
await page.getByText('Custom Rate').click();
await page
.getByPlaceholder('Billable Rate')
.fill(newBillableRate.toString());
await page.getByRole('button', { name: 'Update Member' }).click();
await Promise.all([
page.getByRole('button', { name: 'Yes, update existing time' }).click(),
page.waitForRequest(
async (request) =>
request.url().includes('/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("/organizations/") &&
response.request().method() === "PUT" &&
response.status() === 200 &&
(await response.json()).data.billable_rate === (newBillableRate * 100)
)*/
]);
});

View File

@@ -1,4 +1,4 @@
import { test, expect } from '../playwright/fixtures';
import { expect, test } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
async function goToOrganizationSettings(page) {
@@ -68,4 +68,78 @@ test('test that error shows if no role is selected', async ({ page }) => {
]);
});
test('test that organization billable rate can be updated', async ({
page,
}) => {
await goToOrganizationSettings(page);
const newBillableRate = Math.round(Math.random() * 10000);
await page.getByLabel('Organization Billable Rate').click();
await page
.getByLabel('Organization Billable Rate')
.fill(newBillableRate.toString());
await page
.locator('button')
.filter({ hasText: /^Save$/ })
.click();
await Promise.all([
page
.getByRole('button', { name: 'No, only for new time entries' })
.click(),
page.waitForRequest(
async (request) =>
request.url().includes('/organizations/') &&
request.method() === 'PUT' &&
request.postDataJSON().billable_rate ===
newBillableRate * 100 &&
request.postDataJSON().billable_rate_update_time_entries ===
false
),
page.waitForResponse(
async (response) =>
response.url().includes('/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.billable_rate ===
newBillableRate * 100
),
]);
});
test('test that organization billable rate can be updated with all existing time entries', async ({
page,
}) => {
await goToOrganizationSettings(page);
const newBillableRate = Math.round(Math.random() * 10000);
await page.getByLabel('Organization Billable Rate').click();
await page
.getByLabel('Organization Billable Rate')
.fill(newBillableRate.toString());
await page
.locator('button')
.filter({ hasText: /^Save$/ })
.click();
await Promise.all([
page
.getByRole('button', { name: 'Yes, update existing time entries' })
.click(),
page.waitForRequest(
async (request) =>
request.url().includes('/organizations/') &&
request.method() === 'PUT' &&
request.postDataJSON().billable_rate ===
newBillableRate * 100 &&
request.postDataJSON().billable_rate_update_time_entries ===
true
),
page.waitForResponse(
async (response) =>
response.url().includes('/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.billable_rate ===
newBillableRate * 100
),
]);
});
// TODO: Add Test for import

View File

@@ -5,11 +5,15 @@ const ClientResource = z
.object({
id: z.string(),
name: z.string(),
is_archived: z.boolean(),
created_at: z.string(),
updated_at: z.string(),
})
.passthrough();
const ClientCollection = z.array(ClientResource);
const updateClient_Body = z
.object({ name: z.string(), is_archived: z.boolean().optional() })
.passthrough();
const importData_Body = z
.object({ type: z.string(), data: z.string() })
.passthrough();
@@ -33,9 +37,11 @@ const MemberPivotResource = z
.passthrough();
const updateMember_Body = z
.object({
billable_rate: z.union([z.number(), z.null()]).optional(),
role: Role,
billable_rate: z.union([z.number(), z.null()]),
billable_rate_update_time_entries: z.boolean(),
})
.partial()
.passthrough();
const MemberResource = z
.object({
@@ -60,6 +66,7 @@ const updateOrganization_Body = z
.object({
name: z.string(),
billable_rate: z.union([z.number(), z.null()]).optional(),
billable_rate_update_time_entries: z.boolean().optional(),
})
.passthrough();
const ProjectResource = z
@@ -68,6 +75,7 @@ const ProjectResource = z
name: z.string(),
color: z.string(),
client_id: z.union([z.string(), z.null()]),
is_archived: z.boolean(),
billable_rate: z.union([z.number(), z.null()]),
is_billable: z.boolean(),
})
@@ -81,6 +89,17 @@ const createProject_Body = z
client_id: z.union([z.string(), z.null()]).optional(),
})
.passthrough();
const updateProject_Body = z
.object({
name: z.string(),
color: z.string(),
is_billable: z.boolean(),
is_archived: z.boolean().optional(),
client_id: z.union([z.string(), z.null()]).optional(),
billable_rate: z.union([z.number(), z.null()]).optional(),
billable_rate_update_time_entries: z.boolean().optional(),
})
.passthrough();
const ProjectMemberResource = z
.object({
id: z.string(),
@@ -96,7 +115,10 @@ const createProjectMember_Body = z
})
.passthrough();
const updateProjectMember_Body = z
.object({ billable_rate: z.union([z.number(), z.null()]) })
.object({
billable_rate: z.union([z.number(), z.null()]),
billable_rate_update_time_entries: z.boolean(),
})
.partial()
.passthrough();
const TagResource = z
@@ -112,6 +134,7 @@ const TaskResource = z
.object({
id: z.string(),
name: z.string(),
is_done: z.boolean(),
project_id: z.string(),
created_at: z.string(),
updated_at: z.string(),
@@ -120,6 +143,9 @@ const TaskResource = z
const createTask_Body = z
.object({ name: z.string(), project_id: z.string() })
.passthrough();
const updateTask_Body = z
.object({ name: z.string(), is_done: z.boolean().optional() })
.passthrough();
const start = z.union([z.string(), z.null()]).optional();
const TimeEntryResource = z
.object({
@@ -149,7 +175,7 @@ const createTimeEntry_Body = z
tags: z.union([z.array(z.string()), z.null()]).optional(),
})
.passthrough();
const v1_time_entries_update_multiple_Body = z
const updateMultipleTimeEntries_Body = z
.object({
ids: z.array(z.string()),
changes: z
@@ -182,6 +208,7 @@ const updateTimeEntry_Body = z
export const schemas = {
ClientResource,
ClientCollection,
updateClient_Body,
importData_Body,
InvitationResource,
Role,
@@ -193,6 +220,7 @@ export const schemas = {
updateOrganization_Body,
ProjectResource,
createProject_Body,
updateProject_Body,
ProjectMemberResource,
createProjectMember_Body,
updateProjectMember_Body,
@@ -200,11 +228,12 @@ export const schemas = {
TagCollection,
TaskResource,
createTask_Body,
updateTask_Body,
start,
TimeEntryResource,
TimeEntryCollection,
createTimeEntry_Body,
v1_time_entries_update_multiple_Body,
updateMultipleTimeEntries_Body,
updateTimeEntry_Body,
};
@@ -287,6 +316,16 @@ const endpoints = makeApi([
type: 'Path',
schema: z.string(),
},
{
name: 'page',
type: 'Query',
schema: z.number().int().gte(1).optional(),
},
{
name: 'archived',
type: 'Query',
schema: z.enum(['true', 'false', 'all']).optional(),
},
],
response: z.object({ data: ClientCollection }).passthrough(),
errors: [
@@ -300,6 +339,16 @@ const endpoints = makeApi([
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
},
{
@@ -352,7 +401,7 @@ const endpoints = makeApi([
{
name: 'body',
type: 'Body',
schema: z.object({ name: z.string() }).passthrough(),
schema: updateClient_Body,
},
{
name: 'organization',
@@ -1034,6 +1083,11 @@ const endpoints = makeApi([
type: 'Query',
schema: z.number().int().gte(1).optional(),
},
{
name: 'archived',
type: 'Query',
schema: z.enum(['true', 'false', 'all']).optional(),
},
],
response: z
.object({
@@ -1172,7 +1226,7 @@ const endpoints = makeApi([
{
name: 'body',
type: 'Body',
schema: createProject_Body,
schema: updateProject_Body,
},
{
name: 'organization',
@@ -1552,6 +1606,11 @@ const endpoints = makeApi([
type: 'Query',
schema: z.string().uuid().optional(),
},
{
name: 'done',
type: 'Query',
schema: z.enum(['true', 'false', 'all']).optional(),
},
],
response: z
.object({
@@ -1659,7 +1718,7 @@ const endpoints = makeApi([
{
name: 'body',
type: 'Body',
schema: z.object({ name: z.string() }).passthrough(),
schema: updateTask_Body,
},
{
name: 'organization',
@@ -1891,13 +1950,13 @@ Users with the permission `time-entries:view:own` can only use this en
{
method: 'patch',
path: '/v1/organizations/:organization/time-entries',
alias: 'v1.time-entries.update-multiple',
alias: 'updateMultipleTimeEntries',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: v1_time_entries_update_multiple_Body,
schema: updateMultipleTimeEntries_Body,
},
{
name: 'organization',

View File

@@ -0,0 +1,65 @@
<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';
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 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-0.5 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-0.5 text-center font-semibold">
Do you want to update all existing time entries 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>
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="show = false"> Cancel </SecondaryButton>
</template>
</DialogModal>
</template>
<style scoped></style>

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import SelectDropdown from '@/Components/Common/SelectDropdown.vue';
import type { BillableKey } from '@/utils/useProjects';
import Badge from '@/Components/Common/Badge.vue';
import { ChevronDownIcon } from '@heroicons/vue/20/solid';
const model = defineModel<BillableKey>({
default: 'default-rate',
});
type Option = { key: BillableKey; name: string };
const options: Option[] = [
{
key: 'default-rate',
name: 'Organization Default Rate',
},
{
key: 'custom-rate',
name: 'Custom Rate',
},
];
function getKeyFromItem(item: Option) {
return item.key;
}
function getNameFromItem(item: Option) {
return item.name;
}
function getNameForKey(key: BillableKey | undefined) {
const item = options.find((item) => getKeyFromItem(item) === key);
if (item) {
return getNameFromItem(item);
}
return '';
}
</script>
<template>
<SelectDropdown
v-model="model"
:get-key-from-item="getKeyFromItem"
:get-name-for-item="getNameFromItem"
:items="options">
<template #trigger>
<Badge size="xlarge" class="bg-input-background cursor-pointer">
<span>
{{ getNameForKey(model) }}
</span>
<ChevronDownIcon class="text-muted w-5"></ChevronDownIcon>
</Badge>
</template>
</SelectDropdown>
</template>
<style scoped></style>

View File

@@ -4,8 +4,12 @@ import DialogModal from '@/Components/DialogModal.vue';
import { ref } from 'vue';
import type { Member, UpdateMemberBody } from '@/utils/api';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import { useMembersStore } from '@/utils/useMembers';
import { type MemberBillableKey, useMembersStore } from '@/utils/useMembers';
import BillableRateInput from '@/Components/Common/BillableRateInput.vue';
import InputLabel from '@/Components/InputLabel.vue';
import MemberBillableRateModal from '@/Components/Common/Member/MemberBillableRateModal.vue';
import MemberBillableSelect from '@/Components/Common/Member/MemberBillableSelect.vue';
import { onMounted, watch } from 'vue';
const { updateMember } = useMembersStore();
const show = defineModel('show', { default: false });
@@ -21,13 +25,49 @@ const memberBody = ref<UpdateMemberBody>({
billable_rate: props.member.billable_rate,
});
async function submit() {
async function submit(billableRateUpdateTimeEntries: boolean) {
memberBody.value.billable_rate_update_time_entries =
billableRateUpdateTimeEntries;
await updateMember(props.member.id, memberBody.value);
show.value = false;
showBillableRateModal.value = true;
}
const showBillableRateModal = ref(false);
function openBillableRateModalIfNeeded() {
if (memberBody.value.billable_rate !== props.member.billable_rate) {
showBillableRateModal.value = true;
show.value = false;
} else {
submit(false);
}
}
const billableRateSelect = ref<MemberBillableKey>('default-rate');
onMounted(() => {
if (props.member.billable_rate !== null) {
billableRateSelect.value = 'custom-rate';
} else {
billableRateSelect.value = 'default-rate';
}
});
watch(billableRateSelect, () => {
if (billableRateSelect.value === 'default-rate') {
memberBody.value.billable_rate = null;
} else if (billableRateSelect.value === 'custom-rate') {
memberBody.value.billable_rate = props.member.billable_rate ?? 0;
}
});
</script>
<template>
<MemberBillableRateModal
v-model:saving="saving"
v-model:show="showBillableRateModal"
:member-name="member.name"
:newBillableRate="memberBody.billable_rate"
@submit="submit"></MemberBillableRateModal>
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
@@ -37,11 +77,29 @@ async function submit() {
<template #content>
<div class="flex items-center space-x-4">
<div class="col-span-6 sm:col-span-4 flex-1">
<BillableRateInput
focus
name="billable_rate"
v-model="memberBody.billable_rate"></BillableRateInput>
<div class="col-span-6 sm:col-span-4 flex-1 flex space-x-5">
<div>
<InputLabel for="billableType" value="Billable" />
<MemberBillableSelect
class="mt-2"
name="billableType"
v-model="billableRateSelect"></MemberBillableSelect>
</div>
<div
class="flex-1"
v-if="billableRateSelect === 'custom-rate'">
<InputLabel
for="memberBillableRate"
value="Billable Rate" />
<BillableRateInput
focus
class="w-full"
@keydown.enter="openBillableRateModalIfNeeded()"
name="memberBillableRate"
v-model="
memberBody.billable_rate
"></BillableRateInput>
</div>
</div>
</div>
</template>
@@ -52,8 +110,8 @@ async function submit() {
class="ms-3"
:class="{ 'opacity-25': saving }"
:disabled="saving"
@click="submit">
Update Client
@click="openBillableRateModalIfNeeded()">
Update Member
</PrimaryButton>
</template>
</DialogModal>

View File

@@ -16,20 +16,22 @@ const props = defineProps<{
<template>
<Dropdown align="bottom-end">
<template #trigger>
<svg
data-testid="client_actions"
:aria-label="'Actions for Member ' + props.member.name"
class="h-10 w-10 p-2 rounded-full hover:bg-card-background opacity-20 group-hover:opacity-100 transition"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
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
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="'Actions for Member ' + props.member.name">
<svg
class="h-10 w-10 p-2 rounded-full"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
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 #content>
<div class="min-w-[150px]">

View File

@@ -0,0 +1,64 @@
<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';
const show = defineModel('show', { default: false });
const saving = defineModel('saving', { default: false });
defineProps<{
newBillableRate?: number | null;
}>();
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 Organization 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-0.5 text-center">
The organization billable rate will be updated to
<strong>{{
newBillableRate
? formatCents(newBillableRate)
: ' none.'
}}</strong
>.
</p>
<p class="py-0.5 text-center font-semibold">
Do you want to update all existing time entries 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>
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="show = false"> Cancel </SecondaryButton>
</template>
</DialogModal>
</template>
<style scoped></style>

View File

@@ -89,6 +89,7 @@ const filteredProjects = computed(() => {
value: '',
client_id: null,
billable_rate: null,
is_archived: false,
is_billable: false,
},
tasks: [],

View File

@@ -11,6 +11,7 @@ defineProps<{
<Component
:is="href ? Link : 'div'"
:href="href"
role="row"
:class="
twMerge(
'contents group [&>*]:transition [&>*]:border-row-separator [&>*]:border-b',

View File

@@ -7,14 +7,15 @@ import type { UpdateOrganizationBody } from '@/utils/api';
import BillableRateInput from '@/Components/Common/BillableRateInput.vue';
import { useOrganizationStore } from '@/utils/useOrganization';
import { storeToRefs } from 'pinia';
import OrganizationBillableRateModal from '@/Components/Common/Organization/OrganizationBillableRateModal.vue';
const store = useOrganizationStore();
const { fetchOrganization, updateOrganization } = store;
const { organization } = storeToRefs(store);
const saving = ref(false);
const organizationBody = ref<UpdateOrganizationBody>({
name: '',
billable_rate: null,
billable_rate: null as number | null,
});
onMounted(async () => {
@@ -24,9 +25,15 @@ onMounted(async () => {
billable_rate: organization.value?.billable_rate,
};
});
const showConfirmationModal = ref(false);
function submit() {
updateOrganization(organizationBody.value);
async function submit(billableRateUpdateTimeEntries: boolean) {
saving.value = true;
organizationBody.value.billable_rate_update_time_entries =
billableRateUpdateTimeEntries;
await updateOrganization(organizationBody.value);
saving.value = false;
showConfirmationModal.value = false;
}
</script>
@@ -39,6 +46,12 @@ function submit() {
</template>
<template #form>
<OrganizationBillableRateModal
v-model:show="showConfirmationModal"
@submit="submit"
:new-billable-rate="
organizationBody.billable_rate
"></OrganizationBillableRateModal>
<!-- Organization Owner Information -->
<div class="col-span-6">
<div class="col-span-6 sm:col-span-4">
@@ -53,7 +66,9 @@ function submit() {
</div>
</template>
<template #actions>
<PrimaryButton @click="submit">Save</PrimaryButton>
<PrimaryButton @click="showConfirmationModal = true"
>Save</PrimaryButton
>
</template>
</FormSection>
</template>

View File

@@ -9,6 +9,8 @@ import type {
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
export type MemberBillableKey = 'default-rate' | 'custom-rate';
export const useMembersStore = defineStore('members', () => {
const membersResponse = ref<MemberIndexResponse | null>(null);
const { handleApiRequestNotifications } = useNotificationsStore();