Added organisation views, types, api and store patterns

This commit is contained in:
Viswamedha Nalabotu 2026-01-18 16:15:24 +00:00
parent 2790677407
commit 08fb386b14
8 changed files with 479 additions and 18 deletions

View file

@ -37,10 +37,10 @@ const navItems: NavItem[] = [
{ key: '/pricing', label: 'Pricing', icon: PayCircleOutlined, path: '/pricing' }, { key: '/pricing', label: 'Pricing', icon: PayCircleOutlined, path: '/pricing' },
{ key: '/agents', label: 'Agents', icon: RobotOutlined, path: '/agents', manager: true }, { key: '/agents', label: 'Agents', icon: RobotOutlined, path: '/agents', manager: true },
{ {
key: '/organizations', key: '/organization',
label: 'Organizations', label: 'Organizations',
icon: BuildOutlined, icon: BuildOutlined,
path: '/organizations', path: '/organization',
children: [ children: [
{ key: '/roles', label: 'Roles', icon: TeamOutlined, path: '/roles', manager: true }, { key: '/roles', label: 'Roles', icon: TeamOutlined, path: '/roles', manager: true },
{ key: '/onboarding', label: 'Onboarding', icon: RocketOutlined, path: '/onboarding' }, { key: '/onboarding', label: 'Onboarding', icon: RocketOutlined, path: '/onboarding' },
@ -86,11 +86,9 @@ const onSelect = (info: SimpleMenuInfo) => {
} }
} }
if (found && found.path && route.path !== found.path) { if (found && found.path && route.path !== found.path) {
const selectedOrgUuid = ( const selectedOrgUuid = userStore.selectedOrganizationUuid
userStore as unknown as { selectedOrganizationUuid?: string | null } if (found.path === '/organization' && selectedOrgUuid) {
).selectedOrganizationUuid router.push(`/organization/${selectedOrgUuid}`)
if (found.path === '/organizations' && selectedOrgUuid) {
router.push(`/organizations/${selectedOrgUuid}`)
} else { } else {
router.push(found.path) router.push(found.path)
} }
@ -106,14 +104,7 @@ onMounted(() => {
userStore.fetchSession() userStore.fetchSession()
}) })
const user = userStore as unknown as { const user = userStore
organizations?: Array<{ uuid: string; name: string }>
selectedOrganizationUuid?: string | null
setSelectedOrganization?: (val: string | null) => void
displayName?: string
loading?: boolean
isAuthenticated?: boolean
}
</script> </script>
<template> <template>

View file

@ -74,6 +74,12 @@ export const API = {
logout: () => '/api/user/logout/', logout: () => '/api/user/logout/',
session: () => '/api/user/session/', session: () => '/api/user/session/',
signup: () => '/api/user/signup/', signup: () => '/api/user/signup/',
organizations: () => '/api/organization/',
organization: (id: string) => `/api/organization/${id}/`,
organizationRoles: (orgUuid: string) => `/api/organization/${orgUuid}/role/`,
organizationRoleMembers: (orgUuid: string, roleId: number) =>
`/api/organization/${orgUuid}/role/${roleId}/members/`,
organizationMembers: (orgUuid: string) => `/api/organization/${orgUuid}/members/`,
} }
export const apiClient = new ApiClient() export const apiClient = new ApiClient()

View file

@ -32,6 +32,18 @@ const router = createRouter({
component: () => import('../views/RegisterView.vue'), component: () => import('../views/RegisterView.vue'),
meta: { guestOnly: true }, meta: { guestOnly: true },
}, },
{
path: '/organization',
name: 'organization',
component: () => import('../views/OrganizationsView.vue'),
meta: { requiresAuth: true },
},
{
path: '/organization/:id',
name: 'organization-detail',
component: () => import('../views/OrganizationView.vue'),
meta: { requiresAuth: true },
},
], ],
}) })

View file

@ -24,6 +24,9 @@ export interface SessionResponse {
export const useUserStore = defineStore('user', () => { export const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null) const user = ref<User | null>(null)
const organizations = ref<Array<{ id: number; uuid: string; name: string }>>([])
const selectedOrganizationUuid = ref<string | null>(null)
const initialized = ref(false) const initialized = ref(false)
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
@ -41,6 +44,36 @@ export const useUserStore = defineStore('user', () => {
initialized.value = true initialized.value = true
} }
const setOrganizations = (list: Array<{ id: number; uuid: string; name: string }>) => {
organizations.value = list || []
const stored = localStorage.getItem('selectedOrganizationUuid')
if (!organizations.value.length) {
selectedOrganizationUuid.value = null
localStorage.removeItem('selectedOrganizationUuid')
return
}
if (organizations.value.length === 1) {
selectedOrganizationUuid.value = organizations.value[0].uuid
localStorage.setItem('selectedOrganizationUuid', selectedOrganizationUuid.value)
return
}
if (stored) {
const found = organizations.value.find((o) => o.uuid === stored)
if (found) {
selectedOrganizationUuid.value = stored
return
}
}
selectedOrganizationUuid.value = organizations.value[0].uuid
localStorage.setItem('selectedOrganizationUuid', selectedOrganizationUuid.value)
}
const setSelectedOrganization = (uuid: string | null) => {
selectedOrganizationUuid.value = uuid
if (uuid) localStorage.setItem('selectedOrganizationUuid', uuid)
else localStorage.removeItem('selectedOrganizationUuid')
}
const fetchSession = async (force = false) => { const fetchSession = async (force = false) => {
if (initialized.value && !force) return user.value if (initialized.value && !force) return user.value
loading.value = true loading.value = true
@ -50,6 +83,7 @@ export const useUserStore = defineStore('user', () => {
if (sessionRes.data?.isAuthenticated) { if (sessionRes.data?.isAuthenticated) {
const meRes = await apiClient.get<User>(API.me()) const meRes = await apiClient.get<User>(API.me())
setUser(meRes.data) setUser(meRes.data)
await fetchOrganizations()
} else { } else {
setUser(null) setUser(null)
} }
@ -144,8 +178,24 @@ export const useUserStore = defineStore('user', () => {
} }
} }
const fetchOrganizations = async () => {
try {
const res = await apiClient.get<Array<{ id: number; uuid: string; name: string }>>(
API.organizations(),
)
setOrganizations(res.data || [])
return organizations.value
} catch (err: unknown) {
console.error('Failed to fetch organizations', err)
setOrganizations([])
return []
}
}
return { return {
user, user,
organizations,
selectedOrganizationUuid,
loading, loading,
initialized, initialized,
error, error,
@ -155,5 +205,7 @@ export const useUserStore = defineStore('user', () => {
login, login,
register, register,
logout, logout,
fetchOrganizations,
setSelectedOrganization,
} }
}) })

31
src/types/organization.ts Normal file
View file

@ -0,0 +1,31 @@
export interface Organization {
id: number
uuid: string
name: string
description?: string
owner?: {
id: number
first_name: string
last_name: string
email_address: string
}
member_count?: number
role_count?: number
created_at?: string
}
export interface Role {
id: number
uuid: string
name: string
description?: string
member_count?: number
}
export interface RoleMembership {
id: number
role: {
id: number
name: string
}
}

View file

@ -0,0 +1,265 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { Card, Typography, Button, List, Space, Spin, message, Tag, Divider } from 'ant-design-vue'
import { apiClient, isAxiosError, API } from '../router/api'
import { useUserStore as useAuthStore } from '../stores/userStore'
import type { Role, Organization } from '../types/organization'
const router = useRouter()
const route = useRoute()
const orgId = route.params.id as string
const organization = ref<Organization | null>(null)
const roles = ref<Role[]>([])
const members = ref<Array<{ user: { id: number }; role: string }>>([])
const userRoles = ref<number[]>([])
const loading = ref(false)
const auth = useAuthStore()
const isManager = computed(() => {
if (!auth.user || !organization.value) return false
if (organization.value.owner?.id === auth.user.id) return true
return members.value.some((m) => m.user?.id === auth.user?.id && m.role === 'employer')
})
const fetchOrganization = async () => {
loading.value = true
try {
const response = await apiClient.get<Organization>(API.organization(orgId))
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.organizationRoles(organization.value.uuid))
roles.value = response.data
} catch (error) {
console.error('Failed to fetch roles:', error)
}
}
const fetchUserRoleMemberships = async () => {
userRoles.value = []
const userId = auth.user?.id
if (!userId) return
try {
const checks = roles.value.map(async (r) => {
try {
const resp = await apiClient.get<Array<{ user: { id: number } }>>(
API.organizationRoleMembers(organization.value!.uuid, r.id),
)
const found =
Array.isArray(resp.data) && resp.data.some((m) => m.user?.id === userId)
if (found) userRoles.value.push(r.id)
} catch {}
})
await Promise.all(checks)
} catch {
console.error('Failed to fetch user role memberships')
}
}
const fetchMembers = async () => {
if (!organization.value?.uuid) return
try {
const response = await apiClient.get<Array<{ user: { id: number }; role: string }>>(
API.organizationMembers(organization.value.uuid),
)
members.value = response.data
} catch (error) {
console.error('Failed to fetch members:', error)
}
}
const selectRole = async (roleId: number) => {
if (!organization.value?.uuid) {
message.error('Organization not loaded')
return
}
let userId = auth.user?.id
if (!userId) {
try {
const u = await auth.fetchSession(true)
userId = u?.id
} catch {
message.error('You must be signed in to join a role')
return
}
}
if (userRoles.value.includes(roleId)) {
message.info('You are already a member of this role')
return
}
try {
await apiClient.post(API.organizationRoleMembers(organization.value.uuid, roleId), {
user_id: userId,
})
message.success('Successfully joined role')
if (!userRoles.value.includes(roleId)) userRoles.value.push(roleId)
} catch (error) {
console.error('Failed to join role:', error)
if (isAxiosError(error)) {
message.error(error.response?.data?.error || 'Failed to join role')
}
}
}
onMounted(() => {
fetchOrganization().then(async () => {
await fetchMembers()
await fetchRoles()
await fetchUserRoleMemberships()
})
})
</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">
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"
:description="item.description || 'No description available'"
/>
<Space>
<Tag>{{ item.member_count ?? 0 }} members</Tag>
<Button
type="default"
size="small"
@click="router.push(`/onboarding/${item.uuid}`)"
>
Start Onboarding
</Button>
<Button
v-if="!userRoles.includes(item.id)"
type="primary"
size="small"
@click="selectRole(item.id)"
>
Join Role
</Button>
<Tag v-else color="success">Joined</Tag>
</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 {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
.panel {
background: #0f172a;
border: 1px solid #1f2937;
color: #e5e7eb;
}
.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: #e5e7eb;
background: #0f172a;
border: 1px solid #1f2937;
color: #e5e7eb;
}
.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: #e5e7eb;
}
.panel ::v-deep .ant-typography,
.panel ::v-deep .ant-typography * {
color: #ffffff !important;
}
.panel ::v-deep .ant-list-item-meta-title,
.panel ::v-deep .ant-list-item-meta-description {
color: #ffffff !important;
}
</style>

View file

@ -0,0 +1,104 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Card, List, Typography, Spin, message, Button } from 'ant-design-vue'
import { apiClient, isAxiosError, API } from '../router/api'
import type { Organization } from '../types/organization'
const router = useRouter()
const organizations = ref<Organization[]>([])
const loading = ref(false)
const fetchOrganizations = async () => {
loading.value = true
try {
const resp = await apiClient.get<Organization[]>(API.organizations())
organizations.value = resp.data || []
} catch (err: unknown) {
console.error('Failed to fetch organizations:', err)
if (isAxiosError(err)) {
message.error(
err.response?.data?.error ||
err.response?.data?.detail ||
'Failed to load organizations',
)
} else if (err instanceof Error) {
message.error(err.message)
} else {
message.error('Failed to load organizations')
}
} finally {
loading.value = false
}
}
onMounted(() => {
fetchOrganizations()
})
const openOrg = (org: Organization) => {
const id = org.uuid || String(org.id)
router.push(`/organization/${id}`)
}
</script>
<template>
<div class="page">
<Spin :spinning="loading" tip="Loading organizations...">
<Card class="panel" :bordered="false">
<div class="header">
<Typography.Title :level="2">Organizations</Typography.Title>
<Button type="primary" @click="fetchOrganizations">Refresh</Button>
</div>
<div v-if="organizations.length > 0">
<List :data-source="organizations" :bordered="false">
<template #renderItem="{ item }">
<List.Item>
<List.Item.Meta
:title="item.name"
:description="item.description || 'No description provided'"
/>
<div>
<Button size="small" type="primary" @click="openOrg(item)">
Open
</Button>
</div>
</List.Item>
</template>
</List>
</div>
<Typography.Paragraph v-else type="secondary">
No organizations found.
</Typography.Paragraph>
</Card>
</Spin>
</div>
</template>
<style scoped>
.page {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
.panel {
background: #0f172a;
border: 1px solid #1f2937;
color: #e5e7eb;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.panel ::v-deep .ant-typography,
.panel ::v-deep .ant-typography * {
color: #ffffff !important;
}
.panel ::v-deep .ant-list-item-meta-title,
.panel ::v-deep .ant-list-item-meta-description {
color: #ffffff !important;
}
</style>

View file

@ -5,7 +5,7 @@ import { useRouter } from 'vue-router'
const plans = [ const plans = [
{ {
name: 'Community', name: 'Community',
price: '$0', price: '£0',
summary: 'Completely free — full feature development preview', summary: 'Completely free — full feature development preview',
bullets: [ bullets: [
'Single project, unlimited users', 'Single project, unlimited users',
@ -34,8 +34,8 @@ const router = useRouter()
const selfHostSteps = [ const selfHostSteps = [
'Clone the repository locally', 'Clone the repository locally',
'Copy and edit `.env.template` (or create `.env`) with your settings', 'Copy and edit .env.template (or create .env) with your settings',
'Run `docker compose -f compose/dev/docker-compose.yml up --build` for development or the prod/docker-compose.yml for production', 'Run docker compose -f compose/dev/docker-compose.yml up --build for development or the prod/docker-compose.yml for production',
'Open the frontend at http://localhost:5173 and the API at http://localhost:8000', 'Open the frontend at http://localhost:5173 and the API at http://localhost:8000',
] ]
</script> </script>