Customised training file filters, role and member search and fixed invite redirect bug
This commit is contained in:
parent
e9f329be54
commit
c40c7b5e3a
5 changed files with 144 additions and 25 deletions
|
|
@ -19,11 +19,17 @@ class TrainingFileViewSet(ModelViewSet):
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
return TrainingFile.objects.filter(
|
queryset = TrainingFile.objects.filter(
|
||||||
Q(role__organization__owner=user) |
|
Q(role__organization__owner=user) |
|
||||||
Q(role__organization__members=user)
|
Q(role__organization__members=user)
|
||||||
).distinct()
|
).distinct()
|
||||||
|
|
||||||
|
organization_uuid = self.request.query_params.get('organization_uuid')
|
||||||
|
if organization_uuid:
|
||||||
|
queryset = queryset.filter(role__organization__uuid=organization_uuid)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
role_uuid = self.request.data.get('role')
|
role_uuid = self.request.data.get('role')
|
||||||
|
|
||||||
|
|
@ -33,7 +39,7 @@ class TrainingFileViewSet(ModelViewSet):
|
||||||
return Response({'error': 'Role not found'}, status=status.HTTP_404_NOT_FOUND)
|
return Response({'error': 'Role not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
is_owner = role.organization.owner == self.request.user
|
is_owner = role.organization.owner == self.request.user
|
||||||
is_member = role.organization.members.filter(id=self.request.user.id).exists()
|
is_member = role.organization.members.filter(uuid=self.request.user.uuid).exists()
|
||||||
|
|
||||||
if not (is_owner or is_member):
|
if not (is_owner or is_member):
|
||||||
return Response({'error': 'Permission denied'}, status=status.HTTP_403_FORBIDDEN)
|
return Response({'error': 'Permission denied'}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
|
||||||
|
|
@ -12,18 +12,22 @@ const loading = ref(false)
|
||||||
const accepting = ref(false)
|
const accepting = ref(false)
|
||||||
const accepted = ref(false)
|
const accepted = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
const joinedOrganizationUuid = ref<string | null>(null)
|
||||||
|
|
||||||
const acceptInvite = async () => {
|
const acceptInvite = async () => {
|
||||||
accepting.value = true
|
accepting.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post<{ message: string; success: boolean; uuid: string }>(
|
const response = await apiClient.post<{ message?: string; organization?: { uuid?: string } }>(
|
||||||
API.organization.invites.join(inviteUuid),
|
API.organization.invites.join(inviteUuid),
|
||||||
)
|
)
|
||||||
|
joinedOrganizationUuid.value = response.data?.organization?.uuid || null
|
||||||
message.success(response.data?.message || 'Successfully joined organization')
|
message.success(response.data?.message || 'Successfully joined organization')
|
||||||
accepted.value = true
|
accepted.value = true
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (response.data?.uuid) router.push(`/organization/${response.data.uuid}`)
|
if (joinedOrganizationUuid.value) {
|
||||||
|
router.push(`/organization/${joinedOrganizationUuid.value}`)
|
||||||
|
}
|
||||||
else router.push('/')
|
else router.push('/')
|
||||||
}, 1500)
|
}, 1500)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
|
|
@ -32,6 +32,8 @@ const members = ref<User[]>([])
|
||||||
const invites = ref<InviteToken[]>([])
|
const invites = ref<InviteToken[]>([])
|
||||||
const newInviteMaxUses = ref<number>(1)
|
const newInviteMaxUses = ref<number>(1)
|
||||||
const Roles = ref<Role[]>([])
|
const Roles = ref<Role[]>([])
|
||||||
|
const memberSearch = ref('')
|
||||||
|
const roleSearch = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const creatingRole = ref(false)
|
const creatingRole = ref(false)
|
||||||
const deletingRoleUuid = ref<string | null>(null)
|
const deletingRoleUuid = ref<string | null>(null)
|
||||||
|
|
@ -45,6 +47,37 @@ const newInviteUrl = ref('')
|
||||||
const editingDescription = ref(false)
|
const editingDescription = ref(false)
|
||||||
const newDescription = ref('')
|
const newDescription = ref('')
|
||||||
|
|
||||||
|
const filteredMembers = computed(() => {
|
||||||
|
const query = memberSearch.value.trim().toLowerCase()
|
||||||
|
if (!query) return members.value
|
||||||
|
return members.value.filter((member) => {
|
||||||
|
const fullName = `${member.first_name} ${member.last_name}`.toLowerCase()
|
||||||
|
return fullName.includes(query)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredRoles = computed(() => {
|
||||||
|
const query = roleSearch.value.trim().toLowerCase()
|
||||||
|
if (!query) return Roles.value
|
||||||
|
return Roles.value.filter((role) => {
|
||||||
|
const name = role.name?.toLowerCase() || ''
|
||||||
|
const description = role.description?.toLowerCase() || ''
|
||||||
|
return name.includes(query) || description.includes(query)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const memberEmptyMessage = computed(() => {
|
||||||
|
if (members.value.length === 0) return 'No members in this organization yet.'
|
||||||
|
if (memberSearch.value.trim()) return 'No members match your search.'
|
||||||
|
return 'No members in this organization yet.'
|
||||||
|
})
|
||||||
|
|
||||||
|
const roleEmptyMessage = computed(() => {
|
||||||
|
if (Roles.value.length === 0) return 'No Roles in this organization yet.'
|
||||||
|
if (roleSearch.value.trim()) return 'No roles match your search.'
|
||||||
|
return 'No Roles in this organization yet.'
|
||||||
|
})
|
||||||
|
|
||||||
const fetchOrganization = async () => {
|
const fetchOrganization = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
|
@ -276,18 +309,35 @@ onMounted(async () => {
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<Typography.Title :level="4" style="color: #ffffff !important">
|
<Typography.Title :level="4" style="color: #ffffff !important">
|
||||||
Members ({{ members.length }})
|
Members ({{ filteredMembers.length }})
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
|
<Input
|
||||||
|
v-model:value="memberSearch"
|
||||||
|
allow-clear
|
||||||
|
class="search-input"
|
||||||
|
placeholder="Search members by name"
|
||||||
|
style="max-width: 280px"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<List :data-source="members" :bordered="false">
|
<List
|
||||||
|
v-if="filteredMembers.length > 0"
|
||||||
|
:data-source="filteredMembers"
|
||||||
|
:bordered="false"
|
||||||
|
>
|
||||||
<template #renderItem="{ item }">
|
<template #renderItem="{ item }">
|
||||||
<List.Item class="member-item">
|
<List.Item class="member-item">
|
||||||
<List.Item.Meta
|
<List.Item.Meta
|
||||||
:title="`${item.first_name} ${item.last_name}`"
|
:title="`${item.first_name} ${item.last_name}`"
|
||||||
:description="item.bio || 'No bio provided'"
|
:description="item.email_address"
|
||||||
/>
|
/>
|
||||||
<Space>
|
<Space>
|
||||||
|
<Tag v-if="item.uuid === organization.owner.uuid" color="blue">
|
||||||
|
Owner
|
||||||
|
</Tag>
|
||||||
|
<Tag v-else :color="item.is_manager ? 'purple' : 'default'">
|
||||||
|
{{ item.is_manager ? 'Manager' : 'Member' }}
|
||||||
|
</Tag>
|
||||||
<Button
|
<Button
|
||||||
v-if="item.uuid !== organization.owner.uuid"
|
v-if="item.uuid !== organization.owner.uuid"
|
||||||
danger
|
danger
|
||||||
|
|
@ -296,11 +346,13 @@ onMounted(async () => {
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</Button>
|
</Button>
|
||||||
<Tag v-else color="blue">Owner</Tag>
|
|
||||||
</Space>
|
</Space>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
</template>
|
</template>
|
||||||
</List>
|
</List>
|
||||||
|
<Typography.Paragraph v-else type="secondary">
|
||||||
|
{{ memberEmptyMessage }}
|
||||||
|
</Typography.Paragraph>
|
||||||
</div>
|
</div>
|
||||||
</Tabs.TabPane>
|
</Tabs.TabPane>
|
||||||
|
|
||||||
|
|
@ -361,14 +413,27 @@ onMounted(async () => {
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<Typography.Title :level="4" style="color: #ffffff !important">
|
<Typography.Title :level="4" style="color: #ffffff !important">
|
||||||
Roles ({{ Roles.length }})
|
Roles ({{ filteredRoles.length }})
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
<Button type="primary" @click="roleModalVisible = true">
|
<Space>
|
||||||
Create Role
|
<Input
|
||||||
</Button>
|
v-model:value="roleSearch"
|
||||||
|
allow-clear
|
||||||
|
class="search-input"
|
||||||
|
placeholder="Search roles by name or description"
|
||||||
|
style="width: 300px"
|
||||||
|
/>
|
||||||
|
<Button type="primary" @click="roleModalVisible = true">
|
||||||
|
Create Role
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<List v-if="Roles.length > 0" :data-source="Roles" :bordered="false">
|
<List
|
||||||
|
v-if="filteredRoles.length > 0"
|
||||||
|
:data-source="filteredRoles"
|
||||||
|
:bordered="false"
|
||||||
|
>
|
||||||
<template #renderItem="{ item }">
|
<template #renderItem="{ item }">
|
||||||
<List.Item class="Role-item">
|
<List.Item class="Role-item">
|
||||||
<List.Item.Meta
|
<List.Item.Meta
|
||||||
|
|
@ -390,7 +455,7 @@ onMounted(async () => {
|
||||||
</template>
|
</template>
|
||||||
</List>
|
</List>
|
||||||
<Typography.Paragraph v-else type="secondary">
|
<Typography.Paragraph v-else type="secondary">
|
||||||
No Roles in this organization yet.
|
{{ roleEmptyMessage }}
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
</div>
|
</div>
|
||||||
</Tabs.TabPane>
|
</Tabs.TabPane>
|
||||||
|
|
@ -495,4 +560,19 @@ onMounted(async () => {
|
||||||
background: #111827;
|
background: #111827;
|
||||||
border-color: #334155;
|
border-color: #334155;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:deep(.search-input) {
|
||||||
|
background: #1f2937 !important;
|
||||||
|
border-color: #475569 !important;
|
||||||
|
color: #f8fafc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.search-input::placeholder) {
|
||||||
|
color: #cbd5e1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.search-input::selection) {
|
||||||
|
background: #475569 !important;
|
||||||
|
color: #f8fafc !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,9 @@ const selectRole = async (roleUuid: string) => {
|
||||||
const fetchTrainingFiles = async () => {
|
const fetchTrainingFiles = async () => {
|
||||||
if (!organization.value?.uuid) return
|
if (!organization.value?.uuid) return
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get<TrainingFile[]>(API.knowledge.trainingFiles.list())
|
const response = await apiClient.get<TrainingFile[]>(API.knowledge.trainingFiles.list(), {
|
||||||
|
params: { organization_uuid: organization.value.uuid },
|
||||||
|
})
|
||||||
trainingFiles.value = response.data
|
trainingFiles.value = response.data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch training files:', error)
|
console.error('Failed to fetch training files:', error)
|
||||||
|
|
@ -166,6 +168,20 @@ const selectedFile = ref<File | null>(null)
|
||||||
const fileDescription = ref('')
|
const fileDescription = ref('')
|
||||||
const selectedRoleUuid = ref<string>('')
|
const selectedRoleUuid = ref<string>('')
|
||||||
|
|
||||||
|
const handleOpenUploadModal = () => {
|
||||||
|
if (!isManager.value) {
|
||||||
|
message.error('Only managers can upload training files')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roles.value.length === 0) {
|
||||||
|
message.error('No roles found for this organization. Create a role first in Manage Organization.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
showUploadModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
const handleFileSelected = (file: File) => {
|
const handleFileSelected = (file: File) => {
|
||||||
selectedFile.value = file
|
selectedFile.value = file
|
||||||
}
|
}
|
||||||
|
|
@ -386,11 +402,19 @@ onMounted(async () => {
|
||||||
<div style="margin-bottom: 1rem">
|
<div style="margin-bottom: 1rem">
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
@click="showUploadModal = true"
|
:disabled="!isManager"
|
||||||
|
@click="handleOpenUploadModal"
|
||||||
style="margin-bottom: 1rem"
|
style="margin-bottom: 1rem"
|
||||||
>
|
>
|
||||||
Upload Training File
|
Upload Training File
|
||||||
</Button>
|
</Button>
|
||||||
|
<Typography.Paragraph
|
||||||
|
v-if="isManager && roles.length === 0"
|
||||||
|
type="secondary"
|
||||||
|
style="margin: 0.25rem 0 0"
|
||||||
|
>
|
||||||
|
Create a role in Manage Organization before uploading training files.
|
||||||
|
</Typography.Paragraph>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="trainingFiles.length > 0">
|
<div v-if="trainingFiles.length > 0">
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,11 @@ const resetCreateOrganizationForm = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreateOrganization = async () => {
|
const handleCreateOrganization = async () => {
|
||||||
|
if (!auth.isGeneralManager) {
|
||||||
|
message.error('Only managers can create organizations')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const name = createOrgForm.value.name.trim()
|
const name = createOrgForm.value.name.trim()
|
||||||
const description = createOrgForm.value.description.trim()
|
const description = createOrgForm.value.description.trim()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue