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)),
|
||||
)
|
||||
|
||||
const organizationCount = computed(() => userStore.userJoinedOrganizations?.length || 0)
|
||||
const singleOrganization = computed(() =>
|
||||
organizationCount.value === 1 ? userStore.userJoinedOrganizations[0] : null,
|
||||
)
|
||||
|
||||
const selectedKeys = computed(() => {
|
||||
for (const item of visibleNavItems.value) {
|
||||
if (item.key === '/' && route.path === '/') return [item.key]
|
||||
|
|
@ -85,6 +90,14 @@ const handleLogout = async () => {
|
|||
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(() => {
|
||||
userStore.fetchSession()
|
||||
})
|
||||
|
|
@ -99,7 +112,7 @@ const user = userStore
|
|||
<div style="margin-right: 1rem" v-if="user.isAuthenticated"></div>
|
||||
<Menu
|
||||
mode="horizontal"
|
||||
theme="dark"
|
||||
theme="light"
|
||||
:selectedKeys="selectedKeys"
|
||||
class="shell-menu"
|
||||
@select="onSelect"
|
||||
|
|
@ -147,16 +160,10 @@ const user = userStore
|
|||
<template v-if="user.isAuthenticated">
|
||||
<Select
|
||||
v-if="
|
||||
user.userJoinedOrganizations && user.userJoinedOrganizations.length > 0
|
||||
user.userJoinedOrganizations && user.userJoinedOrganizations.length > 1
|
||||
"
|
||||
:value="user.userSelectedOrganization?.uuid ?? undefined"
|
||||
@change="
|
||||
(val) => {
|
||||
const org = user.userJoinedOrganizations.find((o) => o.uuid === val)
|
||||
user.setSelectedOrganization &&
|
||||
user.setSelectedOrganization(org ?? null)
|
||||
}
|
||||
"
|
||||
@change="(val) => handleOrganizationChange(String(val))"
|
||||
style="min-width: 220px; margin-right: 0.5rem"
|
||||
placeholder="Select organization"
|
||||
>
|
||||
|
|
@ -169,6 +176,14 @@ const user = userStore
|
|||
</Select.Option>
|
||||
</Select>
|
||||
|
||||
<Typography.Text
|
||||
v-else-if="singleOrganization"
|
||||
class="org-chip"
|
||||
strong
|
||||
>
|
||||
{{ singleOrganization.name }}
|
||||
</Typography.Text>
|
||||
|
||||
<Typography.Text class="user-chip" strong>
|
||||
{{ user.displayName || 'Account' }}
|
||||
</Typography.Text>
|
||||
|
|
@ -205,17 +220,18 @@ const user = userStore
|
|||
<style scoped>
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
background: #0b1220;
|
||||
background: #f5f7fb;
|
||||
}
|
||||
.shell-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0 1.25rem;
|
||||
background: #0f172a;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #dbe3ec;
|
||||
}
|
||||
.brand {
|
||||
color: #e5e7eb;
|
||||
color: #1f2937;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font-size: 1.05rem;
|
||||
|
|
@ -226,7 +242,7 @@ const user = userStore
|
|||
border-bottom: none;
|
||||
}
|
||||
.shell-body {
|
||||
background: #0b1220;
|
||||
background: #f5f7fb;
|
||||
min-height: calc(100vh - 64px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -238,7 +254,8 @@ const user = userStore
|
|||
}
|
||||
.shell-footer {
|
||||
text-align: center;
|
||||
background: #0f172a;
|
||||
background: #ffffff;
|
||||
border-top: 1px solid #dbe3ec;
|
||||
}
|
||||
:deep(.ant-menu-dark) {
|
||||
background: transparent;
|
||||
|
|
@ -256,76 +273,84 @@ const user = userStore
|
|||
:deep(.ant-statistic-content),
|
||||
:deep(.ant-card-meta-title),
|
||||
:deep(.ant-card-meta-description) {
|
||||
color: #e5e7eb;
|
||||
color: #1f2937;
|
||||
}
|
||||
:deep(.ant-typography-secondary) {
|
||||
color: #cbd5e1 !important;
|
||||
color: #6b7280 !important;
|
||||
}
|
||||
:deep(.ant-form-item-label > label) {
|
||||
color: #e5e7eb;
|
||||
color: #1f2937;
|
||||
}
|
||||
:deep(.ant-input),
|
||||
:deep(.ant-select-selector),
|
||||
:deep(.ant-select-selection-item),
|
||||
:deep(.ant-picker-input input) {
|
||||
background: #111827;
|
||||
color: #e5e7eb;
|
||||
border-color: #334155;
|
||||
background: #ffffff;
|
||||
color: #1f2937;
|
||||
border-color: #d0d8e2;
|
||||
}
|
||||
:deep(.ant-input::placeholder),
|
||||
:deep(.ant-select-selection-placeholder),
|
||||
:deep(.ant-picker-input input::placeholder) {
|
||||
color: #9ca3af;
|
||||
color: #6b7280;
|
||||
}
|
||||
:deep(.ant-card) {
|
||||
background: #0f172a;
|
||||
border-color: #1f2937;
|
||||
background: #ffffff;
|
||||
border-color: #dbe3ec;
|
||||
}
|
||||
:deep(.ant-btn:not(.ant-btn-primary)) {
|
||||
color: #e5e7eb;
|
||||
border-color: #334155;
|
||||
background: #111827;
|
||||
color: #1f2937;
|
||||
border-color: #d0d8e2;
|
||||
background: #ffffff;
|
||||
}
|
||||
:deep(.ant-btn-primary) {
|
||||
background: linear-gradient(90deg, #6366f1, #8b5cf6);
|
||||
background: #2563eb;
|
||||
border: none;
|
||||
}
|
||||
.user-chip {
|
||||
color: #e5e7eb;
|
||||
color: #1f2937;
|
||||
}
|
||||
.org-chip {
|
||||
color: #1f2937;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
:deep(.ant-typography-secondary) {
|
||||
color: #cbd5e1 !important;
|
||||
color: #6b7280 !important;
|
||||
}
|
||||
:deep(.ant-form-item-label > label) {
|
||||
color: #e5e7eb;
|
||||
color: #1f2937;
|
||||
}
|
||||
:deep(.ant-input),
|
||||
:deep(.ant-select-selector),
|
||||
:deep(.ant-select-selection-item),
|
||||
:deep(.ant-picker-input input) {
|
||||
background: #111827;
|
||||
color: #e5e7eb;
|
||||
border-color: #334155;
|
||||
background: #ffffff;
|
||||
color: #1f2937;
|
||||
border-color: #d0d8e2;
|
||||
}
|
||||
:deep(.ant-input::placeholder),
|
||||
:deep(.ant-select-selection-placeholder),
|
||||
:deep(.ant-picker-input input::placeholder) {
|
||||
color: #9ca3af;
|
||||
color: #6b7280;
|
||||
}
|
||||
:deep(.ant-card) {
|
||||
background: #0f172a;
|
||||
border-color: #1f2937;
|
||||
background: #ffffff;
|
||||
border-color: #dbe3ec;
|
||||
}
|
||||
:deep(.ant-btn:not(.ant-btn-primary)) {
|
||||
color: #e5e7eb;
|
||||
border-color: #334155;
|
||||
background: #111827;
|
||||
color: #1f2937;
|
||||
border-color: #d0d8e2;
|
||||
background: #ffffff;
|
||||
}
|
||||
:deep(.ant-btn-primary) {
|
||||
background: linear-gradient(90deg, #6366f1, #8b5cf6);
|
||||
background: #2563eb;
|
||||
border: none;
|
||||
}
|
||||
.user-chip {
|
||||
color: #e5e7eb;
|
||||
color: #1f2937;
|
||||
}
|
||||
.org-chip {
|
||||
color: #1f2937;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -93,7 +93,8 @@ const features = [
|
|||
gap: 1rem;
|
||||
}
|
||||
.feature-card {
|
||||
background: var(--ant-card-background, #08121a);
|
||||
background: #ffffff;
|
||||
border: 1px solid #dbe3ec;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
|
|
@ -107,4 +108,8 @@ const features = [
|
|||
.feature-body {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.feature-body :deep(.ant-typography-secondary) {
|
||||
color: #4b5563 !important;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -346,9 +346,9 @@ onUnmounted(() => {
|
|||
}
|
||||
|
||||
.panel {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
color: #e5e7eb;
|
||||
background: #ffffff;
|
||||
border: 1px solid #dbe3ec;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.header {
|
||||
|
|
@ -369,26 +369,26 @@ onUnmounted(() => {
|
|||
gap: 0.5rem;
|
||||
margin: 1rem 0;
|
||||
padding: 0.5rem;
|
||||
background: #1f2937;
|
||||
background: #f8fafc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.execution-controls {
|
||||
background: #1f2937;
|
||||
background: #f8fafc;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.log-container {
|
||||
background: #1f2937;
|
||||
background: #f8fafc;
|
||||
border-radius: 4px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.log-item {
|
||||
border-bottom: 1px solid #374151 !important;
|
||||
border-bottom: 1px solid #dbe3ec !important;
|
||||
padding: 0.75rem !important;
|
||||
}
|
||||
|
||||
|
|
@ -405,16 +405,16 @@ onUnmounted(() => {
|
|||
|
||||
.log-time {
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.log-message {
|
||||
color: #e5e7eb;
|
||||
color: #1f2937;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.log-content {
|
||||
background: #111827;
|
||||
background: #ffffff;
|
||||
padding: 0.5rem;
|
||||
border-radius: 3px;
|
||||
overflow-x: auto;
|
||||
|
|
@ -423,7 +423,7 @@ onUnmounted(() => {
|
|||
.log-content pre {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: #d1d5db;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.response-section {
|
||||
|
|
@ -431,17 +431,17 @@ onUnmounted(() => {
|
|||
}
|
||||
|
||||
.response-card {
|
||||
background: #1f2937;
|
||||
border: 1px solid #374151;
|
||||
background: #ffffff;
|
||||
border: 1px solid #dbe3ec;
|
||||
}
|
||||
|
||||
.response-final {
|
||||
border-color: #6366f1;
|
||||
box-shadow: 0 0 0 1px rgba(99, 102, 241, 0.35);
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 1px rgba(37, 99, 235, 0.25);
|
||||
}
|
||||
|
||||
.response-content {
|
||||
color: #e5e7eb;
|
||||
color: #1f2937;
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
|
|
@ -452,7 +452,7 @@ onUnmounted(() => {
|
|||
.markdown-body :deep(h1),
|
||||
.markdown-body :deep(h2),
|
||||
.markdown-body :deep(h3) {
|
||||
color: #f8fafc;
|
||||
color: #1f2937;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
|
@ -461,15 +461,15 @@ onUnmounted(() => {
|
|||
.markdown-body :deep(ol) {
|
||||
padding-left: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #e5e7eb;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.markdown-body :deep(code) {
|
||||
background: #020617;
|
||||
background: #eff6ff;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
color: #10b981;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.markdown-body :deep(p) {
|
||||
|
|
|
|||
|
|
@ -147,8 +147,8 @@ onMounted(() => {
|
|||
padding: 2rem 1rem;
|
||||
}
|
||||
.panel {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
background: #ffffff;
|
||||
border: 1px solid #dbe3ec;
|
||||
}
|
||||
.filters {
|
||||
display: flex;
|
||||
|
|
@ -157,11 +157,11 @@ onMounted(() => {
|
|||
flex-wrap: wrap;
|
||||
}
|
||||
.item :deep(.ant-list-item-meta-title) {
|
||||
color: #f8fafc;
|
||||
color: #1f2937;
|
||||
font-weight: 600;
|
||||
}
|
||||
.config-summary {
|
||||
color: #94a3b8;
|
||||
color: #6b7280;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.empty {
|
||||
|
|
|
|||
|
|
@ -78,10 +78,10 @@ const testimonials = [
|
|||
]
|
||||
|
||||
const logos = [
|
||||
'https://dummyimage.com/120x40/111827/ffffff&text=Nova',
|
||||
'https://dummyimage.com/120x40/1f2937/ffffff&text=Helio',
|
||||
'https://dummyimage.com/120x40/111827/ffffff&text=Arcus',
|
||||
'https://dummyimage.com/120x40/1f2937/ffffff&text=Vertex',
|
||||
'https://dummyimage.com/120x40/f8fafc/1f2937&text=Nova',
|
||||
'https://dummyimage.com/120x40/f1f5f9/1f2937&text=Helio',
|
||||
'https://dummyimage.com/120x40/f8fafc/1f2937&text=Arcus',
|
||||
'https://dummyimage.com/120x40/f1f5f9/1f2937&text=Vertex',
|
||||
]
|
||||
</script>
|
||||
|
||||
|
|
@ -102,7 +102,7 @@ const logos = [
|
|||
<RouterLink to="/about">
|
||||
<Button type="primary" size="large">Learn More</Button>
|
||||
</RouterLink>
|
||||
<RouterLink to="/onboarding">
|
||||
<RouterLink to="/organization">
|
||||
<Button size="large">See Onboarding Flows</Button>
|
||||
</RouterLink>
|
||||
</Space>
|
||||
|
|
@ -136,7 +136,7 @@ const logos = [
|
|||
<Row :gutter="16">
|
||||
<Col v-for="feature in features" :key="feature.title" :xs="24" :md="8">
|
||||
<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.Paragraph>{{ feature.description }}</Typography.Paragraph>
|
||||
<Tag color="purple">Live</Tag>
|
||||
|
|
@ -198,7 +198,7 @@ const logos = [
|
|||
}
|
||||
.hero-sub {
|
||||
font-size: 1.05rem;
|
||||
color: #cbd5e1;
|
||||
color: #6b7280;
|
||||
}
|
||||
.hero-card {
|
||||
border: none;
|
||||
|
|
@ -211,12 +211,12 @@ const logos = [
|
|||
}
|
||||
.hero-overlay {
|
||||
margin-top: 0.75rem;
|
||||
color: #8b5cf6;
|
||||
color: #2563eb;
|
||||
font-weight: 600;
|
||||
}
|
||||
.stat-card {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
background: #ffffff;
|
||||
border: 1px solid #dbe3ec;
|
||||
}
|
||||
.trusted {
|
||||
text-align: center;
|
||||
|
|
@ -239,17 +239,17 @@ const logos = [
|
|||
}
|
||||
.feature-card {
|
||||
height: 100%;
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
color: #e5e7eb;
|
||||
background: #ffffff;
|
||||
border: 1px solid #dbe3ec;
|
||||
color: #1f2937;
|
||||
}
|
||||
.journeys {
|
||||
margin: 2.5rem 0;
|
||||
}
|
||||
.journey-card {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
color: #e5e7eb;
|
||||
background: #ffffff;
|
||||
border: 1px solid #dbe3ec;
|
||||
color: #1f2937;
|
||||
}
|
||||
.testimonials {
|
||||
margin: 2.5rem 0;
|
||||
|
|
@ -258,9 +258,9 @@ const logos = [
|
|||
padding: 0 6px;
|
||||
}
|
||||
.testimonial-card {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
color: #e5e7eb;
|
||||
background: #ffffff;
|
||||
border: 1px solid #dbe3ec;
|
||||
color: #1f2937;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.page {
|
||||
|
|
|
|||
|
|
@ -17,13 +17,12 @@ import {
|
|||
Tag,
|
||||
Popconfirm,
|
||||
} from 'ant-design-vue'
|
||||
import { apiClient, API } from '../router/api'
|
||||
import { useAgentStore } from '../stores/agentStore'
|
||||
import { apiClient, API, isAxiosError } from '../router/api'
|
||||
import { useOnboardingAgentStore } from '../stores/onboardingAgentStore'
|
||||
import type {
|
||||
OnboardingFlow,
|
||||
OnboardingPage,
|
||||
OnboardingSession,
|
||||
OnboardingSessionSummary,
|
||||
OnboardingFlowSummary,
|
||||
} from '../types/onboarding'
|
||||
|
||||
|
|
@ -33,7 +32,7 @@ import DOMPurify from 'dompurify'
|
|||
const marked = new Marked()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const agentStore = useAgentStore()
|
||||
const agentStore = useOnboardingAgentStore()
|
||||
|
||||
const roleId = computed(() => route.params.roleId as string)
|
||||
const flowDetails = ref<OnboardingFlow | null>(null)
|
||||
|
|
@ -43,6 +42,17 @@ const loading = ref(false)
|
|||
const isAutoGenerating = ref(false)
|
||||
const generationHandled = 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
|
||||
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 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(() => {
|
||||
if (!currentPage.value?.body) return ''
|
||||
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 => {
|
||||
if (typeof flowData.role === 'string') return flowData.role
|
||||
return flowData.role?.uuid
|
||||
}
|
||||
|
||||
const findCompletedSessionForRole = async (): Promise<OnboardingSessionSummary | null> => {
|
||||
const sessionRes = await apiClient.get<OnboardingSessionSummary[]>(API.onboarding.sessions.list(), {
|
||||
params: { 'role__uuid': roleId.value },
|
||||
})
|
||||
return (
|
||||
sessionRes.data.find(
|
||||
(item) => item.status === 'completed' && getSessionRoleUuid(item) === roleId.value,
|
||||
) || null
|
||||
const getPageIndexByUuid = (pageUuid?: string | null): number => {
|
||||
if (!pageUuid) return -1
|
||||
return pages.value.findIndex((page) => String(page.uuid) === String(pageUuid))
|
||||
}
|
||||
|
||||
const restorePageProgressFromSession = () => {
|
||||
const sessionState = (session.value as unknown as { state?: Record<string, unknown> } | null)?.state
|
||||
|
||||
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 () => {
|
||||
|
|
@ -86,7 +161,7 @@ const retryGeneration = async () => {
|
|||
|
||||
try {
|
||||
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) {
|
||||
|
|
@ -116,6 +191,8 @@ const resetCurrentFlow = async () => {
|
|||
flowDetails.value = null
|
||||
session.value = null
|
||||
currentPageIndex.value = 0
|
||||
visitedPageUuids.value = []
|
||||
quizResult.value = null
|
||||
Object.keys(formState).forEach((k) => delete formState[k])
|
||||
|
||||
generationHandled.value = false
|
||||
|
|
@ -137,7 +214,7 @@ const initOnboarding = async () => {
|
|||
loading.value = true
|
||||
try {
|
||||
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) {
|
||||
|
|
@ -145,19 +222,12 @@ const initOnboarding = async () => {
|
|||
if (!matchingFlow) {
|
||||
flowDetails.value = null
|
||||
session.value = null
|
||||
visitedPageUuids.value = []
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
} else {
|
||||
if (!generationHandled.value) {
|
||||
|
|
@ -212,6 +282,8 @@ watch(
|
|||
flowDetails.value = null
|
||||
session.value = null
|
||||
currentPageIndex.value = 0
|
||||
visitedPageUuids.value = []
|
||||
quizResult.value = null
|
||||
generationHandled.value = false
|
||||
isAutoGenerating.value = false
|
||||
Object.keys(formState).forEach((k) => delete formState[k])
|
||||
|
|
@ -234,41 +306,243 @@ const loadFlow = async (flowUuid: string) => {
|
|||
return
|
||||
}
|
||||
|
||||
restorePageProgressFromSession()
|
||||
syncVisitedPages()
|
||||
hydrateFormState()
|
||||
await persistCurrentPageVisit()
|
||||
}
|
||||
|
||||
const hydrateFormState = () => {
|
||||
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])
|
||||
currentPage.value.fields?.forEach((f) => {
|
||||
if (pageStoredResponses && f.key in pageStoredResponses) {
|
||||
formState[f.key] = pageStoredResponses[f.key]
|
||||
return
|
||||
}
|
||||
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 () => {
|
||||
if (!currentPage.value || !session.value) return
|
||||
|
||||
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,
|
||||
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) {
|
||||
currentPageIndex.value++
|
||||
hydrateFormState()
|
||||
window.scrollTo(0, 0)
|
||||
} else {
|
||||
await apiClient.post(API.onboarding.sessions.complete(session.value.uuid))
|
||||
message.success('Onboarding Finished!')
|
||||
router.push('/organization')
|
||||
return
|
||||
}
|
||||
|
||||
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!')
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
onUnmounted(() => agentStore.disconnect())
|
||||
|
||||
watch(
|
||||
() => currentPageIndex.value,
|
||||
async () => {
|
||||
kaQuestion.value = ''
|
||||
syncVisitedPages()
|
||||
hydrateFormState()
|
||||
await persistCurrentPageVisit()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -315,8 +589,9 @@ onUnmounted(() => agentStore.disconnect())
|
|||
</div>
|
||||
</Card>
|
||||
|
||||
<Card v-else-if="flowDetails" class="dark-panel content-card">
|
||||
<div v-if="session?.status === 'completed'" class="completed-card">
|
||||
<template v-else-if="flowDetails">
|
||||
<Card v-if="session?.status === 'completed'" class="dark-panel content-card">
|
||||
<div class="completed-card">
|
||||
<Typography.Title :level="3" class="white-text">
|
||||
Onboarding Already Completed
|
||||
</Typography.Title>
|
||||
|
|
@ -338,77 +613,182 @@ onUnmounted(() => agentStore.disconnect())
|
|||
<Button danger :loading="deletingFlow">Delete Flow</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="flow-header-row">
|
||||
<Typography.Title :level="2" class="white-text flow-title">
|
||||
{{ flowDetails.title }}
|
||||
</Typography.Title>
|
||||
<Popconfirm
|
||||
title="Delete this onboarding flow and regenerate it?"
|
||||
ok-text="Delete"
|
||||
cancel-text="Cancel"
|
||||
@confirm="resetCurrentFlow"
|
||||
>
|
||||
<Button danger :loading="deletingFlow">Delete Flow</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
<Typography.Paragraph class="white-text" style="opacity: 0.8">
|
||||
{{ flowDetails.description }}
|
||||
</Typography.Paragraph>
|
||||
<Divider style="border-color: #334155" />
|
||||
</Card>
|
||||
|
||||
<div v-if="currentPage">
|
||||
<Typography.Title :level="4" class="white-text">
|
||||
{{ currentPage.title }}
|
||||
</Typography.Title>
|
||||
<div class="markdown-body" v-html="renderedBody"></div>
|
||||
<Divider dashed style="border-color: #334155" />
|
||||
|
||||
<Form layout="vertical" :model="formState" @finish="onSubmitPage">
|
||||
<Form.Item
|
||||
v-for="field in currentPage.fields"
|
||||
:key="field.uuid"
|
||||
:label="field.label"
|
||||
class="white-label"
|
||||
>
|
||||
<Input
|
||||
v-if="field.field_type === 'text'"
|
||||
v-model:value="formState[field.key]"
|
||||
/>
|
||||
<Input.TextArea
|
||||
v-else-if="field.field_type === 'textarea'"
|
||||
v-model:value="formState[field.key]"
|
||||
/>
|
||||
<Select
|
||||
v-else-if="field.field_type === 'select'"
|
||||
v-model:value="formState[field.key]"
|
||||
:options="
|
||||
field.options?.map((o) => ({
|
||||
label: String(o),
|
||||
value: String(o),
|
||||
}))
|
||||
"
|
||||
/>
|
||||
<Switch
|
||||
v-else-if="field.field_type === 'boolean'"
|
||||
v-model:checked="formState[field.key]"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<div class="form-actions">
|
||||
<Button :disabled="!hasPrev" @click="currentPageIndex--">
|
||||
Back
|
||||
</Button>
|
||||
<Button type="primary" html-type="submit" size="large">
|
||||
{{ hasNext ? 'Next Module' : 'Complete Onboarding' }}
|
||||
</Button>
|
||||
<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>
|
||||
</Form>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</aside>
|
||||
</Card>
|
||||
|
||||
<Card class="dark-panel content-card main-content-card">
|
||||
<div class="flow-header-row">
|
||||
<Typography.Title :level="2" class="white-text flow-title">
|
||||
{{ flowDetails.title }}
|
||||
</Typography.Title>
|
||||
<Popconfirm
|
||||
title="Delete this onboarding flow and regenerate it?"
|
||||
ok-text="Delete"
|
||||
cancel-text="Cancel"
|
||||
@confirm="resetCurrentFlow"
|
||||
>
|
||||
<Button danger :loading="deletingFlow">Delete Flow</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
<Typography.Paragraph class="white-text" style="opacity: 0.8">
|
||||
{{ flowDetails.description }}
|
||||
</Typography.Paragraph>
|
||||
<Divider style="border-color: #dbe3ec" />
|
||||
|
||||
<section class="flow-content" v-if="currentPage">
|
||||
<Typography.Title :level="4" class="white-text">
|
||||
{{ currentPage.title }}
|
||||
</Typography.Title>
|
||||
<div class="markdown-body" v-html="renderedBody"></div>
|
||||
<Divider dashed style="border-color: #dbe3ec" />
|
||||
|
||||
<Form layout="vertical" :model="formState" @finish="onSubmitPage">
|
||||
<Form.Item
|
||||
v-for="(field, fieldIndex) in currentPage.fields"
|
||||
:key="field.uuid"
|
||||
:label="`${fieldIndex + 1}. ${field.label}`"
|
||||
class="white-label"
|
||||
>
|
||||
<Input
|
||||
v-if="field.field_type === 'text'"
|
||||
v-model:value="formState[field.key]"
|
||||
/>
|
||||
<Input.TextArea
|
||||
v-else-if="field.field_type === 'textarea'"
|
||||
v-model:value="formState[field.key]"
|
||||
/>
|
||||
<Select
|
||||
v-else-if="field.field_type === 'select'"
|
||||
v-model:value="formState[field.key]"
|
||||
:options="
|
||||
field.options?.map((o) => ({
|
||||
label: String(o),
|
||||
value: String(o),
|
||||
}))
|
||||
"
|
||||
/>
|
||||
<Switch
|
||||
v-else-if="field.field_type === 'boolean'"
|
||||
v-model:checked="formState[field.key]"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<div class="form-actions">
|
||||
<Button :disabled="!hasPrev" @click="goBackPage">
|
||||
Back
|
||||
</Button>
|
||||
<Button type="primary" html-type="submit" size="large">
|
||||
{{ hasNext ? 'Next Module' : 'Submit Quiz & Complete' }}
|
||||
</Button>
|
||||
</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>
|
||||
</section>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Empty v-else-if="!loading" description="Role Context Missing" />
|
||||
</Spin>
|
||||
|
|
@ -417,14 +797,14 @@ onUnmounted(() => agentStore.disconnect())
|
|||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
max-width: 900px;
|
||||
max-width: 1280px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
.dark-panel {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1e293b;
|
||||
color: #f1f5f9;
|
||||
background: #ffffff;
|
||||
border: 1px solid #dbe3ec;
|
||||
color: #1f2937;
|
||||
}
|
||||
.pipeline-header {
|
||||
display: flex;
|
||||
|
|
@ -449,21 +829,87 @@ onUnmounted(() => agentStore.disconnect())
|
|||
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 {
|
||||
color: #ffffff !important;
|
||||
color: #1f2937 !important;
|
||||
}
|
||||
.white-label :deep(.ant-form-item-label > label) {
|
||||
color: #ffffff !important;
|
||||
color: #1f2937 !important;
|
||||
}
|
||||
|
||||
.orchestrator-logs {
|
||||
background: #020617;
|
||||
background: #f8fafc;
|
||||
padding: 1.2rem;
|
||||
border-radius: 8px;
|
||||
height: 300px;
|
||||
overflow-y: auto;
|
||||
font-family: monospace;
|
||||
border: 1px solid #334155;
|
||||
border: 1px solid #dbe3ec;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
margin-bottom: 1rem;
|
||||
|
|
@ -472,25 +918,25 @@ onUnmounted(() => agentStore.disconnect())
|
|||
.log-entry {
|
||||
margin-bottom: 0.8rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
border-bottom: 1px solid #dbe3ec;
|
||||
}
|
||||
.log-msg {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.markdown-body {
|
||||
line-height: 1.7;
|
||||
color: #e2e8f0;
|
||||
color: #1f2937;
|
||||
}
|
||||
.markdown-body :deep(h1),
|
||||
.markdown-body :deep(h2),
|
||||
.markdown-body :deep(h3),
|
||||
.markdown-body :deep(h4) {
|
||||
color: #ffffff;
|
||||
color: #1f2937;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.markdown-body :deep(p),
|
||||
.markdown-body :deep(li) {
|
||||
color: #e2e8f0;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.error-retry-zone {
|
||||
|
|
@ -517,6 +963,44 @@ onUnmounted(() => agentStore.disconnect())
|
|||
justify-content: space-between;
|
||||
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 {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
|
@ -532,9 +1016,19 @@ onUnmounted(() => agentStore.disconnect())
|
|||
|
||||
:deep(.ant-steps-item-title),
|
||||
:deep(.ant-steps-item-description) {
|
||||
color: #94a3b8 !important;
|
||||
color: #6b7280 !important;
|
||||
}
|
||||
: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>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<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 {
|
||||
Card,
|
||||
|
|
@ -23,7 +23,7 @@ import type { Role, Organization, TrainingFile } from '../types/organization'
|
|||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const organizationUuid = route.params.organizationUuid as string
|
||||
const organizationUuid = computed(() => String(route.params.organizationUuid || ''))
|
||||
|
||||
const organization = ref<Organization | null>(null)
|
||||
const roles = ref<Role[]>([])
|
||||
|
|
@ -31,6 +31,7 @@ const members = ref<Array<{ uuid: string; is_manager?: boolean }>>([])
|
|||
const trainingFiles = ref<TrainingFile[]>([])
|
||||
const loading = ref(false)
|
||||
const uploading = ref(false)
|
||||
const leavingOrganization = ref(false)
|
||||
const showUploadModal = ref(false)
|
||||
const auth = useUserStore()
|
||||
|
||||
|
|
@ -42,11 +43,27 @@ const isManager = computed(() => {
|
|||
return members.value.some((member) => member.uuid === auth.user?.uuid && member.is_manager)
|
||||
})
|
||||
|
||||
const isOwner = computed(() => {
|
||||
if (!auth.user || !organization.value) return false
|
||||
return organization.value.owner?.uuid === auth.user.uuid
|
||||
})
|
||||
|
||||
const canLeaveCurrentOrganization = computed(() => {
|
||||
if (!organization.value) return false
|
||||
if (!isOwner.value) return true
|
||||
return (organization.value.member_count ?? 0) <= 1
|
||||
})
|
||||
|
||||
const fetchOrganization = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
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
|
||||
auth.setSelectedOrganization(response.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch organization:', error)
|
||||
message.error('Failed to load organization details')
|
||||
|
|
@ -58,7 +75,7 @@ const fetchOrganization = async () => {
|
|||
const fetchRoles = async () => {
|
||||
if (!organization.value?.uuid) return
|
||||
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
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch roles:', error)
|
||||
|
|
@ -68,7 +85,7 @@ const fetchRoles = async () => {
|
|||
const fetchUserRoleMemberships = async () => {
|
||||
if (!organization.value?.uuid) return
|
||||
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 orgUuid = organization.value.uuid
|
||||
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)
|
||||
}
|
||||
|
||||
const canStartOnboarding = (roleUuid: string | undefined) => {
|
||||
if (!roleUuid) return false
|
||||
if (isManager.value) return true
|
||||
return isRoleJoined(roleUuid)
|
||||
}
|
||||
|
||||
const startOnboarding = (roleUuid: string | undefined) => {
|
||||
if (!roleUuid) return
|
||||
|
||||
if (!canStartOnboarding(roleUuid)) {
|
||||
message.warning('Join this role before starting onboarding.')
|
||||
return
|
||||
}
|
||||
|
||||
router.push(`/onboarding/${roleUuid}`)
|
||||
}
|
||||
|
||||
const fetchMembers = async () => {
|
||||
if (!organization.value?.uuid) return
|
||||
try {
|
||||
|
|
@ -118,7 +152,7 @@ const selectRole = async (roleUuid: string) => {
|
|||
}
|
||||
|
||||
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')
|
||||
if (!auth.userJoinedRoles.some((role) => role.uuid === roleUuid)) {
|
||||
auth.setJoinedRoles([
|
||||
|
|
@ -138,7 +172,7 @@ const fetchTrainingFiles = async () => {
|
|||
if (!organization.value?.uuid) return
|
||||
try {
|
||||
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
|
||||
} catch (error) {
|
||||
|
|
@ -219,7 +253,7 @@ const handleFileUpload = async (file: File, description: string = '') => {
|
|||
formData.append('file_name', file.name)
|
||||
formData.append('description', description)
|
||||
if (selectedRoleUuid.value) {
|
||||
formData.append('role', selectedRoleUuid.value)
|
||||
formData.append('role_uuid', selectedRoleUuid.value)
|
||||
}
|
||||
|
||||
const response = await apiClient.post<TrainingFile>(
|
||||
|
|
@ -352,14 +386,68 @@ const trainingFileColumns = [
|
|||
},
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
const loadOrganizationContext = async () => {
|
||||
await auth.fetchSession(true)
|
||||
await fetchOrganization()
|
||||
await fetchMembers()
|
||||
await fetchRoles()
|
||||
await fetchUserRoleMemberships()
|
||||
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>
|
||||
|
||||
<template>
|
||||
|
|
@ -368,13 +456,28 @@ onMounted(async () => {
|
|||
<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>
|
||||
<Space>
|
||||
<Button
|
||||
v-if="isManager"
|
||||
type="primary"
|
||||
@click="router.push(`/organization/${organization.uuid}/manage`)"
|
||||
>
|
||||
Manage Organization
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
:loading="leavingOrganization"
|
||||
:disabled="!canLeaveCurrentOrganization"
|
||||
:title="
|
||||
!canLeaveCurrentOrganization
|
||||
? 'Owner can leave only when no other members/managers remain.'
|
||||
: 'Leave organization'
|
||||
"
|
||||
@click="leaveOrganization"
|
||||
>
|
||||
Leave Organization
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Typography.Paragraph v-if="organization.description">
|
||||
|
|
@ -450,19 +553,31 @@ onMounted(async () => {
|
|||
<Button
|
||||
type="default"
|
||||
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
|
||||
v-if="item.uuid && !isRoleJoined(item.uuid) && !isManager"
|
||||
v-if="!isManager && item.uuid && !isRoleJoined(item.uuid)"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="selectRole(item.uuid)"
|
||||
>
|
||||
Join Role
|
||||
</Button>
|
||||
<Button v-else size="small" disabled>Joined</Button>
|
||||
<Button
|
||||
v-else-if="!isManager"
|
||||
size="small"
|
||||
disabled
|
||||
>
|
||||
Joined
|
||||
</Button>
|
||||
</Space>
|
||||
</List.Item>
|
||||
</template>
|
||||
|
|
@ -543,8 +658,10 @@ onMounted(async () => {
|
|||
|
||||
.role-item :deep(.ant-list-item-meta-title),
|
||||
.role-item :deep(.ant-list-item-meta-description) {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
color: #e5e7eb;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.role-item {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const auth = useUserStore()
|
|||
const organizations = ref<Organization[]>([])
|
||||
const loading = ref(false)
|
||||
const creatingOrganization = ref(false)
|
||||
const leavingOrganizationUuid = ref<string | null>(null)
|
||||
const showCreateOrgModal = ref(false)
|
||||
const createOrgForm = ref({
|
||||
name: '',
|
||||
|
|
@ -54,6 +55,49 @@ const openOrg = (org: Organization) => {
|
|||
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 = () => {
|
||||
createOrgForm.value = { name: '', description: '' }
|
||||
}
|
||||
|
|
@ -122,6 +166,21 @@ const handleCreateOrganization = async () => {
|
|||
<Button size="small" type="primary" @click="openOrg(item)">
|
||||
Open
|
||||
</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>
|
||||
</List.Item>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ const loadData = async () => {
|
|||
loading.value = true
|
||||
try {
|
||||
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()),
|
||||
])
|
||||
|
||||
|
|
@ -196,8 +196,8 @@ onMounted(() => {
|
|||
padding: 2rem 1rem;
|
||||
}
|
||||
.panel {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
background: #ffffff;
|
||||
border: 1px solid #dbe3ec;
|
||||
}
|
||||
.head-row {
|
||||
display: flex;
|
||||
|
|
@ -213,6 +213,6 @@ onMounted(() => {
|
|||
}
|
||||
.feedback {
|
||||
white-space: pre-wrap;
|
||||
color: #cbd5e1;
|
||||
color: #6b7280;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ const loadProgress = async () => {
|
|||
loading.value = true
|
||||
try {
|
||||
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<ProgressFlowApi[]>(API.onboarding.flows.list()),
|
||||
])
|
||||
|
|
@ -177,8 +177,8 @@ onMounted(() => {
|
|||
padding: 2rem 1rem;
|
||||
}
|
||||
.panel {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
background: #ffffff;
|
||||
border: 1px solid #dbe3ec;
|
||||
}
|
||||
.row-meta {
|
||||
display: flex;
|
||||
|
|
@ -191,7 +191,7 @@ onMounted(() => {
|
|||
}
|
||||
.feedback-text {
|
||||
margin-top: 0.4rem;
|
||||
color: #cbd5e1;
|
||||
color: #6b7280;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Reference in a new issue