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: '/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>
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
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 = [
|
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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue