2026-02-26 01:32:04 +00:00
|
|
|
<script setup lang="ts">
|
2026-03-08 13:20:15 +00:00
|
|
|
import { ref, onMounted, computed, h, watch } from 'vue'
|
2026-02-26 01:32:04 +00:00
|
|
|
import { useRouter, useRoute } from 'vue-router'
|
|
|
|
|
import {
|
|
|
|
|
Card,
|
|
|
|
|
Typography,
|
|
|
|
|
Button,
|
|
|
|
|
List,
|
|
|
|
|
Space,
|
|
|
|
|
Spin,
|
|
|
|
|
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'
|
|
|
|
|
|
|
|
|
|
const router = useRouter()
|
|
|
|
|
const route = useRoute()
|
2026-03-08 13:20:15 +00:00
|
|
|
const organizationUuid = computed(() => String(route.params.organizationUuid || ''))
|
2026-02-26 01:32:04 +00:00
|
|
|
|
|
|
|
|
const organization = ref<Organization | null>(null)
|
|
|
|
|
const roles = ref<Role[]>([])
|
2026-02-27 12:53:19 +00:00
|
|
|
const members = ref<Array<{ uuid: string; is_manager?: boolean }>>([])
|
2026-02-26 01:32:04 +00:00
|
|
|
const trainingFiles = ref<TrainingFile[]>([])
|
|
|
|
|
const loading = ref(false)
|
|
|
|
|
const uploading = ref(false)
|
2026-03-08 13:20:15 +00:00
|
|
|
const leavingOrganization = ref(false)
|
2026-02-26 01:32:04 +00:00
|
|
|
const showUploadModal = ref(false)
|
|
|
|
|
const auth = useUserStore()
|
|
|
|
|
|
|
|
|
|
const isManager = computed(() => {
|
|
|
|
|
if (!auth.user || !organization.value) return false
|
|
|
|
|
if ((organization.value as Organization & { is_manager?: boolean }).is_manager === true)
|
|
|
|
|
return true
|
2026-02-27 12:53:19 +00:00
|
|
|
if (organization.value.owner?.uuid === auth.user.uuid) return true
|
|
|
|
|
return members.value.some((member) => member.uuid === auth.user?.uuid && member.is_manager)
|
2026-02-26 01:32:04 +00:00
|
|
|
})
|
|
|
|
|
|
2026-03-08 13:20:15 +00:00
|
|
|
const isOwner = computed(() => {
|
|
|
|
|
if (!auth.user || !organization.value) return false
|
|
|
|
|
return organization.value.owner?.uuid === auth.user.uuid
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const canLeaveCurrentOrganization = computed(() => {
|
|
|
|
|
if (!organization.value) return false
|
|
|
|
|
if (!isOwner.value) return true
|
|
|
|
|
return (organization.value.member_count ?? 0) <= 1
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-26 01:32:04 +00:00
|
|
|
const fetchOrganization = async () => {
|
|
|
|
|
loading.value = true
|
|
|
|
|
try {
|
2026-03-08 13:20:15 +00:00
|
|
|
if (!organizationUuid.value) {
|
|
|
|
|
organization.value = null
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
const response = await apiClient.get<Organization>(API.organization.byId(organizationUuid.value))
|
2026-02-26 01:32:04 +00:00
|
|
|
organization.value = response.data
|
2026-03-08 13:20:15 +00:00
|
|
|
auth.setSelectedOrganization(response.data)
|
2026-02-26 01:32:04 +00:00
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to fetch organization:', error)
|
|
|
|
|
message.error('Failed to load organization details')
|
|
|
|
|
} finally {
|
|
|
|
|
loading.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fetchRoles = async () => {
|
|
|
|
|
if (!organization.value?.uuid) return
|
|
|
|
|
try {
|
2026-03-08 13:20:15 +00:00
|
|
|
const response = await apiClient.get<Role[]>(API.roles.list(organization.value.uuid))
|
2026-02-26 01:32:04 +00:00
|
|
|
roles.value = response.data
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to fetch roles:', error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fetchUserRoleMemberships = async () => {
|
|
|
|
|
if (!organization.value?.uuid) return
|
|
|
|
|
try {
|
2026-03-08 13:20:15 +00:00
|
|
|
const response = await apiClient.get<Role[]>(API.roles.mine())
|
2026-02-26 01:32:04 +00:00
|
|
|
const mine = Array.isArray(response.data) ? response.data : []
|
|
|
|
|
const orgUuid = organization.value.uuid
|
|
|
|
|
const joinedRoles = mine.filter((role) => role.organization?.uuid === orgUuid)
|
|
|
|
|
auth.setJoinedRoles(joinedRoles)
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Failed to fetch user role memberships', err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const isRoleJoined = (roleUuid: string | undefined) => {
|
|
|
|
|
if (!roleUuid) return false
|
|
|
|
|
return auth.userJoinedRoles.some((role) => role.uuid === roleUuid)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 13:20:15 +00:00
|
|
|
const canStartOnboarding = (roleUuid: string | undefined) => {
|
|
|
|
|
if (!roleUuid) return false
|
|
|
|
|
if (isManager.value) return true
|
|
|
|
|
return isRoleJoined(roleUuid)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const startOnboarding = (roleUuid: string | undefined) => {
|
|
|
|
|
if (!roleUuid) return
|
|
|
|
|
|
|
|
|
|
if (!canStartOnboarding(roleUuid)) {
|
|
|
|
|
message.warning('Join this role before starting onboarding.')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
router.push(`/onboarding/${roleUuid}`)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 01:32:04 +00:00
|
|
|
const fetchMembers = async () => {
|
|
|
|
|
if (!organization.value?.uuid) return
|
|
|
|
|
try {
|
2026-02-27 12:53:19 +00:00
|
|
|
const response = await apiClient.get<Array<{ uuid: string; is_manager?: boolean }>>(
|
2026-02-27 12:26:51 +00:00
|
|
|
API.organization.members.list(organization.value.uuid),
|
2026-02-26 01:32:04 +00:00
|
|
|
)
|
|
|
|
|
members.value = response.data
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to fetch members:', error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const selectRole = async (roleUuid: string) => {
|
|
|
|
|
if (!organization.value?.uuid) {
|
|
|
|
|
message.error('Organization not loaded')
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-02-27 14:27:30 +00:00
|
|
|
if (isManager.value) {
|
|
|
|
|
message.error('Managers cannot join roles from this page')
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-02-27 12:53:19 +00:00
|
|
|
if (!auth.user?.uuid) {
|
2026-02-26 01:32:04 +00:00
|
|
|
try {
|
|
|
|
|
await auth.fetchSession(true)
|
|
|
|
|
} catch {
|
|
|
|
|
message.error('You must be signed in to join a role')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (auth.userJoinedRoles.some((role) => role.uuid === roleUuid)) {
|
|
|
|
|
message.info('You are already a member of this role')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2026-03-08 13:20:15 +00:00
|
|
|
await apiClient.post(API.roles.join(organization.value.uuid, roleUuid))
|
2026-02-26 01:32:04 +00:00
|
|
|
message.success('Successfully joined role')
|
|
|
|
|
if (!auth.userJoinedRoles.some((role) => role.uuid === roleUuid)) {
|
|
|
|
|
auth.setJoinedRoles([
|
|
|
|
|
...auth.userJoinedRoles,
|
|
|
|
|
roles.value.find((role) => role.uuid === roleUuid)!,
|
|
|
|
|
])
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to join role:', error)
|
|
|
|
|
if (isAxiosError(error)) {
|
|
|
|
|
message.error(error.response?.data?.error || 'Failed to join role')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fetchTrainingFiles = async () => {
|
|
|
|
|
if (!organization.value?.uuid) return
|
|
|
|
|
try {
|
2026-02-27 13:58:00 +00:00
|
|
|
const response = await apiClient.get<TrainingFile[]>(API.knowledge.trainingFiles.list(), {
|
2026-03-08 13:20:15 +00:00
|
|
|
params: { organization_uuid: organization.value.uuid },
|
2026-02-27 13:58:00 +00:00
|
|
|
})
|
2026-02-26 01:32:04 +00:00
|
|
|
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>('')
|
|
|
|
|
|
2026-02-27 13:58:00 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 01:32:04 +00:00
|
|
|
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) {
|
2026-03-08 13:20:15 +00:00
|
|
|
formData.append('role_uuid', selectedRoleUuid.value)
|
2026-02-26 01:32:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const response = await apiClient.post<TrainingFile>(
|
2026-02-27 12:26:51 +00:00
|
|
|
API.knowledge.trainingFiles.list(),
|
2026-02-26 01:32:04 +00:00
|
|
|
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 {
|
2026-02-27 12:26:51 +00:00
|
|
|
await apiClient.delete(API.knowledge.trainingFiles.byId(uuid))
|
2026-02-26 01:32:04 +00:00
|
|
|
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 }) => {
|
2026-02-27 12:53:19 +00:00
|
|
|
if (isManager.value || auth.user?.uuid === record.uploaded_by?.uuid) {
|
2026-02-26 01:32:04 +00:00
|
|
|
return h(
|
|
|
|
|
Button,
|
|
|
|
|
{
|
|
|
|
|
danger: true,
|
|
|
|
|
size: 'small',
|
|
|
|
|
icon: h(DeleteOutlined),
|
|
|
|
|
onClick: () => deleteFile(record.uuid, record.file_name),
|
|
|
|
|
},
|
|
|
|
|
() => 'Delete',
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
return null
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
2026-03-08 13:20:15 +00:00
|
|
|
const loadOrganizationContext = async () => {
|
2026-02-26 01:32:04 +00:00
|
|
|
await auth.fetchSession(true)
|
|
|
|
|
await fetchOrganization()
|
|
|
|
|
await fetchMembers()
|
|
|
|
|
await fetchRoles()
|
|
|
|
|
await fetchUserRoleMemberships()
|
|
|
|
|
await fetchTrainingFiles()
|
2026-03-08 13:20:15 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const leaveOrganization = () => {
|
|
|
|
|
if (!organization.value) return
|
|
|
|
|
|
|
|
|
|
Modal.confirm({
|
|
|
|
|
title: 'Leave organization',
|
|
|
|
|
content: isOwner.value
|
|
|
|
|
? 'As owner, leaving will delete this organization because no other members remain. Continue?'
|
|
|
|
|
: `Are you sure you want to leave "${organization.value.name}"?`,
|
|
|
|
|
okText: 'Leave',
|
|
|
|
|
okType: 'danger',
|
|
|
|
|
cancelText: 'Cancel',
|
|
|
|
|
onOk: async () => {
|
|
|
|
|
if (!organization.value) return
|
|
|
|
|
|
|
|
|
|
leavingOrganization.value = true
|
|
|
|
|
try {
|
|
|
|
|
await apiClient.post(API.organization.leave(organization.value.uuid))
|
|
|
|
|
message.success(
|
|
|
|
|
isOwner.value
|
|
|
|
|
? 'Organization deleted and ownership closed.'
|
|
|
|
|
: 'You left the organization.',
|
|
|
|
|
)
|
|
|
|
|
auth.setJoinedRoles([])
|
|
|
|
|
await auth.fetchJoinedOrganizations()
|
|
|
|
|
await router.push('/organization')
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to leave organization:', error)
|
|
|
|
|
if (isAxiosError(error)) {
|
|
|
|
|
message.error(error.response?.data?.error || 'Failed to leave organization')
|
|
|
|
|
} else {
|
|
|
|
|
message.error('Failed to leave organization')
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
leavingOrganization.value = false
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onMounted(async () => {
|
|
|
|
|
await loadOrganizationContext()
|
2026-02-26 01:32:04 +00:00
|
|
|
})
|
2026-03-08 13:20:15 +00:00
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
() => organizationUuid.value,
|
|
|
|
|
async (next, prev) => {
|
|
|
|
|
if (!next || next === prev) return
|
|
|
|
|
roles.value = []
|
|
|
|
|
members.value = []
|
|
|
|
|
trainingFiles.value = []
|
|
|
|
|
await loadOrganizationContext()
|
|
|
|
|
},
|
|
|
|
|
)
|
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">{{ organization.name }}</Typography.Title>
|
2026-03-08 13:20:15 +00:00
|
|
|
<Space>
|
|
|
|
|
<Button
|
|
|
|
|
v-if="isManager"
|
|
|
|
|
type="primary"
|
|
|
|
|
@click="router.push(`/organization/${organization.uuid}/manage`)"
|
|
|
|
|
>
|
|
|
|
|
Manage Organization
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
danger
|
|
|
|
|
:loading="leavingOrganization"
|
|
|
|
|
:disabled="!canLeaveCurrentOrganization"
|
|
|
|
|
:title="
|
|
|
|
|
!canLeaveCurrentOrganization
|
|
|
|
|
? 'Owner can leave only when no other members/managers remain.'
|
|
|
|
|
: 'Leave organization'
|
|
|
|
|
"
|
|
|
|
|
@click="leaveOrganization"
|
|
|
|
|
>
|
|
|
|
|
Leave Organization
|
|
|
|
|
</Button>
|
|
|
|
|
</Space>
|
2026-02-26 01:32:04 +00:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Typography.Paragraph v-if="organization.description">
|
|
|
|
|
{{ organization.description }}
|
|
|
|
|
</Typography.Paragraph>
|
|
|
|
|
<Typography.Paragraph v-else type="secondary">
|
|
|
|
|
No description provided
|
|
|
|
|
</Typography.Paragraph>
|
|
|
|
|
|
|
|
|
|
<Space direction="vertical" :size="4" style="margin: 1rem 0">
|
|
|
|
|
<div>
|
|
|
|
|
<Typography.Text strong>Owner:</Typography.Text>
|
|
|
|
|
{{ organization.owner?.first_name }} {{ organization.owner?.last_name }}
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<Typography.Text strong>Members:</Typography.Text>
|
|
|
|
|
{{ organization.member_count ?? 0 }}
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<Typography.Text strong>Roles:</Typography.Text>
|
|
|
|
|
{{ organization.role_count ?? 0 }}
|
|
|
|
|
</div>
|
|
|
|
|
</Space>
|
|
|
|
|
|
|
|
|
|
<Divider />
|
|
|
|
|
|
|
|
|
|
<Typography.Title :level="4" class="section-title">Training Files</Typography.Title>
|
|
|
|
|
|
|
|
|
|
<div style="margin-bottom: 1rem">
|
|
|
|
|
<Button
|
|
|
|
|
type="primary"
|
2026-02-27 13:58:00 +00:00
|
|
|
:disabled="!isManager"
|
|
|
|
|
@click="handleOpenUploadModal"
|
2026-02-26 01:32:04 +00:00
|
|
|
style="margin-bottom: 1rem"
|
|
|
|
|
>
|
|
|
|
|
Upload Training File
|
|
|
|
|
</Button>
|
2026-02-27 13:58:00 +00:00
|
|
|
<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>
|
2026-02-26 01:32:04 +00:00
|
|
|
</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>
|
|
|
|
|
|
|
|
|
|
<div v-if="roles.length > 0">
|
|
|
|
|
<List :data-source="roles" :bordered="false">
|
|
|
|
|
<template #renderItem="{ item }">
|
|
|
|
|
<List.Item class="role-item">
|
|
|
|
|
<List.Item.Meta :title="item.name" />
|
|
|
|
|
<Space>
|
|
|
|
|
<Tag color="cyan">{{ item.member_count ?? 0 }} members</Tag>
|
|
|
|
|
<Button
|
|
|
|
|
type="default"
|
|
|
|
|
size="small"
|
2026-03-08 13:20:15 +00:00
|
|
|
:disabled="!canStartOnboarding(item.uuid)"
|
|
|
|
|
:title="
|
|
|
|
|
!canStartOnboarding(item.uuid)
|
|
|
|
|
? 'Join this role before starting onboarding.'
|
|
|
|
|
: 'Start onboarding'
|
|
|
|
|
"
|
|
|
|
|
@click="startOnboarding(item.uuid)"
|
2026-02-26 01:32:04 +00:00
|
|
|
>
|
2026-03-08 13:20:15 +00:00
|
|
|
{{ canStartOnboarding(item.uuid) ? 'Start Onboarding' : 'Join Role First' }}
|
2026-02-26 01:32:04 +00:00
|
|
|
</Button>
|
|
|
|
|
<Button
|
2026-03-08 13:20:15 +00:00
|
|
|
v-if="!isManager && item.uuid && !isRoleJoined(item.uuid)"
|
2026-02-26 01:32:04 +00:00
|
|
|
type="primary"
|
|
|
|
|
size="small"
|
|
|
|
|
@click="selectRole(item.uuid)"
|
|
|
|
|
>
|
|
|
|
|
Join Role
|
|
|
|
|
</Button>
|
2026-03-08 13:20:15 +00:00
|
|
|
<Button
|
|
|
|
|
v-else-if="!isManager"
|
|
|
|
|
size="small"
|
|
|
|
|
disabled
|
|
|
|
|
>
|
|
|
|
|
Joined
|
|
|
|
|
</Button>
|
2026-02-26 01:32:04 +00:00
|
|
|
</Space>
|
|
|
|
|
</List.Item>
|
|
|
|
|
</template>
|
|
|
|
|
</List>
|
|
|
|
|
</div>
|
|
|
|
|
<Typography.Paragraph v-else type="secondary">
|
|
|
|
|
No roles available in this organization.
|
|
|
|
|
</Typography.Paragraph>
|
|
|
|
|
</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>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
.page {
|
|
|
|
|
padding: 1rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.header {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
align-items: center;
|
|
|
|
|
margin-bottom: 1rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.section-title {
|
|
|
|
|
margin-top: 1.5rem !important;
|
|
|
|
|
margin-bottom: 1rem !important;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.role-item :deep(.ant-list-item-meta-title),
|
|
|
|
|
.role-item :deep(.ant-list-item-meta-description) {
|
2026-03-08 13:20:15 +00:00
|
|
|
color: #1f2937;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.role-item {
|
|
|
|
|
border-bottom: none !important;
|
2026-02-26 01:32:04 +00:00
|
|
|
}
|
|
|
|
|
</style>
|