Modified styling to be light, add edge case UI tweaks, revamped styling and page status

This commit is contained in:
Viswamedha Nalabotu 2026-03-08 13:20:15 +00:00
parent 1c0551a809
commit e59cef27ca
10 changed files with 935 additions and 235 deletions

View file

@ -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>

View file

@ -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>

View file

@ -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) {

View file

@ -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 {

View file

@ -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 {

View file

@ -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)) }
message.success('Onboarding Finished!')
router.push('/organization') 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') 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>
@ -338,77 +613,182 @@ onUnmounted(() => agentStore.disconnect())
<Button danger :loading="deletingFlow">Delete Flow</Button> <Button danger :loading="deletingFlow">Delete Flow</Button>
</Popconfirm> </Popconfirm>
</div> </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> </div>
<Typography.Paragraph class="white-text" style="opacity: 0.8"> </Card>
{{ flowDetails.description }}
</Typography.Paragraph>
<Divider style="border-color: #334155" />
<div v-if="currentPage"> <div v-else class="flow-shell">
<Typography.Title :level="4" class="white-text"> <Card class="dark-panel toc-card">
{{ currentPage.title }} <aside>
</Typography.Title> <Typography.Title :level="5" class="white-text toc-title">
<div class="markdown-body" v-html="renderedBody"></div> Contents
<Divider dashed style="border-color: #334155" /> </Typography.Title>
<div class="toc-list">
<Form layout="vertical" :model="formState" @finish="onSubmitPage"> <button
<Form.Item v-for="(page, index) in pages"
v-for="field in currentPage.fields" :key="page.uuid"
:key="field.uuid" type="button"
:label="field.label" class="toc-item"
class="white-label" :class="{
> active: index === currentPageIndex,
<Input blocked: !canNavigateToPage(index),
v-if="field.field_type === 'text'" }"
v-model:value="formState[field.key]" @click="jumpToPage(index)"
/> >
<Input.TextArea <span class="toc-index">{{ index + 1 }}</span>
v-else-if="field.field_type === 'textarea'" <span class="toc-text">{{ page.title }}</span>
v-model:value="formState[field.key]" <Tag :color="getPageStatusColor(page, index)">
/> {{ getPageStatus(page, index) }}
<Select </Tag>
v-else-if="field.field_type === 'select'" </button>
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> </div>
</Form> </aside>
</div> </Card>
</template>
</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 dont 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" /> <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>

View file

@ -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,13 +456,28 @@ 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>
<Button <Space>
v-if="isManager" <Button
type="primary" v-if="isManager"
@click="router.push(`/organization/${organization.uuid}/manage`)" type="primary"
> @click="router.push(`/organization/${organization.uuid}/manage`)"
Manage Organization >
</Button> 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> </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>

View file

@ -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>

View file

@ -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>

View file

@ -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>