mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-15 11:42:15 +01:00
add billable rate time entries update support for existing time entries (member & organization)
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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>
|
||||
@@ -89,6 +89,7 @@ const filteredProjects = computed(() => {
|
||||
value: '',
|
||||
client_id: null,
|
||||
billable_rate: null,
|
||||
is_archived: false,
|
||||
is_billable: false,
|
||||
},
|
||||
tasks: [],
|
||||
|
||||
@@ -11,6 +11,7 @@ defineProps<{
|
||||
<Component
|
||||
:is="href ? Link : 'div'"
|
||||
:href="href"
|
||||
role="row"
|
||||
:class="
|
||||
twMerge(
|
||||
'contents group [&>*]:transition [&>*]:border-row-separator [&>*]:border-b',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user