mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +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);
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
<h3
|
||||
class="text-white font-bold pb-4 text-base flex items-center space-x-2.5">
|
||||
<component
|
||||
v-if="icon"
|
||||
:is="icon"
|
||||
class="w-6 text-icon-default"></component>
|
||||
<span>
|
||||
{{ title }}
|
||||
</span>
|
||||
</h3>
|
||||
<div class="flex w-full items-center justify-between pb-4">
|
||||
<h3
|
||||
class="text-white font-bold text-base flex items-center space-x-2.5">
|
||||
<component
|
||||
v-if="icon"
|
||||
:is="icon"
|
||||
class="w-6 text-icon-default"></component>
|
||||
<span>
|
||||
{{ title }}
|
||||
</span>
|
||||
</h3>
|
||||
<div>
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 MemberMoreOptionsDropdown from '@/Components/Common/Member/MemberMoreOptionsDropdown.vue';
|
||||
import TableRow from '@/Components/TableRow.vue';
|
||||
import { capitalizeFirstLetter } from '../../../utils/format';
|
||||
|
||||
const props = defineProps<{
|
||||
member: Member;
|
||||
@@ -12,10 +13,6 @@ const props = defineProps<{
|
||||
function removeMember() {
|
||||
useClientsStore().deleteClient(props.member.id);
|
||||
}
|
||||
|
||||
function capitalizeFirstLetter(string: string) {
|
||||
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -12,7 +12,7 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||
Client
|
||||
</div>
|
||||
<div class="px-3 py-1.5 text-left text-sm font-semibold text-white">
|
||||
Team
|
||||
Billable Rate
|
||||
</div>
|
||||
<div class="px-3 py-1.5 text-left text-sm font-semibold text-white">
|
||||
Status
|
||||
|
||||
@@ -54,24 +54,7 @@ function deleteProject() {
|
||||
<div v-else>No client</div>
|
||||
</div>
|
||||
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted">
|
||||
<div class="isolate flex -space-x-1 opacity-50">
|
||||
<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>
|
||||
{{ project.billable_rate ?? '--' }}
|
||||
</div>
|
||||
<div
|
||||
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="[widthClass, alignmentClasses]"
|
||||
style="display: none"
|
||||
@click="onContentClick">
|
||||
@click.prevent="onContentClick">
|
||||
<div
|
||||
class="rounded-lg ring-1 relative ring-black ring-opacity-5"
|
||||
:class="contentClasses">
|
||||
|
||||
@@ -3,13 +3,23 @@ import MainContainer from '@/Pages/MainContainer.vue';
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import { FolderIcon, PlusIcon } from '@heroicons/vue/16/solid';
|
||||
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useProjectsStore } from '@/utils/useProjects';
|
||||
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 TaskCreateModal from '@/Components/Common/Task/TaskCreateModal.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 project = computed(() => {
|
||||
@@ -20,8 +30,14 @@ const project = computed(() => {
|
||||
);
|
||||
});
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -37,7 +53,7 @@ const projectId: string = route().params.project;
|
||||
class="flex items-center space-x-2.5">
|
||||
<FolderIcon
|
||||
class="w-6 text-icon-default"></FolderIcon>
|
||||
<span> Projects </span>
|
||||
<span class="font-medium">Projects</span>
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
@@ -60,13 +76,49 @@ const projectId: string = route().params.project;
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<SecondaryButton :icon="PlusIcon" @click="createTask = true"
|
||||
>Create Task
|
||||
</SecondaryButton>
|
||||
<TaskCreateModal
|
||||
:project-id="projectId"
|
||||
v-model:show="createTask"></TaskCreateModal>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -24,6 +24,18 @@ export type CreateProjectBody = ZodiosBodyByAlias<
|
||||
'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 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', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
}).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