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

@@ -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();