Dynavera/site/src/views/OrganizationView.vue

551 lines
18 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { ref, onMounted, computed, h } from 'vue'
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()
const organizationUuid = route.params.organizationUuid as string
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 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
if (organization.value.owner?.uuid === auth.user.uuid) return true
return members.value.some((member) => member.uuid === auth.user?.uuid && member.is_manager)
})
const fetchOrganization = async () => {
loading.value = true
try {
const response = await apiClient.get<Organization>(API.organization.byId(organizationUuid))
organization.value = response.data
} 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 {
const response = await apiClient.get<Role[]>(API.organization.roles.list(organization.value.uuid))
roles.value = response.data
} catch (error) {
console.error('Failed to fetch roles:', error)
}
}
const fetchUserRoleMemberships = async () => {
if (!organization.value?.uuid) return
try {
const response = await apiClient.get<Role[]>(API.organization.roles.mine())
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)
}
const fetchMembers = async () => {
if (!organization.value?.uuid) return
try {
const response = await apiClient.get<Array<{ uuid: string; is_manager?: boolean }>>(
API.organization.members.list(organization.value.uuid),
)
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
}
if (isManager.value) {
message.error('Managers cannot join roles from this page')
return
}
if (!auth.user?.uuid) {
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 {
await apiClient.post(API.organization.roles.join(organization.value.uuid, roleUuid))
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 {
const response = await apiClient.get<TrainingFile[]>(API.knowledge.trainingFiles.list(), {
params: { 'role__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', 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
},
},
]
onMounted(async () => {
await auth.fetchSession(true)
await fetchOrganization()
await fetchMembers()
await fetchRoles()
await fetchUserRoleMemberships()
await fetchTrainingFiles()
})
</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>
<Button
v-if="isManager"
type="primary"
@click="router.push(`/organization/${organization.uuid}/manage`)"
>
Manage Organization
</Button>
</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"
: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>
<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"
@click="router.push(`/onboarding/${item.uuid}`)"
>
Start Onboarding
</Button>
<Button
v-if="item.uuid && !isRoleJoined(item.uuid) && !isManager"
type="primary"
size="small"
@click="selectRole(item.uuid)"
>
Join Role
</Button>
<Button v-else size="small" disabled>Joined</Button>
</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) {
background: #0f172a;
border: 1px solid #1f2937;
color: #e5e7eb;
}
</style>