2026-02-26 01:32:04 +00:00
|
|
|
<script setup lang="ts">
|
2026-03-15 21:59:48 +00:00
|
|
|
import { ref, onMounted, computed, h } from 'vue'
|
2026-02-26 01:32:04 +00:00
|
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
|
|
|
import {
|
|
|
|
|
Card,
|
|
|
|
|
Typography,
|
|
|
|
|
Button,
|
|
|
|
|
List,
|
|
|
|
|
Space,
|
|
|
|
|
Spin,
|
|
|
|
|
Input,
|
|
|
|
|
message,
|
|
|
|
|
Tag,
|
|
|
|
|
Modal,
|
|
|
|
|
Tabs,
|
|
|
|
|
InputNumber,
|
2026-03-15 21:59:48 +00:00
|
|
|
Select,
|
|
|
|
|
Upload,
|
|
|
|
|
Steps,
|
|
|
|
|
Table,
|
2026-02-26 01:32:04 +00:00
|
|
|
} from 'ant-design-vue'
|
|
|
|
|
import { apiClient, isAxiosError, API } from '../router/api'
|
|
|
|
|
import { useUserStore } from '../stores/userStore'
|
|
|
|
|
import type { Organization } from '../types/organization'
|
|
|
|
|
import type { User } from '../types/user'
|
|
|
|
|
import type { InviteToken } from '../types/organization'
|
|
|
|
|
import type { Role } from '../types/organization'
|
2026-03-15 21:59:48 +00:00
|
|
|
import type { TrainingFile } from '../types/organization'
|
2026-03-22 09:09:14 +00:00
|
|
|
import { InboxOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons-vue'
|
2026-02-26 01:32:04 +00:00
|
|
|
|
|
|
|
|
const route = useRoute()
|
|
|
|
|
const router = useRouter()
|
|
|
|
|
const auth = useUserStore()
|
|
|
|
|
|
2026-02-27 12:53:19 +00:00
|
|
|
const organizationUuid = route.params.organizationUuid as string
|
2026-02-26 01:32:04 +00:00
|
|
|
const organization = ref<Organization | null>(null)
|
|
|
|
|
const members = ref<User[]>([])
|
|
|
|
|
const invites = ref<InviteToken[]>([])
|
|
|
|
|
const newInviteMaxUses = ref<number>(1)
|
|
|
|
|
const Roles = ref<Role[]>([])
|
2026-03-15 21:59:48 +00:00
|
|
|
const trainingFiles = ref<TrainingFile[]>([])
|
2026-02-27 13:58:00 +00:00
|
|
|
const memberSearch = ref('')
|
|
|
|
|
const roleSearch = ref('')
|
2026-02-26 01:32:04 +00:00
|
|
|
const loading = ref(false)
|
|
|
|
|
const deletingRoleUuid = ref<string | null>(null)
|
|
|
|
|
const roleModalVisible = ref(false)
|
2026-03-10 19:38:47 +00:00
|
|
|
const roleMembersModalVisible = ref(false)
|
|
|
|
|
const selectedRoleForMembers = ref<Role | null>(null)
|
|
|
|
|
const selectedRoleMembers = ref<User[]>([])
|
2026-03-15 21:59:48 +00:00
|
|
|
const roleWizardStep = ref(0)
|
|
|
|
|
const creatingRoleWizard = ref(false)
|
|
|
|
|
const createdRoleForWizard = ref<Role | null>(null)
|
|
|
|
|
const wizardSelectedFile = ref<File | null>(null)
|
|
|
|
|
const wizardFileDescription = ref('')
|
|
|
|
|
const wizardUploading = ref(false)
|
|
|
|
|
const wizardUploadedFiles = ref<TrainingFile[]>([])
|
|
|
|
|
const uploadModalVisible = ref(false)
|
|
|
|
|
const uploadRoleUuid = ref('')
|
|
|
|
|
const uploadSelectedFile = ref<File | null>(null)
|
|
|
|
|
const uploadFileDescription = ref('')
|
|
|
|
|
const uploadingFile = ref(false)
|
2026-02-26 01:32:04 +00:00
|
|
|
const createRoleForm = ref({
|
|
|
|
|
name: '',
|
|
|
|
|
description: '',
|
|
|
|
|
})
|
|
|
|
|
const inviteModalVisible = ref(false)
|
|
|
|
|
const newInviteUrl = ref('')
|
|
|
|
|
const editingDescription = ref(false)
|
|
|
|
|
const newDescription = ref('')
|
2026-03-15 22:19:12 +00:00
|
|
|
const ORGANIZATION_WIDE_SCOPE = '__organization_wide__'
|
|
|
|
|
|
|
|
|
|
const uploadRoleOptions = computed(() => [
|
|
|
|
|
{ label: 'Organization-wide (all roles)', value: ORGANIZATION_WIDE_SCOPE },
|
|
|
|
|
...Roles.value.map((role) => ({ label: role.name, value: role.uuid })),
|
|
|
|
|
])
|
2026-02-26 01:32:04 +00:00
|
|
|
|
2026-02-27 13:58:00 +00:00
|
|
|
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.'
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-26 01:32:04 +00:00
|
|
|
const fetchOrganization = async () => {
|
|
|
|
|
loading.value = true
|
|
|
|
|
try {
|
2026-02-27 12:53:19 +00:00
|
|
|
const response = await apiClient.get<Organization>(API.organization.byId(organizationUuid))
|
2026-02-26 01:32:04 +00:00
|
|
|
organization.value = response.data
|
|
|
|
|
newDescription.value = response.data.description
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to fetch organization:', error)
|
|
|
|
|
message.error('Failed to load organization details')
|
|
|
|
|
} finally {
|
|
|
|
|
loading.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fetchMembers = async () => {
|
|
|
|
|
try {
|
2026-02-27 12:53:19 +00:00
|
|
|
const response = await apiClient.get<User[]>(API.organization.members.list(organizationUuid))
|
2026-02-26 01:32:04 +00:00
|
|
|
members.value = response.data
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to fetch members:', error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fetchInvites = async () => {
|
|
|
|
|
try {
|
2026-03-08 13:19:17 +00:00
|
|
|
const response = await apiClient.get<InviteToken[]>(API.invites.list(organizationUuid))
|
2026-02-26 01:32:04 +00:00
|
|
|
invites.value = response.data
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to fetch invites:', error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fetchRoles = async () => {
|
|
|
|
|
try {
|
2026-03-08 13:19:17 +00:00
|
|
|
const response = await apiClient.get<Role[]>(API.roles.list(organizationUuid))
|
2026-02-26 01:32:04 +00:00
|
|
|
Roles.value = response.data as unknown as Role[]
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to fetch Roles:', error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 21:59:48 +00:00
|
|
|
const fetchTrainingFiles = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const response = await apiClient.get<TrainingFile[]>(API.knowledge.trainingFiles.list(), {
|
|
|
|
|
params: { organization_uuid: organizationUuid },
|
|
|
|
|
})
|
|
|
|
|
trainingFiles.value = response.data
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to fetch training files:', error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 22:19:12 +00:00
|
|
|
const getScopeLabel = (file: TrainingFile) => (file.role?.name ? file.role.name : 'Organization-wide')
|
|
|
|
|
|
2026-03-15 21:59:48 +00:00
|
|
|
|
|
|
|
|
const resetRoleWizard = () => {
|
|
|
|
|
roleWizardStep.value = 0
|
2026-02-26 01:32:04 +00:00
|
|
|
createRoleForm.value = { name: '', description: '' }
|
2026-03-15 21:59:48 +00:00
|
|
|
createdRoleForWizard.value = null
|
|
|
|
|
wizardSelectedFile.value = null
|
|
|
|
|
wizardFileDescription.value = ''
|
|
|
|
|
wizardUploadedFiles.value = []
|
|
|
|
|
creatingRoleWizard.value = false
|
|
|
|
|
wizardUploading.value = false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const closeRoleWizard = () => {
|
|
|
|
|
roleModalVisible.value = false
|
|
|
|
|
resetRoleWizard()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const openRoleWizard = () => {
|
|
|
|
|
resetRoleWizard()
|
|
|
|
|
roleModalVisible.value = true
|
2026-02-26 01:32:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const hasDuplicateRoleName = (name: string) =>
|
|
|
|
|
Roles.value.some((role) => role.name.trim().toLowerCase() === name.trim().toLowerCase())
|
|
|
|
|
|
2026-03-15 21:59:48 +00:00
|
|
|
const allowedExtensions = ['txt', 'pdf', 'md', 'csv', 'json', 'docx', 'doc']
|
|
|
|
|
const maxUploadBytes = 50 * 1024 * 1024
|
|
|
|
|
|
|
|
|
|
const validateUploadFile = (file: File): boolean => {
|
|
|
|
|
const extension = file.name.split('.').pop()?.toLowerCase()
|
|
|
|
|
|
|
|
|
|
if (!extension || !allowedExtensions.includes(extension)) {
|
|
|
|
|
message.error(
|
|
|
|
|
`File type ".${extension}" is not allowed. Allowed types: ${allowedExtensions.join(', ')}`,
|
|
|
|
|
)
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (file.size > maxUploadBytes) {
|
|
|
|
|
message.error(
|
|
|
|
|
`File size must not exceed 50MB. Current size: ${(file.size / 1024 / 1024).toFixed(2)}MB`,
|
|
|
|
|
)
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const uploadTrainingFile = async (
|
2026-03-15 22:19:12 +00:00
|
|
|
roleUuid: string | null,
|
2026-03-15 21:59:48 +00:00
|
|
|
file: File,
|
|
|
|
|
description: string,
|
|
|
|
|
): Promise<TrainingFile | null> => {
|
|
|
|
|
const formData = new FormData()
|
|
|
|
|
formData.append('file', file)
|
|
|
|
|
formData.append('file_name', file.name)
|
|
|
|
|
formData.append('description', description)
|
2026-03-15 22:19:12 +00:00
|
|
|
formData.append('organization_uuid', organizationUuid)
|
|
|
|
|
if (roleUuid) {
|
|
|
|
|
formData.append('role_uuid', roleUuid)
|
|
|
|
|
}
|
2026-03-15 21:59:48 +00:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await apiClient.post<TrainingFile>(API.knowledge.trainingFiles.list(), formData, {
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'multipart/form-data',
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
return response.data
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (isAxiosError(error)) {
|
|
|
|
|
const errorMsg =
|
|
|
|
|
error.response?.data?.error ||
|
|
|
|
|
error.response?.data?.file?.[0] ||
|
|
|
|
|
'Failed to upload file'
|
|
|
|
|
message.error(errorMsg)
|
|
|
|
|
} else {
|
|
|
|
|
message.error('Failed to upload file')
|
|
|
|
|
}
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const getTrainingFilesByRole = (roleUuid: string): TrainingFile[] =>
|
|
|
|
|
trainingFiles.value.filter((file) => file.role?.uuid === roleUuid)
|
|
|
|
|
|
2026-03-15 22:19:12 +00:00
|
|
|
const organizationWideTrainingFiles = computed(() =>
|
|
|
|
|
trainingFiles.value.filter((file) => !file.role?.uuid),
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-15 21:59:48 +00:00
|
|
|
const deleteTrainingFile = async (uuid: string, fileName: string) => {
|
|
|
|
|
Modal.confirm({
|
|
|
|
|
title: 'Delete File',
|
|
|
|
|
content: `Are you sure you want to delete "${fileName}"? This action cannot be undone.`,
|
|
|
|
|
okText: 'Delete',
|
|
|
|
|
okType: 'danger',
|
|
|
|
|
cancelText: 'Cancel',
|
|
|
|
|
onOk: async () => {
|
|
|
|
|
try {
|
|
|
|
|
await apiClient.delete(API.knowledge.trainingFiles.byId(uuid))
|
|
|
|
|
message.success('File deleted successfully')
|
|
|
|
|
trainingFiles.value = trainingFiles.value.filter((file) => file.uuid !== uuid)
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to delete file:', error)
|
|
|
|
|
message.error('Failed to delete file')
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 09:09:14 +00:00
|
|
|
const retryIngestion = async (uuid: string) => {
|
|
|
|
|
try {
|
|
|
|
|
const response = await apiClient.post<TrainingFile>(API.knowledge.trainingFiles.retry(uuid))
|
|
|
|
|
const idx = trainingFiles.value.findIndex((f) => f.uuid === uuid)
|
|
|
|
|
if (idx !== -1) trainingFiles.value[idx] = response.data
|
|
|
|
|
message.success('Ingestion re-queued')
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to retry ingestion:', error)
|
|
|
|
|
message.error('Failed to retry ingestion')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 21:59:48 +00:00
|
|
|
const canDeleteTrainingFile = (record: TrainingFile): boolean => {
|
|
|
|
|
if (auth.user?.uuid === record.uploaded_by?.uuid) return true
|
|
|
|
|
if (organization.value?.owner?.uuid === auth.user?.uuid) return true
|
|
|
|
|
return members.value.some((member) => member.uuid === auth.user?.uuid && member.is_manager)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const formatFileSize = (bytes: number) => {
|
|
|
|
|
if (bytes === 0) return '0 Bytes'
|
|
|
|
|
const k = 1024
|
|
|
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
|
|
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
|
|
|
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const trainingFileColumns = [
|
|
|
|
|
{
|
|
|
|
|
title: 'File Name',
|
|
|
|
|
key: 'file_name',
|
2026-03-22 09:09:14 +00:00
|
|
|
customRender: ({ record }: { record: TrainingFile }) =>
|
|
|
|
|
h('a', { href: record.file_url, target: '_blank', rel: 'noopener noreferrer' }, record.file_name),
|
2026-03-15 21:59:48 +00:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: 'Uploaded By',
|
|
|
|
|
key: 'uploaded_by',
|
|
|
|
|
customRender: ({ record }: { record: TrainingFile }) => {
|
|
|
|
|
if (!record.uploaded_by) return '-'
|
|
|
|
|
return `${record.uploaded_by.first_name} ${record.uploaded_by.last_name}`
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: 'Size',
|
|
|
|
|
dataIndex: 'file_size',
|
|
|
|
|
key: 'file_size',
|
|
|
|
|
customRender: ({ value }: { value: number }) => formatFileSize(value || 0),
|
|
|
|
|
},
|
|
|
|
|
{
|
2026-03-15 22:19:12 +00:00
|
|
|
title: 'Scope',
|
2026-03-15 21:59:48 +00:00
|
|
|
key: 'role',
|
2026-03-15 22:19:12 +00:00
|
|
|
customRender: ({ record }: { record: TrainingFile }) => getScopeLabel(record),
|
2026-03-15 21:59:48 +00:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: 'Status',
|
|
|
|
|
dataIndex: 'status',
|
|
|
|
|
key: 'status',
|
|
|
|
|
customRender: ({ value }: { value: string }) => {
|
|
|
|
|
const statusMap: Record<string, { color: string; label: string }> = {
|
|
|
|
|
ingesting: { color: 'processing', label: 'Ingesting' },
|
|
|
|
|
chunked: { color: 'blue', label: 'Chunked' },
|
|
|
|
|
embedded: { color: 'success', label: 'Embedded' },
|
|
|
|
|
failed: { color: 'error', label: 'Failed' },
|
|
|
|
|
}
|
|
|
|
|
const status = statusMap[value] || { color: 'default', label: value }
|
|
|
|
|
return h(Tag, { color: status.color }, () => status.label)
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: 'Uploaded',
|
|
|
|
|
dataIndex: 'created_at',
|
|
|
|
|
key: 'created_at',
|
|
|
|
|
customRender: ({ value }: { value: string }) => new Date(value).toLocaleDateString(),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: 'Action',
|
|
|
|
|
key: 'action',
|
|
|
|
|
customRender: ({ record }: { record: TrainingFile }) => {
|
2026-03-22 09:09:14 +00:00
|
|
|
const buttons: ReturnType<typeof h>[] = []
|
|
|
|
|
if (record.status === 'failed' && canDeleteTrainingFile(record)) {
|
|
|
|
|
buttons.push(
|
|
|
|
|
h(
|
|
|
|
|
Button,
|
|
|
|
|
{
|
|
|
|
|
size: 'small',
|
|
|
|
|
icon: h(ReloadOutlined),
|
|
|
|
|
onClick: () => retryIngestion(record.uuid),
|
|
|
|
|
},
|
|
|
|
|
() => 'Retry',
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-03-15 21:59:48 +00:00
|
|
|
if (canDeleteTrainingFile(record)) {
|
2026-03-22 09:09:14 +00:00
|
|
|
buttons.push(
|
|
|
|
|
h(
|
|
|
|
|
Button,
|
|
|
|
|
{
|
|
|
|
|
danger: true,
|
|
|
|
|
size: 'small',
|
|
|
|
|
icon: h(DeleteOutlined),
|
|
|
|
|
onClick: () => deleteTrainingFile(record.uuid, record.file_name),
|
|
|
|
|
},
|
|
|
|
|
() => 'Delete',
|
|
|
|
|
),
|
2026-03-15 21:59:48 +00:00
|
|
|
)
|
|
|
|
|
}
|
2026-03-22 09:09:14 +00:00
|
|
|
return buttons.length ? h(Space, { size: 'small' }, () => buttons) : null
|
2026-03-15 21:59:48 +00:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const createRoleForWizard = async (): Promise<Role | null> => {
|
2026-02-26 01:32:04 +00:00
|
|
|
const name = createRoleForm.value.name.trim()
|
|
|
|
|
const description = createRoleForm.value.description.trim()
|
|
|
|
|
|
|
|
|
|
if (!name) {
|
|
|
|
|
message.error('Role name is required')
|
2026-03-15 21:59:48 +00:00
|
|
|
return null
|
2026-02-26 01:32:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (hasDuplicateRoleName(name)) {
|
|
|
|
|
message.error('A role with this name already exists')
|
2026-03-15 21:59:48 +00:00
|
|
|
return null
|
2026-02-26 01:32:04 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-15 21:59:48 +00:00
|
|
|
creatingRoleWizard.value = true
|
2026-02-26 01:32:04 +00:00
|
|
|
try {
|
2026-03-15 21:59:48 +00:00
|
|
|
const response = await apiClient.post<Role>(API.roles.list(organizationUuid), { name, description })
|
|
|
|
|
message.success('Role created successfully. You can upload training files now.')
|
2026-02-26 01:32:04 +00:00
|
|
|
await fetchRoles()
|
2026-03-15 21:59:48 +00:00
|
|
|
|
|
|
|
|
if (response.data?.uuid) {
|
|
|
|
|
return response.data
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Roles.value.find((role) => role.name.trim().toLowerCase() === name.toLowerCase()) || null
|
2026-02-26 01:32:04 +00:00
|
|
|
} catch (error) {
|
2026-03-15 21:59:48 +00:00
|
|
|
console.error('Failed to create role in wizard:', error)
|
2026-02-26 01:32:04 +00:00
|
|
|
if (isAxiosError(error)) {
|
|
|
|
|
message.error(error.response?.data?.error || 'Failed to create role')
|
|
|
|
|
} else {
|
|
|
|
|
message.error('Failed to create role')
|
|
|
|
|
}
|
2026-03-15 21:59:48 +00:00
|
|
|
return null
|
2026-02-26 01:32:04 +00:00
|
|
|
} finally {
|
2026-03-15 21:59:48 +00:00
|
|
|
creatingRoleWizard.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleRoleWizardOk = async () => {
|
|
|
|
|
if (roleWizardStep.value === 0) {
|
|
|
|
|
const role = await createRoleForWizard()
|
|
|
|
|
if (!role) return
|
|
|
|
|
createdRoleForWizard.value = role
|
|
|
|
|
roleWizardStep.value = 1
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
closeRoleWizard()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleRoleWizardFileSelected = (file: File) => {
|
|
|
|
|
if (!validateUploadFile(file)) {
|
|
|
|
|
wizardSelectedFile.value = null
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
wizardSelectedFile.value = file
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const uploadFileFromWizard = async () => {
|
|
|
|
|
const roleUuid = createdRoleForWizard.value?.uuid
|
|
|
|
|
if (!roleUuid) {
|
|
|
|
|
message.error('Role is not available for upload')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!wizardSelectedFile.value) {
|
|
|
|
|
message.error('Please select a file to upload')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
wizardUploading.value = true
|
|
|
|
|
try {
|
|
|
|
|
const uploaded = await uploadTrainingFile(
|
|
|
|
|
roleUuid,
|
|
|
|
|
wizardSelectedFile.value,
|
|
|
|
|
wizardFileDescription.value,
|
|
|
|
|
)
|
|
|
|
|
if (!uploaded) return
|
|
|
|
|
|
|
|
|
|
trainingFiles.value.unshift(uploaded)
|
|
|
|
|
wizardUploadedFiles.value.unshift(uploaded)
|
|
|
|
|
message.success(`File "${wizardSelectedFile.value.name}" uploaded successfully`)
|
|
|
|
|
wizardSelectedFile.value = null
|
|
|
|
|
wizardFileDescription.value = ''
|
|
|
|
|
} finally {
|
|
|
|
|
wizardUploading.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const openUploadModal = (role?: Role) => {
|
2026-03-15 22:19:12 +00:00
|
|
|
uploadRoleUuid.value = role?.uuid || ORGANIZATION_WIDE_SCOPE
|
2026-03-15 21:59:48 +00:00
|
|
|
uploadSelectedFile.value = null
|
|
|
|
|
uploadFileDescription.value = ''
|
|
|
|
|
uploadModalVisible.value = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleUploadModalFileSelected = (file: File) => {
|
|
|
|
|
if (!validateUploadFile(file)) {
|
|
|
|
|
uploadSelectedFile.value = null
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
uploadSelectedFile.value = file
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleUploadModalOk = async () => {
|
|
|
|
|
if (!uploadSelectedFile.value) {
|
|
|
|
|
message.error('Please select a file to upload')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 22:19:12 +00:00
|
|
|
const selectedRoleUuid =
|
|
|
|
|
uploadRoleUuid.value === ORGANIZATION_WIDE_SCOPE ? null : uploadRoleUuid.value
|
|
|
|
|
|
2026-03-15 21:59:48 +00:00
|
|
|
uploadingFile.value = true
|
|
|
|
|
try {
|
|
|
|
|
const uploaded = await uploadTrainingFile(
|
2026-03-15 22:19:12 +00:00
|
|
|
selectedRoleUuid,
|
2026-03-15 21:59:48 +00:00
|
|
|
uploadSelectedFile.value,
|
|
|
|
|
uploadFileDescription.value,
|
|
|
|
|
)
|
|
|
|
|
if (!uploaded) return
|
|
|
|
|
|
|
|
|
|
trainingFiles.value.unshift(uploaded)
|
|
|
|
|
message.success(`File "${uploadSelectedFile.value.name}" uploaded successfully`)
|
|
|
|
|
uploadModalVisible.value = false
|
|
|
|
|
uploadSelectedFile.value = null
|
|
|
|
|
uploadFileDescription.value = ''
|
|
|
|
|
} finally {
|
|
|
|
|
uploadingFile.value = false
|
2026-02-26 01:32:04 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const deleteRole = async (role: Role) => {
|
|
|
|
|
Modal.confirm({
|
|
|
|
|
title: 'Delete role',
|
|
|
|
|
content: `Are you sure you want to delete "${role.name}"?`,
|
|
|
|
|
okText: 'Delete',
|
|
|
|
|
okType: 'danger',
|
|
|
|
|
cancelText: 'Cancel',
|
|
|
|
|
onOk: async () => {
|
|
|
|
|
deletingRoleUuid.value = role.uuid
|
|
|
|
|
try {
|
2026-03-08 13:19:17 +00:00
|
|
|
await apiClient.delete(API.roles.remove(organizationUuid, role.uuid))
|
2026-02-26 01:32:04 +00:00
|
|
|
message.success('Role deleted successfully')
|
|
|
|
|
await fetchRoles()
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to delete role:', error)
|
|
|
|
|
if (isAxiosError(error)) {
|
|
|
|
|
message.error(error.response?.data?.error || 'Failed to delete role')
|
|
|
|
|
} else {
|
|
|
|
|
message.error('Failed to delete role')
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
deletingRoleUuid.value = null
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 19:38:47 +00:00
|
|
|
const openRoleMembersModal = (role: Role) => {
|
|
|
|
|
selectedRoleForMembers.value = role
|
|
|
|
|
selectedRoleMembers.value = Array.isArray(role.members) ? role.members : []
|
|
|
|
|
roleMembersModalVisible.value = true
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 01:32:04 +00:00
|
|
|
const createInvite = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const response = await apiClient.post<InviteToken>(
|
2026-03-08 13:19:17 +00:00
|
|
|
API.invites.create(organizationUuid, newInviteMaxUses.value),
|
2026-02-26 01:32:04 +00:00
|
|
|
)
|
|
|
|
|
newInviteUrl.value = response.data.invite_url
|
|
|
|
|
inviteModalVisible.value = true
|
|
|
|
|
fetchInvites()
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to create invite:', error)
|
|
|
|
|
message.error('Failed to create invite')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 13:19:17 +00:00
|
|
|
const fallbackCopyText = (text: string): boolean => {
|
|
|
|
|
const textarea = document.createElement('textarea')
|
|
|
|
|
textarea.value = text
|
|
|
|
|
textarea.setAttribute('readonly', 'true')
|
|
|
|
|
textarea.style.position = 'fixed'
|
|
|
|
|
textarea.style.opacity = '0'
|
|
|
|
|
textarea.style.pointerEvents = 'none'
|
|
|
|
|
document.body.appendChild(textarea)
|
|
|
|
|
textarea.focus()
|
|
|
|
|
textarea.select()
|
|
|
|
|
|
|
|
|
|
const copied = document.execCommand('copy')
|
|
|
|
|
document.body.removeChild(textarea)
|
|
|
|
|
return copied
|
2026-02-26 01:32:04 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-08 13:19:17 +00:00
|
|
|
const copyToClipboard = async (text: string): Promise<boolean> => {
|
|
|
|
|
const safeText = String(text || '').trim()
|
|
|
|
|
if (!safeText) return false
|
|
|
|
|
|
|
|
|
|
if (window.isSecureContext && window.navigator.clipboard?.writeText) {
|
|
|
|
|
try {
|
|
|
|
|
await window.navigator.clipboard.writeText(safeText)
|
|
|
|
|
return true
|
|
|
|
|
} catch {
|
|
|
|
|
// Fall through to legacy copy for restricted browser contexts.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return fallbackCopyText(safeText)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const copyInviteUrl = async () => {
|
|
|
|
|
const copied = await copyToClipboard(newInviteUrl.value)
|
|
|
|
|
if (copied) {
|
|
|
|
|
message.success('Invite URL copied to clipboard')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
message.error('Could not copy invite URL. Please copy it manually.')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const copyUrl = async (url: string) => {
|
|
|
|
|
const copied = await copyToClipboard(url)
|
|
|
|
|
if (copied) {
|
|
|
|
|
message.success('Copied to clipboard')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
message.error('Could not copy URL. Please copy it manually.')
|
2026-02-26 01:32:04 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-27 12:53:19 +00:00
|
|
|
const revokeInvite = async (inviteUuid: string) => {
|
2026-02-26 01:32:04 +00:00
|
|
|
try {
|
2026-03-08 13:19:17 +00:00
|
|
|
await apiClient.delete(API.invites.revoke(organizationUuid, inviteUuid))
|
2026-02-26 01:32:04 +00:00
|
|
|
message.success('Invite revoked')
|
|
|
|
|
fetchInvites()
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to revoke invite:', error)
|
|
|
|
|
message.error('Failed to revoke invite')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 12:53:19 +00:00
|
|
|
const removeMember = async (userUuid: string) => {
|
2026-02-26 01:32:04 +00:00
|
|
|
try {
|
2026-02-27 12:53:19 +00:00
|
|
|
await apiClient.post(API.organization.members.remove(organizationUuid, userUuid))
|
2026-02-26 01:32:04 +00:00
|
|
|
message.success('Member removed')
|
|
|
|
|
fetchMembers()
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to remove member:', error)
|
|
|
|
|
if (isAxiosError(error)) {
|
|
|
|
|
message.error(error.response?.data?.error || 'Failed to remove member')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const saveDescription = async () => {
|
|
|
|
|
try {
|
2026-02-27 12:53:19 +00:00
|
|
|
await apiClient.patch(API.organization.byId(organizationUuid), {
|
2026-02-26 01:32:04 +00:00
|
|
|
description: newDescription.value,
|
|
|
|
|
})
|
|
|
|
|
message.success('Description updated')
|
|
|
|
|
editingDescription.value = false
|
|
|
|
|
fetchOrganization()
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to update description:', error)
|
|
|
|
|
message.error('Failed to update description')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onMounted(async () => {
|
|
|
|
|
await fetchOrganization()
|
|
|
|
|
await fetchMembers()
|
|
|
|
|
await fetchInvites()
|
|
|
|
|
await fetchRoles()
|
2026-03-15 21:59:48 +00:00
|
|
|
await fetchTrainingFiles()
|
2026-02-26 01:32:04 +00:00
|
|
|
|
2026-02-27 12:53:19 +00:00
|
|
|
const currentUserUuid = auth.user?.uuid
|
|
|
|
|
const isOwner = organization.value?.owner?.uuid === currentUserUuid
|
|
|
|
|
const myMembership = members.value.find((member) => member.uuid === currentUserUuid)
|
2026-02-26 01:32:04 +00:00
|
|
|
const isEmployer = myMembership?.is_manager
|
|
|
|
|
|
|
|
|
|
if (!isOwner && !isEmployer) {
|
|
|
|
|
message.error('You do not have permission to manage this organization')
|
2026-02-27 12:53:19 +00:00
|
|
|
router.replace(`/organization/${organizationUuid}`)
|
2026-02-26 01:32:04 +00:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
<div class="page">
|
|
|
|
|
<Spin :spinning="loading" tip="Loading organization...">
|
|
|
|
|
<Card v-if="organization" class="panel" :bordered="false">
|
|
|
|
|
<div class="header">
|
|
|
|
|
<Typography.Title :level="2">Manage {{ organization.name }}</Typography.Title>
|
2026-02-27 12:53:19 +00:00
|
|
|
<Button type="default" @click="router.push(`/organization/${organizationUuid}`)">
|
2026-02-26 01:32:04 +00:00
|
|
|
Back to Organization
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Tabs>
|
|
|
|
|
<Tabs.TabPane key="details" tab="Details">
|
|
|
|
|
<div class="section">
|
2026-03-08 13:19:17 +00:00
|
|
|
<Typography.Title :level="4" style="color: #1f2937 !important">
|
2026-02-26 01:32:04 +00:00
|
|
|
Description
|
|
|
|
|
</Typography.Title>
|
|
|
|
|
<div v-if="!editingDescription">
|
|
|
|
|
<Typography.Paragraph>
|
|
|
|
|
{{ organization.description || 'No description provided' }}
|
|
|
|
|
</Typography.Paragraph>
|
|
|
|
|
<Button @click="editingDescription = true" size="small">
|
|
|
|
|
Edit Description
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
<div v-else>
|
|
|
|
|
<Input.TextArea
|
|
|
|
|
v-model:value="newDescription"
|
|
|
|
|
:rows="4"
|
|
|
|
|
placeholder="Enter organization description"
|
|
|
|
|
/>
|
|
|
|
|
<Space style="margin-top: 0.5rem">
|
|
|
|
|
<Button type="primary" @click="saveDescription">Save</Button>
|
|
|
|
|
<Button @click="editingDescription = false">Cancel</Button>
|
|
|
|
|
</Space>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Tabs.TabPane>
|
|
|
|
|
|
|
|
|
|
<Tabs.TabPane key="members" tab="Members">
|
|
|
|
|
<div class="section">
|
|
|
|
|
<div class="section-header">
|
2026-03-08 13:19:17 +00:00
|
|
|
<Typography.Title :level="4" style="color: #1f2937 !important">
|
2026-02-27 13:58:00 +00:00
|
|
|
Members ({{ filteredMembers.length }})
|
2026-02-26 01:32:04 +00:00
|
|
|
</Typography.Title>
|
2026-02-27 13:58:00 +00:00
|
|
|
<Input
|
|
|
|
|
v-model:value="memberSearch"
|
|
|
|
|
allow-clear
|
|
|
|
|
class="search-input"
|
|
|
|
|
placeholder="Search members by name"
|
|
|
|
|
style="max-width: 280px"
|
|
|
|
|
/>
|
2026-02-26 01:32:04 +00:00
|
|
|
</div>
|
|
|
|
|
|
2026-02-27 13:58:00 +00:00
|
|
|
<List
|
|
|
|
|
v-if="filteredMembers.length > 0"
|
|
|
|
|
:data-source="filteredMembers"
|
|
|
|
|
:bordered="false"
|
|
|
|
|
>
|
2026-02-26 01:32:04 +00:00
|
|
|
<template #renderItem="{ item }">
|
|
|
|
|
<List.Item class="member-item">
|
|
|
|
|
<List.Item.Meta
|
|
|
|
|
:title="`${item.first_name} ${item.last_name}`"
|
2026-02-27 13:58:00 +00:00
|
|
|
:description="item.email_address"
|
2026-02-26 01:32:04 +00:00
|
|
|
/>
|
|
|
|
|
<Space>
|
2026-02-27 13:58:00 +00:00
|
|
|
<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>
|
2026-02-26 01:32:04 +00:00
|
|
|
<Button
|
2026-02-27 12:53:19 +00:00
|
|
|
v-if="item.uuid !== organization.owner.uuid"
|
2026-02-26 01:32:04 +00:00
|
|
|
danger
|
|
|
|
|
size="small"
|
2026-02-27 12:53:19 +00:00
|
|
|
@click="removeMember(item.uuid)"
|
2026-02-26 01:32:04 +00:00
|
|
|
>
|
|
|
|
|
Remove
|
|
|
|
|
</Button>
|
|
|
|
|
</Space>
|
|
|
|
|
</List.Item>
|
|
|
|
|
</template>
|
|
|
|
|
</List>
|
2026-02-27 13:58:00 +00:00
|
|
|
<Typography.Paragraph v-else type="secondary">
|
|
|
|
|
{{ memberEmptyMessage }}
|
|
|
|
|
</Typography.Paragraph>
|
2026-02-26 01:32:04 +00:00
|
|
|
</div>
|
|
|
|
|
</Tabs.TabPane>
|
|
|
|
|
|
|
|
|
|
<Tabs.TabPane key="invites" tab="Invites">
|
|
|
|
|
<div class="section">
|
|
|
|
|
<div class="section-header">
|
2026-03-08 13:19:17 +00:00
|
|
|
<Typography.Title :level="4" style="color: #1f2937 !important">
|
2026-02-26 01:32:04 +00:00
|
|
|
Invite Tokens
|
|
|
|
|
</Typography.Title>
|
|
|
|
|
<Space>
|
|
|
|
|
<InputNumber v-model:value="newInviteMaxUses" :min="1" />
|
|
|
|
|
<Button type="primary" @click="createInvite">
|
|
|
|
|
Create Invite
|
|
|
|
|
</Button>
|
|
|
|
|
</Space>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<List
|
|
|
|
|
v-if="invites.length > 0"
|
|
|
|
|
:data-source="invites"
|
|
|
|
|
:bordered="false"
|
|
|
|
|
>
|
|
|
|
|
<template #renderItem="{ item }">
|
|
|
|
|
<List.Item class="invite-item">
|
|
|
|
|
<List.Item.Meta
|
|
|
|
|
:title="`Created by ${item.created_by.first_name} ${item.created_by.last_name}`"
|
|
|
|
|
:description="`Expires: ${new Date(item.expires_at).toLocaleDateString()}`"
|
|
|
|
|
/>
|
|
|
|
|
<Space>
|
|
|
|
|
<Tag :color="item.is_valid ? 'green' : 'red'">
|
|
|
|
|
{{ item.is_valid ? 'Valid' : 'Expired' }}
|
|
|
|
|
</Tag>
|
|
|
|
|
<Tag class="white-tag">
|
|
|
|
|
Uses: {{ item.uses || 0 }} /
|
|
|
|
|
{{ item.max_uses || 1 }}
|
|
|
|
|
</Tag>
|
|
|
|
|
<Button size="small" @click="copyUrl(item.invite_url)">
|
|
|
|
|
Copy URL
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
danger
|
|
|
|
|
size="small"
|
2026-02-27 12:53:19 +00:00
|
|
|
@click="revokeInvite(item.uuid)"
|
2026-02-26 01:32:04 +00:00
|
|
|
>
|
|
|
|
|
Revoke
|
|
|
|
|
</Button>
|
|
|
|
|
</Space>
|
|
|
|
|
</List.Item>
|
|
|
|
|
</template>
|
|
|
|
|
</List>
|
|
|
|
|
<Typography.Paragraph v-else type="secondary">
|
|
|
|
|
No active invites. Create one to invite new members.
|
|
|
|
|
</Typography.Paragraph>
|
|
|
|
|
</div>
|
|
|
|
|
</Tabs.TabPane>
|
|
|
|
|
|
|
|
|
|
<Tabs.TabPane key="Roles" tab="Roles">
|
|
|
|
|
<div class="section">
|
|
|
|
|
<div class="section-header">
|
2026-03-08 13:19:17 +00:00
|
|
|
<Typography.Title :level="4" style="color: #1f2937 !important">
|
2026-02-27 13:58:00 +00:00
|
|
|
Roles ({{ filteredRoles.length }})
|
2026-02-26 01:32:04 +00:00
|
|
|
</Typography.Title>
|
2026-02-27 13:58:00 +00:00
|
|
|
<Space>
|
|
|
|
|
<Input
|
|
|
|
|
v-model:value="roleSearch"
|
|
|
|
|
allow-clear
|
|
|
|
|
class="search-input"
|
|
|
|
|
placeholder="Search roles by name or description"
|
|
|
|
|
style="width: 300px"
|
|
|
|
|
/>
|
2026-03-15 21:59:48 +00:00
|
|
|
<Button @click="openUploadModal()">
|
|
|
|
|
Upload Training File
|
|
|
|
|
</Button>
|
|
|
|
|
<Button type="primary" @click="openRoleWizard">
|
2026-02-27 13:58:00 +00:00
|
|
|
Create Role
|
|
|
|
|
</Button>
|
|
|
|
|
</Space>
|
2026-02-26 01:32:04 +00:00
|
|
|
</div>
|
|
|
|
|
|
2026-02-27 13:58:00 +00:00
|
|
|
<List
|
|
|
|
|
v-if="filteredRoles.length > 0"
|
|
|
|
|
:data-source="filteredRoles"
|
|
|
|
|
:bordered="false"
|
|
|
|
|
>
|
2026-02-26 01:32:04 +00:00
|
|
|
<template #renderItem="{ item }">
|
|
|
|
|
<List.Item class="Role-item">
|
2026-03-15 21:59:48 +00:00
|
|
|
<div class="role-content">
|
|
|
|
|
<div class="role-head">
|
|
|
|
|
<List.Item.Meta
|
|
|
|
|
:title="item.name"
|
|
|
|
|
:description="item.description || 'No description'"
|
|
|
|
|
/>
|
|
|
|
|
<Space>
|
|
|
|
|
<Tag>{{ item.member_count }} members</Tag>
|
|
|
|
|
<Tag color="blue">{{ getTrainingFilesByRole(item.uuid).length }} files</Tag>
|
|
|
|
|
<Button size="small" @click="openUploadModal(item)">
|
|
|
|
|
Upload Files
|
|
|
|
|
</Button>
|
|
|
|
|
<Button size="small" @click="openRoleMembersModal(item)">
|
|
|
|
|
View Members
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
danger
|
|
|
|
|
size="small"
|
|
|
|
|
:loading="deletingRoleUuid === item.uuid"
|
|
|
|
|
@click="deleteRole(item)"
|
|
|
|
|
>
|
|
|
|
|
Delete
|
|
|
|
|
</Button>
|
|
|
|
|
</Space>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="role-files">
|
|
|
|
|
<Typography.Text strong>Training files for this role</Typography.Text>
|
|
|
|
|
<List
|
|
|
|
|
v-if="getTrainingFilesByRole(item.uuid).length > 0"
|
|
|
|
|
:data-source="getTrainingFilesByRole(item.uuid)"
|
|
|
|
|
size="small"
|
|
|
|
|
:bordered="false"
|
|
|
|
|
>
|
|
|
|
|
<template #renderItem="{ item: file }">
|
|
|
|
|
<List.Item>
|
|
|
|
|
<Space style="display: flex; justify-content: space-between; width: 100%">
|
|
|
|
|
<Typography.Text>{{ file.file_name }}</Typography.Text>
|
|
|
|
|
<Tag>{{ formatFileSize(file.file_size || 0) }}</Tag>
|
|
|
|
|
</Space>
|
|
|
|
|
</List.Item>
|
|
|
|
|
</template>
|
|
|
|
|
</List>
|
|
|
|
|
<Typography.Paragraph v-else type="secondary" style="margin: 0.5rem 0 0">
|
|
|
|
|
No training files uploaded for this role yet.
|
|
|
|
|
</Typography.Paragraph>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-02-26 01:32:04 +00:00
|
|
|
</List.Item>
|
|
|
|
|
</template>
|
|
|
|
|
</List>
|
2026-03-15 22:19:12 +00:00
|
|
|
<div v-if="organizationWideTrainingFiles.length > 0" class="role-files" style="margin-top: 1rem">
|
|
|
|
|
<Typography.Text strong>
|
|
|
|
|
Organization-wide training files (applies to all roles)
|
|
|
|
|
</Typography.Text>
|
|
|
|
|
<List
|
|
|
|
|
:data-source="organizationWideTrainingFiles"
|
|
|
|
|
size="small"
|
|
|
|
|
:bordered="false"
|
|
|
|
|
>
|
|
|
|
|
<template #renderItem="{ item: file }">
|
|
|
|
|
<List.Item>
|
|
|
|
|
<Space style="display: flex; justify-content: space-between; width: 100%">
|
|
|
|
|
<Typography.Text>{{ file.file_name }}</Typography.Text>
|
|
|
|
|
<Tag color="geekblue">Organization-wide</Tag>
|
|
|
|
|
</Space>
|
|
|
|
|
</List.Item>
|
|
|
|
|
</template>
|
|
|
|
|
</List>
|
|
|
|
|
</div>
|
|
|
|
|
<Typography.Paragraph
|
|
|
|
|
v-if="filteredRoles.length === 0 && organizationWideTrainingFiles.length === 0"
|
|
|
|
|
type="secondary"
|
|
|
|
|
>
|
2026-02-27 13:58:00 +00:00
|
|
|
{{ roleEmptyMessage }}
|
2026-02-26 01:32:04 +00:00
|
|
|
</Typography.Paragraph>
|
|
|
|
|
</div>
|
|
|
|
|
</Tabs.TabPane>
|
2026-03-15 21:59:48 +00:00
|
|
|
|
|
|
|
|
<Tabs.TabPane key="files" tab="Files">
|
|
|
|
|
<div class="section">
|
|
|
|
|
<div class="section-header">
|
|
|
|
|
<Typography.Title :level="4" style="color: #1f2937 !important">
|
|
|
|
|
Training Files ({{ trainingFiles.length }})
|
|
|
|
|
</Typography.Title>
|
|
|
|
|
<Button @click="openUploadModal()">Upload Training File</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Table
|
|
|
|
|
v-if="trainingFiles.length > 0"
|
|
|
|
|
:columns="trainingFileColumns"
|
|
|
|
|
:data-source="trainingFiles"
|
|
|
|
|
:pagination="{ pageSize: 10 }"
|
|
|
|
|
:row-key="(record: TrainingFile) => record.uuid"
|
|
|
|
|
size="small"
|
|
|
|
|
/>
|
|
|
|
|
<Typography.Paragraph v-else type="secondary">
|
|
|
|
|
No training files uploaded yet.
|
|
|
|
|
</Typography.Paragraph>
|
|
|
|
|
</div>
|
|
|
|
|
</Tabs.TabPane>
|
2026-02-26 01:32:04 +00:00
|
|
|
</Tabs>
|
|
|
|
|
</Card>
|
|
|
|
|
</Spin>
|
|
|
|
|
|
|
|
|
|
<Modal
|
|
|
|
|
v-model:open="roleModalVisible"
|
2026-03-15 21:59:48 +00:00
|
|
|
:title="roleWizardStep === 0 ? 'Create Role' : 'Upload Training Files'"
|
|
|
|
|
:ok-text="roleWizardStep === 0 ? 'Next' : 'Finish'"
|
2026-02-26 01:32:04 +00:00
|
|
|
cancel-text="Cancel"
|
2026-03-15 21:59:48 +00:00
|
|
|
:ok-button-props="{ loading: roleWizardStep === 0 ? creatingRoleWizard : false }"
|
|
|
|
|
@ok="handleRoleWizardOk"
|
|
|
|
|
@cancel="closeRoleWizard"
|
2026-02-26 01:32:04 +00:00
|
|
|
>
|
2026-03-15 21:59:48 +00:00
|
|
|
<Steps :current="roleWizardStep" size="small" style="margin-bottom: 1rem">
|
|
|
|
|
<Steps.Step title="Role Details" />
|
|
|
|
|
<Steps.Step title="Training Files" />
|
|
|
|
|
</Steps>
|
|
|
|
|
|
|
|
|
|
<div v-if="roleWizardStep === 0" style="display: flex; flex-direction: column; gap: 0.75rem">
|
2026-02-26 01:32:04 +00:00
|
|
|
<Input
|
|
|
|
|
v-model:value="createRoleForm.name"
|
|
|
|
|
placeholder="Role name"
|
|
|
|
|
:maxlength="100"
|
2026-03-15 21:59:48 +00:00
|
|
|
@pressEnter="handleRoleWizardOk"
|
2026-02-26 01:32:04 +00:00
|
|
|
/>
|
|
|
|
|
<Input.TextArea
|
|
|
|
|
v-model:value="createRoleForm.description"
|
|
|
|
|
placeholder="Role description"
|
|
|
|
|
:rows="4"
|
|
|
|
|
:maxlength="1000"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-03-15 21:59:48 +00:00
|
|
|
|
|
|
|
|
<div v-else style="display: flex; flex-direction: column; gap: 0.75rem">
|
|
|
|
|
<Typography.Paragraph type="secondary" style="margin-bottom: 0">
|
|
|
|
|
Upload optional training files for
|
|
|
|
|
<strong>{{ createdRoleForWizard?.name }}</strong>
|
2026-03-15 22:19:12 +00:00
|
|
|
. You can also do this later. Use the main Upload Training File modal for
|
|
|
|
|
organization-wide files.
|
2026-03-15 21:59:48 +00:00
|
|
|
</Typography.Paragraph>
|
|
|
|
|
|
|
|
|
|
<Input.TextArea
|
|
|
|
|
v-model:value="wizardFileDescription"
|
|
|
|
|
placeholder="Optional file description"
|
|
|
|
|
:rows="2"
|
|
|
|
|
:maxlength="500"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<Upload.Dragger
|
|
|
|
|
accept=".txt,.pdf,.md,.csv,.json,.docx,.doc"
|
|
|
|
|
:before-upload="
|
|
|
|
|
(file) => {
|
|
|
|
|
handleRoleWizardFileSelected(file)
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
"
|
|
|
|
|
:multiple="false"
|
|
|
|
|
:auto-upload="false"
|
2026-03-18 22:04:07 +00:00
|
|
|
:file-list="[]"
|
2026-03-15 21:59:48 +00:00
|
|
|
>
|
|
|
|
|
<p class="ant-upload-drag-icon">
|
|
|
|
|
<InboxOutlined />
|
|
|
|
|
</p>
|
|
|
|
|
<p class="ant-upload-text">Click or drag file to this area to select</p>
|
|
|
|
|
<p class="ant-upload-hint">
|
|
|
|
|
{{ wizardSelectedFile ? wizardSelectedFile.name : 'Single file upload' }}
|
|
|
|
|
</p>
|
|
|
|
|
</Upload.Dragger>
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
type="primary"
|
|
|
|
|
:disabled="!wizardSelectedFile"
|
|
|
|
|
:loading="wizardUploading"
|
|
|
|
|
@click="uploadFileFromWizard"
|
|
|
|
|
>
|
|
|
|
|
Upload Selected File
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
<div v-if="wizardUploadedFiles.length > 0" class="uploaded-list">
|
|
|
|
|
<Typography.Text strong>Uploaded in this setup:</Typography.Text>
|
|
|
|
|
<List :data-source="wizardUploadedFiles" :bordered="false" size="small">
|
|
|
|
|
<template #renderItem="{ item }">
|
|
|
|
|
<List.Item>
|
|
|
|
|
<List.Item.Meta :title="item.file_name" :description="item.role?.name" />
|
|
|
|
|
</List.Item>
|
|
|
|
|
</template>
|
|
|
|
|
</List>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Modal>
|
|
|
|
|
|
|
|
|
|
<Modal
|
|
|
|
|
v-model:open="uploadModalVisible"
|
|
|
|
|
title="Upload Training File"
|
|
|
|
|
ok-text="Upload"
|
|
|
|
|
cancel-text="Cancel"
|
2026-03-15 22:19:12 +00:00
|
|
|
:ok-button-props="{ loading: uploadingFile, disabled: !uploadSelectedFile }"
|
2026-03-15 21:59:48 +00:00
|
|
|
@ok="handleUploadModalOk"
|
|
|
|
|
@cancel="uploadModalVisible = false"
|
|
|
|
|
>
|
|
|
|
|
<div style="display: flex; flex-direction: column; gap: 0.75rem">
|
|
|
|
|
<Typography.Text>
|
|
|
|
|
Supported formats:
|
|
|
|
|
<strong>txt, pdf, md, csv, json, docx, doc</strong>
|
|
|
|
|
(Max 50MB)
|
|
|
|
|
</Typography.Text>
|
|
|
|
|
|
|
|
|
|
<div>
|
2026-03-15 22:19:12 +00:00
|
|
|
<Typography.Text strong>Scope</Typography.Text>
|
2026-03-15 21:59:48 +00:00
|
|
|
<Select
|
|
|
|
|
v-model:value="uploadRoleUuid"
|
2026-03-15 22:19:12 +00:00
|
|
|
placeholder="Select training scope"
|
2026-03-15 21:59:48 +00:00
|
|
|
style="width: 100%"
|
2026-03-15 22:19:12 +00:00
|
|
|
:options="uploadRoleOptions"
|
2026-03-15 21:59:48 +00:00
|
|
|
/>
|
2026-03-15 22:19:12 +00:00
|
|
|
<Typography.Paragraph type="secondary" style="margin: 0.5rem 0 0">
|
|
|
|
|
Organization-wide files apply to every role in this organization.
|
|
|
|
|
</Typography.Paragraph>
|
2026-03-15 21:59:48 +00:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Input.TextArea
|
|
|
|
|
v-model:value="uploadFileDescription"
|
|
|
|
|
placeholder="Optional file description"
|
|
|
|
|
:rows="2"
|
|
|
|
|
:maxlength="500"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<Upload.Dragger
|
|
|
|
|
accept=".txt,.pdf,.md,.csv,.json,.docx,.doc"
|
|
|
|
|
:before-upload="
|
|
|
|
|
(file) => {
|
|
|
|
|
handleUploadModalFileSelected(file)
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
"
|
|
|
|
|
:multiple="false"
|
|
|
|
|
:auto-upload="false"
|
2026-03-18 22:04:07 +00:00
|
|
|
:file-list="[]"
|
2026-03-15 21:59:48 +00:00
|
|
|
>
|
|
|
|
|
<p class="ant-upload-drag-icon">
|
|
|
|
|
<InboxOutlined />
|
|
|
|
|
</p>
|
|
|
|
|
<p class="ant-upload-text">Click or drag file to this area to select</p>
|
|
|
|
|
<p class="ant-upload-hint">
|
|
|
|
|
{{ uploadSelectedFile ? uploadSelectedFile.name : 'Single file upload' }}
|
|
|
|
|
</p>
|
|
|
|
|
</Upload.Dragger>
|
|
|
|
|
</div>
|
2026-02-26 01:32:04 +00:00
|
|
|
</Modal>
|
|
|
|
|
|
|
|
|
|
<Modal
|
|
|
|
|
v-model:open="inviteModalVisible"
|
|
|
|
|
title="Invite Created"
|
|
|
|
|
@ok="inviteModalVisible = false"
|
|
|
|
|
>
|
|
|
|
|
<div>
|
|
|
|
|
<Typography.Paragraph>
|
|
|
|
|
Share this URL with people you want to invite:
|
|
|
|
|
</Typography.Paragraph>
|
|
|
|
|
<Input
|
|
|
|
|
:value="newInviteUrl"
|
|
|
|
|
readonly
|
|
|
|
|
@click="copyInviteUrl"
|
|
|
|
|
style="cursor: pointer"
|
|
|
|
|
/>
|
|
|
|
|
<Button type="primary" block style="margin-top: 1rem" @click="copyInviteUrl">
|
|
|
|
|
Copy to Clipboard
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</Modal>
|
2026-03-10 19:38:47 +00:00
|
|
|
|
|
|
|
|
<Modal
|
|
|
|
|
v-model:open="roleMembersModalVisible"
|
|
|
|
|
:title="`Members in ${selectedRoleForMembers?.name || 'Role'}`"
|
|
|
|
|
:footer="null"
|
|
|
|
|
>
|
|
|
|
|
<List
|
|
|
|
|
v-if="selectedRoleMembers.length > 0"
|
|
|
|
|
:data-source="selectedRoleMembers"
|
|
|
|
|
:bordered="false"
|
|
|
|
|
>
|
|
|
|
|
<template #renderItem="{ item }">
|
|
|
|
|
<List.Item>
|
|
|
|
|
<List.Item.Meta
|
|
|
|
|
:title="`${item.first_name} ${item.last_name}`"
|
|
|
|
|
:description="item.email_address"
|
|
|
|
|
/>
|
|
|
|
|
<Tag :color="item.is_manager ? 'purple' : 'default'">
|
|
|
|
|
{{ item.is_manager ? 'Manager' : 'Member' }}
|
|
|
|
|
</Tag>
|
|
|
|
|
</List.Item>
|
|
|
|
|
</template>
|
|
|
|
|
</List>
|
|
|
|
|
<Typography.Paragraph v-else type="secondary">
|
|
|
|
|
No members assigned to this role yet.
|
|
|
|
|
</Typography.Paragraph>
|
|
|
|
|
</Modal>
|
2026-02-26 01:32:04 +00:00
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
.page {
|
|
|
|
|
padding: 1rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.header {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
align-items: center;
|
|
|
|
|
margin-bottom: 1rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.section {
|
|
|
|
|
margin: 2rem 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.section-header {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
align-items: center;
|
|
|
|
|
margin-bottom: 1rem;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 21:59:48 +00:00
|
|
|
.role-content {
|
|
|
|
|
width: 100%;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.role-head {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.role-files {
|
|
|
|
|
background: #f8fafc;
|
|
|
|
|
border: 1px solid #e5e7eb;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
padding: 0.75rem;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 01:32:04 +00:00
|
|
|
.white-tag {
|
|
|
|
|
background-color: #ffffff !important;
|
|
|
|
|
color: #000000 !important;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
:deep(.ant-typography),
|
|
|
|
|
:deep(.ant-typography p),
|
|
|
|
|
:deep(.ant-typography span),
|
|
|
|
|
:deep(.ant-typography h4),
|
|
|
|
|
:deep(.ant-list-item),
|
|
|
|
|
:deep(.ant-list-item-meta-title),
|
|
|
|
|
:deep(.ant-list-item-meta-description),
|
|
|
|
|
:deep(.ant-tabs-tab),
|
|
|
|
|
:deep(.ant-input-number),
|
|
|
|
|
:deep(.ant-input-number-input) {
|
2026-03-08 13:19:17 +00:00
|
|
|
color: #1f2937 !important;
|
2026-02-26 01:32:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
:deep(.ant-typography-secondary) {
|
2026-03-08 13:19:17 +00:00
|
|
|
color: #6b7280 !important;
|
2026-02-26 01:32:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
:deep(.ant-input-number) {
|
2026-03-08 13:19:17 +00:00
|
|
|
background: #ffffff;
|
|
|
|
|
border-color: #d0d8e2;
|
2026-02-26 01:32:04 +00:00
|
|
|
}
|
2026-02-27 13:58:00 +00:00
|
|
|
|
|
|
|
|
:deep(.search-input) {
|
2026-03-08 13:19:17 +00:00
|
|
|
background: #ffffff !important;
|
|
|
|
|
border-color: #d0d8e2 !important;
|
|
|
|
|
color: #1f2937 !important;
|
2026-02-27 13:58:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
:deep(.search-input::placeholder) {
|
2026-03-08 13:19:17 +00:00
|
|
|
color: #6b7280 !important;
|
2026-02-27 13:58:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
:deep(.search-input::selection) {
|
2026-03-08 13:19:17 +00:00
|
|
|
background: #dbeafe !important;
|
|
|
|
|
color: #1f2937 !important;
|
2026-02-27 13:58:00 +00:00
|
|
|
}
|
2026-02-26 01:32:04 +00:00
|
|
|
</style>
|