Added organisation views, types, api and store patterns
This commit is contained in:
parent
2790677407
commit
08fb386b14
8 changed files with 479 additions and 18 deletions
21
src/App.vue
21
src/App.vue
|
|
@ -37,10 +37,10 @@ const navItems: NavItem[] = [
|
|||
{ key: '/pricing', label: 'Pricing', icon: PayCircleOutlined, path: '/pricing' },
|
||||
{ key: '/agents', label: 'Agents', icon: RobotOutlined, path: '/agents', manager: true },
|
||||
{
|
||||
key: '/organizations',
|
||||
key: '/organization',
|
||||
label: 'Organizations',
|
||||
icon: BuildOutlined,
|
||||
path: '/organizations',
|
||||
path: '/organization',
|
||||
children: [
|
||||
{ key: '/roles', label: 'Roles', icon: TeamOutlined, path: '/roles', manager: true },
|
||||
{ key: '/onboarding', label: 'Onboarding', icon: RocketOutlined, path: '/onboarding' },
|
||||
|
|
@ -86,11 +86,9 @@ const onSelect = (info: SimpleMenuInfo) => {
|
|||
}
|
||||
}
|
||||
if (found && found.path && route.path !== found.path) {
|
||||
const selectedOrgUuid = (
|
||||
userStore as unknown as { selectedOrganizationUuid?: string | null }
|
||||
).selectedOrganizationUuid
|
||||
if (found.path === '/organizations' && selectedOrgUuid) {
|
||||
router.push(`/organizations/${selectedOrgUuid}`)
|
||||
const selectedOrgUuid = userStore.selectedOrganizationUuid
|
||||
if (found.path === '/organization' && selectedOrgUuid) {
|
||||
router.push(`/organization/${selectedOrgUuid}`)
|
||||
} else {
|
||||
router.push(found.path)
|
||||
}
|
||||
|
|
@ -106,14 +104,7 @@ onMounted(() => {
|
|||
userStore.fetchSession()
|
||||
})
|
||||
|
||||
const user = userStore as unknown as {
|
||||
organizations?: Array<{ uuid: string; name: string }>
|
||||
selectedOrganizationUuid?: string | null
|
||||
setSelectedOrganization?: (val: string | null) => void
|
||||
displayName?: string
|
||||
loading?: boolean
|
||||
isAuthenticated?: boolean
|
||||
}
|
||||
const user = userStore
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
|||
|
|
@ -74,6 +74,12 @@ export const API = {
|
|||
logout: () => '/api/user/logout/',
|
||||
session: () => '/api/user/session/',
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -32,6 +32,18 @@ const router = createRouter({
|
|||
component: () => import('../views/RegisterView.vue'),
|
||||
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 },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ export interface SessionResponse {
|
|||
export const useUserStore = defineStore('user', () => {
|
||||
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 loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
|
@ -41,6 +44,36 @@ export const useUserStore = defineStore('user', () => {
|
|||
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) => {
|
||||
if (initialized.value && !force) return user.value
|
||||
loading.value = true
|
||||
|
|
@ -50,6 +83,7 @@ export const useUserStore = defineStore('user', () => {
|
|||
if (sessionRes.data?.isAuthenticated) {
|
||||
const meRes = await apiClient.get<User>(API.me())
|
||||
setUser(meRes.data)
|
||||
await fetchOrganizations()
|
||||
} else {
|
||||
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 {
|
||||
user,
|
||||
organizations,
|
||||
selectedOrganizationUuid,
|
||||
loading,
|
||||
initialized,
|
||||
error,
|
||||
|
|
@ -155,5 +205,7 @@ export const useUserStore = defineStore('user', () => {
|
|||
login,
|
||||
register,
|
||||
logout,
|
||||
fetchOrganizations,
|
||||
setSelectedOrganization,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
31
src/types/organization.ts
Normal file
31
src/types/organization.ts
Normal 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
|
||||
}
|
||||
}
|
||||
265
src/views/OrganizationView.vue
Normal file
265
src/views/OrganizationView.vue
Normal 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>
|
||||
104
src/views/OrganizationsView.vue
Normal file
104
src/views/OrganizationsView.vue
Normal 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>
|
||||
|
|
@ -5,7 +5,7 @@ import { useRouter } from 'vue-router'
|
|||
const plans = [
|
||||
{
|
||||
name: 'Community',
|
||||
price: '$0',
|
||||
price: '£0',
|
||||
summary: 'Completely free — full feature development preview',
|
||||
bullets: [
|
||||
'Single project, unlimited users',
|
||||
|
|
@ -34,8 +34,8 @@ const router = useRouter()
|
|||
|
||||
const selfHostSteps = [
|
||||
'Clone the repository locally',
|
||||
'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',
|
||||
'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',
|
||||
'Open the frontend at http://localhost:5173 and the API at http://localhost:8000',
|
||||
]
|
||||
</script>
|
||||
|
|
|
|||
Loading…
Reference in a new issue