Added separate training file area and polling to refresh page
This commit is contained in:
parent
b8bef6a711
commit
6ccb7822c9
4 changed files with 672 additions and 350 deletions
60
site/package-lock.json
generated
60
site/package-lock.json
generated
|
|
@ -8,6 +8,7 @@
|
|||
"name": "dynavera",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"axios": "^1.13.2",
|
||||
"dompurify": "^3.3.1",
|
||||
|
|
@ -100,6 +101,7 @@
|
|||
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/generator": "^7.28.6",
|
||||
|
|
@ -1745,6 +1747,7 @@
|
|||
"integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
|
|
@ -1756,6 +1759,12 @@
|
|||
"devOptional": true,
|
||||
"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": {
|
||||
"version": "8.53.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.0.tgz",
|
||||
|
|
@ -1801,6 +1810,7 @@
|
|||
"integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "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": {
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -2666,6 +2715,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
|
|
@ -3215,6 +3265,7 @@
|
|||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
|
|
@ -3275,6 +3326,7 @@
|
|||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
|
|
@ -3322,6 +3374,7 @@
|
|||
"integrity": "sha512-nA5yUs/B1KmKzvC42fyD0+l9Yd+LtEpVhWRbXuDj0e+ZURcTtyRbMDWUeJmTAh2wC6jC83raS63anNM2YT3NPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.4.0",
|
||||
"natural-compare": "^1.4.0",
|
||||
|
|
@ -4018,6 +4071,7 @@
|
|||
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
|
|
@ -4696,6 +4750,7 @@
|
|||
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
|
|
@ -5143,6 +5198,7 @@
|
|||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -5222,6 +5278,7 @@
|
|||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
@ -5345,6 +5402,7 @@
|
|||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
|
|
@ -5635,6 +5693,7 @@
|
|||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -5654,6 +5713,7 @@
|
|||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
|
||||
"integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.26",
|
||||
"@vue/compiler-sfc": "3.5.26",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
"format": "prettier --write --experimental-cli src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"axios": "^1.13.2",
|
||||
"dompurify": "^3.3.1",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, onMounted, computed, h } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
Card,
|
||||
|
|
@ -14,6 +14,10 @@ import {
|
|||
Modal,
|
||||
Tabs,
|
||||
InputNumber,
|
||||
Select,
|
||||
Upload,
|
||||
Steps,
|
||||
Table,
|
||||
} from 'ant-design-vue'
|
||||
import { apiClient, isAxiosError, API } from '../router/api'
|
||||
import { useUserStore } from '../stores/userStore'
|
||||
|
|
@ -21,6 +25,8 @@ import type { Organization } from '../types/organization'
|
|||
import type { User } from '../types/user'
|
||||
import type { InviteToken } 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 router = useRouter()
|
||||
|
|
@ -32,15 +38,27 @@ const members = ref<User[]>([])
|
|||
const invites = ref<InviteToken[]>([])
|
||||
const newInviteMaxUses = ref<number>(1)
|
||||
const Roles = ref<Role[]>([])
|
||||
const trainingFiles = ref<TrainingFile[]>([])
|
||||
const memberSearch = ref('')
|
||||
const roleSearch = ref('')
|
||||
const loading = ref(false)
|
||||
const creatingRole = ref(false)
|
||||
const deletingRoleUuid = ref<string | null>(null)
|
||||
const roleModalVisible = ref(false)
|
||||
const roleMembersModalVisible = ref(false)
|
||||
const selectedRoleForMembers = ref<Role | null>(null)
|
||||
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({
|
||||
name: '',
|
||||
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: '' }
|
||||
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) =>
|
||||
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 description = createRoleForm.value.description.trim()
|
||||
|
||||
if (!name) {
|
||||
message.error('Role name is required')
|
||||
return
|
||||
return null
|
||||
}
|
||||
|
||||
if (hasDuplicateRoleName(name)) {
|
||||
message.error('A role with this name already exists')
|
||||
return
|
||||
return null
|
||||
}
|
||||
|
||||
creatingRole.value = true
|
||||
creatingRoleWizard.value = true
|
||||
try {
|
||||
await apiClient.post(API.roles.list(organizationUuid), { name, description })
|
||||
message.success('Role created successfully')
|
||||
roleModalVisible.value = false
|
||||
resetRoleForm()
|
||||
const response = await apiClient.post<Role>(API.roles.list(organizationUuid), { name, description })
|
||||
message.success('Role created successfully. You can upload training files now.')
|
||||
await fetchRoles()
|
||||
|
||||
if (response.data?.uuid) {
|
||||
return response.data
|
||||
}
|
||||
|
||||
return Roles.value.find((role) => role.name.trim().toLowerCase() === name.toLowerCase()) || null
|
||||
} catch (error) {
|
||||
console.error('Failed to create role:', error)
|
||||
console.error('Failed to create role in wizard:', error)
|
||||
if (isAxiosError(error)) {
|
||||
message.error(error.response?.data?.error || 'Failed to create role')
|
||||
} else {
|
||||
message.error('Failed to create role')
|
||||
}
|
||||
return null
|
||||
} 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 fetchInvites()
|
||||
await fetchRoles()
|
||||
await fetchTrainingFiles()
|
||||
|
||||
const currentUserUuid = auth.user?.uuid
|
||||
const isOwner = organization.value?.owner?.uuid === currentUserUuid
|
||||
|
|
@ -472,7 +781,10 @@ onMounted(async () => {
|
|||
placeholder="Search roles by name or description"
|
||||
style="width: 300px"
|
||||
/>
|
||||
<Button type="primary" @click="roleModalVisible = true">
|
||||
<Button @click="openUploadModal()">
|
||||
Upload Training File
|
||||
</Button>
|
||||
<Button type="primary" @click="openRoleWizard">
|
||||
Create Role
|
||||
</Button>
|
||||
</Space>
|
||||
|
|
@ -485,12 +797,18 @@ onMounted(async () => {
|
|||
>
|
||||
<template #renderItem="{ item }">
|
||||
<List.Item class="Role-item">
|
||||
<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>
|
||||
|
|
@ -503,6 +821,30 @@ onMounted(async () => {
|
|||
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>
|
||||
</template>
|
||||
</List>
|
||||
|
|
@ -511,25 +853,53 @@ onMounted(async () => {
|
|||
</Typography.Paragraph>
|
||||
</div>
|
||||
</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>
|
||||
</Card>
|
||||
</Spin>
|
||||
|
||||
<Modal
|
||||
v-model:open="roleModalVisible"
|
||||
title="Create Role"
|
||||
ok-text="Create"
|
||||
:title="roleWizardStep === 0 ? 'Create Role' : 'Upload Training Files'"
|
||||
:ok-text="roleWizardStep === 0 ? 'Next' : 'Finish'"
|
||||
cancel-text="Cancel"
|
||||
:ok-button-props="{ loading: creatingRole }"
|
||||
@ok="createRole"
|
||||
@cancel="resetRoleForm"
|
||||
:ok-button-props="{ loading: roleWizardStep === 0 ? creatingRoleWizard : false }"
|
||||
@ok="handleRoleWizardOk"
|
||||
@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
|
||||
v-model:value="createRoleForm.name"
|
||||
placeholder="Role name"
|
||||
:maxlength="100"
|
||||
@pressEnter="createRole"
|
||||
@pressEnter="handleRoleWizardOk"
|
||||
/>
|
||||
<Input.TextArea
|
||||
v-model:value="createRoleForm.description"
|
||||
|
|
@ -538,6 +908,116 @@ onMounted(async () => {
|
|||
:maxlength="1000"
|
||||
/>
|
||||
</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
|
||||
|
|
@ -613,6 +1093,27 @@ onMounted(async () => {
|
|||
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 {
|
||||
background-color: #ffffff !important;
|
||||
color: #000000 !important;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<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 { useDocumentVisibility, useEventListener, useIntervalFn } from '@vueuse/core'
|
||||
import {
|
||||
Card,
|
||||
Typography,
|
||||
|
|
@ -11,15 +12,11 @@ import {
|
|||
message,
|
||||
Tag,
|
||||
Divider,
|
||||
Upload,
|
||||
Modal,
|
||||
Table,
|
||||
Select,
|
||||
} from 'ant-design-vue'
|
||||
import { apiClient, isAxiosError, API } from '../router/api'
|
||||
import { useUserStore } from '../stores/userStore'
|
||||
import { InboxOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
||||
import type { Role, Organization, TrainingFile } from '../types/organization'
|
||||
import type { Role, Organization } from '../types/organization'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
|
@ -28,12 +25,13 @@ const organizationUuid = computed(() => String(route.params.organizationUuid ||
|
|||
const organization = ref<Organization | null>(null)
|
||||
const roles = ref<Role[]>([])
|
||||
const members = ref<Array<{ uuid: string; is_manager?: boolean }>>([])
|
||||
const trainingFiles = ref<TrainingFile[]>([])
|
||||
const loading = ref(false)
|
||||
const uploading = ref(false)
|
||||
const leavingOrganization = ref(false)
|
||||
const showUploadModal = ref(false)
|
||||
const rolePollingMs = 12000
|
||||
const auth = useUserStore()
|
||||
const visibility = useDocumentVisibility()
|
||||
const pollingInFlight = ref(false)
|
||||
let stopVisibilityListener: (() => void) | null = null
|
||||
|
||||
const isManager = computed(() => {
|
||||
if (!auth.user || !organization.value) return false
|
||||
|
|
@ -154,12 +152,20 @@ const selectRole = async (roleUuid: string) => {
|
|||
try {
|
||||
await apiClient.post(API.roles.join(organization.value.uuid, roleUuid))
|
||||
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)) {
|
||||
auth.setJoinedRoles([
|
||||
...auth.userJoinedRoles,
|
||||
roles.value.find((role) => role.uuid === roleUuid)!,
|
||||
])
|
||||
}
|
||||
|
||||
await fetchRoles()
|
||||
} catch (error) {
|
||||
console.error('Failed to join role:', 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 () => {
|
||||
await auth.fetchSession(true)
|
||||
await fetchOrganization()
|
||||
await fetchMembers()
|
||||
await fetchRoles()
|
||||
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 = () => {
|
||||
|
|
@ -436,16 +270,24 @@ const leaveOrganization = () => {
|
|||
|
||||
onMounted(async () => {
|
||||
await loadOrganizationContext()
|
||||
bindVisibility()
|
||||
startRolePolling()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopRolePolling()
|
||||
unbindVisibility()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => organizationUuid.value,
|
||||
async (next, prev) => {
|
||||
if (!next || next === prev) return
|
||||
stopRolePolling()
|
||||
roles.value = []
|
||||
members.value = []
|
||||
trainingFiles.value = []
|
||||
await loadOrganizationContext()
|
||||
startRolePolling()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
|
@ -504,41 +346,6 @@ watch(
|
|||
|
||||
<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">
|
||||
Available Roles
|
||||
</Typography.Title>
|
||||
|
|
@ -589,53 +396,6 @@ watch(
|
|||
</Card>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue