diff --git a/apps/web/src/api/services/database-health.ts b/apps/web/src/api/services/database-health.ts index 27970a0..3b9b255 100644 --- a/apps/web/src/api/services/database-health.ts +++ b/apps/web/src/api/services/database-health.ts @@ -1,11 +1,15 @@ // Database health check service -const { SimpleSQLiteService } = require('./simple-sqlite.cjs'); +let SimpleSQLiteService: any; export class DatabaseHealthService { private static service: any = null; private static getService() { if (!this.service) { + if (!SimpleSQLiteService) { + const { SimpleSQLiteService: ServiceClass } = require('./simple-sqlite.cjs'); + SimpleSQLiteService = ServiceClass; + } this.service = new SimpleSQLiteService(); } return this.service; diff --git a/apps/web/src/api/services/prisma-auth.ts b/apps/web/src/api/services/prisma-auth.ts index 7791db4..78dec84 100644 --- a/apps/web/src/api/services/prisma-auth.ts +++ b/apps/web/src/api/services/prisma-auth.ts @@ -1,42 +1,51 @@ // Real auth service using SQLite directly -const { SimpleAuthService } = require('./simple-auth.cjs'); +let SimpleAuthService: any; +let authService: any; -// Initialize auth service -const authService = new SimpleAuthService(); +// Lazy initialization to avoid SQLite loading during config phase +function getAuthService() { + if (!authService) { + const { SimpleAuthService: AuthClass } = require('./simple-auth.cjs'); + authService = new AuthClass(); + } + return authService; +} export class PrismaAuthService { /** * Login with email and password */ static async login(email: string, password: string, rememberMe = false) { - return await authService.login(email, password, rememberMe); + return await getAuthService().login(email, password, rememberMe); } /** * Refresh access token using refresh token */ static async refreshToken(refreshToken: string) { - return await authService.refreshToken(refreshToken); + return await getAuthService().refreshToken(refreshToken); } /** * Logout and revoke refresh token */ static async logout(refreshToken: string) { - return await authService.logout(refreshToken); + return await getAuthService().logout(refreshToken); } /** * Get user info from access token */ static getUserFromToken(token: string) { - return authService.getUserFromToken(token); + return getAuthService().getUserFromToken(token); } /** * Close SQLite connection */ static async disconnect() { - await authService.disconnect(); + if (authService) { + await authService.disconnect(); + } } } \ No newline at end of file diff --git a/apps/web/src/api/services/prisma-data.ts b/apps/web/src/api/services/prisma-data.ts index 591965e..10ccc8e 100644 --- a/apps/web/src/api/services/prisma-data.ts +++ b/apps/web/src/api/services/prisma-data.ts @@ -1,36 +1,43 @@ // Real data service that uses SQLite directly -const { SimpleSQLiteService } = require('./simple-sqlite.cjs'); +let SimpleSQLiteService: any; +let sqliteService: any; -// Initialize service -const sqliteService = new SimpleSQLiteService(); +// Lazy initialization to avoid SQLite loading during config phase +function getDataService() { + if (!sqliteService) { + const { SimpleSQLiteService: DataClass } = require('./simple-sqlite.cjs'); + sqliteService = new DataClass(); + } + return sqliteService; +} export class PrismaDataService { /** * Get organization summary KPIs */ static async getOrgSummary(orgId: string) { - return await sqliteService.getOrgSummary(orgId); + return await getDataService().getOrgSummary(orgId); } /** * Get clients overview for organization */ static async getClientsOverview(orgId: string) { - return await sqliteService.getClientsOverview(orgId); + return await getDataService().getClientsOverview(orgId); } /** * Get client summary with KPIs and insights */ static async getClientSummary(clientId: string, orgId: string) { - return await sqliteService.getClientSummary(clientId, orgId); + return await getDataService().getClientSummary(clientId, orgId); } /** * Get client calls with pagination */ static async getClientCalls(clientId: string, orgId: string, limit = 10) { - return await sqliteService.getClientCalls(clientId, orgId, limit); + return await getDataService().getClientCalls(clientId, orgId, limit); } /** @@ -41,13 +48,15 @@ export class PrismaDataService { orgId: string, status?: 'open' | 'done' ) { - return await sqliteService.getClientActionItems(clientId, orgId, status); + return await getDataService().getClientActionItems(clientId, orgId, status); } /** * Close SQLite connection */ static async disconnect() { - await sqliteService.disconnect(); + if (sqliteService) { + await sqliteService.disconnect(); + } } } \ No newline at end of file diff --git a/apps/web/src/auth/AuthService.ts b/apps/web/src/auth/AuthService.ts index 5026649..fc29164 100644 --- a/apps/web/src/auth/AuthService.ts +++ b/apps/web/src/auth/AuthService.ts @@ -6,6 +6,10 @@ import type { } from './types'; import { getAuthItem, setAuthItem, removeAuthItem, clearAuthData } from '../utils/storage'; +// Development mode flag +const isDevelopment = import.meta.env.DEV; +const MOCK_AUTH = isDevelopment && !import.meta.env.VITE_USE_REAL_AUTH; + // HTTP client for API calls class ApiClient { private baseUrl = ''; // Same origin @@ -92,12 +96,55 @@ class AuthService { await new Promise(resolve => setTimeout(resolve, ms)); } + /** + * Create a mock session for development + */ + private createMockSession(): AuthSession { + return { + user: { + id: 'mock-user-1', + email: 'demo@mudul.com', + name: 'Demo User', + createdAt: '2024-01-01T00:00:00Z', + lastLoginAt: new Date().toISOString() + }, + organization: { + id: 'acme', + name: 'Acme Sales Org', + planTier: 'pro', + createdAt: '2024-01-01T00:00:00Z' + }, + membership: { + userId: 'mock-user-1', + orgId: 'acme', + role: 'owner', + createdAt: '2024-01-01T00:00:00Z' + }, + accessToken: 'mock-access-token', + refreshToken: 'mock-refresh-token', + expiresAt: this.calculateExpiryTime(60 * 24) // 24 hours from now + }; + } + /** * Authenticate user with email/password */ async login(credentials: LoginCredentials): Promise { await this.simulateDelay(); + // In development mode with mock auth, always succeed + if (MOCK_AUTH) { + console.log('🔧 Using mock authentication for development'); + const session = this.createMockSession(); + this.currentSession = session; + this.storeSession(session); + + return { + session, + isFirstLogin: false + }; + } + try { const result = await this.apiClient.post('/api/auth/login', credentials); @@ -159,6 +206,17 @@ class AuthService { throw this.createError('no_refresh_token', 'No valid refresh token available'); } + // In development mode with mock auth, just return updated session + if (MOCK_AUTH) { + const newSession: AuthSession = { + ...session, + expiresAt: this.calculateExpiryTime(60 * 24) // 24 hours from now + }; + this.currentSession = newSession; + this.storeSession(newSession); + return newSession; + } + try { const result = await this.apiClient.post('/api/auth/refresh', { refreshToken: session.refreshToken @@ -195,15 +253,18 @@ class AuthService { async logout(): Promise { const session = this.getCurrentSession(); - try { - if (session?.refreshToken) { - await this.apiClient.post('/api/auth/logout', { - refreshToken: session.refreshToken - }); + // In development mode, skip API call + if (!MOCK_AUTH) { + try { + if (session?.refreshToken) { + await this.apiClient.post('/api/auth/logout', { + refreshToken: session.refreshToken + }); + } + } catch (error) { + console.warn('Logout API call failed:', error); + // Continue with local logout even if server call fails } - } catch (error) { - console.warn('Logout API call failed:', error); - // Continue with local logout even if server call fails } // Clear local session diff --git a/apps/web/src/core/adapters/index.ts b/apps/web/src/core/adapters/index.ts index 6993de1..3030182 100644 --- a/apps/web/src/core/adapters/index.ts +++ b/apps/web/src/core/adapters/index.ts @@ -364,6 +364,7 @@ export const Adapters: AdapterMap = { const clientCalls = ctx?.listCallsByClient?.(nodeId) ?? []; const allActions: Array<{ text: string; due: string | null; owner: string; source: string }> = []; + // Add action items from calls clientCalls.forEach(callNode => { const callDetail = ctx?.getCallByNode?.(callNode.id); if (callDetail?.actionItems) { @@ -378,6 +379,17 @@ export const Adapters: AdapterMap = { } }); + // Add standalone action items + const standaloneActions = ctx?.getStandaloneActionItems?.(nodeId) ?? []; + standaloneActions.forEach(action => { + allActions.push({ + text: action.text, + due: action.dueDate, + owner: action.owner, + source: "Manual Entry" + }); + }); + // Sort by due date, prioritizing items with due dates const sortedActions = allActions.sort((a, b) => { if (!a.due && !b.due) return 0; diff --git a/apps/web/src/core/adapters/types.ts b/apps/web/src/core/adapters/types.ts index 5fd0a6e..ce6f160 100644 --- a/apps/web/src/core/adapters/types.ts +++ b/apps/web/src/core/adapters/types.ts @@ -17,6 +17,13 @@ export type AdapterCtx = { getAllCalls?: () => NodeBase[]; getCallByNode?: (nodeId: string) => SalesCallMinimal | null; listCallsByClient?: (clientId: string) => NodeBase[]; + getStandaloneActionItems?: (clientId: string) => Array<{ + id: string; + owner: string; + text: string; + dueDate: string | null; + status: 'open' | 'done'; + }>; }; // Base adapter interface diff --git a/apps/web/src/core/repo.ts b/apps/web/src/core/repo.ts index 159429e..c8f217e 100644 --- a/apps/web/src/core/repo.ts +++ b/apps/web/src/core/repo.ts @@ -1,4 +1,4 @@ -import { nodes, calls } from "./seed"; +import { nodes, calls, standaloneActionItems } from "./seed"; import { DashboardTemplates } from "./registry-json"; import type { NodeBase, SalesCallMinimal } from "./types"; import type { DashboardTemplate } from "./widgets/protocol"; @@ -143,6 +143,33 @@ export function hasExistingAnalysis( // ----- Call creation and lifecycle management ----- +/** + * Create a new client node under the root organization. + * Returns the new node ID. + */ +export function createClient({ name }: { name: string; notes?: string }): string { + const now = new Date().toISOString(); + const nodeId = `client-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${Date.now()}`; + + // Create the client node + nodes[nodeId] = { + id: nodeId, + orgId: "acme", + parentId: "root", + kind: "lead", + name, + slug: name.toLowerCase().replace(/[^a-z0-9]+/g, '-'), + dashboardId: "client-dashboard", + createdAt: now, + updatedAt: now + }; + + // TODO: If we had client-specific data, we would store it here + // For now, client data is just the node itself + + return nodeId; +} + /** * Create a new call node under the specified client. * Returns the new node ID. @@ -200,4 +227,84 @@ export function markNodeActive(nodeId: string): void { if (node) { node.updatedAt = new Date().toISOString(); } +} + +// ----- Standalone Action Items Management ----- + +/** + * Create a new standalone action item for a client + */ +export function createActionItem({ + clientId, + owner, + text, + dueDate, + status = 'open' +}: { + clientId: string; + owner: string; + text: string; + dueDate: string | null; + status?: 'open' | 'done'; +}): string { + const now = new Date().toISOString(); + const actionItemId = `action-standalone-${Date.now()}`; + + standaloneActionItems[actionItemId] = { + id: actionItemId, + clientId, + owner, + text, + dueDate, + status, + createdAt: now, + updatedAt: now + }; + + return actionItemId; +} + +/** + * Get all standalone action items for a client + */ +export function getStandaloneActionItems(clientId: string): Array<{ + id: string; + owner: string; + text: string; + dueDate: string | null; + status: 'open' | 'done'; +}> { + return Object.values(standaloneActionItems) + .filter(item => item.clientId === clientId) + .map(item => ({ + id: item.id, + owner: item.owner, + text: item.text, + dueDate: item.dueDate, + status: item.status + })); +} + +/** + * Update the status of a standalone action item + */ +export function updateActionItemStatus(actionItemId: string, status: 'open' | 'done'): boolean { + const item = standaloneActionItems[actionItemId]; + if (item) { + item.status = status; + item.updatedAt = new Date().toISOString(); + return true; + } + return false; +} + +/** + * Delete a standalone action item + */ +export function deleteActionItem(actionItemId: string): boolean { + if (standaloneActionItems[actionItemId]) { + delete standaloneActionItems[actionItemId]; + return true; + } + return false; } \ No newline at end of file diff --git a/apps/web/src/core/seed.ts b/apps/web/src/core/seed.ts index 0aebdb9..959e447 100644 --- a/apps/web/src/core/seed.ts +++ b/apps/web/src/core/seed.ts @@ -242,4 +242,38 @@ export const calls: Record = { }, complianceFlags: ["multi-stakeholder-approval"] } +}; + +// Standalone action items (not tied to specific calls) +export const standaloneActionItems: Record = { + // Examples - these would be user-created action items + "action-standalone-1": { + id: "action-standalone-1", + clientId: "client-acme", + owner: "Sales Manager", + text: "Schedule quarterly business review", + dueDate: "2024-02-15", + status: "open", + createdAt: now, + updatedAt: now + }, + "action-standalone-2": { + id: "action-standalone-2", + clientId: "client-beta", + owner: "Account Manager", + text: "Send contract renewal reminder", + dueDate: "2024-02-01", + status: "open", + createdAt: now, + updatedAt: now + } }; \ No newline at end of file diff --git a/apps/web/src/core/widgets/registry.tsx b/apps/web/src/core/widgets/registry.tsx index 2e2fdb9..6d20d1e 100644 --- a/apps/web/src/core/widgets/registry.tsx +++ b/apps/web/src/core/widgets/registry.tsx @@ -367,7 +367,8 @@ export function WidgetRenderer({ config, call, nodeId }: WidgetRendererProps) { getAllClients: repo.getAllClients, getAllCalls: repo.getAllCalls, getCallByNode: repo.getCallByNode, - listCallsByClient: repo.listCallsByClient + listCallsByClient: repo.listCallsByClient, + getStandaloneActionItems: repo.getStandaloneActionItems }); // Branch between paper and rich mode diff --git a/apps/web/src/hooks/useRepo.tsx b/apps/web/src/hooks/useRepo.tsx index d0c5c17..a82fefe 100644 --- a/apps/web/src/hooks/useRepo.tsx +++ b/apps/web/src/hooks/useRepo.tsx @@ -11,6 +11,9 @@ interface RepoContextValue { getDashboardId: typeof repo.getDashboardId; getAllClients: typeof repo.getAllClients; getAllCalls: typeof repo.getAllCalls; + getStandaloneActionItems: typeof repo.getStandaloneActionItems; + createClient: (data: { name: string; notes?: string }) => Promise<{ id: string; name: string }>; + createActionItem: (data: { clientId: string; owner: string; text: string; dueDate: string | null; status?: 'open' | 'done' }) => Promise<{ id: string }>; } const RepoContext = createContext(null); @@ -54,6 +57,41 @@ export function RepoProvider({ children }: { children: React.ReactNode }) { }, getAllCalls: () => { return repo.getAllCalls().filter(node => node.orgId === orgId); + }, + getStandaloneActionItems: (clientId: string) => { + const client = repo.getNode(clientId); + if (!client || client.orgId !== orgId) return []; + return repo.getStandaloneActionItems(clientId); + }, + createClient: async (data: { name: string; notes?: string }) => { + // Simulate async operation (in real app this would call API) + await new Promise(resolve => setTimeout(resolve, 300)); + + const nodeId = repo.createClient({ + name: data.name, + notes: data.notes + }); + + const newNode = repo.getNode(nodeId); + if (!newNode) { + throw new Error('Failed to create client'); + } + + return { id: nodeId, name: newNode.name }; + }, + createActionItem: async (data: { clientId: string; owner: string; text: string; dueDate: string | null; status?: 'open' | 'done' }) => { + // Simulate async operation (in real app this would call API) + await new Promise(resolve => setTimeout(resolve, 300)); + + const actionItemId = repo.createActionItem({ + clientId: data.clientId, + owner: data.owner, + text: data.text, + dueDate: data.dueDate, + status: data.status || 'open' + }); + + return { id: actionItemId }; } }; }, [currentOrg?.id]); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 7c0bddd..f6ccdf5 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -12,6 +12,9 @@ import { CallsPage } from "./pages/CallsPage"; import { CallDashboardPage } from "./pages/CallDashboardPage"; import { SettingsPage } from "./pages/SettingsPage"; import { NewCallPage } from "./pages/NewCallPage"; +import { NewClientPage } from "./pages/NewClientPage"; +import { NewActionItemPage } from "./pages/NewActionItemPage"; +import { QAPage } from "./pages/QAPage"; const router = createBrowserRouter([ { @@ -31,6 +34,9 @@ const router = createBrowserRouter([ { path: "calls", element: }, { path: "calls/new", element: }, { path: "calls/:id", element: }, + { path: "clients/new", element: }, + { path: "clients/:clientId/actions/new", element: }, + { path: "qa", element: }, { path: "settings", element: }, ], }, diff --git a/apps/web/src/pages/NewActionItemPage.tsx b/apps/web/src/pages/NewActionItemPage.tsx new file mode 100644 index 0000000..135fbb5 --- /dev/null +++ b/apps/web/src/pages/NewActionItemPage.tsx @@ -0,0 +1,253 @@ +import React, { useState } from 'react'; +import { + Box, + Button, + TextField, + Typography, + Paper, + Stack, + Alert, + FormControl, + InputLabel, + Select, + MenuItem, +} from '@mui/material'; +import { ArrowBack, Save } from '@mui/icons-material'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useRepo } from '../hooks/useRepo'; +import { z } from 'zod'; + +// Validation schema +const CreateActionItemSchema = z.object({ + owner: z.string().min(1, 'Owner is required').max(100, 'Owner name too long'), + text: z.string().min(1, 'Action item text is required').max(500, 'Text too long'), + dueDate: z.string().optional(), + status: z.enum(['open', 'done']).default('open'), +}); + +type CreateActionItemData = z.infer; + +export function NewActionItemPage() { + const navigate = useNavigate(); + const { clientId } = useParams<{ clientId: string }>(); + const repo = useRepo(); + + // Get client info for display + const client = clientId ? repo.getNode(clientId) : null; + + // Form state + const [formData, setFormData] = useState({ + owner: '', + text: '', + dueDate: '', + status: 'open', + }); + + // UI state + const [errors, setErrors] = useState>({}); + const [isSubmitting, setIsSubmitting] = useState(false); + const [successMessage, setSuccessMessage] = useState(''); + + const validateForm = (): boolean => { + try { + CreateActionItemSchema.parse(formData); + setErrors({}); + return true; + } catch (error) { + if (error instanceof z.ZodError) { + const newErrors: Record = {}; + error.errors.forEach((err) => { + if (err.path.length > 0) { + newErrors[err.path[0] as string] = err.message; + } + }); + setErrors(newErrors); + } + return false; + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!clientId) { + setErrors({ general: 'Client ID is required' }); + return; + } + + if (!validateForm()) { + return; + } + + setIsSubmitting(true); + setErrors({}); + + try { + // Create new action item via repository + await repo.createActionItem({ + clientId, + owner: formData.owner.trim(), + text: formData.text.trim(), + dueDate: formData.dueDate || null, + status: formData.status, + }); + + setSuccessMessage('Action item created successfully!'); + + // Redirect to client dashboard after short delay + setTimeout(() => { + navigate(`/node/${clientId}`); + }, 1500); + + } catch (error: any) { + console.error('Failed to create action item:', error); + setErrors({ + general: error.message || 'Failed to create action item. Please try again.' + }); + } finally { + setIsSubmitting(false); + } + }; + + const handleInputChange = (field: keyof CreateActionItemData) => ( + e: React.ChangeEvent + ) => { + setFormData(prev => ({ + ...prev, + [field]: e.target.value + })); + + // Clear field-specific error when user starts typing + if (errors[field]) { + setErrors(prev => { + const newErrors = { ...prev }; + delete newErrors[field]; + return newErrors; + }); + } + }; + + const handleSelectChange = (field: keyof CreateActionItemData) => ( + e: any + ) => { + setFormData(prev => ({ + ...prev, + [field]: e.target.value + })); + }; + + if (!clientId || !client) { + return ( + + + Client not found. Please navigate to this page from a client dashboard. + + + ); + } + + return ( + + + + + New Action Item + + + + +
+ + {errors.general && ( + {errors.general} + )} + + {successMessage && ( + {successMessage} + )} + + + Adding action item for: {client.name} + + + + + + + + + + Status + + + + + + + + + +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/pages/NewClientPage.tsx b/apps/web/src/pages/NewClientPage.tsx new file mode 100644 index 0000000..aefa6bc --- /dev/null +++ b/apps/web/src/pages/NewClientPage.tsx @@ -0,0 +1,183 @@ +import React, { useState } from 'react'; +import { + Box, + Button, + TextField, + Typography, + Paper, + Stack, + Alert, +} from '@mui/material'; +import { ArrowBack, Save } from '@mui/icons-material'; +import { useNavigate } from 'react-router-dom'; +import { useRepo } from '../hooks/useRepo'; +import { z } from 'zod'; + +// Validation schema +const CreateClientSchema = z.object({ + name: z.string().min(1, 'Client name is required').max(100, 'Name too long'), + notes: z.string().max(1000, 'Notes too long').optional(), +}); + +type CreateClientData = z.infer; + +export function NewClientPage() { + const navigate = useNavigate(); + const repo = useRepo(); + + // Form state + const [formData, setFormData] = useState({ + name: '', + notes: '', + }); + + // UI state + const [errors, setErrors] = useState>({}); + const [isSubmitting, setIsSubmitting] = useState(false); + const [successMessage, setSuccessMessage] = useState(''); + + const validateForm = (): boolean => { + try { + CreateClientSchema.parse(formData); + setErrors({}); + return true; + } catch (error) { + if (error instanceof z.ZodError) { + const newErrors: Record = {}; + error.errors.forEach((err) => { + if (err.path.length > 0) { + newErrors[err.path[0] as string] = err.message; + } + }); + setErrors(newErrors); + } + return false; + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + setIsSubmitting(true); + setErrors({}); + + try { + // Create new client via repository + const newClient = await repo.createClient({ + name: formData.name.trim(), + notes: formData.notes?.trim() || '', + }); + + setSuccessMessage('Client created successfully!'); + + // Redirect to client dashboard after short delay + setTimeout(() => { + navigate(`/node/${newClient.id}`); + }, 1500); + + } catch (error: any) { + console.error('Failed to create client:', error); + setErrors({ + general: error.message || 'Failed to create client. Please try again.' + }); + } finally { + setIsSubmitting(false); + } + }; + + const handleInputChange = (field: keyof CreateClientData) => ( + e: React.ChangeEvent + ) => { + setFormData(prev => ({ + ...prev, + [field]: e.target.value + })); + + // Clear field-specific error when user starts typing + if (errors[field]) { + setErrors(prev => { + const newErrors = { ...prev }; + delete newErrors[field]; + return newErrors; + }); + } + }; + + return ( + + + + + New Client + + + + +
+ + {errors.general && ( + {errors.general} + )} + + {successMessage && ( + {successMessage} + )} + + + + + + + + + + + +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/pages/QAPage.tsx b/apps/web/src/pages/QAPage.tsx new file mode 100644 index 0000000..93304f7 --- /dev/null +++ b/apps/web/src/pages/QAPage.tsx @@ -0,0 +1,397 @@ +import React, { useState } from 'react'; +import { + Box, + Button, + Typography, + Paper, + Stack, + Alert, + Chip, + List, + ListItem, + ListItemIcon, + ListItemText, + ListItemSecondaryAction, + CircularProgress, +} from '@mui/material'; +import { + ArrowBack, + CheckCircle, + Error, + PlayArrow, + Stop, + Refresh, + Login, + Business, + Call, + Assignment, + Dashboard, + Print, + CloudUpload, + MonitorHeart +} from '@mui/icons-material'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../auth/AuthContext'; +import { useRepo } from '../hooks/useRepo'; + +interface QATest { + id: string; + name: string; + description: string; + icon: React.ReactNode; + status: 'pending' | 'running' | 'success' | 'error'; + error?: string; + action: () => Promise; +} + +export function QAPage() { + const navigate = useNavigate(); + const { session } = useAuth(); + const repo = useRepo(); + + const [tests, setTests] = useState([ + { + id: 'login', + name: 'Login', + description: 'Verify user authentication works', + icon: , + status: 'pending', + action: async () => { + // Check if already logged in + if (!session) { + throw new Error('User not authenticated'); + } + await new Promise(resolve => setTimeout(resolve, 500)); + } + }, + { + id: 'create-client', + name: 'Create Client', + description: 'Test client creation functionality', + icon: , + status: 'pending', + action: async () => { + const testClient = await repo.createClient({ + name: `QA Test Client ${Date.now()}`, + notes: 'Created by QA test suite' + }); + if (!testClient.id) { + throw new Error('Failed to create client'); + } + } + }, + { + id: 'log-call', + name: 'Log Call', + description: 'Test call logging and analysis', + icon: , + status: 'pending', + action: async () => { + // Navigate to new call page and verify it loads + navigate('/calls/new'); + await new Promise(resolve => setTimeout(resolve, 1000)); + // Navigate back + navigate('/qa'); + } + }, + { + id: 'add-action-item', + name: 'Add Action Item', + description: 'Test standalone action item creation', + icon: , + status: 'pending', + action: async () => { + // Get first client + const clients = repo.getAllClients(); + if (clients.length === 0) { + throw new Error('No clients available for test'); + } + + const testAction = await repo.createActionItem({ + clientId: clients[0].id, + owner: 'QA Test', + text: `QA Test Action Item ${Date.now()}`, + dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], + status: 'open' + }); + + if (!testAction.id) { + throw new Error('Failed to create action item'); + } + } + }, + { + id: 'open-call-detail', + name: 'Open Call Detail', + description: 'Test call detail page navigation', + icon: , + status: 'pending', + action: async () => { + // Get first call + const calls = repo.getAllCalls(); + if (calls.length === 0) { + throw new Error('No calls available for test'); + } + + navigate(`/node/${calls[0].id}`); + await new Promise(resolve => setTimeout(resolve, 1000)); + navigate('/qa'); + } + }, + { + id: 'toggle-paper-mode', + name: 'Toggle Paper Mode', + description: 'Test paper mode switching', + icon: , + status: 'pending', + action: async () => { + // Test URL parameter approach + const currentUrl = window.location.href; + const testUrl = currentUrl.includes('?') + ? `${currentUrl}&mode=paper` + : `${currentUrl}?mode=paper`; + + window.history.pushState({}, '', testUrl); + await new Promise(resolve => setTimeout(resolve, 500)); + window.history.pushState({}, '', currentUrl); + } + }, + { + id: 'health-check', + name: 'Health Check', + description: 'Test system health endpoints', + icon: , + status: 'pending', + action: async () => { + // Simple health check - verify data is accessible + const root = repo.getRoot(); + if (!root) { + throw new Error('Root node not accessible'); + } + + const clients = repo.getAllClients(); + const calls = repo.getAllCalls(); + + if (clients.length === 0 && calls.length === 0) { + throw new Error('No data available'); + } + } + } + ]); + + const runTest = async (testId: string) => { + setTests(prev => prev.map(test => + test.id === testId + ? { ...test, status: 'running', error: undefined } + : test + )); + + try { + const test = tests.find(t => t.id === testId); + if (!test) return; + + await test.action(); + + setTests(prev => prev.map(test => + test.id === testId + ? { ...test, status: 'success' } + : test + )); + } catch (error: any) { + console.error(`QA Test ${testId} failed:`, error); + + setTests(prev => prev.map(test => + test.id === testId + ? { ...test, status: 'error', error: error.message } + : test + )); + } + }; + + const runAllTests = async () => { + for (const test of tests) { + await runTest(test.id); + // Small delay between tests + await new Promise(resolve => setTimeout(resolve, 200)); + } + }; + + const resetTests = () => { + setTests(prev => prev.map(test => ({ + ...test, + status: 'pending', + error: undefined + }))); + }; + + const getStatusColor = (status: QATest['status']) => { + switch (status) { + case 'success': return 'success'; + case 'error': return 'error'; + case 'running': return 'info'; + default: return 'default'; + } + }; + + const getStatusIcon = (status: QATest['status']) => { + switch (status) { + case 'success': return ; + case 'error': return ; + case 'running': return ; + default: return null; + } + }; + + const successCount = tests.filter(t => t.status === 'success').length; + const errorCount = tests.filter(t => t.status === 'error').length; + const runningCount = tests.filter(t => t.status === 'running').length; + + return ( + + + + + QA Checklist + + + + + {/* Test Summary */} + + + Test Summary + + + 0 ? "filled" : "outlined"} + /> + 0 ? "filled" : "outlined"} + /> + 0 ? "filled" : "outlined"} + /> + + + + + + + + + + {/* Test Results */} + + + Individual Tests + + + + {tests.map((test) => ( + + + {test.icon} + + + + {test.description} + + {test.error && ( + + {test.error} + + )} + + } + /> + + + {getStatusIcon(test.status)} + + + + + + ))} + + + + {/* System Info */} + + + System Information + + + + + + + + + + + + + + + + + + + ); +} \ No newline at end of file diff --git a/apps/web/src/shell/AppShell.tsx b/apps/web/src/shell/AppShell.tsx index 1fc543a..993ac5d 100644 --- a/apps/web/src/shell/AppShell.tsx +++ b/apps/web/src/shell/AppShell.tsx @@ -207,8 +207,30 @@ export function AppShell() { })} - {/* New Call Button */} - + {/* Action Buttons */} + + +