add invitations tab to members page

This commit is contained in:
Gregor Vostrak
2024-04-15 23:45:27 +02:00
parent c30763d718
commit e4bf56d55d
11 changed files with 165 additions and 14 deletions

View File

@@ -24,9 +24,9 @@ class InvitationResource extends BaseResource
/** @var string $id ID of the invitation */ /** @var string $id ID of the invitation */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** @var string $email Email */ /** @var string $email Email */
'user_id' => $this->resource->email, 'email' => $this->resource->email,
/** @var string $role Role */ /** @var string $role Role */
'name' => $this->resource->role, 'role' => $this->resource->role,
]; ];
} }
} }

View File

@@ -14,7 +14,7 @@ const importData_Body = z
.object({ type: z.string(), data: z.string() }) .object({ type: z.string(), data: z.string() })
.passthrough(); .passthrough();
const InvitationResource = z const InvitationResource = z
.object({ id: z.string(), user_id: z.string(), name: z.string() }) .object({ id: z.string(), email: z.string(), role: z.string() })
.passthrough(); .passthrough();
const Role = z.enum(['owner', 'admin', 'manager', 'employee', 'placeholder']); const Role = z.enum(['owner', 'admin', 'manager', 'employee', 'placeholder']);
const invite_Body = z const invite_Body = z
@@ -449,7 +449,10 @@ const endpoints = makeApi([
errors: [ errors: [
{ {
status: 400, status: 400,
schema: z.object({ message: z.string() }).passthrough(), schema: z.union([
z.object({ message: z.string() }).passthrough(),
z.object({ message: z.string() }).passthrough(),
]),
}, },
{ {
status: 403, status: 403,

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import { onMounted } from 'vue';
import { storeToRefs } from 'pinia';
import { useInvitationsStore } from '@/utils/useInvitations';
import InvitationTableRow from '@/Components/Common/Invitation/InvitationTableRow.vue';
import InvitationTableHeading from '@/Components/Common/Invitation/InvitationTableHeading.vue';
const { invitations } = storeToRefs(useInvitationsStore());
onMounted(async () => {
await useInvitationsStore().fetchInvitations();
});
</script>
<template>
<div class="flow-root max-w-[100vw] overflow-x-auto">
<div class="inline-block min-w-full align-middle">
<div
data-testid="client_table"
class="grid min-w-full"
style="grid-template-columns: 1fr 1fr">
<InvitationTableHeading></InvitationTableHeading>
<template
v-for="invitation in invitations"
:key="invitation.id">
<InvitationTableRow
:invitation="invitation"></InvitationTableRow>
</template>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import TableHeading from '@/Components/Common/TableHeading.vue';
</script>
<template>
<TableHeading>
<div class="px-3 py-1.5 text-left font-semibold text-white">Email</div>
<div class="px-3 py-1.5 text-left font-semibold text-white">Role</div>
</TableHeading>
</template>
<style scoped></style>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { Invitation } from '@/utils/api';
import TableRow from '@/Components/TableRow.vue';
import { capitalizeFirstLetter } from '../../../utils/format';
defineProps<{
invitation: Invitation;
}>();
</script>
<template>
<TableRow>
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted">
{{ invitation.email }}
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted">
{{ capitalizeFirstLetter(invitation.role) }}
</div>
</TableRow>
</template>
<style scoped></style>

View File

@@ -23,6 +23,8 @@ const addTeamMemberForm = useForm({
role: null as string | null, role: null as string | null,
}); });
const emit = defineEmits(['close']);
async function submit() { async function submit() {
const organizationId = getCurrentOrganizationId(); const organizationId = getCurrentOrganizationId();
if (organizationId) { if (organizationId) {
@@ -31,6 +33,7 @@ async function submit() {
preserveScroll: true, preserveScroll: true,
onSuccess: () => { onSuccess: () => {
addTeamMemberForm.reset(); addTeamMemberForm.reset();
emit('close');
show.value = false; show.value = false;
}, },
}); });

View File

@@ -1,17 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { onMounted } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';
import MemberTableHeading from '@/Components/Common/Member/MemberTableHeading.vue'; import MemberTableHeading from '@/Components/Common/Member/MemberTableHeading.vue';
import MemberTableRow from '@/Components/Common/Member/MemberTableRow.vue'; import MemberTableRow from '@/Components/Common/Member/MemberTableRow.vue';
import { useMembersStore } from '@/utils/useMembers'; import { useMembersStore } from '@/utils/useMembers';
const { members } = storeToRefs(useMembersStore()); const { members } = storeToRefs(useMembersStore());
const createClient = ref(false);
onMounted(async () => {
await useMembersStore().fetchMembers();
});
</script> </script>
<template> <template>
<ClientCreateModal v-model:show="createClient"></ClientCreateModal>
<div class="flow-root max-w-[100vw] overflow-x-auto"> <div class="flow-root max-w-[100vw] overflow-x-auto">
<div class="inline-block min-w-full align-middle"> <div class="inline-block min-w-full align-middle">
<div <div

View File

@@ -11,12 +11,18 @@ import MemberTable from '@/Components/Common/Member/MemberTable.vue';
import MemberInviteModal from '@/Components/Common/Member/MemberInviteModal.vue'; import MemberInviteModal from '@/Components/Common/Member/MemberInviteModal.vue';
import type { Role } from '@/types/jetstream'; import type { Role } from '@/types/jetstream';
import PageTitle from '@/Components/Common/PageTitle.vue'; import PageTitle from '@/Components/Common/PageTitle.vue';
import InvitationTable from '@/Components/Common/Invitation/InvitationTable.vue';
const inviteMember = ref(false); const inviteMember = ref(false);
defineProps<{ defineProps<{
availableRoles: Role[]; availableRoles: Role[];
}>(); }>();
const activeTab = ref<'all' | 'invitations'>('all');
function isActiveTab(tab: string) {
return activeTab.value === tab;
}
</script> </script>
<template> <template>
@@ -26,9 +32,16 @@ defineProps<{
<div class="flex items-center space-x-4 sm:space-x-6"> <div class="flex items-center space-x-4 sm:space-x-6">
<PageTitle :icon="UserGroupIcon" title="Members"> </PageTitle> <PageTitle :icon="UserGroupIcon" title="Members"> </PageTitle>
<TabBar> <TabBar>
<TabBarItem active>All</TabBarItem> <TabBarItem
<TabBarItem>Active</TabBarItem> :active="isActiveTab('all')"
<TabBarItem>Inactive</TabBarItem> @click="activeTab = 'all'"
>All</TabBarItem
>
<TabBarItem
:active="isActiveTab('invitations')"
@click="activeTab = 'invitations'"
>Invitations</TabBarItem
>
</TabBar> </TabBar>
</div> </div>
<SecondaryButton :icon="PlusIcon" @click="inviteMember = true" <SecondaryButton :icon="PlusIcon" @click="inviteMember = true"
@@ -36,8 +49,10 @@ defineProps<{
> >
<MemberInviteModal <MemberInviteModal
:available-roles="availableRoles" :available-roles="availableRoles"
v-model:show="inviteMember"></MemberInviteModal> v-model:show="inviteMember"
@close="activeTab = 'invitations'"></MemberInviteModal>
</MainContainer> </MainContainer>
<MemberTable></MemberTable> <MemberTable v-if="activeTab === 'all'"></MemberTable>
<InvitationTable v-if="activeTab === 'invitations'"></InvitationTable>
</AppLayout> </AppLayout>
</template> </template>

View File

@@ -7,6 +7,15 @@ import { api } from '../../../openapi.json.client';
export type SolidTimeApi = ApiOf<typeof api>; export type SolidTimeApi = ApiOf<typeof api>;
export type InvitationsIndexResponse = ZodiosResponseByAlias<
SolidTimeApi,
'getInvitations'
>;
export type CreateInvitationBody = ZodiosBodyByAlias<SolidTimeApi, 'invite'>;
export type Invitation = InvitationsIndexResponse['data'][0];
export type TimeEntryResponse = ZodiosResponseByAlias< export type TimeEntryResponse = ZodiosResponseByAlias<
SolidTimeApi, SolidTimeApi,
'getTimeEntries' 'getTimeEntries'

View File

@@ -1,3 +1,3 @@
export function capitalizeFirstLetter(string: string) { export function capitalizeFirstLetter(string: string) {
return string.charAt(0).toUpperCase() + string.slice(1); return string?.charAt(0)?.toUpperCase() + string?.slice(1);
} }

View File

@@ -0,0 +1,54 @@
import { defineStore } from 'pinia';
import { api } from '../../../openapi.json.client';
import { computed, ref } from 'vue';
import type {
InvitationsIndexResponse,
CreateInvitationBody,
Invitation,
} from '@/utils/api';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
export const useInvitationsStore = defineStore('invitations', () => {
const invitationsResponse = ref<InvitationsIndexResponse | null>(null);
const { handleApiRequestNotifications } = useNotificationsStore();
async function fetchInvitations() {
const organization = getCurrentOrganizationId();
if (organization) {
invitationsResponse.value = await handleApiRequestNotifications(
api.getInvitations({
params: {
organization: organization,
},
}),
undefined,
'Failed to fetch invitations'
);
}
}
async function createInvitation(
inviteBody: CreateInvitationBody
): Promise<undefined> {
const organization = getCurrentOrganizationId();
if (organization) {
await handleApiRequestNotifications(
api.invite(inviteBody, {
params: {
organization: organization,
},
}),
'User successfully invited',
'Failed to invite user'
);
await fetchInvitations();
}
}
const invitations = computed<Invitation[]>(() => {
return invitationsResponse.value?.data || [];
});
return { invitations, fetchInvitations, createInvitation };
});