Dynavera/site/src/views/OrganizationView.vue

427 lines
14 KiB
Vue

<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useDocumentVisibility, useEventListener, useIntervalFn } from '@vueuse/core'
import {
Card,
Typography,
Button,
List,
Space,
Spin,
message,
Tag,
Divider,
Modal,
} from 'ant-design-vue'
import { apiClient, isAxiosError, API } from '../router/api'
import { useUserStore } from '../stores/userStore'
import type { Role, Organization } from '../types/organization'
const router = useRouter()
const route = useRoute()
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 loading = ref(false)
const leavingOrganization = 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
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 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
})
const fetchOrganization = async () => {
loading.value = true
try {
if (!organizationUuid.value) {
organization.value = null
return
}
const response = await apiClient.get<Organization>(API.organization.byId(organizationUuid.value))
organization.value = response.data
auth.setSelectedOrganization(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.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.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 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}`)
}
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.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)) {
message.error(error.response?.data?.error || 'Failed to join role')
}
}
}
const loadOrganizationContext = async () => {
await auth.fetchSession(true)
await fetchOrganization()
await fetchMembers()
await fetchRoles()
await fetchUserRoleMemberships()
}
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 = () => {
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()
bindVisibility()
startRolePolling()
})
onBeforeUnmount(() => {
stopRolePolling()
unbindVisibility()
})
watch(
() => organizationUuid.value,
async (next, prev) => {
if (!next || next === prev) return
stopRolePolling()
roles.value = []
members.value = []
await loadOrganizationContext()
startRolePolling()
},
)
</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>
<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>
</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">
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"
:disabled="!canStartOnboarding(item.uuid)"
:title="
!canStartOnboarding(item.uuid)
? 'Join this role before starting onboarding.'
: 'Start onboarding'
"
@click="startOnboarding(item.uuid)"
>
{{ canStartOnboarding(item.uuid) ? 'Start Onboarding' : 'Join Role First' }}
</Button>
<Button
v-if="!isManager && item.uuid && !isRoleJoined(item.uuid)"
type="primary"
size="small"
@click="selectRole(item.uuid)"
>
Join Role
</Button>
<Button
v-else-if="!isManager"
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>
</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) {
color: #1f2937;
}
.role-item {
border-bottom: none !important;
}
</style>