Added separate training file area and polling to refresh page

This commit is contained in:
Viswamedha Nalabotu 2026-03-15 21:59:48 +00:00
parent b8bef6a711
commit 6ccb7822c9
4 changed files with 672 additions and 350 deletions

60
site/package-lock.json generated
View file

@ -8,6 +8,7 @@
"name": "dynavera", "name": "dynavera",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@vueuse/core": "^14.2.1",
"ant-design-vue": "^4.2.6", "ant-design-vue": "^4.2.6",
"axios": "^1.13.2", "axios": "^1.13.2",
"dompurify": "^3.3.1", "dompurify": "^3.3.1",
@ -100,6 +101,7 @@
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.28.6", "@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6", "@babel/generator": "^7.28.6",
@ -1745,6 +1747,7 @@
"integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
@ -1756,6 +1759,12 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.53.0", "version": "8.53.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.0.tgz",
@ -1801,6 +1810,7 @@
"integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==", "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/scope-manager": "8.53.0",
"@typescript-eslint/types": "8.53.0", "@typescript-eslint/types": "8.53.0",
@ -2431,12 +2441,51 @@
} }
} }
}, },
"node_modules/@vueuse/core": {
"version": "14.2.1",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.1.tgz",
"integrity": "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "14.2.1",
"@vueuse/shared": "14.2.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/metadata": {
"version": "14.2.1",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.1.tgz",
"integrity": "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "14.2.1",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.1.tgz",
"integrity": "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.15.0", "version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -2666,6 +2715,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@ -3215,6 +3265,7 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@ -3275,6 +3326,7 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"eslint-config-prettier": "bin/cli.js" "eslint-config-prettier": "bin/cli.js"
}, },
@ -3322,6 +3374,7 @@
"integrity": "sha512-nA5yUs/B1KmKzvC42fyD0+l9Yd+LtEpVhWRbXuDj0e+ZURcTtyRbMDWUeJmTAh2wC6jC83raS63anNM2YT3NPw==", "integrity": "sha512-nA5yUs/B1KmKzvC42fyD0+l9Yd+LtEpVhWRbXuDj0e+ZURcTtyRbMDWUeJmTAh2wC6jC83raS63anNM2YT3NPw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.4.0", "@eslint-community/eslint-utils": "^4.4.0",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
@ -4018,6 +4071,7 @@
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
@ -4696,6 +4750,7 @@
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"
}, },
@ -5143,6 +5198,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -5222,6 +5278,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -5345,6 +5402,7 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@ -5635,6 +5693,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -5654,6 +5713,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
"integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.26", "@vue/compiler-dom": "3.5.26",
"@vue/compiler-sfc": "3.5.26", "@vue/compiler-sfc": "3.5.26",

View file

@ -18,6 +18,7 @@
"format": "prettier --write --experimental-cli src/" "format": "prettier --write --experimental-cli src/"
}, },
"dependencies": { "dependencies": {
"@vueuse/core": "^14.2.1",
"ant-design-vue": "^4.2.6", "ant-design-vue": "^4.2.6",
"axios": "^1.13.2", "axios": "^1.13.2",
"dompurify": "^3.3.1", "dompurify": "^3.3.1",

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed, h } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { import {
Card, Card,
@ -14,6 +14,10 @@ import {
Modal, Modal,
Tabs, Tabs,
InputNumber, InputNumber,
Select,
Upload,
Steps,
Table,
} from 'ant-design-vue' } from 'ant-design-vue'
import { apiClient, isAxiosError, API } from '../router/api' import { apiClient, isAxiosError, API } from '../router/api'
import { useUserStore } from '../stores/userStore' import { useUserStore } from '../stores/userStore'
@ -21,6 +25,8 @@ import type { Organization } from '../types/organization'
import type { User } from '../types/user' import type { User } from '../types/user'
import type { InviteToken } from '../types/organization' import type { InviteToken } from '../types/organization'
import type { Role } from '../types/organization' import type { Role } from '../types/organization'
import type { TrainingFile } from '../types/organization'
import { InboxOutlined, DeleteOutlined } from '@ant-design/icons-vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@ -32,15 +38,27 @@ 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 trainingFiles = ref<TrainingFile[]>([])
const memberSearch = ref('') const memberSearch = ref('')
const roleSearch = ref('') const roleSearch = ref('')
const loading = ref(false) const loading = ref(false)
const creatingRole = ref(false)
const deletingRoleUuid = ref<string | null>(null) const deletingRoleUuid = ref<string | null>(null)
const roleModalVisible = ref(false) const roleModalVisible = ref(false)
const roleMembersModalVisible = ref(false) const roleMembersModalVisible = ref(false)
const selectedRoleForMembers = ref<Role | null>(null) const selectedRoleForMembers = ref<Role | null>(null)
const selectedRoleMembers = ref<User[]>([]) const selectedRoleMembers = ref<User[]>([])
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)
const createRoleForm = ref({ const createRoleForm = ref({
name: '', name: '',
description: '', description: '',
@ -122,43 +140,333 @@ const fetchRoles = async () => {
} }
} }
const resetRoleForm = () => { 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)
}
}
const resetRoleWizard = () => {
roleWizardStep.value = 0
createRoleForm.value = { name: '', description: '' } createRoleForm.value = { name: '', description: '' }
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
} }
const hasDuplicateRoleName = (name: string) => const hasDuplicateRoleName = (name: string) =>
Roles.value.some((role) => role.name.trim().toLowerCase() === name.trim().toLowerCase()) Roles.value.some((role) => role.name.trim().toLowerCase() === name.trim().toLowerCase())
const createRole = async () => { 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 (
roleUuid: string,
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)
formData.append('role_uuid', roleUuid)
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)
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')
}
},
})
}
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',
dataIndex: 'file_name',
key: 'file_name',
},
{
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),
},
{
title: 'Role',
key: 'role',
customRender: ({ record }: { record: TrainingFile }) => record.role?.name || '-',
},
{
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 }) => {
if (canDeleteTrainingFile(record)) {
return h(
Button,
{
danger: true,
size: 'small',
icon: h(DeleteOutlined),
onClick: () => deleteTrainingFile(record.uuid, record.file_name),
},
() => 'Delete',
)
}
return null
},
},
]
const createRoleForWizard = async (): Promise<Role | null> => {
const name = createRoleForm.value.name.trim() const name = createRoleForm.value.name.trim()
const description = createRoleForm.value.description.trim() const description = createRoleForm.value.description.trim()
if (!name) { if (!name) {
message.error('Role name is required') message.error('Role name is required')
return return null
} }
if (hasDuplicateRoleName(name)) { if (hasDuplicateRoleName(name)) {
message.error('A role with this name already exists') message.error('A role with this name already exists')
return return null
} }
creatingRole.value = true creatingRoleWizard.value = true
try { try {
await apiClient.post(API.roles.list(organizationUuid), { name, description }) const response = await apiClient.post<Role>(API.roles.list(organizationUuid), { name, description })
message.success('Role created successfully') message.success('Role created successfully. You can upload training files now.')
roleModalVisible.value = false
resetRoleForm()
await fetchRoles() await fetchRoles()
if (response.data?.uuid) {
return response.data
}
return Roles.value.find((role) => role.name.trim().toLowerCase() === name.toLowerCase()) || null
} catch (error) { } catch (error) {
console.error('Failed to create role:', error) console.error('Failed to create role in wizard:', error)
if (isAxiosError(error)) { if (isAxiosError(error)) {
message.error(error.response?.data?.error || 'Failed to create role') message.error(error.response?.data?.error || 'Failed to create role')
} else { } else {
message.error('Failed to create role') message.error('Failed to create role')
} }
return null
} finally { } finally {
creatingRole.value = false 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) => {
uploadRoleUuid.value = role?.uuid || ''
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 (!uploadRoleUuid.value) {
message.error('Please select a role for this training file')
return
}
if (!uploadSelectedFile.value) {
message.error('Please select a file to upload')
return
}
uploadingFile.value = true
try {
const uploaded = await uploadTrainingFile(
uploadRoleUuid.value,
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
} }
} }
@ -302,6 +610,7 @@ onMounted(async () => {
await fetchMembers() await fetchMembers()
await fetchInvites() await fetchInvites()
await fetchRoles() await fetchRoles()
await fetchTrainingFiles()
const currentUserUuid = auth.user?.uuid const currentUserUuid = auth.user?.uuid
const isOwner = organization.value?.owner?.uuid === currentUserUuid const isOwner = organization.value?.owner?.uuid === currentUserUuid
@ -472,7 +781,10 @@ onMounted(async () => {
placeholder="Search roles by name or description" placeholder="Search roles by name or description"
style="width: 300px" style="width: 300px"
/> />
<Button type="primary" @click="roleModalVisible = true"> <Button @click="openUploadModal()">
Upload Training File
</Button>
<Button type="primary" @click="openRoleWizard">
Create Role Create Role
</Button> </Button>
</Space> </Space>
@ -485,24 +797,54 @@ onMounted(async () => {
> >
<template #renderItem="{ item }"> <template #renderItem="{ item }">
<List.Item class="Role-item"> <List.Item class="Role-item">
<List.Item.Meta <div class="role-content">
:title="item.name" <div class="role-head">
:description="item.description || 'No description'" <List.Item.Meta
/> :title="item.name"
<Space> :description="item.description || 'No description'"
<Tag>{{ item.member_count }} members</Tag> />
<Button size="small" @click="openRoleMembersModal(item)"> <Space>
View Members <Tag>{{ item.member_count }} members</Tag>
</Button> <Tag color="blue">{{ getTrainingFilesByRole(item.uuid).length }} files</Tag>
<Button <Button size="small" @click="openUploadModal(item)">
danger Upload Files
size="small" </Button>
:loading="deletingRoleUuid === item.uuid" <Button size="small" @click="openRoleMembersModal(item)">
@click="deleteRole(item)" View Members
> </Button>
Delete <Button
</Button> danger
</Space> 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>
</List.Item> </List.Item>
</template> </template>
</List> </List>
@ -511,25 +853,53 @@ onMounted(async () => {
</Typography.Paragraph> </Typography.Paragraph>
</div> </div>
</Tabs.TabPane> </Tabs.TabPane>
<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>
</Tabs> </Tabs>
</Card> </Card>
</Spin> </Spin>
<Modal <Modal
v-model:open="roleModalVisible" v-model:open="roleModalVisible"
title="Create Role" :title="roleWizardStep === 0 ? 'Create Role' : 'Upload Training Files'"
ok-text="Create" :ok-text="roleWizardStep === 0 ? 'Next' : 'Finish'"
cancel-text="Cancel" cancel-text="Cancel"
:ok-button-props="{ loading: creatingRole }" :ok-button-props="{ loading: roleWizardStep === 0 ? creatingRoleWizard : false }"
@ok="createRole" @ok="handleRoleWizardOk"
@cancel="resetRoleForm" @cancel="closeRoleWizard"
> >
<div style="display: flex; flex-direction: column; gap: 0.75rem"> <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">
<Input <Input
v-model:value="createRoleForm.name" v-model:value="createRoleForm.name"
placeholder="Role name" placeholder="Role name"
:maxlength="100" :maxlength="100"
@pressEnter="createRole" @pressEnter="handleRoleWizardOk"
/> />
<Input.TextArea <Input.TextArea
v-model:value="createRoleForm.description" v-model:value="createRoleForm.description"
@ -538,6 +908,116 @@ onMounted(async () => {
:maxlength="1000" :maxlength="1000"
/> />
</div> </div>
<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>
. You can also do this later.
</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"
>
<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"
:ok-button-props="{ loading: uploadingFile, disabled: !uploadRoleUuid || !uploadSelectedFile }"
@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>
<Typography.Text strong>Role</Typography.Text>
<Select
v-model:value="uploadRoleUuid"
placeholder="Select a role"
style="width: 100%"
:options="Roles.map((role) => ({ label: role.name, value: role.uuid }))"
/>
</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"
>
<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>
</Modal> </Modal>
<Modal <Modal
@ -613,6 +1093,27 @@ onMounted(async () => {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.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;
}
.white-tag { .white-tag {
background-color: #ffffff !important; background-color: #ffffff !important;
color: #000000 !important; color: #000000 !important;

View file

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed, h, watch } from 'vue' import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useDocumentVisibility, useEventListener, useIntervalFn } from '@vueuse/core'
import { import {
Card, Card,
Typography, Typography,
@ -11,15 +12,11 @@ import {
message, message,
Tag, Tag,
Divider, Divider,
Upload,
Modal, Modal,
Table,
Select,
} from 'ant-design-vue' } from 'ant-design-vue'
import { apiClient, isAxiosError, API } from '../router/api' import { apiClient, isAxiosError, API } from '../router/api'
import { useUserStore } from '../stores/userStore' import { useUserStore } from '../stores/userStore'
import { InboxOutlined, DeleteOutlined } from '@ant-design/icons-vue' import type { Role, Organization } from '../types/organization'
import type { Role, Organization, TrainingFile } from '../types/organization'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@ -28,12 +25,13 @@ const organizationUuid = computed(() => String(route.params.organizationUuid ||
const organization = ref<Organization | null>(null) const organization = ref<Organization | null>(null)
const roles = ref<Role[]>([]) const roles = ref<Role[]>([])
const members = ref<Array<{ uuid: string; is_manager?: boolean }>>([]) const members = ref<Array<{ uuid: string; is_manager?: boolean }>>([])
const trainingFiles = ref<TrainingFile[]>([])
const loading = ref(false) const loading = ref(false)
const uploading = ref(false)
const leavingOrganization = ref(false) const leavingOrganization = ref(false)
const showUploadModal = ref(false) const rolePollingMs = 12000
const auth = useUserStore() const auth = useUserStore()
const visibility = useDocumentVisibility()
const pollingInFlight = ref(false)
let stopVisibilityListener: (() => void) | null = null
const isManager = computed(() => { const isManager = computed(() => {
if (!auth.user || !organization.value) return false if (!auth.user || !organization.value) return false
@ -154,12 +152,20 @@ const selectRole = async (roleUuid: string) => {
try { try {
await apiClient.post(API.roles.join(organization.value.uuid, roleUuid)) await apiClient.post(API.roles.join(organization.value.uuid, roleUuid))
message.success('Successfully joined role') message.success('Successfully joined role')
const joinedRole = roles.value.find((role) => role.uuid === roleUuid)
if (joinedRole) {
joinedRole.member_count = (joinedRole.member_count ?? 0) + 1
}
if (!auth.userJoinedRoles.some((role) => role.uuid === roleUuid)) { if (!auth.userJoinedRoles.some((role) => role.uuid === roleUuid)) {
auth.setJoinedRoles([ auth.setJoinedRoles([
...auth.userJoinedRoles, ...auth.userJoinedRoles,
roles.value.find((role) => role.uuid === roleUuid)!, roles.value.find((role) => role.uuid === roleUuid)!,
]) ])
} }
await fetchRoles()
} catch (error) { } catch (error) {
console.error('Failed to join role:', error) console.error('Failed to join role:', error)
if (isAxiosError(error)) { if (isAxiosError(error)) {
@ -168,231 +174,59 @@ const selectRole = async (roleUuid: string) => {
} }
} }
const fetchTrainingFiles = async () => {
if (!organization.value?.uuid) return
try {
const response = await apiClient.get<TrainingFile[]>(API.knowledge.trainingFiles.list(), {
params: { organization_uuid: organization.value.uuid },
})
trainingFiles.value = response.data
} catch (error) {
console.error('Failed to fetch training files:', error)
}
}
const beforeUpload = (file: File) => {
const allowedExtensions = ['txt', 'pdf', 'md', 'csv', 'json', 'docx', 'doc']
const fileExtension = file.name.split('.').pop()?.toLowerCase()
if (!fileExtension || !allowedExtensions.includes(fileExtension)) {
message.error(
`File type ".${fileExtension}" is not allowed. Allowed types: ${allowedExtensions.join(', ')}`,
)
return false
}
const maxSize = 50 * 1024 * 1024
if (file.size > maxSize) {
message.error(
`File size must not exceed 50MB. Current size: ${(file.size / 1024 / 1024).toFixed(2)}MB`,
)
return false
}
return false
}
const selectedFile = ref<File | null>(null)
const fileDescription = ref('')
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) => {
selectedFile.value = file
}
const handleFileUploadClick = async () => {
if (!selectedFile.value) {
message.error('Please select a file to upload')
return
}
if (!selectedRoleUuid.value) {
message.error('Please select a role for this training file')
return
}
await handleFileUpload(selectedFile.value, fileDescription.value)
selectedFile.value = null
fileDescription.value = ''
selectedRoleUuid.value = ''
}
const handleFileUpload = async (file: File, description: string = '') => {
if (!organization.value?.uuid) {
message.error('Organization not loaded')
return
}
uploading.value = true
try {
const formData = new FormData()
formData.append('file', file)
formData.append('file_name', file.name)
formData.append('description', description)
if (selectedRoleUuid.value) {
formData.append('role_uuid', selectedRoleUuid.value)
}
const response = await apiClient.post<TrainingFile>(
API.knowledge.trainingFiles.list(),
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
},
)
message.success(`File "${file.name}" uploaded successfully`)
trainingFiles.value.unshift(response.data)
showUploadModal.value = false
} catch (error) {
console.error('Failed to upload file:', 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')
}
} finally {
uploading.value = false
}
}
const deleteFile = async (uuid: string, fileName: string) => {
if (!organization.value?.uuid) {
message.error('Organization not loaded')
return
}
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((f) => f.uuid !== uuid)
} catch (error) {
console.error('Failed to delete file:', error)
message.error('Failed to delete file')
}
},
})
}
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',
dataIndex: 'file_name',
key: 'file_name',
},
{
title: 'Uploaded By',
key: 'uploaded_by',
customRender: ({ record }: { record: TrainingFile }) => {
if (!record.uploaded_by) return '-'
const full_name = `${record.uploaded_by.first_name} ${record.uploaded_by.last_name}`
return full_name
},
},
{
title: 'Size',
dataIndex: 'file_size',
key: 'file_size',
customRender: ({ value }: { value: number }) => formatFileSize(value || 0),
},
{
title: 'Role',
key: 'role',
customRender: ({ record }: { record: TrainingFile }) => record.role?.name || '-',
},
{
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 }) => {
if (isManager.value || auth.user?.uuid === record.uploaded_by?.uuid) {
return h(
Button,
{
danger: true,
size: 'small',
icon: h(DeleteOutlined),
onClick: () => deleteFile(record.uuid, record.file_name),
},
() => 'Delete',
)
}
return null
},
},
]
const loadOrganizationContext = async () => { const loadOrganizationContext = async () => {
await auth.fetchSession(true) await auth.fetchSession(true)
await fetchOrganization() await fetchOrganization()
await fetchMembers() await fetchMembers()
await fetchRoles() await fetchRoles()
await fetchUserRoleMemberships() await fetchUserRoleMemberships()
await fetchTrainingFiles() }
const canPollRoles = () => Boolean(organization.value?.uuid)
const refreshRolesLive = async () => {
if (pollingInFlight.value || !canPollRoles()) return
pollingInFlight.value = true
try {
await Promise.all([fetchRoles(), fetchUserRoleMemberships()])
} finally {
pollingInFlight.value = false
}
}
const { pause: stopRolePolling, resume: resumeRolePolling } = useIntervalFn(
() => {
void refreshRolesLive()
},
rolePollingMs,
{ immediate: false, immediateCallback: false },
)
const startRolePolling = () => {
stopRolePolling()
if (visibility.value !== 'visible' || !canPollRoles()) return
resumeRolePolling()
}
const bindVisibility = () => {
if (stopVisibilityListener) return
stopVisibilityListener = useEventListener(document, 'visibilitychange', () => {
if (visibility.value !== 'visible') {
stopRolePolling()
return
}
void refreshRolesLive()
if (canPollRoles()) {
resumeRolePolling()
}
})
}
const unbindVisibility = () => {
if (!stopVisibilityListener) return
stopVisibilityListener()
stopVisibilityListener = null
} }
const leaveOrganization = () => { const leaveOrganization = () => {
@ -436,16 +270,24 @@ const leaveOrganization = () => {
onMounted(async () => { onMounted(async () => {
await loadOrganizationContext() await loadOrganizationContext()
bindVisibility()
startRolePolling()
})
onBeforeUnmount(() => {
stopRolePolling()
unbindVisibility()
}) })
watch( watch(
() => organizationUuid.value, () => organizationUuid.value,
async (next, prev) => { async (next, prev) => {
if (!next || next === prev) return if (!next || next === prev) return
stopRolePolling()
roles.value = [] roles.value = []
members.value = [] members.value = []
trainingFiles.value = []
await loadOrganizationContext() await loadOrganizationContext()
startRolePolling()
}, },
) )
</script> </script>
@ -504,41 +346,6 @@ watch(
<Divider /> <Divider />
<Typography.Title :level="4" class="section-title">Training Files</Typography.Title>
<div style="margin-bottom: 1rem">
<Button
type="primary"
:disabled="!isManager"
@click="handleOpenUploadModal"
style="margin-bottom: 1rem"
>
Upload Training File
</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 v-if="trainingFiles.length > 0">
<Table
:columns="trainingFileColumns"
:data-source="trainingFiles"
:pagination="{ pageSize: 10 }"
:row-key="(record: TrainingFile) => record.uuid"
size="small"
/>
</div>
<Typography.Paragraph v-else type="secondary">
No training files uploaded yet.
</Typography.Paragraph>
<Divider />
<Typography.Title :level="4" class="section-title"> <Typography.Title :level="4" class="section-title">
Available Roles Available Roles
</Typography.Title> </Typography.Title>
@ -589,53 +396,6 @@ watch(
</Card> </Card>
</Spin> </Spin>
<Modal
v-model:open="showUploadModal"
title="Upload Training File"
width="600px"
ok-text="Upload"
cancel-text="Cancel"
:ok-button-props="{ loading: uploading, disabled: !selectedFile || !selectedRoleUuid }"
@ok="handleFileUploadClick"
@cancel="showUploadModal = false"
>
<div style="display: flex; flex-direction: column; gap: 1rem">
<Typography.Text>
Supported formats:
<strong>txt, pdf, md, csv, json, docx, doc</strong>
(Max 50MB)
</Typography.Text>
<div>
<Typography.Text strong>Role</Typography.Text>
<Select
v-model:value="selectedRoleUuid"
placeholder="Select a role"
style="width: 100%"
:options="roles.map((role) => ({ label: role.name, value: role.uuid }))"
/>
</div>
<Upload.Dragger
accept=".txt,.pdf,.md,.csv,.json,.docx,.doc"
:before-upload="
(file) => {
beforeUpload(file)
handleFileSelected(file)
return false
}
"
:multiple="false"
:auto-upload="false"
>
<p class="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p class="ant-upload-text">Click or drag file to this area to upload</p>
<p class="ant-upload-hint">
{{ selectedFile ? selectedFile.name : 'Single file upload' }}
</p>
</Upload.Dragger>
</div>
</Modal>
</div> </div>
</template> </template>