Modified styling to be light, add edge case UI tweaks, revamped styling and page status
This commit is contained in:
parent
1c0551a809
commit
e59cef27ca
10 changed files with 935 additions and 235 deletions
107
site/src/App.vue
107
site/src/App.vue
|
|
@ -40,6 +40,11 @@ const visibleNavItems = computed<NavItem[]>(() =>
|
||||||
navItems.filter((item) => (item.manager ? userStore.user?.is_manager : true)),
|
navItems.filter((item) => (item.manager ? userStore.user?.is_manager : true)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const organizationCount = computed(() => userStore.userJoinedOrganizations?.length || 0)
|
||||||
|
const singleOrganization = computed(() =>
|
||||||
|
organizationCount.value === 1 ? userStore.userJoinedOrganizations[0] : null,
|
||||||
|
)
|
||||||
|
|
||||||
const selectedKeys = computed(() => {
|
const selectedKeys = computed(() => {
|
||||||
for (const item of visibleNavItems.value) {
|
for (const item of visibleNavItems.value) {
|
||||||
if (item.key === '/' && route.path === '/') return [item.key]
|
if (item.key === '/' && route.path === '/') return [item.key]
|
||||||
|
|
@ -85,6 +90,14 @@ const handleLogout = async () => {
|
||||||
router.push('/')
|
router.push('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleOrganizationChange = (value: string) => {
|
||||||
|
const organization = userStore.userJoinedOrganizations.find((item) => item.uuid === value) || null
|
||||||
|
userStore.setSelectedOrganization(organization)
|
||||||
|
if (organization) {
|
||||||
|
router.push(`/organization/${organization.uuid}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
userStore.fetchSession()
|
userStore.fetchSession()
|
||||||
})
|
})
|
||||||
|
|
@ -99,7 +112,7 @@ const user = userStore
|
||||||
<div style="margin-right: 1rem" v-if="user.isAuthenticated"></div>
|
<div style="margin-right: 1rem" v-if="user.isAuthenticated"></div>
|
||||||
<Menu
|
<Menu
|
||||||
mode="horizontal"
|
mode="horizontal"
|
||||||
theme="dark"
|
theme="light"
|
||||||
:selectedKeys="selectedKeys"
|
:selectedKeys="selectedKeys"
|
||||||
class="shell-menu"
|
class="shell-menu"
|
||||||
@select="onSelect"
|
@select="onSelect"
|
||||||
|
|
@ -147,16 +160,10 @@ const user = userStore
|
||||||
<template v-if="user.isAuthenticated">
|
<template v-if="user.isAuthenticated">
|
||||||
<Select
|
<Select
|
||||||
v-if="
|
v-if="
|
||||||
user.userJoinedOrganizations && user.userJoinedOrganizations.length > 0
|
user.userJoinedOrganizations && user.userJoinedOrganizations.length > 1
|
||||||
"
|
"
|
||||||
:value="user.userSelectedOrganization?.uuid ?? undefined"
|
:value="user.userSelectedOrganization?.uuid ?? undefined"
|
||||||
@change="
|
@change="(val) => handleOrganizationChange(String(val))"
|
||||||
(val) => {
|
|
||||||
const org = user.userJoinedOrganizations.find((o) => o.uuid === val)
|
|
||||||
user.setSelectedOrganization &&
|
|
||||||
user.setSelectedOrganization(org ?? null)
|
|
||||||
}
|
|
||||||
"
|
|
||||||
style="min-width: 220px; margin-right: 0.5rem"
|
style="min-width: 220px; margin-right: 0.5rem"
|
||||||
placeholder="Select organization"
|
placeholder="Select organization"
|
||||||
>
|
>
|
||||||
|
|
@ -169,6 +176,14 @@ const user = userStore
|
||||||
</Select.Option>
|
</Select.Option>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
<Typography.Text
|
||||||
|
v-else-if="singleOrganization"
|
||||||
|
class="org-chip"
|
||||||
|
strong
|
||||||
|
>
|
||||||
|
{{ singleOrganization.name }}
|
||||||
|
</Typography.Text>
|
||||||
|
|
||||||
<Typography.Text class="user-chip" strong>
|
<Typography.Text class="user-chip" strong>
|
||||||
{{ user.displayName || 'Account' }}
|
{{ user.displayName || 'Account' }}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
|
|
@ -205,17 +220,18 @@ const user = userStore
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.shell {
|
.shell {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #0b1220;
|
background: #f5f7fb;
|
||||||
}
|
}
|
||||||
.shell-header {
|
.shell-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 0 1.25rem;
|
padding: 0 1.25rem;
|
||||||
background: #0f172a;
|
background: #ffffff;
|
||||||
|
border-bottom: 1px solid #dbe3ec;
|
||||||
}
|
}
|
||||||
.brand {
|
.brand {
|
||||||
color: #e5e7eb;
|
color: #1f2937;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
|
|
@ -226,7 +242,7 @@ const user = userStore
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
.shell-body {
|
.shell-body {
|
||||||
background: #0b1220;
|
background: #f5f7fb;
|
||||||
min-height: calc(100vh - 64px);
|
min-height: calc(100vh - 64px);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -238,7 +254,8 @@ const user = userStore
|
||||||
}
|
}
|
||||||
.shell-footer {
|
.shell-footer {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
background: #0f172a;
|
background: #ffffff;
|
||||||
|
border-top: 1px solid #dbe3ec;
|
||||||
}
|
}
|
||||||
:deep(.ant-menu-dark) {
|
:deep(.ant-menu-dark) {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
@ -256,76 +273,84 @@ const user = userStore
|
||||||
:deep(.ant-statistic-content),
|
:deep(.ant-statistic-content),
|
||||||
:deep(.ant-card-meta-title),
|
:deep(.ant-card-meta-title),
|
||||||
:deep(.ant-card-meta-description) {
|
:deep(.ant-card-meta-description) {
|
||||||
color: #e5e7eb;
|
color: #1f2937;
|
||||||
}
|
}
|
||||||
:deep(.ant-typography-secondary) {
|
:deep(.ant-typography-secondary) {
|
||||||
color: #cbd5e1 !important;
|
color: #6b7280 !important;
|
||||||
}
|
}
|
||||||
:deep(.ant-form-item-label > label) {
|
:deep(.ant-form-item-label > label) {
|
||||||
color: #e5e7eb;
|
color: #1f2937;
|
||||||
}
|
}
|
||||||
:deep(.ant-input),
|
:deep(.ant-input),
|
||||||
:deep(.ant-select-selector),
|
:deep(.ant-select-selector),
|
||||||
:deep(.ant-select-selection-item),
|
:deep(.ant-select-selection-item),
|
||||||
:deep(.ant-picker-input input) {
|
:deep(.ant-picker-input input) {
|
||||||
background: #111827;
|
background: #ffffff;
|
||||||
color: #e5e7eb;
|
color: #1f2937;
|
||||||
border-color: #334155;
|
border-color: #d0d8e2;
|
||||||
}
|
}
|
||||||
:deep(.ant-input::placeholder),
|
:deep(.ant-input::placeholder),
|
||||||
:deep(.ant-select-selection-placeholder),
|
:deep(.ant-select-selection-placeholder),
|
||||||
:deep(.ant-picker-input input::placeholder) {
|
:deep(.ant-picker-input input::placeholder) {
|
||||||
color: #9ca3af;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
:deep(.ant-card) {
|
:deep(.ant-card) {
|
||||||
background: #0f172a;
|
background: #ffffff;
|
||||||
border-color: #1f2937;
|
border-color: #dbe3ec;
|
||||||
}
|
}
|
||||||
:deep(.ant-btn:not(.ant-btn-primary)) {
|
:deep(.ant-btn:not(.ant-btn-primary)) {
|
||||||
color: #e5e7eb;
|
color: #1f2937;
|
||||||
border-color: #334155;
|
border-color: #d0d8e2;
|
||||||
background: #111827;
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
:deep(.ant-btn-primary) {
|
:deep(.ant-btn-primary) {
|
||||||
background: linear-gradient(90deg, #6366f1, #8b5cf6);
|
background: #2563eb;
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
.user-chip {
|
.user-chip {
|
||||||
color: #e5e7eb;
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
.org-chip {
|
||||||
|
color: #1f2937;
|
||||||
|
padding: 0 0.5rem;
|
||||||
}
|
}
|
||||||
:deep(.ant-typography-secondary) {
|
:deep(.ant-typography-secondary) {
|
||||||
color: #cbd5e1 !important;
|
color: #6b7280 !important;
|
||||||
}
|
}
|
||||||
:deep(.ant-form-item-label > label) {
|
:deep(.ant-form-item-label > label) {
|
||||||
color: #e5e7eb;
|
color: #1f2937;
|
||||||
}
|
}
|
||||||
:deep(.ant-input),
|
:deep(.ant-input),
|
||||||
:deep(.ant-select-selector),
|
:deep(.ant-select-selector),
|
||||||
:deep(.ant-select-selection-item),
|
:deep(.ant-select-selection-item),
|
||||||
:deep(.ant-picker-input input) {
|
:deep(.ant-picker-input input) {
|
||||||
background: #111827;
|
background: #ffffff;
|
||||||
color: #e5e7eb;
|
color: #1f2937;
|
||||||
border-color: #334155;
|
border-color: #d0d8e2;
|
||||||
}
|
}
|
||||||
:deep(.ant-input::placeholder),
|
:deep(.ant-input::placeholder),
|
||||||
:deep(.ant-select-selection-placeholder),
|
:deep(.ant-select-selection-placeholder),
|
||||||
:deep(.ant-picker-input input::placeholder) {
|
:deep(.ant-picker-input input::placeholder) {
|
||||||
color: #9ca3af;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
:deep(.ant-card) {
|
:deep(.ant-card) {
|
||||||
background: #0f172a;
|
background: #ffffff;
|
||||||
border-color: #1f2937;
|
border-color: #dbe3ec;
|
||||||
}
|
}
|
||||||
:deep(.ant-btn:not(.ant-btn-primary)) {
|
:deep(.ant-btn:not(.ant-btn-primary)) {
|
||||||
color: #e5e7eb;
|
color: #1f2937;
|
||||||
border-color: #334155;
|
border-color: #d0d8e2;
|
||||||
background: #111827;
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
:deep(.ant-btn-primary) {
|
:deep(.ant-btn-primary) {
|
||||||
background: linear-gradient(90deg, #6366f1, #8b5cf6);
|
background: #2563eb;
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
.user-chip {
|
.user-chip {
|
||||||
color: #e5e7eb;
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
.org-chip {
|
||||||
|
color: #1f2937;
|
||||||
|
padding: 0 0.5rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,8 @@ const features = [
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
.feature-card {
|
.feature-card {
|
||||||
background: var(--ant-card-background, #08121a);
|
background: #ffffff;
|
||||||
|
border: 1px solid #dbe3ec;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -107,4 +108,8 @@ const features = [
|
||||||
.feature-body {
|
.feature-body {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.feature-body :deep(.ant-typography-secondary) {
|
||||||
|
color: #4b5563 !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -346,9 +346,9 @@ onUnmounted(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
background: #0f172a;
|
background: #ffffff;
|
||||||
border: 1px solid #1f2937;
|
border: 1px solid #dbe3ec;
|
||||||
color: #e5e7eb;
|
color: #1f2937;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
|
|
@ -369,26 +369,26 @@ onUnmounted(() => {
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
background: #1f2937;
|
background: #f8fafc;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.execution-controls {
|
.execution-controls {
|
||||||
background: #1f2937;
|
background: #f8fafc;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-container {
|
.log-container {
|
||||||
background: #1f2937;
|
background: #f8fafc;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
max-height: 500px;
|
max-height: 500px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-item {
|
.log-item {
|
||||||
border-bottom: 1px solid #374151 !important;
|
border-bottom: 1px solid #dbe3ec !important;
|
||||||
padding: 0.75rem !important;
|
padding: 0.75rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -405,16 +405,16 @@ onUnmounted(() => {
|
||||||
|
|
||||||
.log-time {
|
.log-time {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #9ca3af;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-message {
|
.log-message {
|
||||||
color: #e5e7eb;
|
color: #1f2937;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-content {
|
.log-content {
|
||||||
background: #111827;
|
background: #ffffff;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
|
@ -423,7 +423,7 @@ onUnmounted(() => {
|
||||||
.log-content pre {
|
.log-content pre {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: #d1d5db;
|
color: #4b5563;
|
||||||
}
|
}
|
||||||
|
|
||||||
.response-section {
|
.response-section {
|
||||||
|
|
@ -431,17 +431,17 @@ onUnmounted(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.response-card {
|
.response-card {
|
||||||
background: #1f2937;
|
background: #ffffff;
|
||||||
border: 1px solid #374151;
|
border: 1px solid #dbe3ec;
|
||||||
}
|
}
|
||||||
|
|
||||||
.response-final {
|
.response-final {
|
||||||
border-color: #6366f1;
|
border-color: #2563eb;
|
||||||
box-shadow: 0 0 0 1px rgba(99, 102, 241, 0.35);
|
box-shadow: 0 0 0 1px rgba(37, 99, 235, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.response-content {
|
.response-content {
|
||||||
color: #e5e7eb;
|
color: #1f2937;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
|
@ -452,7 +452,7 @@ onUnmounted(() => {
|
||||||
.markdown-body :deep(h1),
|
.markdown-body :deep(h1),
|
||||||
.markdown-body :deep(h2),
|
.markdown-body :deep(h2),
|
||||||
.markdown-body :deep(h3) {
|
.markdown-body :deep(h3) {
|
||||||
color: #f8fafc;
|
color: #1f2937;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
@ -461,15 +461,15 @@ onUnmounted(() => {
|
||||||
.markdown-body :deep(ol) {
|
.markdown-body :deep(ol) {
|
||||||
padding-left: 1.5rem;
|
padding-left: 1.5rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
color: #e5e7eb;
|
color: #1f2937;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-body :deep(code) {
|
.markdown-body :deep(code) {
|
||||||
background: #020617;
|
background: #eff6ff;
|
||||||
padding: 0.2rem 0.4rem;
|
padding: 0.2rem 0.4rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
color: #10b981;
|
color: #1d4ed8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-body :deep(p) {
|
.markdown-body :deep(p) {
|
||||||
|
|
|
||||||
|
|
@ -147,8 +147,8 @@ onMounted(() => {
|
||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
}
|
}
|
||||||
.panel {
|
.panel {
|
||||||
background: #0f172a;
|
background: #ffffff;
|
||||||
border: 1px solid #1f2937;
|
border: 1px solid #dbe3ec;
|
||||||
}
|
}
|
||||||
.filters {
|
.filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -157,11 +157,11 @@ onMounted(() => {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.item :deep(.ant-list-item-meta-title) {
|
.item :deep(.ant-list-item-meta-title) {
|
||||||
color: #f8fafc;
|
color: #1f2937;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.config-summary {
|
.config-summary {
|
||||||
color: #94a3b8;
|
color: #6b7280;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
.empty {
|
.empty {
|
||||||
|
|
|
||||||
|
|
@ -78,10 +78,10 @@ const testimonials = [
|
||||||
]
|
]
|
||||||
|
|
||||||
const logos = [
|
const logos = [
|
||||||
'https://dummyimage.com/120x40/111827/ffffff&text=Nova',
|
'https://dummyimage.com/120x40/f8fafc/1f2937&text=Nova',
|
||||||
'https://dummyimage.com/120x40/1f2937/ffffff&text=Helio',
|
'https://dummyimage.com/120x40/f1f5f9/1f2937&text=Helio',
|
||||||
'https://dummyimage.com/120x40/111827/ffffff&text=Arcus',
|
'https://dummyimage.com/120x40/f8fafc/1f2937&text=Arcus',
|
||||||
'https://dummyimage.com/120x40/1f2937/ffffff&text=Vertex',
|
'https://dummyimage.com/120x40/f1f5f9/1f2937&text=Vertex',
|
||||||
]
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -102,7 +102,7 @@ const logos = [
|
||||||
<RouterLink to="/about">
|
<RouterLink to="/about">
|
||||||
<Button type="primary" size="large">Learn More</Button>
|
<Button type="primary" size="large">Learn More</Button>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink to="/onboarding">
|
<RouterLink to="/organization">
|
||||||
<Button size="large">See Onboarding Flows</Button>
|
<Button size="large">See Onboarding Flows</Button>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
@ -136,7 +136,7 @@ const logos = [
|
||||||
<Row :gutter="16">
|
<Row :gutter="16">
|
||||||
<Col v-for="feature in features" :key="feature.title" :xs="24" :md="8">
|
<Col v-for="feature in features" :key="feature.title" :xs="24" :md="8">
|
||||||
<Card hoverable class="feature-card">
|
<Card hoverable class="feature-card">
|
||||||
<feature.icon two-tone-color="#8b5cf6" style="font-size: 28px" />
|
<feature.icon two-tone-color="#2563eb" style="font-size: 28px" />
|
||||||
<Typography.Title :level="4">{{ feature.title }}</Typography.Title>
|
<Typography.Title :level="4">{{ feature.title }}</Typography.Title>
|
||||||
<Typography.Paragraph>{{ feature.description }}</Typography.Paragraph>
|
<Typography.Paragraph>{{ feature.description }}</Typography.Paragraph>
|
||||||
<Tag color="purple">Live</Tag>
|
<Tag color="purple">Live</Tag>
|
||||||
|
|
@ -198,7 +198,7 @@ const logos = [
|
||||||
}
|
}
|
||||||
.hero-sub {
|
.hero-sub {
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
color: #cbd5e1;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
.hero-card {
|
.hero-card {
|
||||||
border: none;
|
border: none;
|
||||||
|
|
@ -211,12 +211,12 @@ const logos = [
|
||||||
}
|
}
|
||||||
.hero-overlay {
|
.hero-overlay {
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
color: #8b5cf6;
|
color: #2563eb;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.stat-card {
|
.stat-card {
|
||||||
background: #0f172a;
|
background: #ffffff;
|
||||||
border: 1px solid #1f2937;
|
border: 1px solid #dbe3ec;
|
||||||
}
|
}
|
||||||
.trusted {
|
.trusted {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
@ -239,17 +239,17 @@ const logos = [
|
||||||
}
|
}
|
||||||
.feature-card {
|
.feature-card {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #0f172a;
|
background: #ffffff;
|
||||||
border: 1px solid #1f2937;
|
border: 1px solid #dbe3ec;
|
||||||
color: #e5e7eb;
|
color: #1f2937;
|
||||||
}
|
}
|
||||||
.journeys {
|
.journeys {
|
||||||
margin: 2.5rem 0;
|
margin: 2.5rem 0;
|
||||||
}
|
}
|
||||||
.journey-card {
|
.journey-card {
|
||||||
background: #0f172a;
|
background: #ffffff;
|
||||||
border: 1px solid #1f2937;
|
border: 1px solid #dbe3ec;
|
||||||
color: #e5e7eb;
|
color: #1f2937;
|
||||||
}
|
}
|
||||||
.testimonials {
|
.testimonials {
|
||||||
margin: 2.5rem 0;
|
margin: 2.5rem 0;
|
||||||
|
|
@ -258,9 +258,9 @@ const logos = [
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
}
|
}
|
||||||
.testimonial-card {
|
.testimonial-card {
|
||||||
background: #0f172a;
|
background: #ffffff;
|
||||||
border: 1px solid #1f2937;
|
border: 1px solid #dbe3ec;
|
||||||
color: #e5e7eb;
|
color: #1f2937;
|
||||||
}
|
}
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.page {
|
.page {
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,12 @@ import {
|
||||||
Tag,
|
Tag,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
} from 'ant-design-vue'
|
} from 'ant-design-vue'
|
||||||
import { apiClient, API } from '../router/api'
|
import { apiClient, API, isAxiosError } from '../router/api'
|
||||||
import { useAgentStore } from '../stores/agentStore'
|
import { useOnboardingAgentStore } from '../stores/onboardingAgentStore'
|
||||||
import type {
|
import type {
|
||||||
OnboardingFlow,
|
OnboardingFlow,
|
||||||
OnboardingPage,
|
OnboardingPage,
|
||||||
OnboardingSession,
|
OnboardingSession,
|
||||||
OnboardingSessionSummary,
|
|
||||||
OnboardingFlowSummary,
|
OnboardingFlowSummary,
|
||||||
} from '../types/onboarding'
|
} from '../types/onboarding'
|
||||||
|
|
||||||
|
|
@ -33,7 +32,7 @@ import DOMPurify from 'dompurify'
|
||||||
const marked = new Marked()
|
const marked = new Marked()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const agentStore = useAgentStore()
|
const agentStore = useOnboardingAgentStore()
|
||||||
|
|
||||||
const roleId = computed(() => route.params.roleId as string)
|
const roleId = computed(() => route.params.roleId as string)
|
||||||
const flowDetails = ref<OnboardingFlow | null>(null)
|
const flowDetails = ref<OnboardingFlow | null>(null)
|
||||||
|
|
@ -43,6 +42,17 @@ const loading = ref(false)
|
||||||
const isAutoGenerating = ref(false)
|
const isAutoGenerating = ref(false)
|
||||||
const generationHandled = ref(false)
|
const generationHandled = ref(false)
|
||||||
const deletingFlow = ref(false)
|
const deletingFlow = ref(false)
|
||||||
|
const visitedPageUuids = ref<string[]>([])
|
||||||
|
const quizResult = ref<{
|
||||||
|
score_percentage: number
|
||||||
|
pass_mark: number
|
||||||
|
correct_count: number
|
||||||
|
gradable_count: number
|
||||||
|
missing_required_keys?: string[]
|
||||||
|
} | null>(null)
|
||||||
|
const kaQuestion = ref('')
|
||||||
|
const kaLoading = ref(false)
|
||||||
|
const kaMode = ref<'separate' | 'update_page'>('separate')
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const formState = reactive<Record<string, any>>({})
|
const formState = reactive<Record<string, any>>({})
|
||||||
|
|
||||||
|
|
@ -54,30 +64,95 @@ const hasNext = computed(() => currentPageIndex.value < pages.value.length - 1)
|
||||||
const hasPrev = computed(() => currentPageIndex.value > 0)
|
const hasPrev = computed(() => currentPageIndex.value > 0)
|
||||||
const isError = computed(() => agentStore.executionStatus === 'failed')
|
const isError = computed(() => agentStore.executionStatus === 'failed')
|
||||||
|
|
||||||
|
const completedModules = computed<string[]>(() => {
|
||||||
|
const state = (session.value as unknown as { state?: Record<string, unknown> } | null)?.state
|
||||||
|
const raw = state?.completed_modules
|
||||||
|
return Array.isArray(raw) ? raw.map((item) => String(item)) : []
|
||||||
|
})
|
||||||
|
|
||||||
|
const pageHelpByPage = computed<Record<string, Array<{ question: string; answer: string; timestamp: string }>>>(() => {
|
||||||
|
const state = (session.value as unknown as { state?: Record<string, unknown> } | null)?.state
|
||||||
|
const raw = state?.page_help
|
||||||
|
return raw && typeof raw === 'object'
|
||||||
|
? (raw as Record<string, Array<{ question: string; answer: string; timestamp: string }>>)
|
||||||
|
: {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentPageHelp = computed(() => {
|
||||||
|
if (!currentPage.value) return []
|
||||||
|
return pageHelpByPage.value[currentPage.value.uuid] || []
|
||||||
|
})
|
||||||
|
|
||||||
const renderedBody = computed(() => {
|
const renderedBody = computed(() => {
|
||||||
if (!currentPage.value?.body) return ''
|
if (!currentPage.value?.body) return ''
|
||||||
return DOMPurify.sanitize(marked.parse(currentPage.value.body) as string)
|
return DOMPurify.sanitize(marked.parse(currentPage.value.body) as string)
|
||||||
})
|
})
|
||||||
|
|
||||||
const getSessionRoleUuid = (sessionData: OnboardingSessionSummary): string | undefined => {
|
|
||||||
if (typeof sessionData.role === 'string') return sessionData.role
|
|
||||||
return sessionData.role?.uuid
|
|
||||||
}
|
|
||||||
|
|
||||||
const getFlowRoleUuid = (flowData: OnboardingFlowSummary): string | undefined => {
|
const getFlowRoleUuid = (flowData: OnboardingFlowSummary): string | undefined => {
|
||||||
if (typeof flowData.role === 'string') return flowData.role
|
if (typeof flowData.role === 'string') return flowData.role
|
||||||
return flowData.role?.uuid
|
return flowData.role?.uuid
|
||||||
}
|
}
|
||||||
|
|
||||||
const findCompletedSessionForRole = async (): Promise<OnboardingSessionSummary | null> => {
|
const getPageIndexByUuid = (pageUuid?: string | null): number => {
|
||||||
const sessionRes = await apiClient.get<OnboardingSessionSummary[]>(API.onboarding.sessions.list(), {
|
if (!pageUuid) return -1
|
||||||
params: { 'role__uuid': roleId.value },
|
return pages.value.findIndex((page) => String(page.uuid) === String(pageUuid))
|
||||||
})
|
}
|
||||||
return (
|
|
||||||
sessionRes.data.find(
|
const restorePageProgressFromSession = () => {
|
||||||
(item) => item.status === 'completed' && getSessionRoleUuid(item) === roleId.value,
|
const sessionState = (session.value as unknown as { state?: Record<string, unknown> } | null)?.state
|
||||||
) || null
|
|
||||||
|
const visitedRaw = sessionState?.visited_pages
|
||||||
|
visitedPageUuids.value = Array.isArray(visitedRaw)
|
||||||
|
? visitedRaw.map((uuid) => String(uuid)).filter((uuid) => getPageIndexByUuid(uuid) >= 0)
|
||||||
|
: []
|
||||||
|
|
||||||
|
const lastPageIndex = getPageIndexByUuid(
|
||||||
|
typeof sessionState?.last_page_uuid === 'string' ? sessionState.last_page_uuid : undefined,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const visitedMaxIndex = Array.isArray(visitedRaw)
|
||||||
|
? visitedRaw.reduce((maxIndex, pageUuid) => {
|
||||||
|
const pageIndex = getPageIndexByUuid(String(pageUuid))
|
||||||
|
return pageIndex > maxIndex ? pageIndex : maxIndex
|
||||||
|
}, -1)
|
||||||
|
: -1
|
||||||
|
|
||||||
|
const completedModulesRaw = sessionState?.completed_modules
|
||||||
|
const completedMaxIndex = Array.isArray(completedModulesRaw)
|
||||||
|
? completedModulesRaw.reduce((maxIndex, pageUuid) => {
|
||||||
|
const pageIndex = getPageIndexByUuid(String(pageUuid))
|
||||||
|
return pageIndex > maxIndex ? pageIndex : maxIndex
|
||||||
|
}, -1)
|
||||||
|
: -1
|
||||||
|
|
||||||
|
const inferredInProgressIndex =
|
||||||
|
completedMaxIndex >= 0 && completedMaxIndex < pages.value.length - 1
|
||||||
|
? completedMaxIndex + 1
|
||||||
|
: completedMaxIndex
|
||||||
|
|
||||||
|
const responsesRaw = sessionState?.responses
|
||||||
|
const responseMaxIndex = responsesRaw && typeof responsesRaw === 'object'
|
||||||
|
? Object.keys(responsesRaw as Record<string, unknown>).reduce((maxIndex, pageUuid) => {
|
||||||
|
const pageIndex = getPageIndexByUuid(String(pageUuid))
|
||||||
|
return pageIndex > maxIndex ? pageIndex : maxIndex
|
||||||
|
}, -1)
|
||||||
|
: -1
|
||||||
|
|
||||||
|
const resumeIndex = Math.max(
|
||||||
|
0,
|
||||||
|
lastPageIndex,
|
||||||
|
visitedMaxIndex,
|
||||||
|
responseMaxIndex,
|
||||||
|
completedMaxIndex,
|
||||||
|
inferredInProgressIndex,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (pages.value.length === 0) {
|
||||||
|
currentPageIndex.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPageIndex.value = Math.min(resumeIndex, pages.value.length - 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const retryGeneration = async () => {
|
const retryGeneration = async () => {
|
||||||
|
|
@ -86,7 +161,7 @@ const retryGeneration = async () => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get<OnboardingFlow[]>(API.onboarding.flows.list(), {
|
const response = await apiClient.get<OnboardingFlow[]>(API.onboarding.flows.list(), {
|
||||||
params: { 'role__uuid': roleId.value },
|
params: { role_uuid: roleId.value },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.data && response.data.length > 0) {
|
if (response.data && response.data.length > 0) {
|
||||||
|
|
@ -116,6 +191,8 @@ const resetCurrentFlow = async () => {
|
||||||
flowDetails.value = null
|
flowDetails.value = null
|
||||||
session.value = null
|
session.value = null
|
||||||
currentPageIndex.value = 0
|
currentPageIndex.value = 0
|
||||||
|
visitedPageUuids.value = []
|
||||||
|
quizResult.value = null
|
||||||
Object.keys(formState).forEach((k) => delete formState[k])
|
Object.keys(formState).forEach((k) => delete formState[k])
|
||||||
|
|
||||||
generationHandled.value = false
|
generationHandled.value = false
|
||||||
|
|
@ -137,7 +214,7 @@ const initOnboarding = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get<OnboardingFlow[]>(API.onboarding.flows.list(), {
|
const response = await apiClient.get<OnboardingFlow[]>(API.onboarding.flows.list(), {
|
||||||
params: { 'role__uuid': roleId.value },
|
params: { role_uuid: roleId.value },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.data && response.data.length > 0) {
|
if (response.data && response.data.length > 0) {
|
||||||
|
|
@ -145,19 +222,12 @@ const initOnboarding = async () => {
|
||||||
if (!matchingFlow) {
|
if (!matchingFlow) {
|
||||||
flowDetails.value = null
|
flowDetails.value = null
|
||||||
session.value = null
|
session.value = null
|
||||||
|
visitedPageUuids.value = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
flowDetails.value = matchingFlow
|
flowDetails.value = matchingFlow
|
||||||
|
|
||||||
const completedSession = await findCompletedSessionForRole()
|
|
||||||
if (completedSession) {
|
|
||||||
session.value = completedSession as unknown as OnboardingSession
|
|
||||||
currentPageIndex.value = 0
|
|
||||||
Object.keys(formState).forEach((k) => delete formState[k])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await loadFlow(matchingFlow.uuid)
|
await loadFlow(matchingFlow.uuid)
|
||||||
} else {
|
} else {
|
||||||
if (!generationHandled.value) {
|
if (!generationHandled.value) {
|
||||||
|
|
@ -212,6 +282,8 @@ watch(
|
||||||
flowDetails.value = null
|
flowDetails.value = null
|
||||||
session.value = null
|
session.value = null
|
||||||
currentPageIndex.value = 0
|
currentPageIndex.value = 0
|
||||||
|
visitedPageUuids.value = []
|
||||||
|
quizResult.value = null
|
||||||
generationHandled.value = false
|
generationHandled.value = false
|
||||||
isAutoGenerating.value = false
|
isAutoGenerating.value = false
|
||||||
Object.keys(formState).forEach((k) => delete formState[k])
|
Object.keys(formState).forEach((k) => delete formState[k])
|
||||||
|
|
@ -234,41 +306,243 @@ const loadFlow = async (flowUuid: string) => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
restorePageProgressFromSession()
|
||||||
|
syncVisitedPages()
|
||||||
hydrateFormState()
|
hydrateFormState()
|
||||||
|
await persistCurrentPageVisit()
|
||||||
}
|
}
|
||||||
|
|
||||||
const hydrateFormState = () => {
|
const hydrateFormState = () => {
|
||||||
if (!currentPage.value) return
|
if (!currentPage.value) return
|
||||||
|
|
||||||
|
const sessionState = (session.value as unknown as { state?: Record<string, unknown> } | null)?.state
|
||||||
|
const storedResponsesRaw = sessionState?.responses
|
||||||
|
const pageStoredResponses =
|
||||||
|
storedResponsesRaw &&
|
||||||
|
typeof storedResponsesRaw === 'object' &&
|
||||||
|
currentPage.value.uuid in (storedResponsesRaw as Record<string, unknown>)
|
||||||
|
? ((storedResponsesRaw as Record<string, unknown>)[
|
||||||
|
currentPage.value.uuid
|
||||||
|
] as Record<string, unknown>)
|
||||||
|
: {}
|
||||||
|
|
||||||
Object.keys(formState).forEach((k) => delete formState[k])
|
Object.keys(formState).forEach((k) => delete formState[k])
|
||||||
currentPage.value.fields?.forEach((f) => {
|
currentPage.value.fields?.forEach((f) => {
|
||||||
|
if (pageStoredResponses && f.key in pageStoredResponses) {
|
||||||
|
formState[f.key] = pageStoredResponses[f.key]
|
||||||
|
return
|
||||||
|
}
|
||||||
formState[f.key] = f.default_value ?? ''
|
formState[f.key] = f.default_value ?? ''
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const buildCurrentResponses = () => {
|
||||||
|
const response: Record<string, unknown> = {}
|
||||||
|
currentPage.value?.fields?.forEach((field) => {
|
||||||
|
response[field.key] = formState[field.key]
|
||||||
|
})
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncVisitedPages = () => {
|
||||||
|
const nextVisited = new Set<string>(visitedPageUuids.value)
|
||||||
|
|
||||||
|
const sessionState = (session.value as unknown as { state?: Record<string, unknown> } | null)?.state
|
||||||
|
const visitedRaw = sessionState?.visited_pages
|
||||||
|
if (Array.isArray(visitedRaw)) {
|
||||||
|
visitedRaw.forEach((pageUuid) => {
|
||||||
|
nextVisited.add(String(pageUuid))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
completedModules.value.forEach((pageUuid) => {
|
||||||
|
nextVisited.add(String(pageUuid))
|
||||||
|
})
|
||||||
|
|
||||||
|
const storedResponsesRaw = sessionState?.responses
|
||||||
|
if (storedResponsesRaw && typeof storedResponsesRaw === 'object') {
|
||||||
|
Object.keys(storedResponsesRaw as Record<string, unknown>).forEach((pageUuid) => {
|
||||||
|
nextVisited.add(String(pageUuid))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUuid = currentPage.value?.uuid
|
||||||
|
if (currentUuid) {
|
||||||
|
nextVisited.add(String(currentUuid))
|
||||||
|
}
|
||||||
|
|
||||||
|
visitedPageUuids.value = Array.from(nextVisited)
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistCurrentPageVisit = async () => {
|
||||||
|
if (!session.value || !currentPage.value || session.value.status === 'completed') return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post<{
|
||||||
|
status: string
|
||||||
|
session_state?: Record<string, unknown>
|
||||||
|
}>(API.onboarding.sessions.interact(session.value.uuid), {
|
||||||
|
page_uuid: currentPage.value.uuid,
|
||||||
|
})
|
||||||
|
|
||||||
|
const apiSessionState = response.data?.session_state
|
||||||
|
if (apiSessionState && session.value) {
|
||||||
|
;(session.value as unknown as { state?: Record<string, unknown> }).state = apiSessionState
|
||||||
|
syncVisitedPages()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Avoid noisy errors for background navigation sync attempts.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPageStatus = (page: OnboardingPage, index: number) => {
|
||||||
|
if (completedModules.value.includes(page.uuid)) return 'Completed'
|
||||||
|
if (index === currentPageIndex.value) return 'In Progress'
|
||||||
|
if (visitedPageUuids.value.includes(page.uuid)) return 'In Progress'
|
||||||
|
return 'Not Started'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPageStatusColor = (page: OnboardingPage, index: number) => {
|
||||||
|
const status = getPageStatus(page, index)
|
||||||
|
if (status === 'Completed') return 'green'
|
||||||
|
if (status === 'In Progress') return 'blue'
|
||||||
|
return 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
const canNavigateToPage = (targetIndex: number) => {
|
||||||
|
if (targetIndex <= currentPageIndex.value) return true
|
||||||
|
for (let index = 0; index < targetIndex; index += 1) {
|
||||||
|
const previousPage = pages.value[index]
|
||||||
|
if (!previousPage) return false
|
||||||
|
if (!completedModules.value.includes(previousPage.uuid)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const jumpToPage = (index: number) => {
|
||||||
|
if (!canNavigateToPage(index)) {
|
||||||
|
message.warning('Complete required attempts on earlier modules first.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentPageIndex.value = index
|
||||||
|
window.scrollTo(0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const goBackPage = () => {
|
||||||
|
if (!hasPrev.value) return
|
||||||
|
currentPageIndex.value -= 1
|
||||||
|
window.scrollTo(0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
const onSubmitPage = async () => {
|
const onSubmitPage = async () => {
|
||||||
if (!currentPage.value || !session.value) return
|
if (!currentPage.value || !session.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.post(API.onboarding.sessions.interact(session.value.uuid), {
|
const response = await apiClient.post<{
|
||||||
|
status: string
|
||||||
|
session_state?: Record<string, unknown>
|
||||||
|
}>(API.onboarding.sessions.interact(session.value.uuid), {
|
||||||
page_uuid: currentPage.value.uuid,
|
page_uuid: currentPage.value.uuid,
|
||||||
responses: formState,
|
responses: buildCurrentResponses(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const apiSessionState = response.data?.session_state
|
||||||
|
if (apiSessionState && session.value) {
|
||||||
|
;(session.value as unknown as { state?: Record<string, unknown> }).state = apiSessionState
|
||||||
|
}
|
||||||
|
|
||||||
|
syncVisitedPages()
|
||||||
|
|
||||||
|
quizResult.value = null
|
||||||
|
|
||||||
if (hasNext.value) {
|
if (hasNext.value) {
|
||||||
currentPageIndex.value++
|
currentPageIndex.value++
|
||||||
hydrateFormState()
|
|
||||||
window.scrollTo(0, 0)
|
window.scrollTo(0, 0)
|
||||||
} else {
|
return
|
||||||
await apiClient.post(API.onboarding.sessions.complete(session.value.uuid))
|
}
|
||||||
|
|
||||||
|
const completeResponse = await apiClient.post<{
|
||||||
|
message: string
|
||||||
|
quiz_result?: {
|
||||||
|
score_percentage: number
|
||||||
|
pass_mark: number
|
||||||
|
correct_count: number
|
||||||
|
gradable_count: number
|
||||||
|
missing_required_keys?: string[]
|
||||||
|
}
|
||||||
|
}>(API.onboarding.sessions.complete(session.value.uuid))
|
||||||
|
|
||||||
|
if (completeResponse.data?.quiz_result) {
|
||||||
|
quizResult.value = completeResponse.data.quiz_result
|
||||||
|
}
|
||||||
|
|
||||||
message.success('Onboarding Finished!')
|
message.success('Onboarding Finished!')
|
||||||
router.push('/organization')
|
router.push('/organization')
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (isAxiosError<{ error?: string; quiz_result?: typeof quizResult.value }>(error)) {
|
||||||
|
const data = error.response?.data
|
||||||
|
if (data?.quiz_result) {
|
||||||
|
quizResult.value = data.quiz_result
|
||||||
|
message.error(
|
||||||
|
`${data.error || 'Final quiz not passed.'} Score: ${data.quiz_result.score_percentage}% (required ${data.quiz_result.pass_mark}%).`,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
message.error('Failed to save progress')
|
message.error('Failed to save progress')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const askKnowledgeAgent = async () => {
|
||||||
|
if (!session.value || !currentPage.value || !kaQuestion.value.trim()) return
|
||||||
|
|
||||||
|
kaLoading.value = true
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post<{
|
||||||
|
status: string
|
||||||
|
answer: string
|
||||||
|
updated_page: boolean
|
||||||
|
session_state?: Record<string, unknown>
|
||||||
|
}>(API.onboarding.sessions.askKa(session.value.uuid), {
|
||||||
|
page_uuid: currentPage.value.uuid,
|
||||||
|
message: kaQuestion.value,
|
||||||
|
mode: kaMode.value,
|
||||||
|
})
|
||||||
|
|
||||||
|
const apiSessionState = response.data?.session_state
|
||||||
|
if (apiSessionState && session.value) {
|
||||||
|
;(session.value as unknown as { state?: Record<string, unknown> }).state = apiSessionState
|
||||||
|
}
|
||||||
|
|
||||||
|
syncVisitedPages()
|
||||||
|
|
||||||
|
if (response.data?.updated_page && flowDetails.value) {
|
||||||
|
const flowResponse = await apiClient.get<OnboardingFlow>(
|
||||||
|
API.onboarding.flows.byId(flowDetails.value.uuid),
|
||||||
|
)
|
||||||
|
flowDetails.value = flowResponse.data
|
||||||
|
}
|
||||||
|
|
||||||
|
kaQuestion.value = ''
|
||||||
|
} catch {
|
||||||
|
message.error('Could not retrieve clarification right now')
|
||||||
|
} finally {
|
||||||
|
kaLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => initOnboarding())
|
onMounted(() => initOnboarding())
|
||||||
onUnmounted(() => agentStore.disconnect())
|
onUnmounted(() => agentStore.disconnect())
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => currentPageIndex.value,
|
||||||
|
async () => {
|
||||||
|
kaQuestion.value = ''
|
||||||
|
syncVisitedPages()
|
||||||
|
hydrateFormState()
|
||||||
|
await persistCurrentPageVisit()
|
||||||
|
},
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -315,8 +589,9 @@ onUnmounted(() => agentStore.disconnect())
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card v-else-if="flowDetails" class="dark-panel content-card">
|
<template v-else-if="flowDetails">
|
||||||
<div v-if="session?.status === 'completed'" class="completed-card">
|
<Card v-if="session?.status === 'completed'" class="dark-panel content-card">
|
||||||
|
<div class="completed-card">
|
||||||
<Typography.Title :level="3" class="white-text">
|
<Typography.Title :level="3" class="white-text">
|
||||||
Onboarding Already Completed
|
Onboarding Already Completed
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
|
|
@ -339,8 +614,37 @@ onUnmounted(() => agentStore.disconnect())
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<template v-else>
|
<div v-else class="flow-shell">
|
||||||
|
<Card class="dark-panel toc-card">
|
||||||
|
<aside>
|
||||||
|
<Typography.Title :level="5" class="white-text toc-title">
|
||||||
|
Contents
|
||||||
|
</Typography.Title>
|
||||||
|
<div class="toc-list">
|
||||||
|
<button
|
||||||
|
v-for="(page, index) in pages"
|
||||||
|
:key="page.uuid"
|
||||||
|
type="button"
|
||||||
|
class="toc-item"
|
||||||
|
:class="{
|
||||||
|
active: index === currentPageIndex,
|
||||||
|
blocked: !canNavigateToPage(index),
|
||||||
|
}"
|
||||||
|
@click="jumpToPage(index)"
|
||||||
|
>
|
||||||
|
<span class="toc-index">{{ index + 1 }}</span>
|
||||||
|
<span class="toc-text">{{ page.title }}</span>
|
||||||
|
<Tag :color="getPageStatusColor(page, index)">
|
||||||
|
{{ getPageStatus(page, index) }}
|
||||||
|
</Tag>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="dark-panel content-card main-content-card">
|
||||||
<div class="flow-header-row">
|
<div class="flow-header-row">
|
||||||
<Typography.Title :level="2" class="white-text flow-title">
|
<Typography.Title :level="2" class="white-text flow-title">
|
||||||
{{ flowDetails.title }}
|
{{ flowDetails.title }}
|
||||||
|
|
@ -357,20 +661,20 @@ onUnmounted(() => agentStore.disconnect())
|
||||||
<Typography.Paragraph class="white-text" style="opacity: 0.8">
|
<Typography.Paragraph class="white-text" style="opacity: 0.8">
|
||||||
{{ flowDetails.description }}
|
{{ flowDetails.description }}
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
<Divider style="border-color: #334155" />
|
<Divider style="border-color: #dbe3ec" />
|
||||||
|
|
||||||
<div v-if="currentPage">
|
<section class="flow-content" v-if="currentPage">
|
||||||
<Typography.Title :level="4" class="white-text">
|
<Typography.Title :level="4" class="white-text">
|
||||||
{{ currentPage.title }}
|
{{ currentPage.title }}
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
<div class="markdown-body" v-html="renderedBody"></div>
|
<div class="markdown-body" v-html="renderedBody"></div>
|
||||||
<Divider dashed style="border-color: #334155" />
|
<Divider dashed style="border-color: #dbe3ec" />
|
||||||
|
|
||||||
<Form layout="vertical" :model="formState" @finish="onSubmitPage">
|
<Form layout="vertical" :model="formState" @finish="onSubmitPage">
|
||||||
<Form.Item
|
<Form.Item
|
||||||
v-for="field in currentPage.fields"
|
v-for="(field, fieldIndex) in currentPage.fields"
|
||||||
:key="field.uuid"
|
:key="field.uuid"
|
||||||
:label="field.label"
|
:label="`${fieldIndex + 1}. ${field.label}`"
|
||||||
class="white-label"
|
class="white-label"
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -398,17 +702,93 @@ onUnmounted(() => agentStore.disconnect())
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<Button :disabled="!hasPrev" @click="currentPageIndex--">
|
<Button :disabled="!hasPrev" @click="goBackPage">
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="primary" html-type="submit" size="large">
|
<Button type="primary" html-type="submit" size="large">
|
||||||
{{ hasNext ? 'Next Module' : 'Complete Onboarding' }}
|
{{ hasNext ? 'Next Module' : 'Submit Quiz & Complete' }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="quizResult && !hasNext" class="feedback-box">
|
||||||
|
<Typography.Title :level="5" class="white-text">
|
||||||
|
Final Quiz Result
|
||||||
|
</Typography.Title>
|
||||||
|
<Typography.Paragraph
|
||||||
|
class="white-text"
|
||||||
|
style="opacity: 0.8"
|
||||||
|
>
|
||||||
|
Score: {{ quizResult.score_percentage }}% | Pass mark:
|
||||||
|
{{ quizResult.pass_mark }}% | Correct:
|
||||||
|
{{ quizResult.correct_count }}/{{ quizResult.gradable_count }}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Typography.Paragraph
|
||||||
|
v-if="quizResult.missing_required_keys?.length"
|
||||||
|
class="white-text"
|
||||||
|
style="opacity: 0.8"
|
||||||
|
>
|
||||||
|
Missing required answers:
|
||||||
|
{{ quizResult.missing_required_keys.join(', ') }}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider dashed style="border-color: #dbe3ec" />
|
||||||
|
|
||||||
|
<div class="ka-help-box">
|
||||||
|
<Typography.Title :level="5" class="white-text" style="margin-bottom: 8px">
|
||||||
|
Need clarification?
|
||||||
|
</Typography.Title>
|
||||||
|
<Typography.Paragraph class="white-text" style="opacity: 0.8">
|
||||||
|
Ask the Knowledge Agent to explain this page or refine the page content.
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Input.TextArea
|
||||||
|
v-model:value="kaQuestion"
|
||||||
|
:auto-size="{ minRows: 2, maxRows: 5 }"
|
||||||
|
placeholder="Ask what you don’t understand about this module..."
|
||||||
|
/>
|
||||||
|
<div class="ka-actions">
|
||||||
|
<Select
|
||||||
|
v-model:value="kaMode"
|
||||||
|
:options="[
|
||||||
|
{ label: 'Show separate answer below (will not save)', value: 'separate' },
|
||||||
|
{ label: 'Update current page content', value: 'update_page' },
|
||||||
|
]"
|
||||||
|
style="min-width: 280px"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="default"
|
||||||
|
:loading="kaLoading"
|
||||||
|
:disabled="!kaQuestion.trim()"
|
||||||
|
@click="askKnowledgeAgent"
|
||||||
|
>
|
||||||
|
Ask KA
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="currentPageHelp.length" class="ka-thread">
|
||||||
|
<div
|
||||||
|
v-for="(entry, idx) in currentPageHelp"
|
||||||
|
:key="`${entry.timestamp}-${idx}`"
|
||||||
|
class="ka-thread-item"
|
||||||
|
>
|
||||||
|
<Typography.Text class="white-text" strong>
|
||||||
|
You:
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Paragraph class="white-text" style="opacity: 0.9; margin-bottom: 6px">
|
||||||
|
{{ entry.question }}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Typography.Text class="white-text" strong>
|
||||||
|
KA:
|
||||||
|
</Typography.Text>
|
||||||
|
<div class="markdown-body" v-html="DOMPurify.sanitize(marked.parse(entry.answer) as string)"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
|
</section>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Empty v-else-if="!loading" description="Role Context Missing" />
|
<Empty v-else-if="!loading" description="Role Context Missing" />
|
||||||
</Spin>
|
</Spin>
|
||||||
|
|
@ -417,14 +797,14 @@ onUnmounted(() => agentStore.disconnect())
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page-container {
|
.page-container {
|
||||||
max-width: 900px;
|
max-width: 1280px;
|
||||||
margin: 2rem auto;
|
margin: 2rem auto;
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
}
|
}
|
||||||
.dark-panel {
|
.dark-panel {
|
||||||
background: #0f172a;
|
background: #ffffff;
|
||||||
border: 1px solid #1e293b;
|
border: 1px solid #dbe3ec;
|
||||||
color: #f1f5f9;
|
color: #1f2937;
|
||||||
}
|
}
|
||||||
.pipeline-header {
|
.pipeline-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -449,21 +829,87 @@ onUnmounted(() => agentStore.disconnect())
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flow-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px minmax(0, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-card {
|
||||||
|
position: sticky;
|
||||||
|
top: 1rem;
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content-card {
|
||||||
|
width: min(900px, 100%);
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-title {
|
||||||
|
margin-bottom: 0.75rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #dbe3ec;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #1f2937;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-item.active {
|
||||||
|
border-color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-item.blocked {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-index {
|
||||||
|
min-width: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-text {
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-content {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.white-text {
|
.white-text {
|
||||||
color: #ffffff !important;
|
color: #1f2937 !important;
|
||||||
}
|
}
|
||||||
.white-label :deep(.ant-form-item-label > label) {
|
.white-label :deep(.ant-form-item-label > label) {
|
||||||
color: #ffffff !important;
|
color: #1f2937 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.orchestrator-logs {
|
.orchestrator-logs {
|
||||||
background: #020617;
|
background: #f8fafc;
|
||||||
padding: 1.2rem;
|
padding: 1.2rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
height: 300px;
|
height: 300px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
border: 1px solid #334155;
|
border: 1px solid #dbe3ec;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column-reverse;
|
flex-direction: column-reverse;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
|
@ -472,25 +918,25 @@ onUnmounted(() => agentStore.disconnect())
|
||||||
.log-entry {
|
.log-entry {
|
||||||
margin-bottom: 0.8rem;
|
margin-bottom: 0.8rem;
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
border-bottom: 1px solid #1e293b;
|
border-bottom: 1px solid #dbe3ec;
|
||||||
}
|
}
|
||||||
.log-msg {
|
.log-msg {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
.markdown-body {
|
.markdown-body {
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
color: #e2e8f0;
|
color: #1f2937;
|
||||||
}
|
}
|
||||||
.markdown-body :deep(h1),
|
.markdown-body :deep(h1),
|
||||||
.markdown-body :deep(h2),
|
.markdown-body :deep(h2),
|
||||||
.markdown-body :deep(h3),
|
.markdown-body :deep(h3),
|
||||||
.markdown-body :deep(h4) {
|
.markdown-body :deep(h4) {
|
||||||
color: #ffffff;
|
color: #1f2937;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
.markdown-body :deep(p),
|
.markdown-body :deep(p),
|
||||||
.markdown-body :deep(li) {
|
.markdown-body :deep(li) {
|
||||||
color: #e2e8f0;
|
color: #374151;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-retry-zone {
|
.error-retry-zone {
|
||||||
|
|
@ -517,6 +963,44 @@ onUnmounted(() => agentStore.disconnect())
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.feedback-box {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid #dbe3ec;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ka-help-box {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid #dbe3ec;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ka-actions {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ka-thread {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ka-thread-item {
|
||||||
|
border: 1px solid #dbe3ec;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
.pulse {
|
.pulse {
|
||||||
animation: pulse 2s infinite;
|
animation: pulse 2s infinite;
|
||||||
}
|
}
|
||||||
|
|
@ -532,9 +1016,19 @@ onUnmounted(() => agentStore.disconnect())
|
||||||
|
|
||||||
:deep(.ant-steps-item-title),
|
:deep(.ant-steps-item-title),
|
||||||
:deep(.ant-steps-item-description) {
|
:deep(.ant-steps-item-description) {
|
||||||
color: #94a3b8 !important;
|
color: #6b7280 !important;
|
||||||
}
|
}
|
||||||
:deep(.ant-steps-item-active .ant-steps-item-title) {
|
:deep(.ant-steps-item-active .ant-steps-item-title) {
|
||||||
color: #ffffff !important;
|
color: #1f2937 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.flow-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-card {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed, h } from 'vue'
|
import { ref, onMounted, computed, h, watch } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
|
|
@ -23,7 +23,7 @@ import type { Role, Organization, TrainingFile } from '../types/organization'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const organizationUuid = route.params.organizationUuid as string
|
const organizationUuid = computed(() => String(route.params.organizationUuid || ''))
|
||||||
|
|
||||||
const organization = ref<Organization | null>(null)
|
const organization = ref<Organization | null>(null)
|
||||||
const roles = ref<Role[]>([])
|
const roles = ref<Role[]>([])
|
||||||
|
|
@ -31,6 +31,7 @@ const members = ref<Array<{ uuid: string; is_manager?: boolean }>>([])
|
||||||
const trainingFiles = ref<TrainingFile[]>([])
|
const trainingFiles = ref<TrainingFile[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const uploading = ref(false)
|
const uploading = ref(false)
|
||||||
|
const leavingOrganization = ref(false)
|
||||||
const showUploadModal = ref(false)
|
const showUploadModal = ref(false)
|
||||||
const auth = useUserStore()
|
const auth = useUserStore()
|
||||||
|
|
||||||
|
|
@ -42,11 +43,27 @@ const isManager = computed(() => {
|
||||||
return members.value.some((member) => member.uuid === auth.user?.uuid && member.is_manager)
|
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 () => {
|
const fetchOrganization = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get<Organization>(API.organization.byId(organizationUuid))
|
if (!organizationUuid.value) {
|
||||||
|
organization.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const response = await apiClient.get<Organization>(API.organization.byId(organizationUuid.value))
|
||||||
organization.value = response.data
|
organization.value = response.data
|
||||||
|
auth.setSelectedOrganization(response.data)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch organization:', error)
|
console.error('Failed to fetch organization:', error)
|
||||||
message.error('Failed to load organization details')
|
message.error('Failed to load organization details')
|
||||||
|
|
@ -58,7 +75,7 @@ const fetchOrganization = async () => {
|
||||||
const fetchRoles = async () => {
|
const fetchRoles = async () => {
|
||||||
if (!organization.value?.uuid) return
|
if (!organization.value?.uuid) return
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get<Role[]>(API.organization.roles.list(organization.value.uuid))
|
const response = await apiClient.get<Role[]>(API.roles.list(organization.value.uuid))
|
||||||
roles.value = response.data
|
roles.value = response.data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch roles:', error)
|
console.error('Failed to fetch roles:', error)
|
||||||
|
|
@ -68,7 +85,7 @@ const fetchRoles = async () => {
|
||||||
const fetchUserRoleMemberships = async () => {
|
const fetchUserRoleMemberships = async () => {
|
||||||
if (!organization.value?.uuid) return
|
if (!organization.value?.uuid) return
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get<Role[]>(API.organization.roles.mine())
|
const response = await apiClient.get<Role[]>(API.roles.mine())
|
||||||
const mine = Array.isArray(response.data) ? response.data : []
|
const mine = Array.isArray(response.data) ? response.data : []
|
||||||
const orgUuid = organization.value.uuid
|
const orgUuid = organization.value.uuid
|
||||||
const joinedRoles = mine.filter((role) => role.organization?.uuid === orgUuid)
|
const joinedRoles = mine.filter((role) => role.organization?.uuid === orgUuid)
|
||||||
|
|
@ -83,6 +100,23 @@ const isRoleJoined = (roleUuid: string | undefined) => {
|
||||||
return auth.userJoinedRoles.some((role) => role.uuid === roleUuid)
|
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 () => {
|
const fetchMembers = async () => {
|
||||||
if (!organization.value?.uuid) return
|
if (!organization.value?.uuid) return
|
||||||
try {
|
try {
|
||||||
|
|
@ -118,7 +152,7 @@ const selectRole = async (roleUuid: string) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.post(API.organization.roles.join(organization.value.uuid, roleUuid))
|
await apiClient.post(API.roles.join(organization.value.uuid, roleUuid))
|
||||||
message.success('Successfully joined role')
|
message.success('Successfully joined role')
|
||||||
if (!auth.userJoinedRoles.some((role) => role.uuid === roleUuid)) {
|
if (!auth.userJoinedRoles.some((role) => role.uuid === roleUuid)) {
|
||||||
auth.setJoinedRoles([
|
auth.setJoinedRoles([
|
||||||
|
|
@ -138,7 +172,7 @@ const fetchTrainingFiles = async () => {
|
||||||
if (!organization.value?.uuid) return
|
if (!organization.value?.uuid) return
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get<TrainingFile[]>(API.knowledge.trainingFiles.list(), {
|
const response = await apiClient.get<TrainingFile[]>(API.knowledge.trainingFiles.list(), {
|
||||||
params: { 'role__organization__uuid': organization.value.uuid },
|
params: { organization_uuid: organization.value.uuid },
|
||||||
})
|
})
|
||||||
trainingFiles.value = response.data
|
trainingFiles.value = response.data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -219,7 +253,7 @@ const handleFileUpload = async (file: File, description: string = '') => {
|
||||||
formData.append('file_name', file.name)
|
formData.append('file_name', file.name)
|
||||||
formData.append('description', description)
|
formData.append('description', description)
|
||||||
if (selectedRoleUuid.value) {
|
if (selectedRoleUuid.value) {
|
||||||
formData.append('role', selectedRoleUuid.value)
|
formData.append('role_uuid', selectedRoleUuid.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await apiClient.post<TrainingFile>(
|
const response = await apiClient.post<TrainingFile>(
|
||||||
|
|
@ -352,14 +386,68 @@ const trainingFileColumns = [
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
onMounted(async () => {
|
const loadOrganizationContext = async () => {
|
||||||
await auth.fetchSession(true)
|
await auth.fetchSession(true)
|
||||||
await fetchOrganization()
|
await fetchOrganization()
|
||||||
await fetchMembers()
|
await fetchMembers()
|
||||||
await fetchRoles()
|
await fetchRoles()
|
||||||
await fetchUserRoleMemberships()
|
await fetchUserRoleMemberships()
|
||||||
await fetchTrainingFiles()
|
await fetchTrainingFiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => organizationUuid.value,
|
||||||
|
async (next, prev) => {
|
||||||
|
if (!next || next === prev) return
|
||||||
|
roles.value = []
|
||||||
|
members.value = []
|
||||||
|
trainingFiles.value = []
|
||||||
|
await loadOrganizationContext()
|
||||||
|
},
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -368,6 +456,7 @@ onMounted(async () => {
|
||||||
<Card v-if="organization" class="panel" :bordered="false">
|
<Card v-if="organization" class="panel" :bordered="false">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<Typography.Title :level="2">{{ organization.name }}</Typography.Title>
|
<Typography.Title :level="2">{{ organization.name }}</Typography.Title>
|
||||||
|
<Space>
|
||||||
<Button
|
<Button
|
||||||
v-if="isManager"
|
v-if="isManager"
|
||||||
type="primary"
|
type="primary"
|
||||||
|
|
@ -375,6 +464,20 @@ onMounted(async () => {
|
||||||
>
|
>
|
||||||
Manage Organization
|
Manage Organization
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Typography.Paragraph v-if="organization.description">
|
<Typography.Paragraph v-if="organization.description">
|
||||||
|
|
@ -450,19 +553,31 @@ onMounted(async () => {
|
||||||
<Button
|
<Button
|
||||||
type="default"
|
type="default"
|
||||||
size="small"
|
size="small"
|
||||||
@click="router.push(`/onboarding/${item.uuid}`)"
|
:disabled="!canStartOnboarding(item.uuid)"
|
||||||
|
:title="
|
||||||
|
!canStartOnboarding(item.uuid)
|
||||||
|
? 'Join this role before starting onboarding.'
|
||||||
|
: 'Start onboarding'
|
||||||
|
"
|
||||||
|
@click="startOnboarding(item.uuid)"
|
||||||
>
|
>
|
||||||
Start Onboarding
|
{{ canStartOnboarding(item.uuid) ? 'Start Onboarding' : 'Join Role First' }}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
v-if="item.uuid && !isRoleJoined(item.uuid) && !isManager"
|
v-if="!isManager && item.uuid && !isRoleJoined(item.uuid)"
|
||||||
type="primary"
|
type="primary"
|
||||||
size="small"
|
size="small"
|
||||||
@click="selectRole(item.uuid)"
|
@click="selectRole(item.uuid)"
|
||||||
>
|
>
|
||||||
Join Role
|
Join Role
|
||||||
</Button>
|
</Button>
|
||||||
<Button v-else size="small" disabled>Joined</Button>
|
<Button
|
||||||
|
v-else-if="!isManager"
|
||||||
|
size="small"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
Joined
|
||||||
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -543,8 +658,10 @@ onMounted(async () => {
|
||||||
|
|
||||||
.role-item :deep(.ant-list-item-meta-title),
|
.role-item :deep(.ant-list-item-meta-title),
|
||||||
.role-item :deep(.ant-list-item-meta-description) {
|
.role-item :deep(.ant-list-item-meta-description) {
|
||||||
background: #0f172a;
|
color: #1f2937;
|
||||||
border: 1px solid #1f2937;
|
}
|
||||||
color: #e5e7eb;
|
|
||||||
|
.role-item {
|
||||||
|
border-bottom: none !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ const auth = useUserStore()
|
||||||
const organizations = ref<Organization[]>([])
|
const organizations = ref<Organization[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const creatingOrganization = ref(false)
|
const creatingOrganization = ref(false)
|
||||||
|
const leavingOrganizationUuid = ref<string | null>(null)
|
||||||
const showCreateOrgModal = ref(false)
|
const showCreateOrgModal = ref(false)
|
||||||
const createOrgForm = ref({
|
const createOrgForm = ref({
|
||||||
name: '',
|
name: '',
|
||||||
|
|
@ -54,6 +55,49 @@ const openOrg = (org: Organization) => {
|
||||||
router.push(`/organization/${org.uuid}`)
|
router.push(`/organization/${org.uuid}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isOwner = (org: Organization) => {
|
||||||
|
return auth.user?.uuid === org.owner?.uuid
|
||||||
|
}
|
||||||
|
|
||||||
|
const canLeaveOrganization = (org: Organization) => {
|
||||||
|
if (!isOwner(org)) return true
|
||||||
|
return (org.member_count ?? 0) <= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const leaveOrganization = (org: Organization) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: 'Leave organization',
|
||||||
|
content: isOwner(org)
|
||||||
|
? 'As owner, leaving will delete this organization because no other members remain. Continue?'
|
||||||
|
: `Are you sure you want to leave "${org.name}"?`,
|
||||||
|
okText: 'Leave',
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: 'Cancel',
|
||||||
|
onOk: async () => {
|
||||||
|
leavingOrganizationUuid.value = org.uuid
|
||||||
|
try {
|
||||||
|
await apiClient.post(API.organization.leave(org.uuid))
|
||||||
|
message.success(
|
||||||
|
isOwner(org)
|
||||||
|
? 'Organization deleted and ownership closed.'
|
||||||
|
: 'You left the organization.',
|
||||||
|
)
|
||||||
|
await auth.fetchJoinedOrganizations()
|
||||||
|
await fetchOrganizations()
|
||||||
|
} 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 {
|
||||||
|
leavingOrganizationUuid.value = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const resetCreateOrganizationForm = () => {
|
const resetCreateOrganizationForm = () => {
|
||||||
createOrgForm.value = { name: '', description: '' }
|
createOrgForm.value = { name: '', description: '' }
|
||||||
}
|
}
|
||||||
|
|
@ -122,6 +166,21 @@ const handleCreateOrganization = async () => {
|
||||||
<Button size="small" type="primary" @click="openOrg(item)">
|
<Button size="small" type="primary" @click="openOrg(item)">
|
||||||
Open
|
Open
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
size="small"
|
||||||
|
style="margin-left: 0.5rem"
|
||||||
|
:loading="leavingOrganizationUuid === item.uuid"
|
||||||
|
:disabled="!canLeaveOrganization(item)"
|
||||||
|
:title="
|
||||||
|
!canLeaveOrganization(item)
|
||||||
|
? 'Owner can leave only when no other members/managers remain.'
|
||||||
|
: 'Leave organization'
|
||||||
|
"
|
||||||
|
@click="leaveOrganization(item)"
|
||||||
|
>
|
||||||
|
Leave
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ const loadData = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const [rolesRes, sessionsRes] = await Promise.all([
|
const [rolesRes, sessionsRes] = await Promise.all([
|
||||||
apiClient.get<Role[]>(API.organization.roles.mine()),
|
apiClient.get<Role[]>(API.roles.mine()),
|
||||||
apiClient.get<ProgressSessionApi[]>(API.onboarding.sessions.list()),
|
apiClient.get<ProgressSessionApi[]>(API.onboarding.sessions.list()),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
@ -196,8 +196,8 @@ onMounted(() => {
|
||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
}
|
}
|
||||||
.panel {
|
.panel {
|
||||||
background: #0f172a;
|
background: #ffffff;
|
||||||
border: 1px solid #1f2937;
|
border: 1px solid #dbe3ec;
|
||||||
}
|
}
|
||||||
.head-row {
|
.head-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -213,6 +213,6 @@ onMounted(() => {
|
||||||
}
|
}
|
||||||
.feedback {
|
.feedback {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
color: #cbd5e1;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ const loadProgress = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const [rolesRes, sessionsRes, flowsRes] = await Promise.all([
|
const [rolesRes, sessionsRes, flowsRes] = await Promise.all([
|
||||||
apiClient.get<Role[]>(API.organization.roles.mine()),
|
apiClient.get<Role[]>(API.roles.mine()),
|
||||||
apiClient.get<ProgressSessionApi[]>(API.onboarding.sessions.list()),
|
apiClient.get<ProgressSessionApi[]>(API.onboarding.sessions.list()),
|
||||||
apiClient.get<ProgressFlowApi[]>(API.onboarding.flows.list()),
|
apiClient.get<ProgressFlowApi[]>(API.onboarding.flows.list()),
|
||||||
])
|
])
|
||||||
|
|
@ -177,8 +177,8 @@ onMounted(() => {
|
||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
}
|
}
|
||||||
.panel {
|
.panel {
|
||||||
background: #0f172a;
|
background: #ffffff;
|
||||||
border: 1px solid #1f2937;
|
border: 1px solid #dbe3ec;
|
||||||
}
|
}
|
||||||
.row-meta {
|
.row-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -191,7 +191,7 @@ onMounted(() => {
|
||||||
}
|
}
|
||||||
.feedback-text {
|
.feedback-text {
|
||||||
margin-top: 0.4rem;
|
margin-top: 0.4rem;
|
||||||
color: #cbd5e1;
|
color: #6b7280;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue