mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-17 12:42:15 +01:00
add project members to project show page
This commit is contained in:
@@ -1487,6 +1487,21 @@ const endpoints = makeApi([
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'get',
|
||||||
|
path: '/v1/users/me/time-entries/active',
|
||||||
|
alias: 'getMyActiveTimeEntry',
|
||||||
|
description: `This endpoint is independent of organization.`,
|
||||||
|
requestFormat: 'json',
|
||||||
|
response: z.object({ data: TimeEntryResource }).passthrough(),
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
status: 404,
|
||||||
|
description: `Not found`,
|
||||||
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const api = new Zodios('/api', endpoints);
|
export const api = new Zodios('/api', endpoints);
|
||||||
|
|||||||
72
resources/js/Components/Common/BillableRateInput.vue
Normal file
72
resources/js/Components/Common/BillableRateInput.vue
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import TextInput from '@/Components/TextInput.vue';
|
||||||
|
import {
|
||||||
|
formatMoney,
|
||||||
|
getOrganizationCurrencyString,
|
||||||
|
getOrganizationCurrencySymbol,
|
||||||
|
} from '../../utils/money';
|
||||||
|
|
||||||
|
const model = defineModel({
|
||||||
|
default: null,
|
||||||
|
type: Number,
|
||||||
|
});
|
||||||
|
|
||||||
|
function cleanUpDecimalValue(value: string) {
|
||||||
|
value = value.replace(/,/g, '');
|
||||||
|
value = value.replace(getOrganizationCurrencySymbol(), '');
|
||||||
|
return value.replace(/\./g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRate(value: string) {
|
||||||
|
if (value.includes(',')) {
|
||||||
|
const parts = value.split(',');
|
||||||
|
const lastPart = (parts[parts.length - 1] = parts[parts.length - 1]);
|
||||||
|
if (lastPart.length === 2) {
|
||||||
|
// we detected a decimal number with 2 digits after the comma
|
||||||
|
value = cleanUpDecimalValue(value);
|
||||||
|
model.value = parseInt(value);
|
||||||
|
}
|
||||||
|
} else if (value.includes('.')) {
|
||||||
|
const parts = value.split('.');
|
||||||
|
const lastPart = (parts[parts.length - 1] = parts[parts.length - 1]);
|
||||||
|
if (lastPart.length === 2) {
|
||||||
|
value = cleanUpDecimalValue(value);
|
||||||
|
model.value = parseInt(value);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// if it doesn't contain a comma or a dot, it's probably a whole number so let's convert it to cents
|
||||||
|
model.value = parseInt(cleanUpDecimalValue(value)) * 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function formatCents(modelValue: number) {
|
||||||
|
const formattedValue = formatMoney(
|
||||||
|
modelValue / 100,
|
||||||
|
getOrganizationCurrencyString()
|
||||||
|
);
|
||||||
|
return formattedValue.replace(getOrganizationCurrencySymbol(), '');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative">
|
||||||
|
<TextInput
|
||||||
|
id="projectMemberRate"
|
||||||
|
ref="projectMemberRateInput"
|
||||||
|
:modelValue="formatCents(model)"
|
||||||
|
@blur="updateRate($event.target.value)"
|
||||||
|
type="text"
|
||||||
|
placeholder="Billable Rate"
|
||||||
|
class="mt-1 block w-full"
|
||||||
|
autocomplete="teamMemberRate" />
|
||||||
|
<span>
|
||||||
|
<div
|
||||||
|
class="absolute top-0 right-0 h-full flex items-center px-4 font-medium">
|
||||||
|
<span>
|
||||||
|
{{ getOrganizationCurrencyString() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
7
resources/js/Components/Common/Card.vue
Normal file
7
resources/js/Components/Common/Card.vue
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script setup lang="ts"></script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="rounded-lg border border-card-border">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -8,16 +8,21 @@ defineProps<{
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<h3
|
<div class="flex w-full items-center justify-between pb-4">
|
||||||
class="text-white font-bold pb-4 text-base flex items-center space-x-2.5">
|
<h3
|
||||||
<component
|
class="text-white font-bold text-base flex items-center space-x-2.5">
|
||||||
v-if="icon"
|
<component
|
||||||
:is="icon"
|
v-if="icon"
|
||||||
class="w-6 text-icon-default"></component>
|
:is="icon"
|
||||||
<span>
|
class="w-6 text-icon-default"></component>
|
||||||
{{ title }}
|
<span>
|
||||||
</span>
|
{{ title }}
|
||||||
</h3>
|
</span>
|
||||||
|
</h3>
|
||||||
|
<div>
|
||||||
|
<slot name="actions"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|||||||
190
resources/js/Components/Common/Member/MemberCombobox.vue
Normal file
190
resources/js/Components/Common/Member/MemberCombobox.vue
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import Dropdown from '@/Components/Dropdown.vue';
|
||||||
|
import { type Component, computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import ClientDropdownItem from '@/Components/Common/Client/ClientDropdownItem.vue';
|
||||||
|
import { useMembersStore } from '@/utils/useMembers';
|
||||||
|
import { UserIcon, XMarkIcon } from '@heroicons/vue/24/solid';
|
||||||
|
import TextInput from '@/Components/TextInput.vue';
|
||||||
|
import { useFocus } from '@vueuse/core';
|
||||||
|
import type { ProjectMember } from '@/utils/api';
|
||||||
|
|
||||||
|
const membersStore = useMembersStore();
|
||||||
|
const { members } = storeToRefs(membersStore);
|
||||||
|
|
||||||
|
const model = defineModel<string>({
|
||||||
|
default: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
hiddenMembers: ProjectMember[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const searchInput = ref<HTMLInputElement | null>(null);
|
||||||
|
const dropdownViewport = ref<Component | null>(null);
|
||||||
|
|
||||||
|
const searchValue = ref('');
|
||||||
|
|
||||||
|
function isMemberSelected(id: string) {
|
||||||
|
return model.value === id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { focused } = useFocus(searchInput, { initialValue: true });
|
||||||
|
|
||||||
|
const filteredMembers = computed(() => {
|
||||||
|
return members.value.filter((member) => {
|
||||||
|
return (
|
||||||
|
member.name
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchValue.value?.toLowerCase()?.trim() || '') &&
|
||||||
|
!props.hiddenMembers.some(
|
||||||
|
(hiddenMember) => hiddenMember.user_id === member.id
|
||||||
|
) &&
|
||||||
|
member.is_placeholder === false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(filteredMembers, () => {
|
||||||
|
resetHighlightedItem();
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
resetHighlightedItem();
|
||||||
|
});
|
||||||
|
|
||||||
|
function resetHighlightedItem() {
|
||||||
|
if (filteredMembers.value.length > 0) {
|
||||||
|
highlightedItemId.value = filteredMembers.value[0].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSearchValue(event: Event) {
|
||||||
|
const newInput = (event.target as HTMLInputElement).value;
|
||||||
|
if (newInput === ' ') {
|
||||||
|
searchValue.value = '';
|
||||||
|
const highlightedClientId = highlightedItemId.value;
|
||||||
|
if (highlightedClientId) {
|
||||||
|
const highlightedClient = members.value.find(
|
||||||
|
(member) => member.id === highlightedClientId
|
||||||
|
);
|
||||||
|
if (highlightedClient) {
|
||||||
|
model.value = highlightedClient.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
searchValue.value = newInput;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'changed']);
|
||||||
|
|
||||||
|
function updateMember(newValue: string | null) {
|
||||||
|
if (newValue) {
|
||||||
|
model.value = newValue;
|
||||||
|
nextTick(() => {
|
||||||
|
emit('changed');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveHighlightUp() {
|
||||||
|
if (highlightedItem.value) {
|
||||||
|
const currentHightlightedIndex = filteredMembers.value.indexOf(
|
||||||
|
highlightedItem.value
|
||||||
|
);
|
||||||
|
if (currentHightlightedIndex === 0) {
|
||||||
|
highlightedItemId.value =
|
||||||
|
filteredMembers.value[filteredMembers.value.length - 1].id;
|
||||||
|
} else {
|
||||||
|
highlightedItemId.value =
|
||||||
|
filteredMembers.value[currentHightlightedIndex - 1].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveHighlightDown() {
|
||||||
|
if (highlightedItem.value) {
|
||||||
|
const currentHightlightedIndex = filteredMembers.value.indexOf(
|
||||||
|
highlightedItem.value
|
||||||
|
);
|
||||||
|
if (currentHightlightedIndex === filteredMembers.value.length - 1) {
|
||||||
|
highlightedItemId.value = filteredMembers.value[0].id;
|
||||||
|
} else {
|
||||||
|
highlightedItemId.value =
|
||||||
|
filteredMembers.value[currentHightlightedIndex + 1].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlightedItemId = ref<string | null>(null);
|
||||||
|
const highlightedItem = computed(() => {
|
||||||
|
return members.value.find(
|
||||||
|
(member) => member.id === highlightedItemId.value
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentValue = computed(() => {
|
||||||
|
if (model.value) {
|
||||||
|
return members.value.find((member) => member.id === model.value)?.name;
|
||||||
|
}
|
||||||
|
return searchValue.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasMemberSelected = computed(() => {
|
||||||
|
return model.value !== '';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex relative">
|
||||||
|
<div
|
||||||
|
class="absolute h-full items-center px-3 w-full flex justify-between">
|
||||||
|
<UserIcon class="relative z-10 w-4 text-muted"></UserIcon>
|
||||||
|
<button
|
||||||
|
v-if="hasMemberSelected"
|
||||||
|
@click="model = ''"
|
||||||
|
class="focus:text-accent-200 focus:bg-card-background text-muted">
|
||||||
|
<XMarkIcon class="relative z-10 w-4"></XMarkIcon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<TextInput
|
||||||
|
:value="currentValue"
|
||||||
|
@input="updateSearchValue"
|
||||||
|
data-testid="member_dropdown_search"
|
||||||
|
@keydown.enter.prevent="updateMember(highlightedItemId)"
|
||||||
|
@keydown.up.prevent="moveHighlightUp"
|
||||||
|
class="relative w-full pl-10"
|
||||||
|
@keydown.down.prevent="moveHighlightDown"
|
||||||
|
placeholder="Search for a member..."
|
||||||
|
ref="searchInput" />
|
||||||
|
</div>
|
||||||
|
<Dropdown
|
||||||
|
align="left"
|
||||||
|
width="300"
|
||||||
|
v-model="focused"
|
||||||
|
:closeOnContentClick="true">
|
||||||
|
<template #content>
|
||||||
|
<div ref="dropdownViewport" class="w-60">
|
||||||
|
<div
|
||||||
|
v-for="member in filteredMembers"
|
||||||
|
:key="member.id"
|
||||||
|
role="option"
|
||||||
|
:value="member.id"
|
||||||
|
:class="{
|
||||||
|
'bg-card-background-active':
|
||||||
|
member.id === highlightedItemId,
|
||||||
|
}"
|
||||||
|
@click="updateMember(member.id)"
|
||||||
|
data-testid="client_dropdown_entries"
|
||||||
|
:data-client-id="member.id">
|
||||||
|
<ClientDropdownItem
|
||||||
|
:selected="isMemberSelected(member.id)"
|
||||||
|
:name="member.name"></ClientDropdownItem>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dropdown>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -4,6 +4,7 @@ import { CheckCircleIcon, UserCircleIcon } from '@heroicons/vue/20/solid';
|
|||||||
import { useClientsStore } from '@/utils/useClients';
|
import { useClientsStore } from '@/utils/useClients';
|
||||||
import MemberMoreOptionsDropdown from '@/Components/Common/Member/MemberMoreOptionsDropdown.vue';
|
import MemberMoreOptionsDropdown from '@/Components/Common/Member/MemberMoreOptionsDropdown.vue';
|
||||||
import TableRow from '@/Components/TableRow.vue';
|
import TableRow from '@/Components/TableRow.vue';
|
||||||
|
import { capitalizeFirstLetter } from '../../../utils/format';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
member: Member;
|
member: Member;
|
||||||
@@ -12,10 +13,6 @@ const props = defineProps<{
|
|||||||
function removeMember() {
|
function removeMember() {
|
||||||
useClientsStore().deleteClient(props.member.id);
|
useClientsStore().deleteClient(props.member.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function capitalizeFirstLetter(string: string) {
|
|
||||||
return string.charAt(0).toUpperCase() + string.slice(1);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
|||||||
Client
|
Client
|
||||||
</div>
|
</div>
|
||||||
<div class="px-3 py-1.5 text-left text-sm font-semibold text-white">
|
<div class="px-3 py-1.5 text-left text-sm font-semibold text-white">
|
||||||
Team
|
Billable Rate
|
||||||
</div>
|
</div>
|
||||||
<div class="px-3 py-1.5 text-left text-sm font-semibold text-white">
|
<div class="px-3 py-1.5 text-left text-sm font-semibold text-white">
|
||||||
Status
|
Status
|
||||||
|
|||||||
@@ -54,24 +54,7 @@ function deleteProject() {
|
|||||||
<div v-else>No client</div>
|
<div v-else>No client</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted">
|
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted">
|
||||||
<div class="isolate flex -space-x-1 opacity-50">
|
{{ project.billable_rate ?? '--' }}
|
||||||
<img
|
|
||||||
class="relative z-30 inline-block h-6 w-6 rounded-full ring-4 ring-card-background"
|
|
||||||
src="https://images.unsplash.com/photo-1491528323818-fdd1faba62cc?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
|
|
||||||
alt="" />
|
|
||||||
<img
|
|
||||||
class="relative z-20 inline-block h-6 w-6 rounded-full ring-4 ring-card-background"
|
|
||||||
src="https://images.unsplash.com/photo-1550525811-e5869dd03032?ixlib=rb-1.2.1&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
|
|
||||||
alt="" />
|
|
||||||
<img
|
|
||||||
class="relative z-10 inline-block h-6 w-6 rounded-full ring-4 ring-card-background"
|
|
||||||
src="https://images.unsplash.com/photo-1500648767791-00dcc994a43e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2.25&w=256&h=256&q=80"
|
|
||||||
alt="" />
|
|
||||||
<img
|
|
||||||
class="relative z-0 inline-block h-6 w-6 rounded-full ring-4 ring-card-background"
|
|
||||||
src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
|
|
||||||
alt="" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="whitespace-nowrap px-3 py-4 text-sm text-muted flex space-x-1 items-center font-medium">
|
class="whitespace-nowrap px-3 py-4 text-sm text-muted flex space-x-1 items-center font-medium">
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
||||||
|
import DialogModal from '@/Components/DialogModal.vue';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import type { CreateProjectMemberBody, ProjectMember } from '@/utils/api';
|
||||||
|
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||||
|
import { useFocus } from '@vueuse/core';
|
||||||
|
import { useProjectMembersStore } from '@/utils/useProjectMembers';
|
||||||
|
import MemberCombobox from '@/Components/Common/Member/MemberCombobox.vue';
|
||||||
|
import BillableRateInput from '@/Components/Common/BillableRateInput.vue';
|
||||||
|
const { createProjectMember } = useProjectMembersStore();
|
||||||
|
const show = defineModel('show', { default: false });
|
||||||
|
const saving = ref(false);
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
projectId: string;
|
||||||
|
existingMembers: ProjectMember[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const projectMember = ref<CreateProjectMemberBody>({
|
||||||
|
user_id: '',
|
||||||
|
billable_rate: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
await createProjectMember(props.projectId, projectMember.value);
|
||||||
|
show.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectNameInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
useFocus(projectNameInput, { initialValue: true });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogModal closeable :show="show" @close="show = false">
|
||||||
|
<template #title>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<span>Add Project Member</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #content>
|
||||||
|
<div class="grid grid-cols-3 items-center space-x-4">
|
||||||
|
<div class="col-span-3 sm:col-span-2">
|
||||||
|
<MemberCombobox
|
||||||
|
:hidden-members="props.existingMembers"
|
||||||
|
v-model="projectMember.user_id"></MemberCombobox>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-3 sm:col-span-1 flex-1">
|
||||||
|
<BillableRateInput
|
||||||
|
v-model="
|
||||||
|
projectMember.billable_rate
|
||||||
|
"></BillableRateInput>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<SecondaryButton @click="show = false">Cancel</SecondaryButton>
|
||||||
|
<PrimaryButton
|
||||||
|
class="ms-3"
|
||||||
|
:class="{ 'opacity-25': saving }"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="submit">
|
||||||
|
Add Project Member
|
||||||
|
</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
</DialogModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import Dropdown from '@/Components/Dropdown.vue';
|
||||||
|
import { TrashIcon } from '@heroicons/vue/20/solid';
|
||||||
|
import type { ProjectMember } from '@/utils/api';
|
||||||
|
import { useMembersStore } from '@/utils/useMembers';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
const emit = defineEmits<{
|
||||||
|
delete: [];
|
||||||
|
}>();
|
||||||
|
const props = defineProps<{
|
||||||
|
projectMember: ProjectMember;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { members } = storeToRefs(useMembersStore());
|
||||||
|
|
||||||
|
const currentMember = computed(() => {
|
||||||
|
return members.value.find(
|
||||||
|
(member) => member.id === props.projectMember.user_id
|
||||||
|
);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dropdown>
|
||||||
|
<template #trigger>
|
||||||
|
<svg
|
||||||
|
data-testid="project_actions"
|
||||||
|
:aria-label="
|
||||||
|
'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"
|
||||||
|
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>
|
||||||
|
</template>
|
||||||
|
<template #content>
|
||||||
|
<button
|
||||||
|
@click.prevent="emit('delete')"
|
||||||
|
:aria-label="'Delete Project Member ' + currentMember?.name"
|
||||||
|
data-testid="project_delete"
|
||||||
|
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
|
||||||
|
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
|
||||||
|
<span>Remove from Team</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</Dropdown>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
||||||
|
import { PlusIcon } from '@heroicons/vue/16/solid';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import ProjectMemberTableRow from '@/Components/Common/ProjectMember/ProjectMemberTableRow.vue';
|
||||||
|
import { UserGroupIcon } from '@heroicons/vue/24/solid';
|
||||||
|
import ProjectMemberTableHeading from '@/Components/Common/ProjectMember/ProjectMemberTableHeading.vue';
|
||||||
|
import ProjectMemberCreateModal from '@/Components/Common/ProjectMember/ProjectMemberCreateModal.vue';
|
||||||
|
import type { ProjectMember } from '@/utils/api';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
projectId: string;
|
||||||
|
projectMembers: ProjectMember[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const createProjectMember = ref(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ProjectMemberCreateModal
|
||||||
|
:existing-members="projectMembers"
|
||||||
|
:project-id="projectId"
|
||||||
|
v-model:show="createProjectMember"></ProjectMemberCreateModal>
|
||||||
|
<div class="flow-root">
|
||||||
|
<div class="inline-block min-w-full align-middle">
|
||||||
|
<div
|
||||||
|
data-testid="project_table"
|
||||||
|
class="grid min-w-full"
|
||||||
|
style="grid-template-columns: 1fr 150px 150px 80px">
|
||||||
|
<ProjectMemberTableHeading></ProjectMemberTableHeading>
|
||||||
|
<div
|
||||||
|
class="col-span-5 py-24 text-center"
|
||||||
|
v-if="projectMembers.length === 0">
|
||||||
|
<UserGroupIcon
|
||||||
|
class="w-8 text-icon-default inline pb-2"></UserGroupIcon>
|
||||||
|
<h3 class="text-white font-semibold">No project members</h3>
|
||||||
|
<p class="pb-5">Add the first project member!</p>
|
||||||
|
<SecondaryButton
|
||||||
|
@click="createProjectMember = true"
|
||||||
|
:icon="PlusIcon"
|
||||||
|
>Add a new Project Member
|
||||||
|
</SecondaryButton>
|
||||||
|
</div>
|
||||||
|
<template
|
||||||
|
v-for="projectMember in projectMembers"
|
||||||
|
:key="projectMember.id">
|
||||||
|
<ProjectMemberTableRow
|
||||||
|
:project-member="projectMember"></ProjectMemberTableRow>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TableHeading>
|
||||||
|
<div
|
||||||
|
class="py-1.5 pr-3 text-left text-sm font-semibold text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||||
|
Name
|
||||||
|
</div>
|
||||||
|
<div class="px-3 py-1.5 text-left text-sm font-semibold text-white">
|
||||||
|
Billable Rate
|
||||||
|
</div>
|
||||||
|
<div class="px-3 py-1.5 text-left text-sm font-semibold text-white">
|
||||||
|
Role
|
||||||
|
</div>
|
||||||
|
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||||
|
<span class="sr-only">Edit</span>
|
||||||
|
</div>
|
||||||
|
</TableHeading>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ProjectMember } from '@/utils/api';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import TableRow from '@/Components/TableRow.vue';
|
||||||
|
import { useMembersStore } from '@/utils/useMembers';
|
||||||
|
import { useProjectMembersStore } from '@/utils/useProjectMembers';
|
||||||
|
import ProjectMemberMoreOptionsDropdown from '@/Components/Common/ProjectMember/ProjectMemberMoreOptionsDropdown.vue';
|
||||||
|
import { formatCents } from '@/utils/money';
|
||||||
|
import { capitalizeFirstLetter } from '@/utils/format';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
projectMember: ProjectMember;
|
||||||
|
}>();
|
||||||
|
function deleteProjectMember() {
|
||||||
|
useProjectMembersStore().deleteProjectMember(
|
||||||
|
props.projectMember.project_id,
|
||||||
|
props.projectMember.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { members } = storeToRefs(useMembersStore());
|
||||||
|
const member = computed(() => {
|
||||||
|
return members.value.find(
|
||||||
|
(member) => member.id === props.projectMember.user_id
|
||||||
|
);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TableRow>
|
||||||
|
<div
|
||||||
|
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||||
|
<span>
|
||||||
|
{{ member?.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted">
|
||||||
|
{{
|
||||||
|
projectMember.billable_rate
|
||||||
|
? formatCents(projectMember.billable_rate)
|
||||||
|
: '--'
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted">
|
||||||
|
{{ capitalizeFirstLetter(member?.role ?? '') }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||||
|
<ProjectMemberMoreOptionsDropdown
|
||||||
|
:project-member="projectMember"
|
||||||
|
@delete="
|
||||||
|
deleteProjectMember
|
||||||
|
"></ProjectMemberMoreOptionsDropdown>
|
||||||
|
</div>
|
||||||
|
</TableRow>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -102,7 +102,7 @@ function onBackgroundClick() {
|
|||||||
class="absolute z-50 mt-2 rounded-md shadow-lg"
|
class="absolute z-50 mt-2 rounded-md shadow-lg"
|
||||||
:class="[widthClass, alignmentClasses]"
|
:class="[widthClass, alignmentClasses]"
|
||||||
style="display: none"
|
style="display: none"
|
||||||
@click="onContentClick">
|
@click.prevent="onContentClick">
|
||||||
<div
|
<div
|
||||||
class="rounded-lg ring-1 relative ring-black ring-opacity-5"
|
class="rounded-lg ring-1 relative ring-black ring-opacity-5"
|
||||||
:class="contentClasses">
|
:class="contentClasses">
|
||||||
|
|||||||
@@ -3,13 +3,23 @@ import MainContainer from '@/Pages/MainContainer.vue';
|
|||||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||||
import { FolderIcon, PlusIcon } from '@heroicons/vue/16/solid';
|
import { FolderIcon, PlusIcon } from '@heroicons/vue/16/solid';
|
||||||
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { useProjectsStore } from '@/utils/useProjects';
|
import { useProjectsStore } from '@/utils/useProjects';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { ChevronRightIcon } from '@heroicons/vue/20/solid';
|
import {
|
||||||
|
ChevronRightIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
UserGroupIcon,
|
||||||
|
} from '@heroicons/vue/20/solid';
|
||||||
import { Link } from '@inertiajs/vue3';
|
import { Link } from '@inertiajs/vue3';
|
||||||
import TaskCreateModal from '@/Components/Common/Task/TaskCreateModal.vue';
|
import TaskCreateModal from '@/Components/Common/Task/TaskCreateModal.vue';
|
||||||
import TaskTable from '@/Components/Common/Task/TaskTable.vue';
|
import TaskTable from '@/Components/Common/Task/TaskTable.vue';
|
||||||
|
import CardTitle from '@/Components/Common/CardTitle.vue';
|
||||||
|
import Card from '@/Components/Common/Card.vue';
|
||||||
|
import ProjectMemberTable from '@/Components/Common/ProjectMember/ProjectMemberTable.vue';
|
||||||
|
import ProjectMemberCreateModal from '@/Components/Common/ProjectMember/ProjectMemberCreateModal.vue';
|
||||||
|
import { useProjectMembersStore } from '@/utils/useProjectMembers';
|
||||||
|
|
||||||
const { projects } = storeToRefs(useProjectsStore());
|
const { projects } = storeToRefs(useProjectsStore());
|
||||||
|
|
||||||
const project = computed(() => {
|
const project = computed(() => {
|
||||||
@@ -20,8 +30,14 @@ const project = computed(() => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
const createTask = ref(false);
|
const createTask = ref(false);
|
||||||
|
const createProjectMember = ref(false);
|
||||||
|
const projectId = route()?.params?.project as string;
|
||||||
|
|
||||||
const projectId: string = route().params.project;
|
const { projectMembers } = storeToRefs(useProjectMembersStore());
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
useProjectMembersStore().fetchProjectMembers(projectId);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -37,7 +53,7 @@ const projectId: string = route().params.project;
|
|||||||
class="flex items-center space-x-2.5">
|
class="flex items-center space-x-2.5">
|
||||||
<FolderIcon
|
<FolderIcon
|
||||||
class="w-6 text-icon-default"></FolderIcon>
|
class="w-6 text-icon-default"></FolderIcon>
|
||||||
<span> Projects </span>
|
<span class="font-medium">Projects</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -60,13 +76,49 @@ const projectId: string = route().params.project;
|
|||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
<SecondaryButton :icon="PlusIcon" @click="createTask = true"
|
|
||||||
>Create Task
|
|
||||||
</SecondaryButton>
|
|
||||||
<TaskCreateModal
|
|
||||||
:project-id="projectId"
|
|
||||||
v-model:show="createTask"></TaskCreateModal>
|
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
<TaskTable :project-id="projectId"></TaskTable>
|
<MainContainer>
|
||||||
|
<div class="grid grid-cols-2 gap-x-6 pt-6">
|
||||||
|
<div>
|
||||||
|
<CardTitle title="Tasks" :icon="CheckCircleIcon">
|
||||||
|
<template #actions>
|
||||||
|
<SecondaryButton
|
||||||
|
:icon="PlusIcon"
|
||||||
|
@click="createTask = true"
|
||||||
|
>Create Task
|
||||||
|
</SecondaryButton>
|
||||||
|
<TaskCreateModal
|
||||||
|
:project-id="projectId"
|
||||||
|
v-model:show="createTask"></TaskCreateModal>
|
||||||
|
</template>
|
||||||
|
</CardTitle>
|
||||||
|
<Card>
|
||||||
|
<TaskTable :project-id="projectId"></TaskTable>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle title="Project Members" :icon="UserGroupIcon">
|
||||||
|
<template #actions>
|
||||||
|
<SecondaryButton
|
||||||
|
:icon="PlusIcon"
|
||||||
|
@click="createProjectMember = true">
|
||||||
|
Add Member
|
||||||
|
</SecondaryButton>
|
||||||
|
<ProjectMemberCreateModal
|
||||||
|
:project-id="projectId"
|
||||||
|
:existing-members="projectMembers"
|
||||||
|
v-model:show="
|
||||||
|
createProjectMember
|
||||||
|
"></ProjectMemberCreateModal>
|
||||||
|
</template>
|
||||||
|
</CardTitle>
|
||||||
|
<Card>
|
||||||
|
<ProjectMemberTable
|
||||||
|
:project-members="projectMembers"
|
||||||
|
:project-id="projectId"></ProjectMemberTable>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MainContainer>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -24,6 +24,18 @@ export type CreateProjectBody = ZodiosBodyByAlias<
|
|||||||
'createProject'
|
'createProject'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export type ProjectMemberResponse = ZodiosResponseByAlias<
|
||||||
|
SolidTimeApi,
|
||||||
|
'getProjectMembers'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type CreateProjectMemberBody = ZodiosBodyByAlias<
|
||||||
|
SolidTimeApi,
|
||||||
|
'createProjectMember'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type ProjectMember = ProjectMemberResponse['data'][0];
|
||||||
|
|
||||||
export type CreateTaskBody = ZodiosBodyByAlias<SolidTimeApi, 'createTask'>;
|
export type CreateTaskBody = ZodiosBodyByAlias<SolidTimeApi, 'createTask'>;
|
||||||
|
|
||||||
export type CreateClientBody = ZodiosBodyByAlias<SolidTimeApi, 'createClient'>;
|
export type CreateClientBody = ZodiosBodyByAlias<SolidTimeApi, 'createClient'>;
|
||||||
|
|||||||
3
resources/js/utils/format.ts
Normal file
3
resources/js/utils/format.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function capitalizeFirstLetter(string: string) {
|
||||||
|
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||||
|
}
|
||||||
@@ -1,6 +1,21 @@
|
|||||||
export function formatMoney(amount: number, currency: string) {
|
export function formatMoney(
|
||||||
|
amount: number,
|
||||||
|
currency: string = getOrganizationCurrencyString()
|
||||||
|
) {
|
||||||
return new Intl.NumberFormat('en-US', {
|
return new Intl.NumberFormat('en-US', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: currency,
|
currency: currency,
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatCents(amount: number) {
|
||||||
|
return formatMoney(amount / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrganizationCurrencyString() {
|
||||||
|
return 'EUR';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrganizationCurrencySymbol() {
|
||||||
|
return '€';
|
||||||
|
}
|
||||||
|
|||||||
71
resources/js/utils/useProjectMembers.ts
Normal file
71
resources/js/utils/useProjectMembers.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { api } from '../../../openapi.json.client';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import type {
|
||||||
|
CreateProjectMemberBody,
|
||||||
|
ProjectMember,
|
||||||
|
ProjectMemberResponse,
|
||||||
|
} from '@/utils/api';
|
||||||
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
|
|
||||||
|
export const useProjectMembersStore = defineStore('project-members', () => {
|
||||||
|
const projectMemberResponse = ref<ProjectMemberResponse | null>(null);
|
||||||
|
|
||||||
|
async function fetchProjectMembers(projectId: string) {
|
||||||
|
const organization = getCurrentOrganizationId();
|
||||||
|
if (organization) {
|
||||||
|
projectMemberResponse.value = await api.getProjectMembers({
|
||||||
|
params: {
|
||||||
|
organization: organization,
|
||||||
|
project: projectId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createProjectMember(
|
||||||
|
projectId: string,
|
||||||
|
projectMemberBody: CreateProjectMemberBody
|
||||||
|
) {
|
||||||
|
const organization = getCurrentOrganizationId();
|
||||||
|
if (organization) {
|
||||||
|
await api.createProjectMember(projectMemberBody, {
|
||||||
|
params: {
|
||||||
|
organization: organization,
|
||||||
|
project: projectId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await fetchProjectMembers(projectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteProjectMember(
|
||||||
|
projectId: string,
|
||||||
|
projectMemberId: string
|
||||||
|
) {
|
||||||
|
const organizationId = getCurrentOrganizationId();
|
||||||
|
if (organizationId) {
|
||||||
|
await api.deleteProjectMember(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
organization: organizationId,
|
||||||
|
projectMember: projectMemberId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await fetchProjectMembers(projectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectMembers = computed<ProjectMember[]>(
|
||||||
|
() => projectMemberResponse.value?.data || []
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectMembers,
|
||||||
|
fetchProjectMembers,
|
||||||
|
createProjectMember,
|
||||||
|
deleteProjectMember,
|
||||||
|
};
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user