Added pages for frontend
This commit is contained in:
parent
a10632e4bf
commit
a5f039d021
21 changed files with 2268 additions and 916 deletions
|
|
@ -4,7 +4,8 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build"
|
"build": "vite build",
|
||||||
|
"format": "prettier --write --tab-width 4 --use-tabs false \"src/**/*.{ts,vue,js,css}\""
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
67
src/lib/api.ts
Normal file
67
src/lib/api.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||||
|
|
||||||
|
class ApiClient {
|
||||||
|
private client: AxiosInstance;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.client = axios.create({ withCredentials: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCsrfToken(): string {
|
||||||
|
const match = document.cookie.match(/(?:^|; )csrftoken=([^;]+)/);
|
||||||
|
return match ? decodeURIComponent(match[1]) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 apiClient = new ApiClient();
|
||||||
|
export { isAxiosError } from 'axios';
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
import 'ant-design-vue/dist/reset.css';
|
||||||
import router from './router';
|
import router from './router';
|
||||||
import { createApp } from 'vue';
|
import { createApp } from 'vue';
|
||||||
|
import { createPinia } from 'pinia';
|
||||||
import App from './app/App.vue';
|
import App from './app/App.vue';
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
|
app.use(createPinia());
|
||||||
app.use(router);
|
app.use(router);
|
||||||
app.mount('#root');
|
app.mount('#root');
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { createRouter, createWebHistory } from 'vue-router';
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
|
@ -17,53 +19,92 @@ const router = createRouter({
|
||||||
path: '/login',
|
path: '/login',
|
||||||
name: 'login',
|
name: 'login',
|
||||||
component: () => import('../views/LoginView.vue'),
|
component: () => import('../views/LoginView.vue'),
|
||||||
|
meta: { guestOnly: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/register',
|
path: '/register',
|
||||||
name: 'register',
|
name: 'register',
|
||||||
component: () => import('../views/RegisterView.vue'),
|
component: () => import('../views/RegisterView.vue'),
|
||||||
|
meta: { guestOnly: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/onboarding',
|
path: '/onboarding',
|
||||||
name: 'onboarding',
|
name: 'onboarding',
|
||||||
component: () => import('../views/OnboardingFlow.vue'),
|
component: () => import('../views/OnboardingFlow.vue'),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/training/:moduleId?',
|
path: '/training/:moduleId?',
|
||||||
name: 'training',
|
name: 'training',
|
||||||
component: () => import('../views/TrainingModule.vue'),
|
component: () => import('../views/TrainingModule.vue'),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/agents',
|
path: '/agents',
|
||||||
name: 'agents',
|
name: 'agents',
|
||||||
component: () => import('../views/Agents.vue'),
|
component: () => import('../views/Agents.vue'),
|
||||||
|
meta: { requiresAuth: true, roles: ['manager', 'admin'] },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/agents/:id',
|
path: '/agents/:id',
|
||||||
name: 'agent-detail',
|
name: 'agent-detail',
|
||||||
component: () => import('../views/AgentDetail.vue'),
|
component: () => import('../views/AgentDetail.vue'),
|
||||||
|
meta: { requiresAuth: true, roles: ['manager', 'admin'] },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/roles',
|
path: '/roles',
|
||||||
name: 'roles',
|
name: 'roles',
|
||||||
component: () => import('../views/RoleProfiles.vue'),
|
component: () => import('../views/RoleProfiles.vue'),
|
||||||
|
meta: { requiresAuth: true, roles: ['manager', 'admin'] },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/progress',
|
path: '/progress',
|
||||||
name: 'progress',
|
name: 'progress',
|
||||||
component: () => import('../views/ProgressDashboard.vue'),
|
component: () => import('../views/ProgressDashboard.vue'),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/assessments',
|
path: '/assessments',
|
||||||
name: 'assessments',
|
name: 'assessments',
|
||||||
component: () => import('../views/Assessments.vue'),
|
component: () => import('../views/Assessments.vue'),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/resources',
|
path: '/resources',
|
||||||
name: 'resources',
|
name: 'resources',
|
||||||
component: () => import('../views/Resources.vue'),
|
component: () => import('../views/Resources.vue'),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
router.beforeEach(async (to, _from, next) => {
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authStore.fetchSession();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch session during navigation:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAuthed = authStore.isAuthenticated;
|
||||||
|
const role = authStore.user?.role;
|
||||||
|
|
||||||
|
if (to.meta?.guestOnly && isAuthed) {
|
||||||
|
return next({ path: '/onboarding' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.meta?.requiresAuth && !isAuthed) {
|
||||||
|
return next({ path: '/login', query: { redirect: to.fullPath } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedRoles = (to.meta?.roles as string[] | undefined) || null;
|
||||||
|
if (allowedRoles && (!role || !allowedRoles.includes(role))) {
|
||||||
|
message.error('You do not have access to that page');
|
||||||
|
return next({ path: '/' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
|
||||||
271
src/stores/agentStore.ts
Normal file
271
src/stores/agentStore.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
|
||||||
|
interface AgentEvent {
|
||||||
|
type: string;
|
||||||
|
content?: string | Record<string, unknown>;
|
||||||
|
message?: string;
|
||||||
|
timestamp: Date;
|
||||||
|
event_type?: string;
|
||||||
|
error_message?: string;
|
||||||
|
execution_id?: string;
|
||||||
|
output_data?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAgentStore = defineStore('agent', () => {
|
||||||
|
const socket = ref<WebSocket | null>(null);
|
||||||
|
const isConnected = ref(false);
|
||||||
|
const currentExecutionId = ref<string | null>(null);
|
||||||
|
const events = ref<AgentEvent[]>([]);
|
||||||
|
const executionStatus = ref<string>('idle');
|
||||||
|
const agentId = ref<string | null>(null);
|
||||||
|
const reconnectAttempts = ref(0);
|
||||||
|
const maxReconnectAttempts = 5;
|
||||||
|
|
||||||
|
const connect = (agentIdParam: string) => {
|
||||||
|
console.log(
|
||||||
|
'[agentStore] connect() called with agent ID:',
|
||||||
|
agentIdParam
|
||||||
|
);
|
||||||
|
|
||||||
|
if (socket.value && isConnected.value) {
|
||||||
|
console.log(
|
||||||
|
'[agentStore] Already connected to agent:',
|
||||||
|
agentIdParam
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
agentId.value = agentIdParam;
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${protocol}//${window.location.host}/ws/agents/${agentIdParam}/`;
|
||||||
|
console.log('[agentStore] WebSocket URL:', wsUrl);
|
||||||
|
|
||||||
|
socket.value = new WebSocket(wsUrl);
|
||||||
|
console.log('[agentStore] WebSocket object created');
|
||||||
|
|
||||||
|
socket.value.onopen = () => {
|
||||||
|
isConnected.value = true;
|
||||||
|
reconnectAttempts.value = 0;
|
||||||
|
console.log(
|
||||||
|
'[agentStore] SUCCESS - WebSocket connected to agent:',
|
||||||
|
agentIdParam
|
||||||
|
);
|
||||||
|
console.log('[agentStore] isConnected is now:', isConnected.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.value.onmessage = (event) => {
|
||||||
|
console.log('[agentStore] Message received from WebSocket');
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
console.log('[agentStore] Parsed message:', data);
|
||||||
|
handleMessage(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
'[agentStore] ERROR - Failed to parse WebSocket message:',
|
||||||
|
error
|
||||||
|
);
|
||||||
|
console.error('[agentStore] Raw message:', event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.value.onerror = (error) => {
|
||||||
|
console.error(
|
||||||
|
'[agentStore] ERROR - WebSocket error occurred:',
|
||||||
|
error
|
||||||
|
);
|
||||||
|
isConnected.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.value.onclose = () => {
|
||||||
|
isConnected.value = false;
|
||||||
|
console.log(
|
||||||
|
'[agentStore] WebSocket closed for agent:',
|
||||||
|
agentIdParam
|
||||||
|
);
|
||||||
|
attemptReconnect(agentIdParam);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const attemptReconnect = (agentIdParam: string) => {
|
||||||
|
console.log('[agentStore] attemptReconnect() called');
|
||||||
|
|
||||||
|
if (reconnectAttempts.value < maxReconnectAttempts) {
|
||||||
|
reconnectAttempts.value++;
|
||||||
|
const delay = Math.min(
|
||||||
|
1000 * Math.pow(2, reconnectAttempts.value),
|
||||||
|
10000
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`[agentStore] Attempting to reconnect... (attempt ${reconnectAttempts.value}/${maxReconnectAttempts}, delay: ${delay}ms)`
|
||||||
|
);
|
||||||
|
setTimeout(() => connect(agentIdParam), delay);
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
'[agentStore] ERROR - Max reconnection attempts reached (${maxReconnectAttempts})'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMessage = (data: Record<string, unknown>) => {
|
||||||
|
console.log(
|
||||||
|
'[agentStore] handleMessage() called with type:',
|
||||||
|
data.type
|
||||||
|
);
|
||||||
|
console.log('[agentStore] Full message data:', data);
|
||||||
|
|
||||||
|
if (data.type === 'connection') {
|
||||||
|
console.log('[agentStore] Connection message:', data.message);
|
||||||
|
} else if (data.type === 'execution_started') {
|
||||||
|
console.log('[agentStore] Execution started');
|
||||||
|
currentExecutionId.value = data.execution_id as string;
|
||||||
|
executionStatus.value = 'running';
|
||||||
|
events.value = [];
|
||||||
|
console.log(
|
||||||
|
'[agentStore] Status changed to: running, execution ID:',
|
||||||
|
currentExecutionId.value
|
||||||
|
);
|
||||||
|
events.value.push({
|
||||||
|
type: 'started',
|
||||||
|
message: data.message as string,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
} else if (data.type === 'agent_event') {
|
||||||
|
console.log('[agentStore] Agent event received:', data.event_type);
|
||||||
|
events.value.push({
|
||||||
|
type: data.event_type as string,
|
||||||
|
content: data.content as string | Record<string, unknown>,
|
||||||
|
timestamp: new Date(data.timestamp as string),
|
||||||
|
});
|
||||||
|
} else if (data.type === 'execution_completed') {
|
||||||
|
console.log('[agentStore] Execution completed');
|
||||||
|
executionStatus.value = 'completed';
|
||||||
|
events.value.push({
|
||||||
|
type: 'completed',
|
||||||
|
content: data.output_data as Record<string, unknown>,
|
||||||
|
message: data.message as string,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
} else if (data.type === 'execution_error') {
|
||||||
|
console.log('[agentStore] Execution error:', data.error_message);
|
||||||
|
executionStatus.value = 'failed';
|
||||||
|
events.value.push({
|
||||||
|
type: 'error',
|
||||||
|
message: data.error_message as string,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
} else if (data.type === 'execution_stopped') {
|
||||||
|
console.log('[agentStore] Execution stopped');
|
||||||
|
executionStatus.value = 'stopped';
|
||||||
|
events.value.push({
|
||||||
|
type: 'stopped',
|
||||||
|
message: data.message as string,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
} else if (data.type === 'error') {
|
||||||
|
console.log('[agentStore] Generic error:', data.message);
|
||||||
|
events.value.push({
|
||||||
|
type: 'error',
|
||||||
|
message: data.message as string,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
'[agentStore] WARNING - Unknown message type:',
|
||||||
|
data.type
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startAgent = (inputData: Record<string, unknown> = {}) => {
|
||||||
|
console.log('[agentStore] startAgent() called with data:', inputData);
|
||||||
|
|
||||||
|
if (!socket.value) {
|
||||||
|
console.error('[agentStore] ERROR - WebSocket not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isConnected.value) {
|
||||||
|
console.error(
|
||||||
|
'[agentStore] ERROR - WebSocket not connected (isConnected:',
|
||||||
|
isConnected.value,
|
||||||
|
')'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const message = {
|
||||||
|
action: 'start_agent',
|
||||||
|
input_data: inputData,
|
||||||
|
};
|
||||||
|
console.log('[agentStore] Sending message:', message);
|
||||||
|
socket.value.send(JSON.stringify(message));
|
||||||
|
console.log('[agentStore] SUCCESS - Message sent to WebSocket');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
'[agentStore] ERROR - Failed to send WebSocket message:',
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopAgent = () => {
|
||||||
|
console.log('[agentStore] stopAgent() called');
|
||||||
|
|
||||||
|
if (!socket.value) {
|
||||||
|
console.error('[agentStore] ERROR - WebSocket not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isConnected.value) {
|
||||||
|
console.error('[agentStore] ERROR - WebSocket not connected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const message = {
|
||||||
|
action: 'stop_agent',
|
||||||
|
execution_id: currentExecutionId.value,
|
||||||
|
};
|
||||||
|
console.log('[agentStore] Sending message:', message);
|
||||||
|
socket.value.send(JSON.stringify(message));
|
||||||
|
console.log(
|
||||||
|
'[agentStore] SUCCESS - Stop message sent to WebSocket'
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
'[agentStore] ERROR - Failed to send stop message:',
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const disconnect = () => {
|
||||||
|
console.log('[agentStore] disconnect() called');
|
||||||
|
reconnectAttempts.value = maxReconnectAttempts;
|
||||||
|
|
||||||
|
if (socket.value) {
|
||||||
|
console.log('[agentStore] Closing WebSocket connection');
|
||||||
|
socket.value.close();
|
||||||
|
console.log('[agentStore] WebSocket close initiated');
|
||||||
|
} else {
|
||||||
|
console.warn('[agentStore] WARNING - No WebSocket to disconnect');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventLog = computed(() => events.value);
|
||||||
|
|
||||||
|
return {
|
||||||
|
socket,
|
||||||
|
isConnected,
|
||||||
|
currentExecutionId,
|
||||||
|
executionStatus,
|
||||||
|
agentId,
|
||||||
|
eventLog,
|
||||||
|
connect,
|
||||||
|
startAgent,
|
||||||
|
stopAgent,
|
||||||
|
disconnect,
|
||||||
|
};
|
||||||
|
});
|
||||||
159
src/stores/authStore.ts
Normal file
159
src/stores/authStore.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { apiClient, isAxiosError } from '../lib/api';
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: number;
|
||||||
|
uuid: string;
|
||||||
|
email_address: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
bio?: string;
|
||||||
|
timezone?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
role?: string;
|
||||||
|
date_of_birth?: string;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionResponse {
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isStaff: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const user = ref<AuthUser | null>(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const initialized = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
const isAuthenticated = computed(() => Boolean(user.value));
|
||||||
|
const hasRole = (roles: string[] = []) => {
|
||||||
|
if (!roles.length) return true;
|
||||||
|
return roles.includes(user.value?.role || '');
|
||||||
|
};
|
||||||
|
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: AuthUser | 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/user/session/'
|
||||||
|
);
|
||||||
|
if (sessionRes.data?.isAuthenticated) {
|
||||||
|
const meRes = await apiClient.get<AuthUser>('/api/user/me/');
|
||||||
|
setUser(meRes.data);
|
||||||
|
} else {
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
return user.value;
|
||||||
|
} catch (err) {
|
||||||
|
error.value = isAxiosError(err)
|
||||||
|
? err.response?.data?.detail || err.message
|
||||||
|
: 'Unable to fetch session';
|
||||||
|
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: AuthUser;
|
||||||
|
message?: string;
|
||||||
|
}>('/api/user/login/', { email_address: emailAddress, password });
|
||||||
|
setUser(res.data?.user ?? null);
|
||||||
|
return res.data;
|
||||||
|
} catch (err) {
|
||||||
|
error.value = isAxiosError(err)
|
||||||
|
? err.response?.data?.error ||
|
||||||
|
err.response?.data?.detail ||
|
||||||
|
err.message
|
||||||
|
: 'Login failed';
|
||||||
|
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/user/signup/', {
|
||||||
|
...payload,
|
||||||
|
confirm_password: payload.confirm_password || payload.password,
|
||||||
|
});
|
||||||
|
await login(payload.email_address, payload.password);
|
||||||
|
} catch (err) {
|
||||||
|
error.value = isAxiosError(err)
|
||||||
|
? err.response?.data?.detail ||
|
||||||
|
err.response?.data?.error ||
|
||||||
|
err.message
|
||||||
|
: 'Registration failed';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
await apiClient.post('/api/user/logout/');
|
||||||
|
} catch (err) {
|
||||||
|
error.value = isAxiosError(err)
|
||||||
|
? err.response?.data?.detail ||
|
||||||
|
err.response?.data?.error ||
|
||||||
|
err.message
|
||||||
|
: 'Logout failed';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setUser(null);
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
initialized,
|
||||||
|
error,
|
||||||
|
isAuthenticated,
|
||||||
|
hasRole,
|
||||||
|
displayName,
|
||||||
|
fetchSession,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
@ -2,7 +2,8 @@ html {
|
||||||
-webkit-text-size-adjust: 100%;
|
-webkit-text-size-adjust: 100%;
|
||||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||||
'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif,
|
'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif,
|
||||||
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||||
|
'Noto Color Emoji';
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
tab-size: 4;
|
tab-size: 4;
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
|
|
@ -11,6 +12,8 @@ body {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
line-height: inherit;
|
line-height: inherit;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
background: #0b1220;
|
||||||
|
color: #e5e7eb;
|
||||||
}
|
}
|
||||||
h1,
|
h1,
|
||||||
h2,
|
h2,
|
||||||
|
|
|
||||||
|
|
@ -1,63 +1,134 @@
|
||||||
|
<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>
|
<template>
|
||||||
<div class="about">
|
<div class="page">
|
||||||
<h1>About Agentic Trainers</h1>
|
<Card class="panel" :bordered="false">
|
||||||
<p>
|
<Typography.Title :level="2"
|
||||||
Agentic Trainers is a lightweight platform for onboarding, training,
|
>About Agentic Trainers</Typography.Title
|
||||||
and assessing employees using modular training content and
|
>
|
||||||
agent-driven workflows. This repo contains a demo front-end and
|
<Typography.Paragraph type="secondary">
|
||||||
example pipelines for role-based experiences.
|
Agentic Trainers is a lightweight platform for onboarding,
|
||||||
</p>
|
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>
|
||||||
|
|
||||||
<h2>Role pathways</h2>
|
<Divider />
|
||||||
<ul>
|
<Typography.Title :level="4">Role pathways</Typography.Title>
|
||||||
<li>
|
<List :data-source="pathways" :bordered="false">
|
||||||
<strong>Admin</strong>: full access to system settings, user
|
<template #renderItem="{ item }">
|
||||||
management, and reporting. Admins can invite or deactivate
|
<List.Item class="row">{{ item }}</List.Item>
|
||||||
managers and view overall progress.
|
</template>
|
||||||
</li>
|
</List>
|
||||||
<li>
|
|
||||||
<strong>Manager</strong>: responsible for company-level tasks:
|
|
||||||
create onboarding flows, assign employees to roles, and monitor
|
|
||||||
team progress.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Employee</strong>: follows onboarding and training
|
|
||||||
modules, completes assessments, and views personal progress on
|
|
||||||
the dashboard.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>Getting started</h2>
|
<Divider />
|
||||||
<ol>
|
<Typography.Title :level="4">Highlights</Typography.Title>
|
||||||
<li>
|
<List :data-source="highlights" :bordered="false">
|
||||||
Register or login from the top-right to choose your role (demo
|
<template #renderItem="{ item }">
|
||||||
only).
|
<List.Item class="row">{{ item }}</List.Item>
|
||||||
</li>
|
</template>
|
||||||
<li>
|
</List>
|
||||||
Use the <em>Onboarding</em> and <em>Training</em> links to
|
|
||||||
begin.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Managers can create roles and assign employees via the
|
|
||||||
<em>Roles</em> page.
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<p>
|
<Divider />
|
||||||
This is a demo implementation — authentication and permissions are
|
<Typography.Title :level="4">Getting started</Typography.Title>
|
||||||
local-storage based for example purposes. For production-grade apps
|
<List :data-source="steps" :bordered="false">
|
||||||
integrate a backend auth provider and persistent user store.
|
<template #renderItem="{ item, index }">
|
||||||
</p>
|
<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 and
|
||||||
|
persistence for production.
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style scoped>
|
||||||
@media (min-width: 768px) {
|
.page {
|
||||||
.about {
|
max-width: 900px;
|
||||||
max-width: 768px;
|
margin: 0 auto;
|
||||||
margin-left: auto;
|
padding: 1rem;
|
||||||
margin-right: auto;
|
}
|
||||||
padding: 0 1rem;
|
.panel {
|
||||||
}
|
background: #0f172a;
|
||||||
|
border: 1px solid #1f2937;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
color: #e5e7eb;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,68 +1,421 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref, onMounted, onUnmounted, computed } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
List,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Input,
|
||||||
|
message,
|
||||||
|
Tag,
|
||||||
|
} from 'ant-design-vue';
|
||||||
|
import { useAgentStore } from '../stores/agentStore';
|
||||||
|
import { apiClient, isAxiosError } from '../lib/api';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const id = route.params.id || 'unknown';
|
console.log('[AgentDetail] Route params:', route.params);
|
||||||
|
|
||||||
const agent = ref({
|
const agentStore = useAgentStore();
|
||||||
id,
|
console.log('[AgentDetail] Store instance:', agentStore);
|
||||||
name: `Agent ${id}`,
|
|
||||||
description:
|
const agentId = route.params.id as string;
|
||||||
'This agent helps new hires by providing step-by-step assistance and quick answers to common questions.',
|
console.log('[AgentDetail] Agent ID:', agentId);
|
||||||
|
|
||||||
|
if (!agentId) {
|
||||||
|
console.error('[AgentDetail] ERROR: No agent ID in route params');
|
||||||
|
}
|
||||||
|
|
||||||
|
const agent = ref<Record<string, unknown>>({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Loading...',
|
||||||
|
description: '',
|
||||||
|
status: 'idle',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[AgentDetail] Initial agent state:', agent.value);
|
||||||
|
|
||||||
|
const queryInput = ref('');
|
||||||
|
const isRunning = computed(() => {
|
||||||
|
console.log(
|
||||||
|
'[AgentDetail] isRunning computed - executionStatus:',
|
||||||
|
agentStore.executionStatus
|
||||||
|
);
|
||||||
|
return agentStore.executionStatus === 'running';
|
||||||
|
});
|
||||||
|
const isConnected = computed(() => {
|
||||||
|
console.log(
|
||||||
|
'[AgentDetail] isConnected computed - isConnected:',
|
||||||
|
agentStore.isConnected
|
||||||
|
);
|
||||||
|
return agentStore.isConnected ?? false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const agentResponse = computed(() => {
|
||||||
|
const completedEvent = agentStore.eventLog?.find(
|
||||||
|
(event) => event.type === 'completed'
|
||||||
|
);
|
||||||
|
if (completedEvent?.content && typeof completedEvent.content === 'object') {
|
||||||
|
const output = completedEvent.content as Record<string, unknown>;
|
||||||
|
return (output.response as string) || null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusColor = (status: string) => {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
idle: 'default',
|
||||||
|
running: 'processing',
|
||||||
|
completed: 'success',
|
||||||
|
failed: 'error',
|
||||||
|
stopped: 'warning',
|
||||||
|
};
|
||||||
|
return colors[status] || 'default';
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAgent = async () => {
|
||||||
|
console.log('[AgentDetail] Fetching agent details for ID:', agentId);
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/api/agent/${agentId}/`);
|
||||||
|
agent.value = response.data;
|
||||||
|
console.log('[AgentDetail] Agent fetched successfully:', agent.value);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AgentDetail] ERROR - Failed to fetch agent:', error);
|
||||||
|
if (isAxiosError(error)) {
|
||||||
|
console.error('[AgentDetail] Axios error details:', {
|
||||||
|
status: error.response?.status,
|
||||||
|
data: error.response?.data,
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
message.error('Failed to load agent details');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startAgent = () => {
|
||||||
|
console.log('[AgentDetail] Starting agent execution');
|
||||||
|
|
||||||
|
if (!agentStore.isConnected) {
|
||||||
|
console.warn('[AgentDetail] WARNING: WebSocket not connected');
|
||||||
|
console.log('[AgentDetail] Connection state:', {
|
||||||
|
isConnected: agentStore.isConnected,
|
||||||
|
});
|
||||||
|
message.error('WebSocket not connected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!queryInput.value.trim()) {
|
||||||
|
message.error('Please enter a query');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
query: queryInput.value.trim(),
|
||||||
|
};
|
||||||
|
console.log('[AgentDetail] Sending data:', data);
|
||||||
|
|
||||||
|
console.log('[AgentDetail] Calling startAgent on store');
|
||||||
|
agentStore.startAgent(data);
|
||||||
|
console.log('[AgentDetail] Agent execution initiated');
|
||||||
|
message.success('Agent execution started');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AgentDetail] ERROR - Failed to start agent:', error);
|
||||||
|
message.error('Failed to start agent');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopAgent = () => {
|
||||||
|
console.log('[AgentDetail] Stopping agent execution');
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('[AgentDetail] Calling stopAgent on store');
|
||||||
|
agentStore.stopAgent();
|
||||||
|
console.log('[AgentDetail] Agent stop signal sent');
|
||||||
|
message.success('Agent stop requested');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AgentDetail] ERROR - Failed to stop agent:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
console.log('[AgentDetail] Component mounted');
|
||||||
|
console.log('[AgentDetail] Lifecycle: onMounted - starting initialization');
|
||||||
|
|
||||||
|
fetchAgent();
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'[AgentDetail] Attempting WebSocket connection for agent:',
|
||||||
|
agentId
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
agentStore.connect(agentId);
|
||||||
|
console.log('[AgentDetail] WebSocket connection initiated');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
'[AgentDetail] ERROR - Failed to connect WebSocket:',
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
console.log('[AgentDetail] Component unmounted');
|
||||||
|
console.log('[AgentDetail] Lifecycle: onUnmounted - cleaning up');
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('[AgentDetail] Disconnecting WebSocket');
|
||||||
|
agentStore.disconnect();
|
||||||
|
console.log('[AgentDetail] WebSocket disconnected successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
'[AgentDetail] ERROR - Failed to disconnect WebSocket:',
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-wrap">
|
<div class="page">
|
||||||
<header class="page-header">
|
<Card class="panel" :bordered="false">
|
||||||
<h1>{{ agent.name }}</h1>
|
<div class="header">
|
||||||
<p class="lead">{{ agent.description }}</p>
|
<Typography.Title :level="2">{{ agent.name }}</Typography.Title>
|
||||||
</header>
|
<Tag
|
||||||
|
:color="
|
||||||
|
statusColor(
|
||||||
|
String(agentStore.executionStatus || 'idle')
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
(agentStore.executionStatus || 'idle')
|
||||||
|
.toString()
|
||||||
|
.toUpperCase()
|
||||||
|
}}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="controls">
|
<Typography.Paragraph type="secondary">{{
|
||||||
<button class="cta-button">Run Simulation</button>
|
agent.description || 'No description available'
|
||||||
<button class="link">Edit Configuration</button>
|
}}</Typography.Paragraph>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="logs">
|
<div class="connection-status">
|
||||||
<h3>Recent Interactions</h3>
|
<span>WebSocket Status:</span>
|
||||||
<ul>
|
<Tag :color="agentStore.isConnected ? 'green' : 'red'">
|
||||||
<li>2025-11-01: Simulated onboarding session (score: 92%)</li>
|
{{ agentStore.isConnected ? 'CONNECTED' : 'DISCONNECTED' }}
|
||||||
<li>2025-11-03: Updated knowledge sources</li>
|
</Tag>
|
||||||
<li>2025-11-07: Minor behavior tweak applied</li>
|
</div>
|
||||||
</ul>
|
|
||||||
</section>
|
<Typography.Title :level="4" class="section-title"
|
||||||
|
>Execution</Typography.Title
|
||||||
|
>
|
||||||
|
|
||||||
|
<div class="execution-controls">
|
||||||
|
<Space direction="vertical" style="width: 100%">
|
||||||
|
<div>
|
||||||
|
<Typography.Text>Query:</Typography.Text>
|
||||||
|
<Input.TextArea
|
||||||
|
v-model:value="queryInput"
|
||||||
|
:disabled="isRunning"
|
||||||
|
placeholder="Enter your query here..."
|
||||||
|
:rows="4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
:disabled="isRunning || !isConnected"
|
||||||
|
@click="startAgent"
|
||||||
|
>
|
||||||
|
Run Agent
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
:disabled="!isRunning"
|
||||||
|
@click="stopAgent"
|
||||||
|
>
|
||||||
|
Stop Agent
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Typography.Title :level="4" class="section-title"
|
||||||
|
>Execution Log</Typography.Title
|
||||||
|
>
|
||||||
|
|
||||||
|
<Spin :spinning="isRunning" tip="Agent running...">
|
||||||
|
<div class="log-container">
|
||||||
|
<List
|
||||||
|
v-if="(agentStore.eventLog?.length ?? 0) > 0"
|
||||||
|
:data-source="agentStore.eventLog || []"
|
||||||
|
:bordered="false"
|
||||||
|
>
|
||||||
|
<template #renderItem="{ item }">
|
||||||
|
<List.Item class="log-item">
|
||||||
|
<div class="log-entry">
|
||||||
|
<Tag class="log-type">{{ item.type }}</Tag>
|
||||||
|
<span class="log-time">{{
|
||||||
|
item.timestamp.toLocaleTimeString()
|
||||||
|
}}</span>
|
||||||
|
<div
|
||||||
|
v-if="item.message"
|
||||||
|
class="log-message"
|
||||||
|
>
|
||||||
|
{{ item.message }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
item.content &&
|
||||||
|
typeof item.content === 'object'
|
||||||
|
"
|
||||||
|
class="log-content"
|
||||||
|
>
|
||||||
|
<pre>{{
|
||||||
|
JSON.stringify(
|
||||||
|
item.content,
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
}}</pre>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="item.content"
|
||||||
|
class="log-content"
|
||||||
|
>
|
||||||
|
{{ item.content }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</List.Item>
|
||||||
|
</template>
|
||||||
|
</List>
|
||||||
|
<Typography.Paragraph v-else type="secondary">
|
||||||
|
No events yet. Start the agent to see execution logs.
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</div>
|
||||||
|
</Spin>
|
||||||
|
|
||||||
|
<div v-if="agentResponse" class="response-section">
|
||||||
|
<Typography.Title :level="4" class="section-title"
|
||||||
|
>Response</Typography.Title
|
||||||
|
>
|
||||||
|
<Card class="response-card" :bordered="false">
|
||||||
|
<div class="response-content">
|
||||||
|
{{ agentResponse }}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page-wrap {
|
.page {
|
||||||
max-width: 960px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
.controls {
|
|
||||||
|
.panel {
|
||||||
|
background: #0f172a;
|
||||||
|
border: 1px solid #1f2937;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin-top: 2rem !important;
|
||||||
|
margin-bottom: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: #1f2937;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-controls {
|
||||||
|
background: #1f2937;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
.cta-button {
|
|
||||||
background-color: #4f46e5;
|
.log-container {
|
||||||
color: white;
|
background: #1f2937;
|
||||||
padding: 0.6rem 1rem;
|
border-radius: 4px;
|
||||||
border-radius: 0.5rem;
|
max-height: 500px;
|
||||||
border: none;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
.link {
|
|
||||||
background: transparent;
|
.log-item {
|
||||||
color: #4f46e5;
|
border-bottom: 1px solid #374151 !important;
|
||||||
border: none;
|
padding: 0.75rem !important;
|
||||||
padding: 0.6rem 1rem;
|
|
||||||
}
|
}
|
||||||
.logs ul {
|
|
||||||
|
.log-entry {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-type {
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-time {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-message {
|
||||||
|
color: #e5e7eb;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-content {
|
||||||
|
background: #111827;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-content pre {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-left: 1.25rem;
|
font-size: 0.8rem;
|
||||||
color: #374151;
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-card {
|
||||||
|
background: #1f2937;
|
||||||
|
border: 1px solid #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-content {
|
||||||
|
color: #e5e7eb;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,61 +1,106 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { List, Typography, Button, Card, Spin, message } from 'ant-design-vue';
|
||||||
|
import { apiClient } from '../lib/api';
|
||||||
|
|
||||||
const agents = ref([
|
interface Agent {
|
||||||
{ id: 'a1', name: 'Onboarding Bot', role: 'Guided tours' },
|
uuid: string;
|
||||||
{ id: 'a2', name: 'Docs Helper', role: 'Knowledge base' },
|
id: string;
|
||||||
{ id: 'a3', name: 'QA Coach', role: 'Assessment' },
|
name: string;
|
||||||
]);
|
description: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const agents = ref<Agent[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const fetchAgents = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get('/api/agent/');
|
||||||
|
agents.value = response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch agents:', error);
|
||||||
|
message.error('Failed to load agents');
|
||||||
|
agents.value = [
|
||||||
|
{
|
||||||
|
uuid: 'a1',
|
||||||
|
id: 'a1',
|
||||||
|
name: 'Onboarding Bot',
|
||||||
|
description: 'Guided tours',
|
||||||
|
status: 'idle',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uuid: 'a2',
|
||||||
|
id: 'a2',
|
||||||
|
name: 'Docs Helper',
|
||||||
|
description: 'Knowledge base',
|
||||||
|
status: 'idle',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uuid: 'a3',
|
||||||
|
id: 'a3',
|
||||||
|
name: 'QA Coach',
|
||||||
|
description: 'Assessment',
|
||||||
|
status: 'idle',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchAgents();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-wrap">
|
<div class="page">
|
||||||
<header class="page-header">
|
<Typography.Title :level="2">Agents</Typography.Title>
|
||||||
<h1>Agents</h1>
|
<Typography.Paragraph type="secondary"
|
||||||
<p class="lead">Manage and inspect the available AI agents.</p>
|
>Manage and inspect the available AI agents.</Typography.Paragraph
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="agent-list">
|
|
||||||
<div v-for="agent in agents" :key="agent.id" class="agent-card">
|
|
||||||
<div>
|
|
||||||
<h3>{{ agent.name }}</h3>
|
|
||||||
<p class="muted">{{ agent.role }}</p>
|
|
||||||
</div>
|
|
||||||
<router-link :to="`/agents/${agent.id}`" class="cta-small"
|
|
||||||
>Open</router-link
|
|
||||||
>
|
>
|
||||||
</div>
|
|
||||||
</section>
|
<Card class="panel" :bordered="false">
|
||||||
|
<Spin :spinning="loading" tip="Loading agents...">
|
||||||
|
<List
|
||||||
|
:data-source="agents"
|
||||||
|
item-layout="horizontal"
|
||||||
|
:bordered="false"
|
||||||
|
>
|
||||||
|
<template #renderItem="{ item }">
|
||||||
|
<List.Item class="item">
|
||||||
|
<List.Item.Meta
|
||||||
|
:title="item.name"
|
||||||
|
:description="`${item.description} • Status: ${item.status}`"
|
||||||
|
/>
|
||||||
|
<RouterLink :to="`/agents/${item.uuid || item.id}`">
|
||||||
|
<Button type="primary" size="small"
|
||||||
|
>Open</Button
|
||||||
|
>
|
||||||
|
</RouterLink>
|
||||||
|
</List.Item>
|
||||||
|
</template>
|
||||||
|
</List>
|
||||||
|
</Spin>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page-wrap {
|
.page {
|
||||||
max-width: 960px;
|
max-width: 900px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
.agent-list {
|
.panel {
|
||||||
display: grid;
|
background: #0f172a;
|
||||||
gap: 0.75rem;
|
border: 1px solid #1f2937;
|
||||||
|
color: #e5e7eb;
|
||||||
}
|
}
|
||||||
.agent-card {
|
.item :deep(.ant-list-item-meta-title),
|
||||||
display: flex;
|
.item :deep(.ant-list-item-meta-description) {
|
||||||
justify-content: space-between;
|
color: #e5e7eb;
|
||||||
align-items: center;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
|
||||||
.muted {
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
.cta-small {
|
|
||||||
background: #4f46e5;
|
|
||||||
color: #fff;
|
|
||||||
padding: 0.4rem 0.6rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
import { Card, Typography, Row, Col, Tag, Button, Space } from 'ant-design-vue';
|
||||||
|
|
||||||
const assessments = ref([
|
const assessments = ref([
|
||||||
{ id: 't1', title: 'Knowledge Check - Basics', type: 'Quiz', passing: 70 },
|
{ id: 't1', title: 'Knowledge Check - Basics', type: 'Quiz', passing: 70 },
|
||||||
|
|
@ -13,59 +14,47 @@ const assessments = ref([
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-wrap">
|
<div class="page">
|
||||||
<header class="page-header">
|
<Typography.Title :level="2">Assessments</Typography.Title>
|
||||||
<h1>Assessments</h1>
|
<Typography.Paragraph type="secondary"
|
||||||
<p class="lead">
|
>Create and run assessments to validate
|
||||||
Create and run assessments to validate readiness.
|
readiness.</Typography.Paragraph
|
||||||
</p>
|
>
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="assess-list">
|
<Row :gutter="16">
|
||||||
<div v-for="a in assessments" :key="a.id" class="assess-card">
|
<Col v-for="a in assessments" :key="a.id" :xs="24" :md="12">
|
||||||
<h3>{{ a.title }}</h3>
|
<Card class="card" hoverable :bordered="false">
|
||||||
<p class="meta">
|
<Typography.Title :level="4">{{
|
||||||
Type: {{ a.type }} · Passing: {{ a.passing }}%
|
a.title
|
||||||
</p>
|
}}</Typography.Title>
|
||||||
<div class="actions">
|
<Typography.Text type="secondary"
|
||||||
<button class="cta-small">Preview</button>
|
>Type: {{ a.type }} · Passing:
|
||||||
<button class="link">Run</button>
|
{{ a.passing }}%</Typography.Text
|
||||||
</div>
|
>
|
||||||
</div>
|
<Space class="actions">
|
||||||
</section>
|
<Tag color="blue">{{ a.type }}</Tag>
|
||||||
|
<Button size="small">Preview</Button>
|
||||||
|
<Button size="small" type="primary">Run</Button>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.assess-list {
|
.page {
|
||||||
display: grid;
|
max-width: 1000px;
|
||||||
gap: 0.75rem;
|
margin: 0 auto;
|
||||||
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
.assess-card {
|
.card {
|
||||||
background: #fff;
|
background: #0f172a;
|
||||||
padding: 0.75rem;
|
border: 1px solid #1f2937;
|
||||||
border-radius: 8px;
|
color: #e5e7eb;
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
|
||||||
.meta {
|
|
||||||
color: #6b7280;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
}
|
||||||
.actions {
|
.actions {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.75rem;
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
.cta-small {
|
|
||||||
background: #4f46e5;
|
|
||||||
color: #fff;
|
|
||||||
padding: 0.4rem 0.6rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
.link {
|
|
||||||
background: transparent;
|
|
||||||
color: #4f46e5;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,87 +1,329 @@
|
||||||
<script setup lang="ts" />
|
<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>
|
<template>
|
||||||
<main class="home">
|
<main class="page">
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<h1>Welcome to Agentic Trainers</h1>
|
<Row :gutter="32" :align="'middle'">
|
||||||
<p>
|
<Col :xs="24" :md="14">
|
||||||
Automate onboarding and support new team members with AI agents.
|
<Typography.Title :level="1" class="hero-title">
|
||||||
Our platform creates domain-specific training workflows tailored
|
Build agentic onboarding that feels bespoke to every
|
||||||
to each role.
|
role
|
||||||
</p>
|
</Typography.Title>
|
||||||
<router-link to="/about" class="cta-button">
|
<Typography.Paragraph class="hero-sub">
|
||||||
Learn More
|
AI-led workflows, assessments, and knowledge delivery
|
||||||
</router-link>
|
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>
|
||||||
|
|
||||||
<section class="features">
|
<section class="features">
|
||||||
<h2>Key Features</h2>
|
<Typography.Title :level="2"
|
||||||
<ul>
|
>Everything you need to ramp faster</Typography.Title
|
||||||
<li>Reusable AI-powered workflows for role induction.</li>
|
>
|
||||||
<li>Adaptive guidance tailored to each team member.</li>
|
<Row :gutter="16">
|
||||||
<li>Track progress and generate actionable insights.</li>
|
<Col
|
||||||
<li>Extensible to any domain or industry.</li>
|
v-for="feature in features"
|
||||||
</ul>
|
: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>
|
||||||
|
|
||||||
<section class="get-started">
|
<section class="journeys">
|
||||||
<h2>Get Started</h2>
|
<Typography.Title :level="2"
|
||||||
<p>
|
>Prebuilt journeys, tailored in minutes</Typography.Title
|
||||||
Begin your AI-driven onboarding journey today. Explore how our
|
>
|
||||||
agentic approach can help your team succeed faster.
|
<Row :gutter="16">
|
||||||
</p>
|
<Col
|
||||||
<router-link to="/about" class="cta-button">
|
v-for="journey in journeys"
|
||||||
Explore Now
|
:key="journey.name"
|
||||||
</router-link>
|
: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>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.home {
|
.page {
|
||||||
max-width: 960px;
|
padding: 2rem 1.5rem 3rem;
|
||||||
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1rem;
|
|
||||||
}
|
}
|
||||||
.hero {
|
.hero {
|
||||||
text-align: center;
|
margin-bottom: 2.5rem;
|
||||||
margin-bottom: 3rem;
|
|
||||||
}
|
}
|
||||||
.hero h1 {
|
.hero-title {
|
||||||
font-size: 2.5rem;
|
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
.hero p {
|
.hero-sub {
|
||||||
font-size: 1.25rem;
|
font-size: 1.05rem;
|
||||||
margin-bottom: 2rem;
|
color: #cbd5e1;
|
||||||
}
|
}
|
||||||
.cta-button {
|
.hero-card {
|
||||||
background-color: #4f46e5;
|
border: none;
|
||||||
color: white;
|
}
|
||||||
padding: 0.75rem 1.5rem;
|
.hero-img {
|
||||||
border-radius: 0.5rem;
|
width: 100%;
|
||||||
text-decoration: none;
|
height: 280px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.hero-overlay {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
color: #8b5cf6;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.cta-button:hover {
|
.stat-card {
|
||||||
background-color: #4338ca;
|
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 {
|
.features {
|
||||||
margin-bottom: 3rem;
|
margin: 2.5rem 0;
|
||||||
}
|
}
|
||||||
.features h2 {
|
.feature-card {
|
||||||
font-size: 2rem;
|
height: 100%;
|
||||||
margin-bottom: 1rem;
|
background: #0f172a;
|
||||||
|
border: 1px solid #1f2937;
|
||||||
|
color: #e5e7eb;
|
||||||
}
|
}
|
||||||
.features ul {
|
.journeys {
|
||||||
list-style-type: disc;
|
margin: 2.5rem 0;
|
||||||
padding-left: 1.5rem;
|
|
||||||
}
|
}
|
||||||
.get-started h2 {
|
.journey-card {
|
||||||
font-size: 2rem;
|
background: #0f172a;
|
||||||
margin-bottom: 1rem;
|
border: 1px solid #1f2937;
|
||||||
|
color: #e5e7eb;
|
||||||
}
|
}
|
||||||
.get-started p {
|
.testimonials {
|
||||||
margin-bottom: 1.5rem;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,53 +1,106 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { reactive, computed, onMounted } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
|
import { Card, Typography, Form, Input, Button, message } from 'ant-design-vue';
|
||||||
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const name = ref('');
|
const route = useRoute();
|
||||||
const role = ref<'admin' | 'manager' | 'employee'>('employee');
|
const authStore = useAuthStore();
|
||||||
|
const loading = computed(() => authStore.loading);
|
||||||
|
|
||||||
function submit() {
|
const formState = reactive({
|
||||||
router.push('/');
|
email: '',
|
||||||
}
|
password: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
try {
|
||||||
|
await authStore.login(formState.email, formState.password);
|
||||||
|
message.success('Login successful');
|
||||||
|
const redirect = (route.query.redirect as string) || '/onboarding';
|
||||||
|
router.push(redirect);
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMsg =
|
||||||
|
authStore.error ||
|
||||||
|
error?.response?.data?.detail ||
|
||||||
|
error?.response?.data?.message ||
|
||||||
|
'Login failed';
|
||||||
|
message.error(errorMsg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await authStore.fetchSession();
|
||||||
|
if (authStore.isAuthenticated) {
|
||||||
|
const redirect = (route.query.redirect as string) || '/onboarding';
|
||||||
|
router.replace(redirect);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="auth">
|
<div class="auth-page">
|
||||||
<h1>Login (demo)</h1>
|
<Card class="panel" :bordered="false">
|
||||||
<div>
|
<Typography.Title :level="3">Login</Typography.Title>
|
||||||
<label>Name</label>
|
<Form
|
||||||
<input v-model="name" placeholder="Your name" />
|
ref="form"
|
||||||
</div>
|
layout="vertical"
|
||||||
|
:model="formState"
|
||||||
<div>
|
@finish="submit"
|
||||||
<label>Role</label>
|
>
|
||||||
<select v-model="role">
|
<Form.Item
|
||||||
<option value="employee">Employee</option>
|
label="Email"
|
||||||
<option value="manager">Manager</option>
|
name="email"
|
||||||
<option value="admin">Admin</option>
|
:rules="[
|
||||||
</select>
|
{ required: true, message: 'Enter your email' },
|
||||||
</div>
|
{
|
||||||
|
type: 'email',
|
||||||
<button class="cta-button" @click="submit">Login</button>
|
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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.auth {
|
.auth-page {
|
||||||
max-width: 480px;
|
display: flex;
|
||||||
margin: 0 auto;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
label {
|
.panel {
|
||||||
display: block;
|
max-width: 400px;
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
input,
|
|
||||||
select {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.5rem;
|
background: #0f172a;
|
||||||
margin-top: 0.25rem;
|
border: 1px solid #1f2937;
|
||||||
}
|
color: #e5e7eb;
|
||||||
.cta-button {
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
import { Card, Typography, Timeline, Button, Space } from 'ant-design-vue';
|
||||||
|
|
||||||
const steps = ref([
|
const steps = ref([
|
||||||
{
|
{
|
||||||
|
|
@ -24,95 +25,63 @@ const steps = ref([
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-wrap">
|
<div class="page">
|
||||||
<header class="page-header">
|
<Card class="panel" :bordered="false">
|
||||||
<h1>Onboarding Flow</h1>
|
<Typography.Title :level="2">Onboarding Flow</Typography.Title>
|
||||||
<p class="lead">
|
<Typography.Paragraph type="secondary">
|
||||||
Step-by-step AI-guided onboarding for new team members.
|
Step-by-step AI-guided onboarding for new team members.
|
||||||
</p>
|
</Typography.Paragraph>
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="steps">
|
<Timeline mode="left" class="timeline">
|
||||||
<div v-for="step in steps" :key="step.id" class="card">
|
<Timeline.Item
|
||||||
<div class="card-left">
|
v-for="step in steps"
|
||||||
<div class="step-index">{{ step.id }}</div>
|
:key="step.id"
|
||||||
</div>
|
color="purple"
|
||||||
<div class="card-body">
|
|
||||||
<h3>{{ step.title }}</h3>
|
|
||||||
<p>{{ step.description }}</p>
|
|
||||||
<div class="meta">Est. time: {{ step.eta }} mins</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer class="actions">
|
|
||||||
<router-link to="/training" class="cta-button"
|
|
||||||
>Start Training</router-link
|
|
||||||
>
|
>
|
||||||
<router-link to="/agents" class="link">View Agents</router-link>
|
<Space direction="vertical" size="small">
|
||||||
</footer>
|
<Typography.Title :level="4">{{
|
||||||
|
step.title
|
||||||
|
}}</Typography.Title>
|
||||||
|
<Typography.Text>{{
|
||||||
|
step.description
|
||||||
|
}}</Typography.Text>
|
||||||
|
<Typography.Text type="secondary"
|
||||||
|
>Est. time: {{ step.eta }} mins</Typography.Text
|
||||||
|
>
|
||||||
|
</Space>
|
||||||
|
</Timeline.Item>
|
||||||
|
</Timeline>
|
||||||
|
|
||||||
|
<Space class="actions" wrap>
|
||||||
|
<RouterLink to="/training"
|
||||||
|
><Button type="primary">Start Training</Button></RouterLink
|
||||||
|
>
|
||||||
|
<RouterLink to="/agents"
|
||||||
|
><Button ghost>View Agents</Button></RouterLink
|
||||||
|
>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page-wrap {
|
.page {
|
||||||
max-width: 960px;
|
max-width: 1100px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
.page-header h1 {
|
.panel {
|
||||||
font-size: 2rem;
|
background: #0f172a;
|
||||||
margin-bottom: 0.25rem;
|
border: 1px solid #1f2937;
|
||||||
|
color: #e5e7eb;
|
||||||
}
|
}
|
||||||
.lead {
|
.timeline :deep(.ant-timeline-item-head) {
|
||||||
color: #6b7280;
|
background: #8b5cf6;
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
}
|
||||||
.steps {
|
.timeline :deep(.ant-typography) {
|
||||||
display: grid;
|
color: #e5e7eb;
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
.card-left {
|
|
||||||
margin-right: 1rem;
|
|
||||||
}
|
|
||||||
.step-index {
|
|
||||||
background: #eef2ff;
|
|
||||||
color: #4f46e5;
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
.card-body h3 {
|
|
||||||
margin: 0 0 0.25rem 0;
|
|
||||||
}
|
|
||||||
.meta {
|
|
||||||
color: #9ca3af;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
}
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
margin-top: 1rem;
|
||||||
gap: 1rem;
|
|
||||||
margin-top: 1.25rem;
|
|
||||||
}
|
|
||||||
.cta-button {
|
|
||||||
background-color: #4f46e5;
|
|
||||||
color: white;
|
|
||||||
padding: 0.6rem 1rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.link {
|
|
||||||
color: #4f46e5;
|
|
||||||
align-self: center;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
|
import { Card, Typography, Progress, List } from 'ant-design-vue';
|
||||||
|
|
||||||
const users = ref([
|
const users = ref([
|
||||||
{ id: 'u1', name: 'Alex', progress: 78 },
|
{ id: 'u1', name: 'Alex', progress: 78 },
|
||||||
|
|
@ -15,76 +16,64 @@ const avg = computed(() =>
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-wrap">
|
<div class="page">
|
||||||
<header class="page-header">
|
<Typography.Title :level="2">Progress Dashboard</Typography.Title>
|
||||||
<h1>Progress Dashboard</h1>
|
<Typography.Paragraph type="secondary"
|
||||||
<p class="lead">Track cohort progress and module completion.</p>
|
>Track cohort progress and module completion.</Typography.Paragraph
|
||||||
</header>
|
>
|
||||||
|
|
||||||
<section class="overview">
|
<div class="overview">
|
||||||
<div class="stat">
|
<Card class="stat" :bordered="false">
|
||||||
<div class="stat-value">{{ avg }}%</div>
|
<Typography.Title :level="3">{{ avg }}%</Typography.Title>
|
||||||
<div class="stat-label">Average Progress</div>
|
<Typography.Text type="secondary"
|
||||||
|
>Average Progress</Typography.Text
|
||||||
|
>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="panel" :bordered="false">
|
||||||
|
<List :data-source="users" :bordered="false">
|
||||||
|
<template #renderItem="{ item }">
|
||||||
|
<List.Item class="user-row">
|
||||||
|
<div class="user-name">{{ item.name }}</div>
|
||||||
|
<Progress
|
||||||
|
:percent="item.progress"
|
||||||
|
stroke-color="#8b5cf6"
|
||||||
|
:show-info="true"
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
</template>
|
||||||
|
</List>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-list">
|
|
||||||
<div v-for="u in users" :key="u.id" class="user-row">
|
|
||||||
<div>{{ u.name }}</div>
|
|
||||||
<div class="progress">
|
|
||||||
<div
|
|
||||||
class="bar"
|
|
||||||
:style="{ width: u.progress + '%' }"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div class="pct">{{ u.progress }}%</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.page {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
.overview {
|
.overview {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
.stat {
|
.stat,
|
||||||
background: #fff;
|
.panel {
|
||||||
padding: 1rem;
|
background: #0f172a;
|
||||||
border-radius: 8px;
|
border: 1px solid #1f2937;
|
||||||
text-align: center;
|
color: #e5e7eb;
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
|
||||||
.stat-value {
|
|
||||||
font-size: 2rem;
|
|
||||||
color: #111827;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.user-list {
|
|
||||||
background: #fff;
|
|
||||||
padding: 0.75rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
}
|
||||||
.user-row {
|
.user-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 1rem;
|
||||||
padding: 0.5rem 0;
|
|
||||||
}
|
}
|
||||||
.progress {
|
.user-name {
|
||||||
flex: 1;
|
width: 140px;
|
||||||
background: #f3f4f6;
|
color: #e5e7eb;
|
||||||
height: 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
.bar {
|
.panel :deep(.ant-progress-text) {
|
||||||
height: 100%;
|
color: #e5e7eb;
|
||||||
background: #4f46e5;
|
|
||||||
}
|
|
||||||
.pct {
|
|
||||||
width: 48px;
|
|
||||||
text-align: right;
|
|
||||||
color: #374151;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,172 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { reactive, computed, onMounted } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Typography,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Button,
|
||||||
|
message,
|
||||||
|
} from 'ant-design-vue';
|
||||||
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const name = ref('');
|
const route = useRoute();
|
||||||
const role = ref<'admin' | 'manager' | 'employee'>('employee');
|
const authStore = useAuthStore();
|
||||||
|
const loading = computed(() => authStore.loading);
|
||||||
|
|
||||||
function submit() {
|
const formState = reactive({
|
||||||
router.push('/');
|
email: '',
|
||||||
}
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
role: 'employee' as 'admin' | 'manager' | 'employee',
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
try {
|
||||||
|
await authStore.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: any) {
|
||||||
|
const errorMsg =
|
||||||
|
authStore.error ||
|
||||||
|
error?.response?.data?.detail ||
|
||||||
|
error?.response?.data?.message ||
|
||||||
|
'Registration failed';
|
||||||
|
message.error(errorMsg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await authStore.fetchSession();
|
||||||
|
if (authStore.isAuthenticated) {
|
||||||
|
const redirect = (route.query.redirect as string) || '/onboarding';
|
||||||
|
router.replace(redirect);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="auth">
|
<div class="auth-page">
|
||||||
<h1>Register (demo)</h1>
|
<Card class="panel" :bordered="false">
|
||||||
<div>
|
<Typography.Title :level="3">Register</Typography.Title>
|
||||||
<label>Name</label>
|
<Form layout="vertical" :model="formState" @finish="submit">
|
||||||
<input v-model="name" placeholder="Your name" />
|
<Form.Item
|
||||||
</div>
|
label="Email"
|
||||||
|
name="email"
|
||||||
<div>
|
:rules="[
|
||||||
<label>Role</label>
|
{ required: true, message: 'Enter your email' },
|
||||||
<select v-model="role">
|
{
|
||||||
<option value="employee">Employee</option>
|
type: 'email',
|
||||||
<option value="manager">Manager</option>
|
message: 'Please enter a valid email',
|
||||||
</select>
|
},
|
||||||
</div>
|
]"
|
||||||
|
>
|
||||||
<button class="cta-button" @click="submit">Register</button>
|
<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="[
|
||||||
|
{ required: true, message: 'Confirm your password' },
|
||||||
|
{
|
||||||
|
validator: (_, value) =>
|
||||||
|
value === formState.password
|
||||||
|
? Promise.resolve()
|
||||||
|
: Promise.reject(
|
||||||
|
new Error('Passwords do not match')
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.auth {
|
.auth-page {
|
||||||
max-width: 480px;
|
max-width: 520px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
label {
|
.panel {
|
||||||
display: block;
|
background: #0f172a;
|
||||||
margin-top: 0.5rem;
|
border: 1px solid #1f2937;
|
||||||
}
|
color: #e5e7eb;
|
||||||
input,
|
|
||||||
select {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.5rem;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
.cta-button {
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Card, Typography, Row, Col, Button, Tag } from 'ant-design-vue';
|
||||||
|
|
||||||
const resources = [
|
const resources = [
|
||||||
{ id: 'r1', title: 'Engineering Handbook', type: 'PDF' },
|
{ id: 'r1', title: 'Engineering Handbook', type: 'PDF' },
|
||||||
{ id: 'r2', title: 'Team Slack Guide', type: 'Article' },
|
{ id: 'r2', title: 'Team Slack Guide', type: 'Article' },
|
||||||
|
|
@ -7,49 +9,41 @@ const resources = [
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-wrap">
|
<div class="page">
|
||||||
<header class="page-header">
|
<Typography.Title :level="2">Resources</Typography.Title>
|
||||||
<h1>Resources</h1>
|
<Typography.Paragraph type="secondary"
|
||||||
<p class="lead">
|
>Curated links and assets to help new hires get
|
||||||
Curated links and assets to help new hires get productive.
|
productive.</Typography.Paragraph
|
||||||
</p>
|
>
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="resource-grid">
|
<Row :gutter="16">
|
||||||
<div v-for="r in resources" :key="r.id" class="resource-card">
|
<Col v-for="r in resources" :key="r.id" :xs="24" :md="8">
|
||||||
<h3>{{ r.title }}</h3>
|
<Card class="card" hoverable :bordered="false">
|
||||||
<p class="muted">{{ r.type }}</p>
|
<Typography.Title :level="4">{{
|
||||||
|
r.title
|
||||||
|
}}</Typography.Title>
|
||||||
|
<Tag color="geekblue">{{ r.type }}</Tag>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="cta-small">Open</button>
|
<Button size="small" type="primary">Open</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
</section>
|
</Col>
|
||||||
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.resource-grid {
|
.page {
|
||||||
display: grid;
|
max-width: 1000px;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
margin: 0 auto;
|
||||||
gap: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
.resource-card {
|
.card {
|
||||||
background: #fff;
|
background: #0f172a;
|
||||||
padding: 0.75rem;
|
border: 1px solid #1f2937;
|
||||||
border-radius: 8px;
|
color: #e5e7eb;
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
|
||||||
.muted {
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
}
|
||||||
.actions {
|
.actions {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
.cta-small {
|
|
||||||
background: #4f46e5;
|
|
||||||
color: #fff;
|
|
||||||
padding: 0.4rem 0.6rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Card, Typography, Row, Col, Button, Tag } from 'ant-design-vue';
|
||||||
|
|
||||||
const roles = [
|
const roles = [
|
||||||
{
|
{
|
||||||
id: 'r1',
|
id: 'r1',
|
||||||
|
|
@ -19,48 +21,51 @@ const roles = [
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-wrap">
|
<div class="page">
|
||||||
<header class="page-header">
|
<Typography.Title :level="2">Role Profiles</Typography.Title>
|
||||||
<h1>Role Profiles</h1>
|
<Typography.Paragraph type="secondary"
|
||||||
<p class="lead">
|
>Pre-built role templates and suggested onboarding
|
||||||
Pre-built role templates and suggested onboarding paths.
|
paths.</Typography.Paragraph
|
||||||
</p>
|
>
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="profiles">
|
<Row :gutter="16">
|
||||||
<div v-for="role in roles" :key="role.id" class="profile-card">
|
<Col v-for="role in roles" :key="role.id" :xs="24" :md="8">
|
||||||
<h3>{{ role.title }}</h3>
|
<Card class="card" hoverable :bordered="false">
|
||||||
<p>{{ role.summary }}</p>
|
<Typography.Title :level="4">{{
|
||||||
|
role.title
|
||||||
|
}}</Typography.Title>
|
||||||
|
<Typography.Paragraph>{{
|
||||||
|
role.summary
|
||||||
|
}}</Typography.Paragraph>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<router-link :to="`/onboarding`" class="cta-small"
|
<Tag color="purple">Template</Tag>
|
||||||
>Use Template</router-link
|
<RouterLink to="/onboarding"
|
||||||
|
><Button type="primary" size="small"
|
||||||
|
>Use Template</Button
|
||||||
|
></RouterLink
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
</section>
|
</Col>
|
||||||
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.profiles {
|
.page {
|
||||||
display: grid;
|
max-width: 1100px;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
margin: 0 auto;
|
||||||
gap: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
.profile-card {
|
.card {
|
||||||
background: #fff;
|
background: #0f172a;
|
||||||
padding: 0.75rem;
|
border: 1px solid #1f2937;
|
||||||
border-radius: 8px;
|
color: #e5e7eb;
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
}
|
||||||
.actions {
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
.cta-small {
|
|
||||||
background: #4f46e5;
|
|
||||||
color: #fff;
|
|
||||||
padding: 0.4rem 0.6rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
import { Card, Typography, Row, Col, Tag, Button } from 'ant-design-vue';
|
||||||
|
|
||||||
const lessons = ref([
|
const lessons = ref([
|
||||||
{
|
{
|
||||||
|
|
@ -24,52 +25,42 @@ const lessons = ref([
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-wrap">
|
<div class="page">
|
||||||
<header class="page-header">
|
<Typography.Title :level="2">Training Module</Typography.Title>
|
||||||
<h1>Training Module</h1>
|
<Typography.Paragraph type="secondary"
|
||||||
<p class="lead">Interactive module with lessons and checkpoints.</p>
|
>Interactive module with lessons and
|
||||||
</header>
|
checkpoints.</Typography.Paragraph
|
||||||
|
|
||||||
<section class="lessons">
|
|
||||||
<article
|
|
||||||
v-for="lesson in lessons"
|
|
||||||
:key="lesson.id"
|
|
||||||
class="lesson-card"
|
|
||||||
>
|
>
|
||||||
<h3>{{ lesson.title }}</h3>
|
|
||||||
<p>{{ lesson.summary }}</p>
|
<Row :gutter="16">
|
||||||
|
<Col v-for="lesson in lessons" :key="lesson.id" :xs="24" :md="8">
|
||||||
|
<Card class="card" hoverable :bordered="false">
|
||||||
|
<Typography.Title :level="4">{{
|
||||||
|
lesson.title
|
||||||
|
}}</Typography.Title>
|
||||||
|
<Typography.Paragraph>{{
|
||||||
|
lesson.summary
|
||||||
|
}}</Typography.Paragraph>
|
||||||
<div class="lesson-footer">
|
<div class="lesson-footer">
|
||||||
<span class="badge">{{ lesson.type }}</span>
|
<Tag color="purple">{{ lesson.type }}</Tag>
|
||||||
<button class="cta-small">Open</button>
|
<Button type="primary" size="small">Open</Button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</Card>
|
||||||
</section>
|
</Col>
|
||||||
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page-wrap {
|
.page {
|
||||||
max-width: 960px;
|
max-width: 1100px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
.page-header h1 {
|
.card {
|
||||||
font-size: 2rem;
|
background: #0f172a;
|
||||||
}
|
border: 1px solid #1f2937;
|
||||||
.lead {
|
color: #e5e7eb;
|
||||||
color: #6b7280;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
.lessons {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
.lesson-card {
|
|
||||||
background: #fff;
|
|
||||||
padding: 0.75rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
}
|
||||||
.lesson-footer {
|
.lesson-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -77,18 +68,4 @@ const lessons = ref([
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
.badge {
|
|
||||||
background: #eef2ff;
|
|
||||||
color: #4f46e5;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.cta-small {
|
|
||||||
background: #4f46e5;
|
|
||||||
color: #fff;
|
|
||||||
padding: 0.4rem 0.6rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue