427 lines
14 KiB
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>
|