Added core pages, router, userStore and api paths
This commit is contained in:
parent
271584864e
commit
939ce6479b
8 changed files with 1302 additions and 11 deletions
356
src/App.vue
356
src/App.vue
|
|
@ -1,16 +1,352 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, type Component } from 'vue'
|
||||
import { Layout, Menu, Button, Space, Typography, Select } from 'ant-design-vue'
|
||||
import {
|
||||
HomeOutlined,
|
||||
InfoCircleOutlined,
|
||||
RocketOutlined,
|
||||
ReadOutlined,
|
||||
TeamOutlined,
|
||||
RobotOutlined,
|
||||
BulbOutlined,
|
||||
AppstoreOutlined,
|
||||
DashboardOutlined,
|
||||
LoginOutlined,
|
||||
UserAddOutlined,
|
||||
BuildOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useUserStore } from './stores/userStore'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
type NavItem = {
|
||||
key: string
|
||||
label: string
|
||||
icon: Component
|
||||
path?: string
|
||||
manager?: boolean
|
||||
children?: NavItem[]
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ key: '/', label: 'Home', icon: HomeOutlined, path: '/' },
|
||||
{ key: '/about', label: 'About', icon: InfoCircleOutlined, path: '/about' },
|
||||
{ key: '/onboarding', label: 'Onboarding', icon: RocketOutlined, path: '/onboarding' },
|
||||
{ key: '/roles', label: 'Roles', icon: TeamOutlined, path: '/roles', manager: true },
|
||||
{ key: '/agents', label: 'Agents', icon: RobotOutlined, path: '/agents', manager: true },
|
||||
{ key: '/progress', label: 'Progress', icon: DashboardOutlined, path: '/progress' },
|
||||
{
|
||||
key: '/organizations',
|
||||
label: 'Organizations',
|
||||
icon: BuildOutlined,
|
||||
path: '/organizations',
|
||||
children: [
|
||||
{ key: '/training', label: 'Training', icon: ReadOutlined, path: '/training' },
|
||||
{ key: '/assessments', label: 'Assessments', icon: BulbOutlined, path: '/assessments' },
|
||||
{ key: '/resources', label: 'Resources', icon: AppstoreOutlined, path: '/resources' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const visibleNavItems = computed<NavItem[]>(() =>
|
||||
navItems.filter((item) => (item.manager ? userStore.user?.is_manager : true)),
|
||||
)
|
||||
|
||||
const selectedKeys = computed(() => {
|
||||
for (const item of visibleNavItems.value) {
|
||||
if (item.key === '/' && route.path === '/') return [item.key]
|
||||
if (route.path.startsWith(item.key)) return [item.key]
|
||||
if (item.children) {
|
||||
const childMatch = item.children.find((c) => route.path.startsWith(c.key))
|
||||
if (childMatch) return [item.key]
|
||||
}
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
type SimpleMenuInfo = { key: string | number | Array<string | number> }
|
||||
|
||||
const onSelect = (info: SimpleMenuInfo) => {
|
||||
const key = String(info.key)
|
||||
let found: NavItem | undefined
|
||||
for (const item of visibleNavItems.value) {
|
||||
if (item.key === key) {
|
||||
found = item
|
||||
break
|
||||
}
|
||||
if (item.children) {
|
||||
const child = item.children.find((c) => c.key === key)
|
||||
if (child) {
|
||||
found = child
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (found && found.path && route.path !== found.path) {
|
||||
const selectedOrgUuid = (
|
||||
userStore as unknown as { selectedOrganizationUuid?: string | null }
|
||||
).selectedOrganizationUuid
|
||||
if (found.path === '/organizations' && selectedOrgUuid) {
|
||||
router.push(`/organizations/${selectedOrgUuid}`)
|
||||
} else {
|
||||
router.push(found.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await userStore.logout()
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
userStore.fetchSession()
|
||||
})
|
||||
|
||||
const user = userStore as unknown as {
|
||||
organizations?: Array<{ uuid: string; name: string }>
|
||||
selectedOrganizationUuid?: string | null
|
||||
setSelectedOrganization?: (val: string | null) => void
|
||||
displayName?: string
|
||||
loading?: boolean
|
||||
isAuthenticated?: boolean
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="app"></div>
|
||||
<Layout class="shell">
|
||||
<Layout.Header class="shell-header">
|
||||
<div class="brand" @click="route.path !== '/' && router.push('/')">Dynavera</div>
|
||||
<div style="margin-right: 1rem" v-if="user.isAuthenticated"></div>
|
||||
<Menu
|
||||
mode="horizontal"
|
||||
theme="dark"
|
||||
:selectedKeys="selectedKeys"
|
||||
class="shell-menu"
|
||||
@select="onSelect"
|
||||
>
|
||||
<template v-for="item in visibleNavItems" :key="item.key">
|
||||
<Menu.SubMenu v-if="item.children" :key="`${item.key}-submenu`">
|
||||
<template #title>
|
||||
<span
|
||||
@click.stop="
|
||||
item.path && route.path !== item.path && router.push(item.path)
|
||||
"
|
||||
>
|
||||
<Space size="small">
|
||||
<component :is="item.icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
</Space>
|
||||
</span>
|
||||
</template>
|
||||
<Menu.Item
|
||||
v-for="child in item.children"
|
||||
:key="child.key"
|
||||
@click="
|
||||
child.path && route.path !== child.path && router.push(child.path)
|
||||
"
|
||||
>
|
||||
<Space size="small">
|
||||
<component :is="child.icon" />
|
||||
<span>{{ child.label }}</span>
|
||||
</Space>
|
||||
</Menu.Item>
|
||||
</Menu.SubMenu>
|
||||
<Menu.Item
|
||||
v-else
|
||||
:key="`${item.key}-item`"
|
||||
@click="item.path && route.path !== item.path && router.push(item.path)"
|
||||
>
|
||||
<Space size="small">
|
||||
<component :is="item.icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
</Space>
|
||||
</Menu.Item>
|
||||
</template>
|
||||
</Menu>
|
||||
<Space>
|
||||
<template v-if="user.isAuthenticated">
|
||||
<Select
|
||||
v-if="user.organizations && user.organizations.length > 0"
|
||||
:value="user.selectedOrganizationUuid ?? undefined"
|
||||
@change="
|
||||
(val) => {
|
||||
user.setSelectedOrganization &&
|
||||
user.setSelectedOrganization(val == null ? null : String(val))
|
||||
}
|
||||
"
|
||||
style="min-width: 220px; margin-right: 0.5rem"
|
||||
placeholder="Select organization"
|
||||
>
|
||||
<Select.Option
|
||||
v-for="o in user.organizations"
|
||||
:key="o.uuid"
|
||||
:value="o.uuid"
|
||||
>
|
||||
{{ o.name }}
|
||||
</Select.Option>
|
||||
</Select>
|
||||
|
||||
<Typography.Text class="user-chip" strong>
|
||||
{{ user.displayName || 'Account' }}
|
||||
</Typography.Text>
|
||||
<Button ghost :loading="user.loading" @click="handleLogout">Logout</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button ghost @click="router.push('/login')">
|
||||
<LoginOutlined />
|
||||
Login
|
||||
</Button>
|
||||
<Button type="primary" @click="router.push('/register')">
|
||||
<UserAddOutlined />
|
||||
Register
|
||||
</Button>
|
||||
</template>
|
||||
</Space>
|
||||
</Layout.Header>
|
||||
|
||||
<Layout class="shell-body">
|
||||
<Layout.Content class="shell-content">
|
||||
<router-view />
|
||||
</Layout.Content>
|
||||
<Layout.Footer class="shell-footer">
|
||||
<Typography.Text type="secondary">
|
||||
<strong>Project Disclaimer:</strong>
|
||||
This is a proof-of-concept demo project for educational purposes. All
|
||||
testimonials, statistics, and company names are fictional placeholders.
|
||||
</Typography.Text>
|
||||
</Layout.Footer>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<script setup></script>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
font-family: Avenir, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
<style scoped>
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
background: #0b1220;
|
||||
}
|
||||
.shell-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0 1.25rem;
|
||||
background: #0f172a;
|
||||
}
|
||||
.brand {
|
||||
color: #e5e7eb;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.shell-menu {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border-bottom: none;
|
||||
}
|
||||
.shell-body {
|
||||
background: #0b1220;
|
||||
min-height: calc(100vh - 64px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.shell-content {
|
||||
padding: 24px;
|
||||
flex: 1;
|
||||
min-height: calc(100vh - 64px - 64px);
|
||||
}
|
||||
.shell-footer {
|
||||
text-align: center;
|
||||
color: #2c3e50;
|
||||
margin-top: 60px;
|
||||
background: #0f172a;
|
||||
}
|
||||
:deep(.ant-menu-dark) {
|
||||
background: transparent;
|
||||
}
|
||||
:deep(.ant-menu-dark .ant-menu-item-selected) {
|
||||
background: transparent !important;
|
||||
}
|
||||
:deep(.ant-typography),
|
||||
:deep(.ant-typography p),
|
||||
:deep(.ant-typography span),
|
||||
:deep(.ant-list-item),
|
||||
:deep(.ant-list-item-meta-title),
|
||||
:deep(.ant-list-item-meta-description),
|
||||
:deep(.ant-statistic-title),
|
||||
:deep(.ant-statistic-content),
|
||||
:deep(.ant-card-meta-title),
|
||||
:deep(.ant-card-meta-description) {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
:deep(.ant-typography-secondary) {
|
||||
color: #cbd5e1 !important;
|
||||
}
|
||||
:deep(.ant-form-item-label > label) {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
:deep(.ant-input),
|
||||
:deep(.ant-select-selector),
|
||||
:deep(.ant-select-selection-item),
|
||||
:deep(.ant-picker-input input) {
|
||||
background: #111827;
|
||||
color: #e5e7eb;
|
||||
border-color: #334155;
|
||||
}
|
||||
:deep(.ant-input::placeholder),
|
||||
:deep(.ant-select-selection-placeholder),
|
||||
:deep(.ant-picker-input input::placeholder) {
|
||||
color: #9ca3af;
|
||||
}
|
||||
:deep(.ant-card) {
|
||||
background: #0f172a;
|
||||
border-color: #1f2937;
|
||||
}
|
||||
:deep(.ant-btn:not(.ant-btn-primary)) {
|
||||
color: #e5e7eb;
|
||||
border-color: #334155;
|
||||
background: #111827;
|
||||
}
|
||||
:deep(.ant-btn-primary) {
|
||||
background: linear-gradient(90deg, #6366f1, #8b5cf6);
|
||||
border: none;
|
||||
}
|
||||
.user-chip {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
:deep(.ant-typography-secondary) {
|
||||
color: #cbd5e1 !important;
|
||||
}
|
||||
:deep(.ant-form-item-label > label) {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
:deep(.ant-input),
|
||||
:deep(.ant-select-selector),
|
||||
:deep(.ant-select-selection-item),
|
||||
:deep(.ant-picker-input input) {
|
||||
background: #111827;
|
||||
color: #e5e7eb;
|
||||
border-color: #334155;
|
||||
}
|
||||
:deep(.ant-input::placeholder),
|
||||
:deep(.ant-select-selection-placeholder),
|
||||
:deep(.ant-picker-input input::placeholder) {
|
||||
color: #9ca3af;
|
||||
}
|
||||
:deep(.ant-card) {
|
||||
background: #0f172a;
|
||||
border-color: #1f2937;
|
||||
}
|
||||
:deep(.ant-btn:not(.ant-btn-primary)) {
|
||||
color: #e5e7eb;
|
||||
border-color: #334155;
|
||||
background: #111827;
|
||||
}
|
||||
:deep(.ant-btn-primary) {
|
||||
background: linear-gradient(90deg, #6366f1, #8b5cf6);
|
||||
border: none;
|
||||
}
|
||||
.user-chip {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
80
src/router/api.ts
Normal file
80
src/router/api.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import axios from 'axios'
|
||||
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
|
||||
class ApiClient {
|
||||
private client: AxiosInstance
|
||||
|
||||
constructor() {
|
||||
this.client = axios.create({ withCredentials: true })
|
||||
}
|
||||
|
||||
private getCsrfToken(): string {
|
||||
let cookieValue = ''
|
||||
if (document.cookie && document.cookie !== '') {
|
||||
const cookies = document.cookie.split(';')
|
||||
for (const rawCookie of cookies) {
|
||||
const cookie = (rawCookie || '').trim()
|
||||
if (cookie.startsWith('csrftoken=')) {
|
||||
cookieValue = decodeURIComponent(cookie.slice('csrftoken='.length))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return cookieValue
|
||||
}
|
||||
|
||||
private withCsrf(config?: AxiosRequestConfig): AxiosRequestConfig {
|
||||
const token = this.getCsrfToken()
|
||||
const csrfHeader = token ? { 'X-CSRFToken': token } : {}
|
||||
return {
|
||||
...config,
|
||||
headers: {
|
||||
...csrfHeader,
|
||||
...(config?.headers || {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
get<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
return this.client.get<T>(url, this.withCsrf(config))
|
||||
}
|
||||
|
||||
post<T = unknown>(
|
||||
url: string,
|
||||
data?: unknown,
|
||||
config?: AxiosRequestConfig,
|
||||
): Promise<AxiosResponse<T>> {
|
||||
return this.client.post<T>(url, data, this.withCsrf(config))
|
||||
}
|
||||
|
||||
put<T = unknown>(
|
||||
url: string,
|
||||
data?: unknown,
|
||||
config?: AxiosRequestConfig,
|
||||
): Promise<AxiosResponse<T>> {
|
||||
return this.client.put<T>(url, data, this.withCsrf(config))
|
||||
}
|
||||
|
||||
patch<T = unknown>(
|
||||
url: string,
|
||||
data?: unknown,
|
||||
config?: AxiosRequestConfig,
|
||||
): Promise<AxiosResponse<T>> {
|
||||
return this.client.patch<T>(url, data, this.withCsrf(config))
|
||||
}
|
||||
|
||||
delete<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
return this.client.delete<T>(url, this.withCsrf(config))
|
||||
}
|
||||
}
|
||||
|
||||
export const API = {
|
||||
me: () => '/api/user/me/',
|
||||
login: () => '/api/user/login/',
|
||||
logout: () => '/api/user/logout/',
|
||||
session: () => '/api/user/session/',
|
||||
signup: () => '/api/user/signup/',
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient()
|
||||
export { isAxiosError } from 'axios'
|
||||
|
|
@ -1,8 +1,47 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useUserStore } from '../stores/userStore'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [],
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: () => import('../views/HomeView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/about',
|
||||
name: 'about',
|
||||
component: () => import('../views/AboutView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('../views/LoginView.vue'),
|
||||
meta: { guestOnly: true },
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'register',
|
||||
component: () => import('../views/RegisterView.vue'),
|
||||
meta: { guestOnly: true },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
const userStore = useUserStore()
|
||||
const isAuthenticated = userStore.isAuthenticated
|
||||
// const is_manager = userStore.user?.is_manager || false
|
||||
|
||||
if (to.meta?.guestOnly && isAuthenticated) {
|
||||
return next({ path: '/' })
|
||||
}
|
||||
if (to.meta?.requiresAuth && !isAuthenticated) {
|
||||
return next({ path: '/login', query: { redirect: to.fullPath } })
|
||||
}
|
||||
|
||||
return next()
|
||||
})
|
||||
|
||||
export default router
|
||||
|
|
|
|||
159
src/stores/userStore.ts
Normal file
159
src/stores/userStore.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { apiClient, isAxiosError, API } from '../router/api'
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
uuid: string
|
||||
email_address: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
date_of_birth?: string
|
||||
timezone?: string
|
||||
avatar_url?: string
|
||||
is_manager: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface SessionResponse {
|
||||
isAuthenticated: boolean
|
||||
isStaff: boolean
|
||||
}
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const user = ref<User | null>(null)
|
||||
|
||||
const initialized = ref(false)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const isAuthenticated = computed(() => Boolean(user.value))
|
||||
const displayName = computed(() => {
|
||||
if (!user.value) return ''
|
||||
if (user.value.first_name || user.value.last_name) {
|
||||
return `${user.value.first_name || ''} ${user.value.last_name || ''}`.trim()
|
||||
}
|
||||
return user.value.email_address
|
||||
})
|
||||
|
||||
const setUser = (value: User | null) => {
|
||||
user.value = value
|
||||
initialized.value = true
|
||||
}
|
||||
|
||||
const fetchSession = async (force = false) => {
|
||||
if (initialized.value && !force) return user.value
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const sessionRes = await apiClient.get<SessionResponse>(API.session())
|
||||
if (sessionRes.data?.isAuthenticated) {
|
||||
const meRes = await apiClient.get<User>(API.me())
|
||||
setUser(meRes.data)
|
||||
} else {
|
||||
setUser(null)
|
||||
}
|
||||
return user.value
|
||||
} catch (err: unknown) {
|
||||
if (isAxiosError(err)) {
|
||||
error.value = err.response?.data?.detail || err.response?.data?.error || err.message
|
||||
} else if (err instanceof Error) {
|
||||
error.value = err.message
|
||||
} else {
|
||||
error.value = String(err)
|
||||
}
|
||||
setUser(null)
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const login = async (emailAddress: string, password: string) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const res = await apiClient.post<{
|
||||
user: User
|
||||
message?: string
|
||||
}>(API.login(), { email_address: emailAddress, password })
|
||||
setUser(res.data?.user ?? null)
|
||||
return res.data
|
||||
} catch (err: unknown) {
|
||||
if (isAxiosError(err)) {
|
||||
error.value = err.response?.data?.error || err.response?.data?.detail || err.message
|
||||
} else if (err instanceof Error) {
|
||||
error.value = err.message
|
||||
} else {
|
||||
error.value = String(err)
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const register = async (payload: {
|
||||
email_address: string
|
||||
password: string
|
||||
confirm_password?: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
date_of_birth?: string
|
||||
role?: string
|
||||
}) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await apiClient.post(API.signup(), {
|
||||
...payload,
|
||||
confirm_password: payload.confirm_password || payload.password,
|
||||
})
|
||||
await login(payload.email_address, payload.password)
|
||||
} catch (err: unknown) {
|
||||
if (isAxiosError(err)) {
|
||||
error.value = err.response?.data?.detail || err.response?.data?.error || err.message
|
||||
} else if (err instanceof Error) {
|
||||
error.value = err.message
|
||||
} else {
|
||||
error.value = String(err)
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await apiClient.post(API.logout())
|
||||
} catch (err: unknown) {
|
||||
if (isAxiosError(err)) {
|
||||
error.value = err.response?.data?.detail || err.response?.data?.error || err.message
|
||||
} else if (err instanceof Error) {
|
||||
error.value = err.message
|
||||
} else {
|
||||
error.value = String(err)
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
setUser(null)
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
loading,
|
||||
initialized,
|
||||
error,
|
||||
isAuthenticated,
|
||||
displayName,
|
||||
fetchSession,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
}
|
||||
})
|
||||
113
src/views/AboutView.vue
Normal file
113
src/views/AboutView.vue
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
<script setup lang="ts">
|
||||
import { Card, Typography, Divider, List, Timeline, Space } from 'ant-design-vue'
|
||||
|
||||
const pathways = [
|
||||
'Admin: system settings, user management, reporting, invitations.',
|
||||
'Manager: create onboarding flows, assign roles, monitor team progress.',
|
||||
'Employee: complete training modules, assessments, and track personal progress.',
|
||||
]
|
||||
|
||||
const highlights = [
|
||||
'Ready for agent-driven workflows that guide people through onboarding tasks.',
|
||||
'Flexible role-based gating across pages (managers/admins vs employees).',
|
||||
'Django REST API + Vue 3 frontend with a shared Pinia auth/session store.',
|
||||
'Docker-friendly dev setup (frontend on 5173, API on 8000).',
|
||||
]
|
||||
|
||||
const roadmap = [
|
||||
{
|
||||
title: 'Short term',
|
||||
items: [
|
||||
'Add richer assessments with adaptive scoring.',
|
||||
'Improve content versioning for training modules.',
|
||||
'Expose activity feed for audits.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Next',
|
||||
items: [
|
||||
'Integrate external IDP (SSO) and SCIM user sync.',
|
||||
'Launch webhooks for downstream HRIS updates.',
|
||||
'Add multilingual content support.',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const steps = [
|
||||
'Register or login (demo credentials only).',
|
||||
'Complete Onboarding and Training to simulate a role journey.',
|
||||
'Managers assign employees to roles and review progress reports.',
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<Card class="panel" :bordered="false">
|
||||
<Typography.Title :level="2">About Agentic Trainers</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
Agentic Trainers is a lightweight platform for onboarding, training, and assessing
|
||||
employees with modular content and agent-driven workflows. It is designed for teams
|
||||
that want to ship tangible learning experiences quickly without complex LMS setup.
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Divider />
|
||||
<Typography.Title :level="4">Role pathways</Typography.Title>
|
||||
<List :data-source="pathways" :bordered="false">
|
||||
<template #renderItem="{ item }">
|
||||
<List.Item class="row">{{ item }}</List.Item>
|
||||
</template>
|
||||
</List>
|
||||
|
||||
<Divider />
|
||||
<Typography.Title :level="4">Highlights</Typography.Title>
|
||||
<List :data-source="highlights" :bordered="false">
|
||||
<template #renderItem="{ item }">
|
||||
<List.Item class="row">{{ item }}</List.Item>
|
||||
</template>
|
||||
</List>
|
||||
|
||||
<Divider />
|
||||
<Typography.Title :level="4">Getting started</Typography.Title>
|
||||
<List :data-source="steps" :bordered="false">
|
||||
<template #renderItem="{ item, index }">
|
||||
<List.Item class="row">
|
||||
<strong>{{ index + 1 }}.</strong>
|
||||
{{ item }}
|
||||
</List.Item>
|
||||
</template>
|
||||
</List>
|
||||
|
||||
<Divider />
|
||||
<Typography.Title :level="4">Roadmap</Typography.Title>
|
||||
<Space :size="24" direction="vertical" style="width: 100%">
|
||||
<Timeline>
|
||||
<Timeline.Item v-for="bucket in roadmap" :key="bucket.title">
|
||||
<Typography.Text strong>{{ bucket.title }}</Typography.Text>
|
||||
<List :data-source="bucket.items" :bordered="false">
|
||||
<template #renderItem="{ item }">
|
||||
<List.Item class="row">{{ item }}</List.Item>
|
||||
</template>
|
||||
</List>
|
||||
</Timeline.Item>
|
||||
</Timeline>
|
||||
</Space>
|
||||
|
||||
<Typography.Paragraph type="secondary" style="margin-top: 1rem">
|
||||
Demo-only auth; integrate a real identity provider for production use.
|
||||
</Typography.Paragraph>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
.panel {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.row {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
</style>
|
||||
275
src/views/HomeView.vue
Normal file
275
src/views/HomeView.vue
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
Row,
|
||||
Col,
|
||||
Card,
|
||||
Button,
|
||||
Typography,
|
||||
Tag,
|
||||
Statistic,
|
||||
Carousel,
|
||||
Avatar,
|
||||
Space,
|
||||
Divider,
|
||||
} from 'ant-design-vue'
|
||||
import { CheckCircleTwoTone, ThunderboltTwoTone, CloudTwoTone } from '@ant-design/icons-vue'
|
||||
|
||||
const heroImage =
|
||||
'https://images.unsplash.com/photo-1521737604893-d14cc237f11d?auto=format&fit=crop&w=1400&q=80'
|
||||
|
||||
const stats = [
|
||||
{ title: 'Teams Onboarded', value: '240+' },
|
||||
{ title: 'Avg. Time Saved', value: '38%' },
|
||||
{ title: 'Playbooks Ready', value: '120' },
|
||||
]
|
||||
|
||||
const features = [
|
||||
{
|
||||
title: 'Adaptive AI Guides',
|
||||
description:
|
||||
'Role-specific checklists, interactive tours, and contextual help tuned to your stack.',
|
||||
icon: CheckCircleTwoTone,
|
||||
},
|
||||
{
|
||||
title: 'Skills & Assessments',
|
||||
description:
|
||||
'Scenario-based quizzes and code tasks with instant insights and coach-like feedback.',
|
||||
icon: ThunderboltTwoTone,
|
||||
},
|
||||
{
|
||||
title: 'Knowledge Mesh',
|
||||
description:
|
||||
'Ingest docs, wikis, and repos — keep assistants current with zero manual updates.',
|
||||
icon: CloudTwoTone,
|
||||
},
|
||||
]
|
||||
|
||||
const journeys = [
|
||||
{
|
||||
name: 'Engineer Launch',
|
||||
steps: 'Access, environments, codebase tour, first PR, observability basics.',
|
||||
image: 'https://images.unsplash.com/photo-1522075469751-3a6694fb2f61?auto=format&fit=crop&w=800&q=80',
|
||||
},
|
||||
{
|
||||
name: 'Customer Success Ramp',
|
||||
steps: 'Playbooks, product scenarios, objection handling, success plans, CRM hygiene.',
|
||||
image: 'https://images.unsplash.com/photo-1521737604893-d14cc237f11d?auto=format&fit=crop&w=900&q=80',
|
||||
},
|
||||
{
|
||||
name: 'Product Discovery',
|
||||
steps: 'Interview templates, JTBD mapping, experiment cards, roadmap debates.',
|
||||
image: 'https://images.unsplash.com/photo-1483478550801-ceba5fe50e8e?auto=format&fit=crop&w=900&q=80',
|
||||
},
|
||||
]
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
name: 'Amira Chen',
|
||||
role: 'VP Engineering, Nimbus',
|
||||
quote: 'We cut onboarding from weeks to days. The guided flows and assessments keep everyone aligned.',
|
||||
avatar: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=200&q=80',
|
||||
},
|
||||
{
|
||||
name: 'Luis Ortega',
|
||||
role: 'Head of Success, Calypso',
|
||||
quote: 'Playbooks stay fresh automatically. New CSMs ship value on day one.',
|
||||
avatar: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=200&q=80',
|
||||
},
|
||||
]
|
||||
|
||||
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',
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="page">
|
||||
<section class="hero">
|
||||
<Row :gutter="32" :align="'middle'">
|
||||
<Col :xs="24" :md="14">
|
||||
<Typography.Title :level="1" class="hero-title">
|
||||
Build agentic onboarding that feels bespoke to every role
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph class="hero-sub">
|
||||
AI-led workflows, assessments, and knowledge delivery that adapt to your
|
||||
stack, your rituals, and your teams - so every new hire ships confidently,
|
||||
faster.
|
||||
</Typography.Paragraph>
|
||||
<Space>
|
||||
<RouterLink to="/about">
|
||||
<Button type="primary" size="large">Learn More</Button>
|
||||
</RouterLink>
|
||||
<RouterLink to="/onboarding">
|
||||
<Button size="large">See Onboarding Flows</Button>
|
||||
</RouterLink>
|
||||
</Space>
|
||||
<Divider />
|
||||
<Row :gutter="16">
|
||||
<Col v-for="stat in stats" :key="stat.title" :xs="24" :sm="8">
|
||||
<Card :bordered="false" class="stat-card" hoverable>
|
||||
<Statistic :title="stat.title" :value="stat.value" />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
<Col :xs="24" :md="10">
|
||||
<Card class="hero-card" hoverable :cover="null">
|
||||
<img :src="heroImage" alt="Team collaborating" class="hero-img" />
|
||||
<div class="hero-overlay">Adaptive AI playbooks</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</section>
|
||||
|
||||
<section class="trusted">
|
||||
<Typography.Text type="secondary">Trusted by modern teams</Typography.Text>
|
||||
<div class="logo-row">
|
||||
<img v-for="logo in logos" :key="logo" :src="logo" alt="logo" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="features">
|
||||
<Typography.Title :level="2">Everything you need to ramp faster</Typography.Title>
|
||||
<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" />
|
||||
<Typography.Title :level="4">{{ feature.title }}</Typography.Title>
|
||||
<Typography.Paragraph>{{ feature.description }}</Typography.Paragraph>
|
||||
<Tag color="purple">Live</Tag>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</section>
|
||||
|
||||
<section class="journeys">
|
||||
<Typography.Title :level="2">Prebuilt journeys, tailored in minutes</Typography.Title>
|
||||
<Row :gutter="16">
|
||||
<Col v-for="journey in journeys" :key="journey.name" :xs="24" :md="8">
|
||||
<Card hoverable class="journey-card">
|
||||
<template #cover>
|
||||
<img :alt="journey.name" :src="journey.image" />
|
||||
</template>
|
||||
<Typography.Title :level="4">{{ journey.name }}</Typography.Title>
|
||||
<Typography.Text>{{ journey.steps }}</Typography.Text>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</section>
|
||||
|
||||
<section class="testimonials">
|
||||
<Typography.Title :level="2">What teams are saying</Typography.Title>
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
style="display: block; text-align: center; margin-bottom: 1rem"
|
||||
>
|
||||
(Demo testimonials, real feedback coming soon...)
|
||||
</Typography.Text>
|
||||
<Carousel autoplay dot-position="bottom">
|
||||
<div v-for="t in testimonials" :key="t.name" class="testimonial-slide">
|
||||
<Card :bordered="false" class="testimonial-card">
|
||||
<Typography.Paragraph>“{{ t.quote }}”</Typography.Paragraph>
|
||||
<Space>
|
||||
<Avatar :src="t.avatar" size="large" />
|
||||
<div>
|
||||
<div class="t-name">{{ t.name }}</div>
|
||||
<Typography.Text type="secondary">{{ t.role }}</Typography.Text>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
</Carousel>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 2rem 1.5rem 3rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.hero {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
.hero-title {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.hero-sub {
|
||||
font-size: 1.05rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.hero-card {
|
||||
border: none;
|
||||
}
|
||||
.hero-img {
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.hero-overlay {
|
||||
margin-top: 0.75rem;
|
||||
color: #8b5cf6;
|
||||
font-weight: 600;
|
||||
}
|
||||
.stat-card {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
}
|
||||
.trusted {
|
||||
text-align: center;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
.logo-row {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.logo-row img {
|
||||
opacity: 0.8;
|
||||
height: 32px;
|
||||
}
|
||||
.features {
|
||||
margin: 2.5rem 0;
|
||||
}
|
||||
.feature-card {
|
||||
height: 100%;
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.journeys {
|
||||
margin: 2.5rem 0;
|
||||
}
|
||||
.journey-card {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.testimonials {
|
||||
margin: 2.5rem 0;
|
||||
}
|
||||
.testimonial-slide {
|
||||
padding: 0 6px;
|
||||
}
|
||||
.testimonial-card {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.page {
|
||||
padding: 1.25rem 1rem 2.5rem;
|
||||
}
|
||||
.hero-img {
|
||||
height: 220px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
120
src/views/LoginView.vue
Normal file
120
src/views/LoginView.vue
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
<script setup lang="ts">
|
||||
import { reactive, computed, onMounted, ref } from 'vue'
|
||||
import type { VNodeRef } from 'vue'
|
||||
defineOptions({ name: 'LoginView' })
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { Card, Typography, Form, Input, Button, message } from 'ant-design-vue'
|
||||
import { useUserStore } from '../stores/userStore'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
const loading = computed(() => userStore.loading)
|
||||
|
||||
const formRef = ref<VNodeRef | null>(null)
|
||||
|
||||
const formState = reactive({
|
||||
email: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
await userStore.login(formState.email, formState.password)
|
||||
message.success('Login successful')
|
||||
const redirect = (route.query.redirect as string) || '/onboarding'
|
||||
router.push(redirect)
|
||||
} catch (error: unknown) {
|
||||
let errorMsg = 'Login failed'
|
||||
if (userStore.error) {
|
||||
errorMsg = userStore.error
|
||||
} else if (typeof error === 'string') {
|
||||
errorMsg = error
|
||||
} else if (error instanceof Error) {
|
||||
errorMsg = error.message || 'Login failed'
|
||||
} else if (typeof error === 'object' && error !== null) {
|
||||
const errObj = error as { [k: string]: unknown }
|
||||
const maybeResp = errObj?.response as unknown
|
||||
if (typeof maybeResp === 'object' && maybeResp !== null) {
|
||||
const respObj = maybeResp as { data?: unknown }
|
||||
const data = respObj.data
|
||||
if (typeof data === 'object' && data !== null) {
|
||||
const detail = (data as { detail?: unknown }).detail
|
||||
const msg = (data as { message?: unknown }).message
|
||||
if (typeof detail === 'string') {
|
||||
errorMsg = detail
|
||||
} else if (typeof msg === 'string') {
|
||||
errorMsg = msg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
message.error(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await userStore.fetchSession()
|
||||
if (userStore.isAuthenticated) {
|
||||
const redirect = (route.query.redirect as string) || '/onboarding'
|
||||
router.replace(redirect)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<Card class="panel" :bordered="false">
|
||||
<Typography.Title :level="3">Login</Typography.Title>
|
||||
<Form :ref="formRef" layout="vertical" :model="formState" @finish="submit">
|
||||
<Form.Item
|
||||
label="Email"
|
||||
name="email"
|
||||
:rules="[
|
||||
{ required: true, message: 'Enter your email' },
|
||||
{
|
||||
type: 'email',
|
||||
message: 'Please enter a valid email',
|
||||
},
|
||||
]"
|
||||
>
|
||||
<Input
|
||||
v-model:value="formState.email"
|
||||
type="email"
|
||||
placeholder="Email address"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Password"
|
||||
name="password"
|
||||
:rules="[{ required: true, message: 'Enter your password' }]"
|
||||
>
|
||||
<Input.Password
|
||||
v-model:value="formState.password"
|
||||
placeholder="Password"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button type="primary" html-type="submit" block :loading="loading">Login</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
}
|
||||
.panel {
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
169
src/views/RegisterView.vue
Normal file
169
src/views/RegisterView.vue
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
<script setup lang="ts">
|
||||
import { reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { Card, Typography, Form, Input, Select, Button, message } from 'ant-design-vue'
|
||||
import { useUserStore } from '../stores/userStore'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
const loading = computed(() => userStore.loading)
|
||||
|
||||
const formState = reactive({
|
||||
email: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
role: 'employee' as 'admin' | 'manager' | 'employee',
|
||||
})
|
||||
|
||||
const confirmRules = [
|
||||
{ required: true, message: 'Confirm your password' },
|
||||
{
|
||||
validator: (_: unknown, value: string) =>
|
||||
value === formState.password
|
||||
? Promise.resolve()
|
||||
: Promise.reject('Passwords do not match'),
|
||||
},
|
||||
]
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
await userStore.register({
|
||||
email_address: formState.email,
|
||||
password: formState.password,
|
||||
confirm_password: formState.confirmPassword,
|
||||
first_name: formState.firstName,
|
||||
last_name: formState.lastName,
|
||||
role: formState.role,
|
||||
})
|
||||
message.success('Account created')
|
||||
const redirect = (route.query.redirect as string) || '/onboarding'
|
||||
router.push(redirect)
|
||||
} catch (error: unknown) {
|
||||
let errorMsg = 'Registration failed'
|
||||
if (userStore.error) {
|
||||
errorMsg = userStore.error
|
||||
} else if (typeof error === 'string') {
|
||||
errorMsg = error
|
||||
} else if (error instanceof Error) {
|
||||
errorMsg = error.message || 'Registration failed'
|
||||
} else if (typeof error === 'object' && error !== null) {
|
||||
const errObj = error as { [k: string]: unknown }
|
||||
const maybeResp = errObj?.response as unknown
|
||||
if (typeof maybeResp === 'object' && maybeResp !== null) {
|
||||
const respObj = maybeResp as { data?: unknown }
|
||||
const data = respObj.data
|
||||
if (typeof data === 'object' && data !== null) {
|
||||
const detail = (data as { detail?: unknown }).detail
|
||||
const msg = (data as { message?: unknown }).message
|
||||
if (typeof detail === 'string') {
|
||||
errorMsg = detail
|
||||
} else if (typeof msg === 'string') {
|
||||
errorMsg = msg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
message.error(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await userStore.fetchSession()
|
||||
if (userStore.isAuthenticated) {
|
||||
const redirect = (route.query.redirect as string) || '/onboarding'
|
||||
router.replace(redirect)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<Card class="panel" :bordered="false">
|
||||
<Typography.Title :level="3">Register</Typography.Title>
|
||||
<Form layout="vertical" :model="formState" @finish="submit">
|
||||
<Form.Item
|
||||
label="Email"
|
||||
name="email"
|
||||
:rules="[
|
||||
{ required: true, message: 'Enter your email' },
|
||||
{
|
||||
type: 'email',
|
||||
message: 'Please enter a valid email',
|
||||
},
|
||||
]"
|
||||
>
|
||||
<Input
|
||||
v-model:value="formState.email"
|
||||
type="email"
|
||||
placeholder="Email address"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="First name"
|
||||
name="firstName"
|
||||
:rules="[{ required: true, message: 'Enter your first name' }]"
|
||||
>
|
||||
<Input
|
||||
v-model:value="formState.firstName"
|
||||
placeholder="First name"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Last name"
|
||||
name="lastName"
|
||||
:rules="[{ required: true, message: 'Enter your last name' }]"
|
||||
>
|
||||
<Input
|
||||
v-model:value="formState.lastName"
|
||||
placeholder="Last name"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Password"
|
||||
name="password"
|
||||
:rules="[{ required: true, message: 'Create a password' }]"
|
||||
>
|
||||
<Input.Password
|
||||
v-model:value="formState.password"
|
||||
placeholder="Password"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Confirm password" name="confirmPassword" :rules="confirmRules">
|
||||
<Input.Password
|
||||
v-model:value="formState.confirmPassword"
|
||||
placeholder="Confirm password"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Role" name="role">
|
||||
<Select v-model:value="formState.role" :disabled="loading">
|
||||
<Select.Option value="employee">Employee</Select.Option>
|
||||
<Select.Option value="manager">Manager</Select.Option>
|
||||
<Select.Option value="admin">Admin</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Button type="primary" html-type="submit" block :loading="loading">Register</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-page {
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
.panel {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in a new issue