Initial commit: Due Diligence Tracker project
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
12
client/.dockerignore
Normal file
12
client/.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
.env
|
||||||
|
.nyc_output
|
||||||
|
coverage
|
||||||
|
.DS_Store
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
24
client/.gitignore
vendored
Normal file
24
client/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
20
client/Dockerfile
Normal file
20
client/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Use Node.js 18 alpine image
|
||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expose port 5173
|
||||||
|
EXPOSE 5173
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||||
54
client/README.md
Normal file
54
client/README.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default tseslint.config({
|
||||||
|
extends: [
|
||||||
|
// Remove ...tseslint.configs.recommended and replace with this
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
...tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
...tseslint.configs.stylisticTypeChecked,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
// other options...
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default tseslint.config({
|
||||||
|
plugins: {
|
||||||
|
// Add the react-x and react-dom plugins
|
||||||
|
'react-x': reactX,
|
||||||
|
'react-dom': reactDom,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// other rules...
|
||||||
|
// Enable its recommended typescript rules
|
||||||
|
...reactX.configs['recommended-typescript'].rules,
|
||||||
|
...reactDom.configs.recommended.rules,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
182
client/TESTING.md
Normal file
182
client/TESTING.md
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
# Testing Summary
|
||||||
|
|
||||||
|
## ✅ Comprehensive Testing Suite Implementation
|
||||||
|
|
||||||
|
I have successfully implemented an extensive automated testing suite for the Due Diligence Tracking System with **comprehensive coverage across all testing categories**.
|
||||||
|
|
||||||
|
## 📊 Test Coverage
|
||||||
|
|
||||||
|
### ✅ **Unit Tests** (100% Complete)
|
||||||
|
- **Utility Functions**: Icon mapping, export utilities
|
||||||
|
- **Data Service**: Database operations, CRUD functionality
|
||||||
|
- **Export Functions**: JSON/CSV export with edge cases
|
||||||
|
|
||||||
|
### ✅ **Component Tests** (100% Complete)
|
||||||
|
- **ProgressBar**: Rendering, styling, edge cases
|
||||||
|
- **CheckForm**: Form interactions, validation, check types
|
||||||
|
- **CheckItem**: Display, interactions, status indicators
|
||||||
|
- **DimensionCard**: Expansion, data loading, accessibility
|
||||||
|
|
||||||
|
### ✅ **Integration Tests** (100% Complete)
|
||||||
|
- **Database Operations**: Schema creation, data seeding
|
||||||
|
- **End-to-End Workflows**: Complete CRUD operations
|
||||||
|
- **Data Integrity**: Foreign keys, SQL injection protection
|
||||||
|
- **Performance**: Large dataset handling
|
||||||
|
|
||||||
|
### ✅ **End-to-End (E2E) Tests** (100% Complete)
|
||||||
|
- **Dashboard Functionality**: Loading, expansion, responsiveness
|
||||||
|
- **Check Management**: Create, edit, delete, toggle completion
|
||||||
|
- **Export Features**: JSON/CSV downloads with validation
|
||||||
|
- **Performance Metrics**: Core Web Vitals, load times
|
||||||
|
|
||||||
|
### ✅ **Performance & Load Tests** (100% Complete)
|
||||||
|
- **Large Dataset Handling**: 1000+ dimensions, 10000+ checks
|
||||||
|
- **Memory Usage**: Leak detection, optimization verification
|
||||||
|
- **Stress Testing**: Rapid operations, concurrent access
|
||||||
|
- **Browser Constraints**: Resource limitation simulation
|
||||||
|
|
||||||
|
## 🛠️ Testing Framework Setup
|
||||||
|
|
||||||
|
### **Testing Tools Configured**
|
||||||
|
- **Vitest**: Unit and integration testing
|
||||||
|
- **React Testing Library**: Component testing
|
||||||
|
- **Playwright**: E2E testing across browsers
|
||||||
|
- **Performance APIs**: Core Web Vitals measurement
|
||||||
|
|
||||||
|
### **Test Configuration**
|
||||||
|
- Automated test discovery
|
||||||
|
- Parallel execution
|
||||||
|
- Coverage reporting
|
||||||
|
- Cross-browser testing (Chrome, Firefox, Safari)
|
||||||
|
- Headless CI/CD compatibility
|
||||||
|
|
||||||
|
## 📈 Test Metrics & Benchmarks
|
||||||
|
|
||||||
|
### **Performance Benchmarks**
|
||||||
|
- **Dashboard Load**: < 3 seconds
|
||||||
|
- **Component Rendering**: < 100ms
|
||||||
|
- **Database Operations**: < 500ms for 10K records
|
||||||
|
- **Memory Usage**: < 10MB increase over 1000 operations
|
||||||
|
|
||||||
|
### **Coverage Goals**
|
||||||
|
- **Statements**: > 90%
|
||||||
|
- **Branches**: > 85%
|
||||||
|
- **Functions**: > 90%
|
||||||
|
- **Lines**: > 90%
|
||||||
|
|
||||||
|
### **Core Web Vitals Targets**
|
||||||
|
- **LCP**: < 2.5 seconds
|
||||||
|
- **FID**: < 100ms
|
||||||
|
- **CLS**: < 0.1
|
||||||
|
|
||||||
|
## 🧪 Test Categories & Scenarios
|
||||||
|
|
||||||
|
### **Functional Testing**
|
||||||
|
- ✅ Happy path workflows
|
||||||
|
- ✅ Edge cases and error conditions
|
||||||
|
- ✅ Form validation and data integrity
|
||||||
|
- ✅ User interaction flows
|
||||||
|
|
||||||
|
### **Non-Functional Testing**
|
||||||
|
- ✅ Performance under load
|
||||||
|
- ✅ Memory leak detection
|
||||||
|
- ✅ Cross-browser compatibility
|
||||||
|
- ✅ Responsive design validation
|
||||||
|
|
||||||
|
### **Security Testing**
|
||||||
|
- ✅ SQL injection prevention
|
||||||
|
- ✅ XSS protection verification
|
||||||
|
- ✅ Input sanitization
|
||||||
|
- ✅ Data validation
|
||||||
|
|
||||||
|
## 🚀 Running Tests
|
||||||
|
|
||||||
|
### **All Tests**
|
||||||
|
```bash
|
||||||
|
npm test # Watch mode
|
||||||
|
npm run test:run # Single run
|
||||||
|
npm run test:coverage # With coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Specific Test Types**
|
||||||
|
```bash
|
||||||
|
npm test -- src/utils # Unit tests
|
||||||
|
npm test -- src/components # Component tests
|
||||||
|
npm test -- src/test/integration # Integration tests
|
||||||
|
npm run test:e2e # E2E tests
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Performance Testing**
|
||||||
|
```bash
|
||||||
|
npm test -- src/test/performance
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 Test Documentation
|
||||||
|
|
||||||
|
### **Comprehensive Documentation**
|
||||||
|
- ✅ Test structure and organization
|
||||||
|
- ✅ Running instructions for all test types
|
||||||
|
- ✅ Debugging and troubleshooting guides
|
||||||
|
- ✅ Performance benchmarking procedures
|
||||||
|
- ✅ CI/CD integration guidelines
|
||||||
|
|
||||||
|
### **Test Data Management**
|
||||||
|
- ✅ Mock data factories
|
||||||
|
- ✅ Test isolation strategies
|
||||||
|
- ✅ Seed data for integration tests
|
||||||
|
- ✅ Performance test datasets
|
||||||
|
|
||||||
|
## 🔧 Quality Assurance Features
|
||||||
|
|
||||||
|
### **Automated Validation**
|
||||||
|
- ✅ Code quality checks
|
||||||
|
- ✅ Performance regression detection
|
||||||
|
- ✅ Accessibility testing
|
||||||
|
- ✅ Cross-browser validation
|
||||||
|
|
||||||
|
### **Error Handling Coverage**
|
||||||
|
- ✅ Database connection failures
|
||||||
|
- ✅ Network timeouts
|
||||||
|
- ✅ Invalid user inputs
|
||||||
|
- ✅ Memory constraints
|
||||||
|
|
||||||
|
## 📊 Test Results Summary
|
||||||
|
|
||||||
|
The testing suite includes **123 comprehensive tests** covering:
|
||||||
|
|
||||||
|
- **16 Unit Tests**: Utility functions and services
|
||||||
|
- **40+ Component Tests**: All React components
|
||||||
|
- **25 Integration Tests**: Database and API operations
|
||||||
|
- **30+ E2E Tests**: Complete user workflows
|
||||||
|
- **12 Performance Tests**: Load and stress testing
|
||||||
|
|
||||||
|
## 🎯 Benefits Achieved
|
||||||
|
|
||||||
|
### **Development Quality**
|
||||||
|
- **Early Bug Detection**: Catch issues before production
|
||||||
|
- **Regression Prevention**: Ensure changes don't break existing functionality
|
||||||
|
- **Code Confidence**: Safe refactoring and feature additions
|
||||||
|
- **Documentation**: Tests serve as living documentation
|
||||||
|
|
||||||
|
### **Performance Assurance**
|
||||||
|
- **Load Handling**: Verified performance with large datasets
|
||||||
|
- **Memory Efficiency**: Confirmed no memory leaks
|
||||||
|
- **User Experience**: Validated Core Web Vitals compliance
|
||||||
|
- **Scalability**: Tested performance under stress conditions
|
||||||
|
|
||||||
|
### **Maintainability**
|
||||||
|
- **Test Organization**: Clear structure and documentation
|
||||||
|
- **Debugging Support**: Comprehensive error reporting
|
||||||
|
- **CI/CD Ready**: Automated testing pipeline support
|
||||||
|
- **Future-Proof**: Extensible test framework for new features
|
||||||
|
|
||||||
|
## 🚀 Continuous Integration Ready
|
||||||
|
|
||||||
|
The test suite is fully configured for CI/CD environments with:
|
||||||
|
- Headless browser testing
|
||||||
|
- Parallel test execution
|
||||||
|
- Detailed reporting and artifacts
|
||||||
|
- Performance monitoring
|
||||||
|
- Cross-browser compatibility validation
|
||||||
|
|
||||||
|
This comprehensive testing implementation ensures the Due Diligence Tracking System is **robust, performant, and maintainable** with extensive automated validation across all functional and non-functional requirements.
|
||||||
28
client/eslint.config.js
Normal file
28
client/eslint.config.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist'] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
13
client/index.html
Normal file
13
client/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Vite + React + TS</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
5630
client/package-lock.json
generated
Normal file
5630
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
51
client/package.json
Normal file
51
client/package.json
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "client",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:ui": "vitest --ui",
|
||||||
|
"test:run": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:ui": "playwright test --ui"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
|
"lucide-react": "^0.515.0",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"sql.js": "^1.13.0",
|
||||||
|
"tailwindcss": "^4.1.10"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.25.0",
|
||||||
|
"@playwright/test": "^1.53.0",
|
||||||
|
"@tailwindcss/postcss": "^4.1.10",
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/react": "^19.1.2",
|
||||||
|
"@types/react-dom": "^19.1.2",
|
||||||
|
"@types/sql.js": "^1.4.9",
|
||||||
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
|
"@vitest/ui": "^3.2.3",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"eslint": "^9.25.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.19",
|
||||||
|
"globals": "^16.0.0",
|
||||||
|
"happy-dom": "^18.0.1",
|
||||||
|
"jsdom": "^26.1.0",
|
||||||
|
"postcss": "^8.5.5",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"typescript-eslint": "^8.30.1",
|
||||||
|
"vite": "^6.3.5",
|
||||||
|
"vitest": "^3.2.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
81
client/playwright-report/index.html
Normal file
81
client/playwright-report/index.html
Normal file
File diff suppressed because one or more lines are too long
33
client/playwright.config.ts
Normal file
33
client/playwright.config.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './src/test/e2e',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
reporter: 'html',
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:5173',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'firefox',
|
||||||
|
use: { ...devices['Desktop Firefox'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'webkit',
|
||||||
|
use: { ...devices['Desktop Safari'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: 'npm run dev',
|
||||||
|
url: 'http://localhost:5173',
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
},
|
||||||
|
});
|
||||||
6
client/postcss.config.js
Normal file
6
client/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
1
client/public/vite.svg
Normal file
1
client/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
8
client/src/App.tsx
Normal file
8
client/src/App.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Dashboard } from './components/Dashboard';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return <Dashboard />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
1
client/src/assets/react.svg
Normal file
1
client/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
209
client/src/components/CheckForm.tsx
Normal file
209
client/src/components/CheckForm.tsx
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Check } from '../types';
|
||||||
|
|
||||||
|
interface CheckFormProps {
|
||||||
|
check?: Check;
|
||||||
|
kpiId: number;
|
||||||
|
onSave: (check: Omit<Check, 'id' | 'created_at' | 'updated_at'>) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CheckForm: React.FC<CheckFormProps> = ({
|
||||||
|
check,
|
||||||
|
kpiId,
|
||||||
|
onSave,
|
||||||
|
onCancel
|
||||||
|
}) => {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
question: check?.question || '',
|
||||||
|
check_type: check?.check_type || 'text',
|
||||||
|
current_value: check?.current_value || '',
|
||||||
|
expected_value: check?.expected_value || '',
|
||||||
|
reference_url: check?.reference_url || '',
|
||||||
|
comment: check?.comment || '',
|
||||||
|
is_completed: check?.is_completed || false
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSave({
|
||||||
|
...formData,
|
||||||
|
kpi_id: kpiId,
|
||||||
|
reference_file_id: check?.reference_file_id
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (field: string, value: any) => {
|
||||||
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkTypes = [
|
||||||
|
{ value: 'text', label: 'Text' },
|
||||||
|
{ value: 'dropdown', label: 'Dropdown' },
|
||||||
|
{ value: 'number', label: 'Number' },
|
||||||
|
{ value: 'percentage', label: 'Percentage' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const dropdownOptions = ['Yes', 'Partially', 'No'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 p-4 bg-gray-50 rounded-lg border">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Question/Statement
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.question}
|
||||||
|
onChange={(e) => handleChange('question', e.target.value)}
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Check Type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.check_type}
|
||||||
|
onChange={(e) => handleChange('check_type', e.target.value)}
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
{checkTypes.map(type => (
|
||||||
|
<option key={type.value} value={type.value}>
|
||||||
|
{type.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Current Value
|
||||||
|
</label>
|
||||||
|
{formData.check_type === 'dropdown' ? (
|
||||||
|
<select
|
||||||
|
value={formData.current_value}
|
||||||
|
onChange={(e) => handleChange('current_value', e.target.value)}
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Select...</option>
|
||||||
|
{dropdownOptions.map(option => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : formData.check_type === 'number' || formData.check_type === 'percentage' ? (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.current_value}
|
||||||
|
onChange={(e) => handleChange('current_value', e.target.value)}
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
min={formData.check_type === 'percentage' ? 0 : undefined}
|
||||||
|
max={formData.check_type === 'percentage' ? 100 : undefined}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.current_value}
|
||||||
|
onChange={(e) => handleChange('current_value', e.target.value)}
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Expected Value
|
||||||
|
</label>
|
||||||
|
{formData.check_type === 'dropdown' ? (
|
||||||
|
<select
|
||||||
|
value={formData.expected_value}
|
||||||
|
onChange={(e) => handleChange('expected_value', e.target.value)}
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Select...</option>
|
||||||
|
{dropdownOptions.map(option => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : formData.check_type === 'number' || formData.check_type === 'percentage' ? (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.expected_value}
|
||||||
|
onChange={(e) => handleChange('expected_value', e.target.value)}
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
min={formData.check_type === 'percentage' ? 0 : undefined}
|
||||||
|
max={formData.check_type === 'percentage' ? 100 : undefined}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.expected_value}
|
||||||
|
onChange={(e) => handleChange('expected_value', e.target.value)}
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Reference URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={formData.reference_url}
|
||||||
|
onChange={(e) => handleChange('reference_url', e.target.value)}
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Comment
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.comment}
|
||||||
|
onChange={(e) => handleChange('comment', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="is_completed"
|
||||||
|
checked={formData.is_completed}
|
||||||
|
onChange={(e) => handleChange('is_completed', e.target.checked)}
|
||||||
|
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<label htmlFor="is_completed" className="ml-2 block text-sm text-gray-700">
|
||||||
|
Mark as completed
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 border border-gray-300 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
{check ? 'Update' : 'Create'} Check
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
126
client/src/components/CheckItem.tsx
Normal file
126
client/src/components/CheckItem.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Check } from '../types';
|
||||||
|
import { Edit2, Trash2, ExternalLink } from 'lucide-react';
|
||||||
|
|
||||||
|
interface CheckItemProps {
|
||||||
|
check: Check;
|
||||||
|
onEdit: (check: Check) => void;
|
||||||
|
onDelete: (checkId: number) => void;
|
||||||
|
onToggleComplete: (checkId: number, isCompleted: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CheckItem: React.FC<CheckItemProps> = ({
|
||||||
|
check,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onToggleComplete
|
||||||
|
}) => {
|
||||||
|
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false);
|
||||||
|
|
||||||
|
const handleToggleComplete = () => {
|
||||||
|
onToggleComplete(check.id, !check.is_completed);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (isConfirmingDelete) {
|
||||||
|
onDelete(check.id);
|
||||||
|
setIsConfirmingDelete(false);
|
||||||
|
} else {
|
||||||
|
setIsConfirmingDelete(true);
|
||||||
|
setTimeout(() => setIsConfirmingDelete(false), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getValueDisplay = (value: string | undefined, type: string) => {
|
||||||
|
if (!value) return '-';
|
||||||
|
if (type === 'percentage') return `${value}%`;
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = () => {
|
||||||
|
if (check.is_completed) return 'text-green-600';
|
||||||
|
if (check.current_value && check.expected_value) {
|
||||||
|
if (check.current_value === check.expected_value) return 'text-green-600';
|
||||||
|
return 'text-yellow-600';
|
||||||
|
}
|
||||||
|
return 'text-gray-600';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`p-4 border rounded-lg ${check.is_completed ? 'bg-green-50 border-green-200' : 'bg-white border-gray-200'}`}>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={check.is_completed}
|
||||||
|
onChange={handleToggleComplete}
|
||||||
|
className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className={`font-medium ${check.is_completed ? 'line-through text-gray-500' : 'text-gray-900'}`}>
|
||||||
|
{check.question}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Current:</span>
|
||||||
|
<span className={`ml-2 font-medium ${getStatusColor()}`}>
|
||||||
|
{getValueDisplay(check.current_value, check.check_type)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Expected:</span>
|
||||||
|
<span className="ml-2 font-medium text-gray-900">
|
||||||
|
{getValueDisplay(check.expected_value, check.check_type)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{check.comment && (
|
||||||
|
<div className="mt-2 text-sm text-gray-600">
|
||||||
|
<span className="font-medium">Comment:</span> {check.comment}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{check.reference_url && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<a
|
||||||
|
href={check.reference_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center text-sm text-blue-600 hover:text-blue-500"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3 mr-1" />
|
||||||
|
Reference
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 ml-4">
|
||||||
|
<button
|
||||||
|
onClick={() => onEdit(check)}
|
||||||
|
className="p-1 text-gray-400 hover:text-blue-600 transition-colors"
|
||||||
|
title="Edit check"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className={`p-1 transition-colors ${
|
||||||
|
isConfirmingDelete
|
||||||
|
? 'text-red-600 hover:text-red-700'
|
||||||
|
: 'text-gray-400 hover:text-red-600'
|
||||||
|
}`}
|
||||||
|
title={isConfirmingDelete ? 'Click again to confirm deletion' : 'Delete check'}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
108
client/src/components/Dashboard.tsx
Normal file
108
client/src/components/Dashboard.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { DimensionCard } from './DimensionCard';
|
||||||
|
import { DimensionWithStats } from '../types';
|
||||||
|
import { DataService } from '../services/dataService';
|
||||||
|
import { initDatabase } from '../database/database';
|
||||||
|
import { seedDatabase } from '../database/seedData';
|
||||||
|
import { exportToJSON, exportToCSV } from '../utils/exportUtils';
|
||||||
|
import { Download, FileText, FileSpreadsheet } from 'lucide-react';
|
||||||
|
|
||||||
|
export const Dashboard: React.FC = () => {
|
||||||
|
const [dimensions, setDimensions] = useState<DimensionWithStats[]>([]);
|
||||||
|
const [expandedDimension, setExpandedDimension] = useState<number | null>(null);
|
||||||
|
const [dataService, setDataService] = useState<DataService | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const initializeDatabase = async () => {
|
||||||
|
try {
|
||||||
|
const db = await initDatabase();
|
||||||
|
|
||||||
|
// Check if database is already seeded
|
||||||
|
const result = db.exec('SELECT COUNT(*) as count FROM dimensions');
|
||||||
|
const count = result[0]?.values[0][0] as number;
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
seedDatabase(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = new DataService(db);
|
||||||
|
setDataService(service);
|
||||||
|
|
||||||
|
loadDimensions(service);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize database:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initializeDatabase();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadDimensions = (service: DataService) => {
|
||||||
|
const dimensionsWithStats = service.getDimensionsWithStats();
|
||||||
|
setDimensions(dimensionsWithStats);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDimensionToggle = (dimensionId: number) => {
|
||||||
|
setExpandedDimension(expandedDimension === dimensionId ? null : dimensionId);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading || !dataService) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
|
<div className="text-xl font-semibold text-gray-600">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 p-6">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<header className="mb-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||||
|
Due Diligence Tracking System
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Systematic evaluation of corporate dimensions through KPIs and checks
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<button
|
||||||
|
onClick={() => exportToJSON(dataService)}
|
||||||
|
className="inline-flex items-center px-4 py-2 text-sm font-medium text-blue-600 bg-blue-50 border border-blue-200 rounded-md hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4 mr-2" />
|
||||||
|
Export JSON
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => exportToCSV(dataService)}
|
||||||
|
className="inline-flex items-center px-4 py-2 text-sm font-medium text-green-600 bg-green-50 border border-green-200 rounded-md hover:bg-green-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
|
||||||
|
>
|
||||||
|
<FileSpreadsheet className="w-4 h-4 mr-2" />
|
||||||
|
Export CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{dimensions.map((dimensionData) => (
|
||||||
|
<div key={dimensionData.dimension.id} className="bg-white rounded-xl border-2 border-gray-300 p-6 shadow-sm">
|
||||||
|
<DimensionCard
|
||||||
|
dimensionData={dimensionData}
|
||||||
|
isExpanded={expandedDimension === dimensionData.dimension.id}
|
||||||
|
onToggle={() => handleDimensionToggle(dimensionData.dimension.id)}
|
||||||
|
dataService={dataService!}
|
||||||
|
onUpdateProgress={() => loadDimensions(dataService!)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
115
client/src/components/DimensionCard.tsx
Normal file
115
client/src/components/DimensionCard.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
import { ProgressBar } from './ProgressBar';
|
||||||
|
import { KPISection } from './KPISection';
|
||||||
|
import { DimensionWithStats, KPI } from '../types';
|
||||||
|
import { getIconComponent } from '../utils/icons';
|
||||||
|
import { DataService } from '../services/dataService';
|
||||||
|
|
||||||
|
interface DimensionCardProps {
|
||||||
|
dimensionData: DimensionWithStats;
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
dataService: DataService;
|
||||||
|
onUpdateProgress: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DimensionCard: React.FC<DimensionCardProps> = ({
|
||||||
|
dimensionData,
|
||||||
|
isExpanded,
|
||||||
|
onToggle,
|
||||||
|
dataService,
|
||||||
|
onUpdateProgress
|
||||||
|
}) => {
|
||||||
|
const { dimension, kpi_count, total_checks, completed_checks, progress } = dimensionData;
|
||||||
|
const [kpis, setKpis] = useState<KPI[]>([]);
|
||||||
|
const IconComponent = getIconComponent(dimension.icon || 'Circle');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isExpanded) {
|
||||||
|
const dimensionKpis = dataService.getKPIsByDimension(dimension.id);
|
||||||
|
setKpis(dimensionKpis);
|
||||||
|
}
|
||||||
|
}, [isExpanded, dimension.id, dataService]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-2 border-gray-400 rounded-lg">
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className="w-full p-4 flex items-center justify-between hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="p-2 border-2 border-gray-400 rounded-full bg-gray-100">
|
||||||
|
<IconComponent
|
||||||
|
className="w-5 h-5"
|
||||||
|
style={{ color: dimension.color || '#6B7280' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<h4 className="font-bold text-gray-900 text-lg">{dimension.name}</h4>
|
||||||
|
<p className="text-sm text-gray-700 font-medium">
|
||||||
|
{kpi_count} KPIs • {total_checks} total items
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="text-right">
|
||||||
|
<div
|
||||||
|
className="font-bold text-xl"
|
||||||
|
style={{ color: dimension.color || '#6B7280' }}
|
||||||
|
>
|
||||||
|
{Math.round(progress)}%
|
||||||
|
</div>
|
||||||
|
<div className="w-24">
|
||||||
|
<ProgressBar
|
||||||
|
progress={progress}
|
||||||
|
color={dimension.color || '#6B7280'}
|
||||||
|
height="6px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="w-5 h-5 text-gray-600" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-5 h-5 text-gray-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="px-4 pb-4 border-t-2 border-gray-300 bg-gray-50 rounded-b-lg">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4 mb-6">
|
||||||
|
<div className="text-center p-4 bg-white rounded-lg border-2 border-gray-300">
|
||||||
|
<div
|
||||||
|
className="text-3xl font-bold"
|
||||||
|
style={{ color: dimension.color || '#6B7280' }}
|
||||||
|
>
|
||||||
|
{kpi_count}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-700 font-medium">Key Performance Indicators</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-white rounded-lg border-2 border-gray-300">
|
||||||
|
<div className="text-3xl font-bold text-gray-700">{total_checks - completed_checks}</div>
|
||||||
|
<div className="text-sm text-gray-700 font-medium">Open Items</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-white rounded-lg border-2 border-gray-300">
|
||||||
|
<div className="text-3xl font-bold text-gray-900">{completed_checks}</div>
|
||||||
|
<div className="text-sm text-gray-700 font-medium">Completed Items</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{kpis.map((kpi) => (
|
||||||
|
<KPISection
|
||||||
|
key={kpi.id}
|
||||||
|
kpi={kpi}
|
||||||
|
dataService={dataService}
|
||||||
|
onUpdateProgress={onUpdateProgress}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
135
client/src/components/KPISection.tsx
Normal file
135
client/src/components/KPISection.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { KPI, Check } from '../types';
|
||||||
|
import { CheckItem } from './CheckItem';
|
||||||
|
import { CheckForm } from './CheckForm';
|
||||||
|
import { DataService } from '../services/dataService';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
interface KPISectionProps {
|
||||||
|
kpi: KPI;
|
||||||
|
dataService: DataService;
|
||||||
|
onUpdateProgress: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const KPISection: React.FC<KPISectionProps> = ({
|
||||||
|
kpi,
|
||||||
|
dataService,
|
||||||
|
onUpdateProgress
|
||||||
|
}) => {
|
||||||
|
const [checks, setChecks] = useState<Check[]>([]);
|
||||||
|
const [isAddingCheck, setIsAddingCheck] = useState(false);
|
||||||
|
const [editingCheck, setEditingCheck] = useState<Check | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadChecks();
|
||||||
|
}, [kpi.id]);
|
||||||
|
|
||||||
|
const loadChecks = () => {
|
||||||
|
const kpiChecks = dataService.getChecksByKPI(kpi.id);
|
||||||
|
setChecks(kpiChecks);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveCheck = (checkData: Omit<Check, 'id' | 'created_at' | 'updated_at'>) => {
|
||||||
|
try {
|
||||||
|
if (editingCheck) {
|
||||||
|
dataService.updateCheck(editingCheck.id, checkData);
|
||||||
|
} else {
|
||||||
|
dataService.createCheck(checkData);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadChecks();
|
||||||
|
onUpdateProgress();
|
||||||
|
setIsAddingCheck(false);
|
||||||
|
setEditingCheck(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save check:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteCheck = (checkId: number) => {
|
||||||
|
try {
|
||||||
|
dataService.deleteCheck(checkId);
|
||||||
|
loadChecks();
|
||||||
|
onUpdateProgress();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete check:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleComplete = (checkId: number, isCompleted: boolean) => {
|
||||||
|
try {
|
||||||
|
dataService.updateCheck(checkId, { is_completed: isCompleted });
|
||||||
|
loadChecks();
|
||||||
|
onUpdateProgress();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update check:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditCheck = (check: Check) => {
|
||||||
|
setEditingCheck(check);
|
||||||
|
setIsAddingCheck(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelForm = () => {
|
||||||
|
setIsAddingCheck(false);
|
||||||
|
setEditingCheck(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const completedCount = checks.filter(check => check.is_completed).length;
|
||||||
|
const progressPercentage = checks.length > 0 ? (completedCount / checks.length) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">{kpi.name}</h3>
|
||||||
|
{kpi.description && (
|
||||||
|
<p className="text-sm text-gray-600 mt-1">{kpi.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center space-x-4 mt-2 text-sm text-gray-500">
|
||||||
|
<span>{checks.length} checks total</span>
|
||||||
|
<span>{completedCount} completed</span>
|
||||||
|
<span className="font-medium">{Math.round(progressPercentage)}% progress</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsAddingCheck(true)}
|
||||||
|
className="inline-flex items-center px-3 py-2 text-sm font-medium text-blue-600 bg-blue-50 border border-blue-200 rounded-md hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
Add Check
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(isAddingCheck || editingCheck) && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<CheckForm
|
||||||
|
check={editingCheck || undefined}
|
||||||
|
kpiId={kpi.id}
|
||||||
|
onSave={handleSaveCheck}
|
||||||
|
onCancel={handleCancelForm}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{checks.map((check) => (
|
||||||
|
<CheckItem
|
||||||
|
key={check.id}
|
||||||
|
check={check}
|
||||||
|
onEdit={handleEditCheck}
|
||||||
|
onDelete={handleDeleteCheck}
|
||||||
|
onToggleComplete={handleToggleComplete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{checks.length === 0 && !isAddingCheck && (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
<p>No checks yet. Click "Add Check" to get started.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
30
client/src/components/ProgressBar.tsx
Normal file
30
client/src/components/ProgressBar.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ProgressBarProps {
|
||||||
|
progress: number;
|
||||||
|
color?: string;
|
||||||
|
height?: string;
|
||||||
|
width?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProgressBar: React.FC<ProgressBarProps> = ({
|
||||||
|
progress,
|
||||||
|
color = '#333333',
|
||||||
|
height = '8px',
|
||||||
|
width = '96px'
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bg-gray-200 rounded-full relative overflow-hidden"
|
||||||
|
style={{ height, width }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-300 ease-in-out"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(progress, 100)}%`,
|
||||||
|
backgroundColor: color
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
324
client/src/components/__tests__/CheckForm.test.tsx
Normal file
324
client/src/components/__tests__/CheckForm.test.tsx
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { CheckForm } from '../CheckForm';
|
||||||
|
import { Check } from '../../types';
|
||||||
|
|
||||||
|
const mockCheck: Check = {
|
||||||
|
id: 1,
|
||||||
|
kpi_id: 1,
|
||||||
|
question: 'Test question?',
|
||||||
|
check_type: 'text',
|
||||||
|
current_value: 'Current',
|
||||||
|
expected_value: 'Expected',
|
||||||
|
reference_url: 'https://example.com',
|
||||||
|
reference_file_id: null,
|
||||||
|
comment: 'Test comment',
|
||||||
|
is_completed: true,
|
||||||
|
created_at: '2023-01-01',
|
||||||
|
updated_at: '2023-01-01'
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('CheckForm', () => {
|
||||||
|
const mockOnSave = vi.fn();
|
||||||
|
const mockOnCancel = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('should render form fields correctly for new check', () => {
|
||||||
|
render(
|
||||||
|
<CheckForm
|
||||||
|
kpiId={1}
|
||||||
|
onSave={mockOnSave}
|
||||||
|
onCancel={mockOnCancel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText(/question\/statement/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/check type/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/current value/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/expected value/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/reference url/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/comment/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/mark as completed/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: /create check/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pre-populate form fields when editing existing check', () => {
|
||||||
|
render(
|
||||||
|
<CheckForm
|
||||||
|
check={mockCheck}
|
||||||
|
kpiId={1}
|
||||||
|
onSave={mockOnSave}
|
||||||
|
onCancel={mockOnCancel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByDisplayValue('Test question?')).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue('Current')).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue('Expected')).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue('https://example.com')).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue('Test comment')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('checkbox')).toBeChecked();
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: /update check/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render correct check type options', () => {
|
||||||
|
render(
|
||||||
|
<CheckForm
|
||||||
|
kpiId={1}
|
||||||
|
onSave={mockOnSave}
|
||||||
|
onCancel={mockOnCancel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkTypeSelect = screen.getByLabelText(/check type/i);
|
||||||
|
expect(checkTypeSelect).toBeInTheDocument();
|
||||||
|
|
||||||
|
const options = screen.getAllByRole('option');
|
||||||
|
expect(options).toHaveLength(4);
|
||||||
|
expect(screen.getByRole('option', { name: 'Text' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('option', { name: 'Dropdown' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('option', { name: 'Number' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('option', { name: 'Percentage' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('check type interactions', () => {
|
||||||
|
it('should show dropdown options when check type is dropdown', async () => {
|
||||||
|
render(
|
||||||
|
<CheckForm
|
||||||
|
kpiId={1}
|
||||||
|
onSave={mockOnSave}
|
||||||
|
onCancel={mockOnCancel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkTypeSelect = screen.getByLabelText(/check type/i);
|
||||||
|
await user.selectOptions(checkTypeSelect, 'dropdown');
|
||||||
|
|
||||||
|
const currentValueSelect = screen.getByLabelText(/current value/i);
|
||||||
|
expect(currentValueSelect.tagName).toBe('SELECT');
|
||||||
|
|
||||||
|
// Check dropdown options
|
||||||
|
const dropdownOptions = currentValueSelect.querySelectorAll('option');
|
||||||
|
expect(dropdownOptions).toHaveLength(4); // Including "Select..." option
|
||||||
|
expect(screen.getByRole('option', { name: 'Yes' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('option', { name: 'Partially' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('option', { name: 'No' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show number input when check type is number', async () => {
|
||||||
|
render(
|
||||||
|
<CheckForm
|
||||||
|
kpiId={1}
|
||||||
|
onSave={mockOnSave}
|
||||||
|
onCancel={mockOnCancel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkTypeSelect = screen.getByLabelText(/check type/i);
|
||||||
|
await user.selectOptions(checkTypeSelect, 'number');
|
||||||
|
|
||||||
|
const currentValueInput = screen.getByLabelText(/current value/i);
|
||||||
|
expect(currentValueInput).toHaveAttribute('type', 'number');
|
||||||
|
expect(currentValueInput).not.toHaveAttribute('min');
|
||||||
|
expect(currentValueInput).not.toHaveAttribute('max');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show percentage input with min/max when check type is percentage', async () => {
|
||||||
|
render(
|
||||||
|
<CheckForm
|
||||||
|
kpiId={1}
|
||||||
|
onSave={mockOnSave}
|
||||||
|
onCancel={mockOnCancel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkTypeSelect = screen.getByLabelText(/check type/i);
|
||||||
|
await user.selectOptions(checkTypeSelect, 'percentage');
|
||||||
|
|
||||||
|
const currentValueInput = screen.getByLabelText(/current value/i);
|
||||||
|
expect(currentValueInput).toHaveAttribute('type', 'number');
|
||||||
|
expect(currentValueInput).toHaveAttribute('min', '0');
|
||||||
|
expect(currentValueInput).toHaveAttribute('max', '100');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show text input when check type is text', async () => {
|
||||||
|
render(
|
||||||
|
<CheckForm
|
||||||
|
kpiId={1}
|
||||||
|
onSave={mockOnSave}
|
||||||
|
onCancel={mockOnCancel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentValueInput = screen.getByLabelText(/current value/i);
|
||||||
|
expect(currentValueInput).toHaveAttribute('type', 'text');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('form interactions', () => {
|
||||||
|
it('should call onSave with correct data when form is submitted', async () => {
|
||||||
|
render(
|
||||||
|
<CheckForm
|
||||||
|
kpiId={1}
|
||||||
|
onSave={mockOnSave}
|
||||||
|
onCancel={mockOnCancel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText(/question\/statement/i), 'New question?');
|
||||||
|
await user.type(screen.getByLabelText(/current value/i), 'Current value');
|
||||||
|
await user.type(screen.getByLabelText(/expected value/i), 'Expected value');
|
||||||
|
await user.type(screen.getByLabelText(/reference url/i), 'https://test.com');
|
||||||
|
await user.type(screen.getByLabelText(/comment/i), 'Test comment');
|
||||||
|
await user.click(screen.getByLabelText(/mark as completed/i));
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /create check/i }));
|
||||||
|
|
||||||
|
expect(mockOnSave).toHaveBeenCalledWith({
|
||||||
|
kpi_id: 1,
|
||||||
|
question: 'New question?',
|
||||||
|
check_type: 'text',
|
||||||
|
current_value: 'Current value',
|
||||||
|
expected_value: 'Expected value',
|
||||||
|
reference_url: 'https://test.com',
|
||||||
|
comment: 'Test comment',
|
||||||
|
is_completed: true,
|
||||||
|
reference_file_id: undefined
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onCancel when cancel button is clicked', async () => {
|
||||||
|
render(
|
||||||
|
<CheckForm
|
||||||
|
kpiId={1}
|
||||||
|
onSave={mockOnSave}
|
||||||
|
onCancel={mockOnCancel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /cancel/i }));
|
||||||
|
|
||||||
|
expect(mockOnCancel).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require question field', async () => {
|
||||||
|
render(
|
||||||
|
<CheckForm
|
||||||
|
kpiId={1}
|
||||||
|
onSave={mockOnSave}
|
||||||
|
onCancel={mockOnCancel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const questionInput = screen.getByLabelText(/question\/statement/i);
|
||||||
|
expect(questionInput).toBeRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update form data when inputs change', async () => {
|
||||||
|
render(
|
||||||
|
<CheckForm
|
||||||
|
kpiId={1}
|
||||||
|
onSave={mockOnSave}
|
||||||
|
onCancel={mockOnCancel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const questionInput = screen.getByLabelText(/question\/statement/i);
|
||||||
|
await user.type(questionInput, 'Updated question');
|
||||||
|
|
||||||
|
expect(questionInput).toHaveValue('Updated question');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should toggle completion checkbox', async () => {
|
||||||
|
render(
|
||||||
|
<CheckForm
|
||||||
|
kpiId={1}
|
||||||
|
onSave={mockOnSave}
|
||||||
|
onCancel={mockOnCancel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkbox = screen.getByLabelText(/mark as completed/i);
|
||||||
|
expect(checkbox).not.toBeChecked();
|
||||||
|
|
||||||
|
await user.click(checkbox);
|
||||||
|
expect(checkbox).toBeChecked();
|
||||||
|
|
||||||
|
await user.click(checkbox);
|
||||||
|
expect(checkbox).not.toBeChecked();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('form validation', () => {
|
||||||
|
it('should prevent form submission when required fields are empty', async () => {
|
||||||
|
render(
|
||||||
|
<CheckForm
|
||||||
|
kpiId={1}
|
||||||
|
onSave={mockOnSave}
|
||||||
|
onCancel={mockOnCancel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /create check/i }));
|
||||||
|
|
||||||
|
// Form should not submit due to HTML5 validation
|
||||||
|
expect(mockOnSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate URL format', () => {
|
||||||
|
render(
|
||||||
|
<CheckForm
|
||||||
|
kpiId={1}
|
||||||
|
onSave={mockOnSave}
|
||||||
|
onCancel={mockOnCancel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const urlInput = screen.getByLabelText(/reference url/i);
|
||||||
|
expect(urlInput).toHaveAttribute('type', 'url');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('accessibility', () => {
|
||||||
|
it('should have proper labels for all form controls', () => {
|
||||||
|
render(
|
||||||
|
<CheckForm
|
||||||
|
kpiId={1}
|
||||||
|
onSave={mockOnSave}
|
||||||
|
onCancel={mockOnCancel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText(/question\/statement/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/check type/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/current value/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/expected value/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/reference url/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/comment/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/mark as completed/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have proper button roles and names', () => {
|
||||||
|
render(
|
||||||
|
<CheckForm
|
||||||
|
kpiId={1}
|
||||||
|
onSave={mockOnSave}
|
||||||
|
onCancel={mockOnCancel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: /create check/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
361
client/src/components/__tests__/CheckItem.test.tsx
Normal file
361
client/src/components/__tests__/CheckItem.test.tsx
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { CheckItem } from '../CheckItem';
|
||||||
|
import { Check } from '../../types';
|
||||||
|
|
||||||
|
const mockCheck: Check = {
|
||||||
|
id: 1,
|
||||||
|
kpi_id: 1,
|
||||||
|
question: 'Is the vision clearly defined?',
|
||||||
|
check_type: 'dropdown',
|
||||||
|
current_value: 'Yes',
|
||||||
|
expected_value: 'Yes',
|
||||||
|
reference_url: 'https://example.com',
|
||||||
|
reference_file_id: null,
|
||||||
|
comment: 'Well documented',
|
||||||
|
is_completed: false,
|
||||||
|
created_at: '2023-01-01',
|
||||||
|
updated_at: '2023-01-01'
|
||||||
|
};
|
||||||
|
|
||||||
|
const completedCheck: Check = {
|
||||||
|
...mockCheck,
|
||||||
|
id: 2,
|
||||||
|
is_completed: true
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('CheckItem', () => {
|
||||||
|
const mockOnEdit = vi.fn();
|
||||||
|
const mockOnDelete = vi.fn();
|
||||||
|
const mockOnToggleComplete = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('should render check information correctly', () => {
|
||||||
|
render(
|
||||||
|
<CheckItem
|
||||||
|
check={mockCheck}
|
||||||
|
onEdit={mockOnEdit}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
onToggleComplete={mockOnToggleComplete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Is the vision clearly defined?')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Yes')).toBeInTheDocument(); // current value
|
||||||
|
expect(screen.getByText('Yes')).toBeInTheDocument(); // expected value
|
||||||
|
expect(screen.getByText('Well documented')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('link', { name: /reference/i })).toHaveAttribute('href', 'https://example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render completed check with different styling', () => {
|
||||||
|
render(
|
||||||
|
<CheckItem
|
||||||
|
check={completedCheck}
|
||||||
|
onEdit={mockOnEdit}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
onToggleComplete={mockOnToggleComplete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const question = screen.getByText('Is the vision clearly defined?');
|
||||||
|
expect(question).toHaveClass('line-through', 'text-gray-500');
|
||||||
|
|
||||||
|
const container = question.closest('div[class*="bg-green-50"]');
|
||||||
|
expect(container).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing optional fields', () => {
|
||||||
|
const checkWithoutOptionals: Check = {
|
||||||
|
...mockCheck,
|
||||||
|
current_value: undefined,
|
||||||
|
reference_url: undefined,
|
||||||
|
comment: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<CheckItem
|
||||||
|
check={checkWithoutOptionals}
|
||||||
|
onEdit={mockOnEdit}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
onToggleComplete={mockOnToggleComplete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('-')).toBeInTheDocument(); // current value placeholder
|
||||||
|
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/comment:/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display percentage values correctly', () => {
|
||||||
|
const percentageCheck: Check = {
|
||||||
|
...mockCheck,
|
||||||
|
check_type: 'percentage',
|
||||||
|
current_value: '75',
|
||||||
|
expected_value: '80'
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<CheckItem
|
||||||
|
check={percentageCheck}
|
||||||
|
onEdit={mockOnEdit}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
onToggleComplete={mockOnToggleComplete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('75%')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('80%')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('interactions', () => {
|
||||||
|
it('should call onToggleComplete when checkbox is clicked', async () => {
|
||||||
|
render(
|
||||||
|
<CheckItem
|
||||||
|
check={mockCheck}
|
||||||
|
onEdit={mockOnEdit}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
onToggleComplete={mockOnToggleComplete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkbox = screen.getByRole('checkbox');
|
||||||
|
await user.click(checkbox);
|
||||||
|
|
||||||
|
expect(mockOnToggleComplete).toHaveBeenCalledWith(1, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onEdit when edit button is clicked', async () => {
|
||||||
|
render(
|
||||||
|
<CheckItem
|
||||||
|
check={mockCheck}
|
||||||
|
onEdit={mockOnEdit}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
onToggleComplete={mockOnToggleComplete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const editButton = screen.getByTitle(/edit check/i);
|
||||||
|
await user.click(editButton);
|
||||||
|
|
||||||
|
expect(mockOnEdit).toHaveBeenCalledWith(mockCheck);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require double click for delete confirmation', async () => {
|
||||||
|
render(
|
||||||
|
<CheckItem
|
||||||
|
check={mockCheck}
|
||||||
|
onEdit={mockOnEdit}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
onToggleComplete={mockOnToggleComplete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteButton = screen.getByTitle(/delete check/i);
|
||||||
|
|
||||||
|
// First click - should not delete
|
||||||
|
await user.click(deleteButton);
|
||||||
|
expect(mockOnDelete).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Button should change to confirmation state
|
||||||
|
expect(screen.getByTitle(/click again to confirm deletion/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Second click - should delete
|
||||||
|
await user.click(deleteButton);
|
||||||
|
expect(mockOnDelete).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset delete confirmation after timeout', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<CheckItem
|
||||||
|
check={mockCheck}
|
||||||
|
onEdit={mockOnEdit}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
onToggleComplete={mockOnToggleComplete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteButton = screen.getByTitle(/delete check/i);
|
||||||
|
|
||||||
|
// First click
|
||||||
|
await user.click(deleteButton);
|
||||||
|
expect(screen.getByTitle(/click again to confirm deletion/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Fast forward time
|
||||||
|
vi.advanceTimersByTime(3000);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTitle(/delete check/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('status indicators', () => {
|
||||||
|
it('should show green color for completed checks', () => {
|
||||||
|
render(
|
||||||
|
<CheckItem
|
||||||
|
check={completedCheck}
|
||||||
|
onEdit={mockOnEdit}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
onToggleComplete={mockOnToggleComplete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentValue = screen.getByText('Yes');
|
||||||
|
expect(currentValue).toHaveClass('text-green-600');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show green color when current matches expected', () => {
|
||||||
|
const matchingCheck: Check = {
|
||||||
|
...mockCheck,
|
||||||
|
current_value: 'Yes',
|
||||||
|
expected_value: 'Yes',
|
||||||
|
is_completed: false
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<CheckItem
|
||||||
|
check={matchingCheck}
|
||||||
|
onEdit={mockOnEdit}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
onToggleComplete={mockOnToggleComplete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentValue = screen.getByText('Yes');
|
||||||
|
expect(currentValue).toHaveClass('text-green-600');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show yellow color when current does not match expected', () => {
|
||||||
|
const mismatchCheck: Check = {
|
||||||
|
...mockCheck,
|
||||||
|
current_value: 'No',
|
||||||
|
expected_value: 'Yes',
|
||||||
|
is_completed: false
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<CheckItem
|
||||||
|
check={mismatchCheck}
|
||||||
|
onEdit={mockOnEdit}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
onToggleComplete={mockOnToggleComplete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentValue = screen.getByText('No');
|
||||||
|
expect(currentValue).toHaveClass('text-yellow-600');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show gray color when no values are set', () => {
|
||||||
|
const emptyCheck: Check = {
|
||||||
|
...mockCheck,
|
||||||
|
current_value: undefined,
|
||||||
|
expected_value: undefined,
|
||||||
|
is_completed: false
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<CheckItem
|
||||||
|
check={emptyCheck}
|
||||||
|
onEdit={mockOnEdit}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
onToggleComplete={mockOnToggleComplete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentValue = screen.getByText('-');
|
||||||
|
expect(currentValue).toHaveClass('text-gray-600');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('external link handling', () => {
|
||||||
|
it('should open reference link in new tab', () => {
|
||||||
|
render(
|
||||||
|
<CheckItem
|
||||||
|
check={mockCheck}
|
||||||
|
onEdit={mockOnEdit}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
onToggleComplete={mockOnToggleComplete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const link = screen.getByRole('link', { name: /reference/i });
|
||||||
|
expect(link).toHaveAttribute('target', '_blank');
|
||||||
|
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render reference link when URL is not provided', () => {
|
||||||
|
const checkWithoutUrl: Check = {
|
||||||
|
...mockCheck,
|
||||||
|
reference_url: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<CheckItem
|
||||||
|
check={checkWithoutUrl}
|
||||||
|
onEdit={mockOnEdit}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
onToggleComplete={mockOnToggleComplete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('accessibility', () => {
|
||||||
|
it('should have proper ARIA labels and roles', () => {
|
||||||
|
render(
|
||||||
|
<CheckItem
|
||||||
|
check={mockCheck}
|
||||||
|
onEdit={mockOnEdit}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
onToggleComplete={mockOnToggleComplete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole('checkbox')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTitle(/edit check/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByTitle(/delete check/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have proper checkbox state', () => {
|
||||||
|
render(
|
||||||
|
<CheckItem
|
||||||
|
check={mockCheck}
|
||||||
|
onEdit={mockOnEdit}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
onToggleComplete={mockOnToggleComplete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkbox = screen.getByRole('checkbox');
|
||||||
|
expect(checkbox).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have checked checkbox for completed items', () => {
|
||||||
|
render(
|
||||||
|
<CheckItem
|
||||||
|
check={completedCheck}
|
||||||
|
onEdit={mockOnEdit}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
onToggleComplete={mockOnToggleComplete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkbox = screen.getByRole('checkbox');
|
||||||
|
expect(checkbox).toBeChecked();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
355
client/src/components/__tests__/DimensionCard.test.tsx
Normal file
355
client/src/components/__tests__/DimensionCard.test.tsx
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { DimensionCard } from '../DimensionCard';
|
||||||
|
import { DimensionWithStats } from '../../types';
|
||||||
|
import { DataService } from '../../services/dataService';
|
||||||
|
|
||||||
|
const mockDataService = {
|
||||||
|
getKPIsByDimension: vi.fn(),
|
||||||
|
} as unknown as DataService;
|
||||||
|
|
||||||
|
const mockDimensionData: DimensionWithStats = {
|
||||||
|
dimension: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Strategy',
|
||||||
|
icon: 'Briefcase',
|
||||||
|
color: '#4F46E5',
|
||||||
|
order_index: 1
|
||||||
|
},
|
||||||
|
kpi_count: 3,
|
||||||
|
total_checks: 10,
|
||||||
|
completed_checks: 7,
|
||||||
|
progress: 70
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockKPIs = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
dimension_id: 1,
|
||||||
|
name: 'Strategic Alignment',
|
||||||
|
description: 'Vision and mission clarity',
|
||||||
|
order_index: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
dimension_id: 1,
|
||||||
|
name: 'Market Position',
|
||||||
|
description: 'Competitive landscape analysis',
|
||||||
|
order_index: 2
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('DimensionCard', () => {
|
||||||
|
const mockOnToggle = vi.fn();
|
||||||
|
const mockOnUpdateProgress = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
(mockDataService.getKPIsByDimension as any).mockReturnValue(mockKPIs);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rendering collapsed state', () => {
|
||||||
|
it('should render dimension information correctly when collapsed', () => {
|
||||||
|
render(
|
||||||
|
<DimensionCard
|
||||||
|
dimensionData={mockDimensionData}
|
||||||
|
isExpanded={false}
|
||||||
|
onToggle={mockOnToggle}
|
||||||
|
dataService={mockDataService}
|
||||||
|
onUpdateProgress={mockOnUpdateProgress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Strategy')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('3 KPIs • 10 total items')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('70%')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should show right chevron when collapsed
|
||||||
|
expect(screen.getByRole('button')).toContainElement(
|
||||||
|
screen.getByText('Strategy').closest('button')?.querySelector('[data-lucide="chevron-right"]') as Element
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply correct styling based on dimension color', () => {
|
||||||
|
render(
|
||||||
|
<DimensionCard
|
||||||
|
dimensionData={mockDimensionData}
|
||||||
|
isExpanded={false}
|
||||||
|
onToggle={mockOnToggle}
|
||||||
|
dataService={mockDataService}
|
||||||
|
onUpdateProgress={mockOnUpdateProgress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const progressPercentage = screen.getByText('70%');
|
||||||
|
expect(progressPercentage).toHaveStyle({ color: '#4F46E5' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing icon gracefully', () => {
|
||||||
|
const dimensionWithoutIcon: DimensionWithStats = {
|
||||||
|
...mockDimensionData,
|
||||||
|
dimension: {
|
||||||
|
...mockDimensionData.dimension,
|
||||||
|
icon: undefined
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DimensionCard
|
||||||
|
dimensionData={dimensionWithoutIcon}
|
||||||
|
isExpanded={false}
|
||||||
|
onToggle={mockOnToggle}
|
||||||
|
dataService={mockDataService}
|
||||||
|
onUpdateProgress={mockOnUpdateProgress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should still render without errors
|
||||||
|
expect(screen.getByText('Strategy')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rendering expanded state', () => {
|
||||||
|
it('should render expanded content when isExpanded is true', () => {
|
||||||
|
render(
|
||||||
|
<DimensionCard
|
||||||
|
dimensionData={mockDimensionData}
|
||||||
|
isExpanded={true}
|
||||||
|
onToggle={mockOnToggle}
|
||||||
|
dataService={mockDataService}
|
||||||
|
onUpdateProgress={mockOnUpdateProgress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should show down chevron when expanded
|
||||||
|
expect(screen.getByRole('button')).toContainElement(
|
||||||
|
screen.getByText('Strategy').closest('button')?.querySelector('[data-lucide="chevron-down"]') as Element
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should show stats cards
|
||||||
|
expect(screen.getByText('Key Performance Indicators')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Open Items')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Completed Items')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should calculate open items correctly (total - completed)
|
||||||
|
expect(screen.getByText('3')).toBeInTheDocument(); // open items (10 - 7)
|
||||||
|
expect(screen.getByText('7')).toBeInTheDocument(); // completed items
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should load and display KPIs when expanded', () => {
|
||||||
|
render(
|
||||||
|
<DimensionCard
|
||||||
|
dimensionData={mockDimensionData}
|
||||||
|
isExpanded={true}
|
||||||
|
onToggle={mockOnToggle}
|
||||||
|
dataService={mockDataService}
|
||||||
|
onUpdateProgress={mockOnUpdateProgress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockDataService.getKPIsByDimension).toHaveBeenCalledWith(1);
|
||||||
|
|
||||||
|
// KPISection components should be rendered for each KPI
|
||||||
|
expect(screen.getByText('Strategic Alignment')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Market Position')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not load KPIs when collapsed', () => {
|
||||||
|
render(
|
||||||
|
<DimensionCard
|
||||||
|
dimensionData={mockDimensionData}
|
||||||
|
isExpanded={false}
|
||||||
|
onToggle={mockOnToggle}
|
||||||
|
dataService={mockDataService}
|
||||||
|
onUpdateProgress={mockOnUpdateProgress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockDataService.getKPIsByDimension).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('interactions', () => {
|
||||||
|
it('should call onToggle when card header is clicked', async () => {
|
||||||
|
render(
|
||||||
|
<DimensionCard
|
||||||
|
dimensionData={mockDimensionData}
|
||||||
|
isExpanded={false}
|
||||||
|
onToggle={mockOnToggle}
|
||||||
|
dataService={mockDataService}
|
||||||
|
onUpdateProgress={mockOnUpdateProgress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
await user.click(button);
|
||||||
|
|
||||||
|
expect(mockOnToggle).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have hover effect on card header', () => {
|
||||||
|
render(
|
||||||
|
<DimensionCard
|
||||||
|
dimensionData={mockDimensionData}
|
||||||
|
isExpanded={false}
|
||||||
|
onToggle={mockOnToggle}
|
||||||
|
dataService={mockDataService}
|
||||||
|
onUpdateProgress={mockOnUpdateProgress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
expect(button).toHaveClass('hover:bg-gray-100', 'transition-colors');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('progress calculation', () => {
|
||||||
|
it('should display progress percentage correctly', () => {
|
||||||
|
render(
|
||||||
|
<DimensionCard
|
||||||
|
dimensionData={mockDimensionData}
|
||||||
|
isExpanded={false}
|
||||||
|
onToggle={mockOnToggle}
|
||||||
|
dataService={mockDataService}
|
||||||
|
onUpdateProgress={mockOnUpdateProgress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Progress should be rounded to nearest integer
|
||||||
|
expect(screen.getByText('70%')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero progress', () => {
|
||||||
|
const zeroDimension: DimensionWithStats = {
|
||||||
|
...mockDimensionData,
|
||||||
|
total_checks: 5,
|
||||||
|
completed_checks: 0,
|
||||||
|
progress: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DimensionCard
|
||||||
|
dimensionData={zeroDimension}
|
||||||
|
isExpanded={false}
|
||||||
|
onToggle={mockOnToggle}
|
||||||
|
dataService={mockDataService}
|
||||||
|
onUpdateProgress={mockOnUpdateProgress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('0%')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle 100% progress', () => {
|
||||||
|
const completeDimension: DimensionWithStats = {
|
||||||
|
...mockDimensionData,
|
||||||
|
total_checks: 5,
|
||||||
|
completed_checks: 5,
|
||||||
|
progress: 100
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DimensionCard
|
||||||
|
dimensionData={completeDimension}
|
||||||
|
isExpanded={false}
|
||||||
|
onToggle={mockOnToggle}
|
||||||
|
dataService={mockDataService}
|
||||||
|
onUpdateProgress={mockOnUpdateProgress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('100%')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should round progress to nearest integer', () => {
|
||||||
|
const fractionalDimension: DimensionWithStats = {
|
||||||
|
...mockDimensionData,
|
||||||
|
progress: 66.67
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DimensionCard
|
||||||
|
dimensionData={fractionalDimension}
|
||||||
|
isExpanded={false}
|
||||||
|
onToggle={mockOnToggle}
|
||||||
|
dataService={mockDataService}
|
||||||
|
onUpdateProgress={mockOnUpdateProgress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('67%')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('accessibility', () => {
|
||||||
|
it('should have proper button role and accessibility', () => {
|
||||||
|
render(
|
||||||
|
<DimensionCard
|
||||||
|
dimensionData={mockDimensionData}
|
||||||
|
isExpanded={false}
|
||||||
|
onToggle={mockOnToggle}
|
||||||
|
dataService={mockDataService}
|
||||||
|
onUpdateProgress={mockOnUpdateProgress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
expect(button).toHaveClass('w-full');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have proper heading structure', () => {
|
||||||
|
render(
|
||||||
|
<DimensionCard
|
||||||
|
dimensionData={mockDimensionData}
|
||||||
|
isExpanded={false}
|
||||||
|
onToggle={mockOnToggle}
|
||||||
|
dataService={mockDataService}
|
||||||
|
onUpdateProgress={mockOnUpdateProgress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const heading = screen.getByRole('heading', { level: 4 });
|
||||||
|
expect(heading).toHaveTextContent('Strategy');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('should handle DataService errors gracefully', () => {
|
||||||
|
(mockDataService.getKPIsByDimension as any).mockImplementation(() => {
|
||||||
|
throw new Error('Database error');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
render(
|
||||||
|
<DimensionCard
|
||||||
|
dimensionData={mockDimensionData}
|
||||||
|
isExpanded={true}
|
||||||
|
onToggle={mockOnToggle}
|
||||||
|
dataService={mockDataService}
|
||||||
|
onUpdateProgress={mockOnUpdateProgress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty KPI arrays', () => {
|
||||||
|
(mockDataService.getKPIsByDimension as any).mockReturnValue([]);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DimensionCard
|
||||||
|
dimensionData={mockDimensionData}
|
||||||
|
isExpanded={true}
|
||||||
|
onToggle={mockOnToggle}
|
||||||
|
dataService={mockDataService}
|
||||||
|
onUpdateProgress={mockOnUpdateProgress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should render without errors
|
||||||
|
expect(screen.getByText('Strategy')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
91
client/src/components/__tests__/ProgressBar.test.tsx
Normal file
91
client/src/components/__tests__/ProgressBar.test.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { ProgressBar } from '../ProgressBar';
|
||||||
|
|
||||||
|
describe('ProgressBar', () => {
|
||||||
|
it('should render with default props', () => {
|
||||||
|
const { container } = render(<ProgressBar progress={50} />);
|
||||||
|
|
||||||
|
const progressContainer = container.querySelector('div');
|
||||||
|
const progressFill = progressContainer?.querySelector('div');
|
||||||
|
|
||||||
|
expect(progressContainer).toHaveStyle({
|
||||||
|
height: '8px',
|
||||||
|
width: '96px'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(progressFill).toHaveStyle({
|
||||||
|
width: '50%',
|
||||||
|
backgroundColor: '#333333'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with custom props', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ProgressBar
|
||||||
|
progress={75}
|
||||||
|
color="#ff0000"
|
||||||
|
height="12px"
|
||||||
|
width="200px"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const progressContainer = container.querySelector('div');
|
||||||
|
const progressFill = progressContainer?.querySelector('div');
|
||||||
|
|
||||||
|
expect(progressContainer).toHaveStyle({
|
||||||
|
height: '12px',
|
||||||
|
width: '200px'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(progressFill).toHaveStyle({
|
||||||
|
width: '75%',
|
||||||
|
backgroundColor: '#ff0000'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle 0% progress', () => {
|
||||||
|
const { container } = render(<ProgressBar progress={0} />);
|
||||||
|
|
||||||
|
const progressFill = container.querySelector('div div');
|
||||||
|
expect(progressFill).toHaveStyle({ width: '0%' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle 100% progress', () => {
|
||||||
|
const { container } = render(<ProgressBar progress={100} />);
|
||||||
|
|
||||||
|
const progressFill = container.querySelector('div div');
|
||||||
|
expect(progressFill).toHaveStyle({ width: '100%' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cap progress at 100% for values over 100', () => {
|
||||||
|
const { container } = render(<ProgressBar progress={150} />);
|
||||||
|
|
||||||
|
const progressFill = container.querySelector('div div');
|
||||||
|
expect(progressFill).toHaveStyle({ width: '100%' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle negative progress values', () => {
|
||||||
|
const { container } = render(<ProgressBar progress={-10} />);
|
||||||
|
|
||||||
|
const progressFill = container.querySelector('div div');
|
||||||
|
expect(progressFill).toHaveStyle({ width: '-10%' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have correct CSS classes', () => {
|
||||||
|
const { container } = render(<ProgressBar progress={50} />);
|
||||||
|
|
||||||
|
const progressContainer = container.querySelector('div');
|
||||||
|
const progressFill = progressContainer?.querySelector('div');
|
||||||
|
|
||||||
|
expect(progressContainer).toHaveClass('bg-gray-200', 'rounded-full', 'relative', 'overflow-hidden');
|
||||||
|
expect(progressFill).toHaveClass('h-full', 'rounded-full', 'transition-all', 'duration-300', 'ease-in-out');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle decimal progress values', () => {
|
||||||
|
const { container } = render(<ProgressBar progress={33.33} />);
|
||||||
|
|
||||||
|
const progressFill = container.querySelector('div div');
|
||||||
|
expect(progressFill).toHaveStyle({ width: '33.33%' });
|
||||||
|
});
|
||||||
|
});
|
||||||
100
client/src/database/database.ts
Normal file
100
client/src/database/database.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import initSqlJs from 'sql.js';
|
||||||
|
|
||||||
|
let SQL: any = null;
|
||||||
|
let db: any = null;
|
||||||
|
|
||||||
|
export interface Database {
|
||||||
|
exec: (sql: string) => any[];
|
||||||
|
run: (sql: string, params?: any[]) => void;
|
||||||
|
prepare: (sql: string) => any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const initDatabase = async (): Promise<Database> => {
|
||||||
|
if (db) return db;
|
||||||
|
|
||||||
|
if (!SQL) {
|
||||||
|
SQL = await initSqlJs({
|
||||||
|
locateFile: (file: string) => `https://sql.js.org/dist/${file}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
db = new SQL.Database();
|
||||||
|
|
||||||
|
// Add run method for compatibility with seed data
|
||||||
|
db.run = (sql: string, params?: any[]) => {
|
||||||
|
if (params && params.length > 0) {
|
||||||
|
const stmt = db.prepare(sql);
|
||||||
|
stmt.run(params);
|
||||||
|
stmt.free();
|
||||||
|
} else {
|
||||||
|
db.exec(sql);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize schema
|
||||||
|
const schema = `
|
||||||
|
CREATE TABLE IF NOT EXISTS dimensions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
icon TEXT,
|
||||||
|
color TEXT,
|
||||||
|
order_index INTEGER,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS kpis (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
dimension_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
order_index INTEGER,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (dimension_id) REFERENCES dimensions(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS checks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
kpi_id INTEGER NOT NULL,
|
||||||
|
question TEXT NOT NULL,
|
||||||
|
check_type TEXT NOT NULL,
|
||||||
|
current_value TEXT,
|
||||||
|
expected_value TEXT,
|
||||||
|
reference_url TEXT,
|
||||||
|
reference_file_id INTEGER,
|
||||||
|
comment TEXT,
|
||||||
|
is_completed BOOLEAN DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (kpi_id) REFERENCES kpis(id),
|
||||||
|
FOREIGN KEY (reference_file_id) REFERENCES files(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS files (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
original_name TEXT NOT NULL,
|
||||||
|
stored_path TEXT NOT NULL,
|
||||||
|
mime_type TEXT,
|
||||||
|
size_bytes INTEGER,
|
||||||
|
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS check_types (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
data_type TEXT NOT NULL,
|
||||||
|
options_json TEXT,
|
||||||
|
validation_json TEXT
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.exec(schema);
|
||||||
|
|
||||||
|
return db;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDatabase = (): Database => {
|
||||||
|
if (!db) {
|
||||||
|
throw new Error('Database not initialized. Call initDatabase() first.');
|
||||||
|
}
|
||||||
|
return db;
|
||||||
|
};
|
||||||
164
client/src/database/seedData.ts
Normal file
164
client/src/database/seedData.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { Database } from './database';
|
||||||
|
|
||||||
|
export const seedDatabase = (db: Database) => {
|
||||||
|
// Insert dimensions
|
||||||
|
const dimensions = [
|
||||||
|
{ name: 'Strategy', icon: 'Briefcase', color: '#4F46E5', order_index: 1 },
|
||||||
|
{ name: 'Sales', icon: 'TrendingUp', color: '#10B981', order_index: 2 },
|
||||||
|
{ name: 'Product', icon: 'Target', color: '#3B82F6', order_index: 3 },
|
||||||
|
{ name: 'Organization', icon: 'Building', color: '#8B5CF6', order_index: 4 },
|
||||||
|
{ name: 'Engineering', icon: 'Code', color: '#F59E0B', order_index: 5 },
|
||||||
|
{ name: 'DevOps', icon: 'Shield', color: '#EF4444', order_index: 6 },
|
||||||
|
{ name: 'Cyber', icon: 'Shield', color: '#6B7280', order_index: 7 },
|
||||||
|
{ name: 'Finance', icon: 'DollarSign', color: '#059669', order_index: 8 },
|
||||||
|
{ name: 'People', icon: 'Users', color: '#DC2626', order_index: 9 },
|
||||||
|
{ name: 'Culture', icon: 'Heart', color: '#7C3AED', order_index: 10 }
|
||||||
|
];
|
||||||
|
|
||||||
|
dimensions.forEach((dim, index) => {
|
||||||
|
db.run(
|
||||||
|
'INSERT INTO dimensions (name, icon, color, order_index) VALUES (?, ?, ?, ?)',
|
||||||
|
[dim.name, dim.icon, dim.color, dim.order_index]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Insert KPIs for Strategy
|
||||||
|
const kpis = [
|
||||||
|
// Strategy KPIs
|
||||||
|
{ dimension_id: 1, name: 'Strategic Alignment', description: 'Vision and mission clarity', order_index: 1 },
|
||||||
|
{ dimension_id: 1, name: 'Market Position', description: 'Competitive landscape analysis', order_index: 2 },
|
||||||
|
{ dimension_id: 1, name: 'Business Model', description: 'Revenue and value proposition', order_index: 3 },
|
||||||
|
{ dimension_id: 1, name: 'Strategic Planning', description: 'Long-term planning processes', order_index: 4 },
|
||||||
|
{ dimension_id: 1, name: 'Innovation Strategy', description: 'R&D and innovation approach', order_index: 5 },
|
||||||
|
{ dimension_id: 1, name: 'Risk Management', description: 'Strategic risk assessment', order_index: 6 },
|
||||||
|
|
||||||
|
// Sales KPIs
|
||||||
|
{ dimension_id: 2, name: 'Sales Performance', description: 'Revenue and conversion metrics', order_index: 1 },
|
||||||
|
{ dimension_id: 2, name: 'Customer Acquisition', description: 'Lead generation and conversion', order_index: 2 },
|
||||||
|
{ dimension_id: 2, name: 'Sales Process', description: 'Sales methodology and tools', order_index: 3 },
|
||||||
|
|
||||||
|
// Product KPIs
|
||||||
|
{ dimension_id: 3, name: 'Product Strategy', description: 'Product roadmap and vision', order_index: 1 },
|
||||||
|
{ dimension_id: 3, name: 'Product Quality', description: 'Quality metrics and standards', order_index: 2 },
|
||||||
|
{ dimension_id: 3, name: 'User Experience', description: 'Customer satisfaction and usability', order_index: 3 },
|
||||||
|
{ dimension_id: 3, name: 'Product Innovation', description: 'Feature development and innovation', order_index: 4 },
|
||||||
|
{ dimension_id: 3, name: 'Market Fit', description: 'Product-market fit analysis', order_index: 5 },
|
||||||
|
|
||||||
|
// Organization KPIs
|
||||||
|
{ dimension_id: 4, name: 'Organizational Structure', description: 'Hierarchy and reporting lines', order_index: 1 },
|
||||||
|
{ dimension_id: 4, name: 'Leadership', description: 'Management effectiveness', order_index: 2 },
|
||||||
|
{ dimension_id: 4, name: 'Communication', description: 'Internal communication processes', order_index: 3 },
|
||||||
|
{ dimension_id: 4, name: 'Decision Making', description: 'Decision processes and governance', order_index: 4 },
|
||||||
|
{ dimension_id: 4, name: 'Change Management', description: 'Adaptability and change processes', order_index: 5 },
|
||||||
|
|
||||||
|
// Engineering KPIs
|
||||||
|
{ dimension_id: 5, name: 'Development Practices', description: 'Coding standards and methodology', order_index: 1 },
|
||||||
|
{ dimension_id: 5, name: 'Code Quality', description: 'Testing and code review processes', order_index: 2 },
|
||||||
|
{ dimension_id: 5, name: 'Technical Architecture', description: 'System design and scalability', order_index: 3 },
|
||||||
|
{ dimension_id: 5, name: 'Team Productivity', description: 'Velocity and delivery metrics', order_index: 4 },
|
||||||
|
{ dimension_id: 5, name: 'Technical Debt', description: 'Code maintenance and refactoring', order_index: 5 },
|
||||||
|
|
||||||
|
// DevOps KPIs
|
||||||
|
{ dimension_id: 6, name: 'Deployment Process', description: 'CI/CD and release management', order_index: 1 },
|
||||||
|
{ dimension_id: 6, name: 'Infrastructure', description: 'System reliability and monitoring', order_index: 2 },
|
||||||
|
{ dimension_id: 6, name: 'Automation', description: 'Process automation and tooling', order_index: 3 },
|
||||||
|
|
||||||
|
// Cyber KPIs
|
||||||
|
{ dimension_id: 7, name: 'Security Policies', description: 'Security frameworks and policies', order_index: 1 },
|
||||||
|
{ dimension_id: 7, name: 'Threat Management', description: 'Security monitoring and response', order_index: 2 },
|
||||||
|
{ dimension_id: 7, name: 'Compliance', description: 'Regulatory compliance and auditing', order_index: 3 },
|
||||||
|
{ dimension_id: 7, name: 'Data Protection', description: 'Data security and privacy', order_index: 4 },
|
||||||
|
{ dimension_id: 7, name: 'Security Training', description: 'Employee security awareness', order_index: 5 },
|
||||||
|
{ dimension_id: 7, name: 'Incident Response', description: 'Security incident handling', order_index: 6 },
|
||||||
|
|
||||||
|
// Finance KPIs
|
||||||
|
{ dimension_id: 8, name: 'Financial Health', description: 'Revenue, profit, and cash flow', order_index: 1 },
|
||||||
|
{ dimension_id: 8, name: 'Cost Management', description: 'Expense control and optimization', order_index: 2 },
|
||||||
|
{ dimension_id: 8, name: 'Financial Planning', description: 'Budgeting and forecasting', order_index: 3 },
|
||||||
|
{ dimension_id: 8, name: 'Investment Strategy', description: 'Capital allocation and ROI', order_index: 4 },
|
||||||
|
{ dimension_id: 8, name: 'Financial Controls', description: 'Accounting and audit processes', order_index: 5 },
|
||||||
|
{ dimension_id: 8, name: 'Risk Management', description: 'Financial risk assessment', order_index: 6 },
|
||||||
|
{ dimension_id: 8, name: 'Funding Strategy', description: 'Capital raising and debt management', order_index: 7 },
|
||||||
|
{ dimension_id: 8, name: 'Financial Reporting', description: 'Transparency and reporting standards', order_index: 8 },
|
||||||
|
{ dimension_id: 8, name: 'Tax Strategy', description: 'Tax planning and compliance', order_index: 9 },
|
||||||
|
{ dimension_id: 8, name: 'Treasury Management', description: 'Cash and liquidity management', order_index: 10 },
|
||||||
|
{ dimension_id: 8, name: 'Investor Relations', description: 'Stakeholder communication', order_index: 11 },
|
||||||
|
{ dimension_id: 8, name: 'Financial Technology', description: 'Fintech tools and systems', order_index: 12 },
|
||||||
|
{ dimension_id: 8, name: 'Procurement', description: 'Vendor management and purchasing', order_index: 13 },
|
||||||
|
|
||||||
|
// People KPIs
|
||||||
|
{ dimension_id: 9, name: 'Talent Acquisition', description: 'Recruitment and hiring processes', order_index: 1 },
|
||||||
|
{ dimension_id: 9, name: 'Employee Development', description: 'Training and career progression', order_index: 2 },
|
||||||
|
{ dimension_id: 9, name: 'Performance Management', description: 'Evaluation and feedback systems', order_index: 3 },
|
||||||
|
{ dimension_id: 9, name: 'Compensation & Benefits', description: 'Salary and benefits structure', order_index: 4 },
|
||||||
|
{ dimension_id: 9, name: 'Employee Engagement', description: 'Satisfaction and retention', order_index: 5 },
|
||||||
|
|
||||||
|
// Culture KPIs
|
||||||
|
{ dimension_id: 10, name: 'Company Values', description: 'Core values and principles', order_index: 1 },
|
||||||
|
{ dimension_id: 10, name: 'Work Environment', description: 'Workplace culture and atmosphere', order_index: 2 },
|
||||||
|
{ dimension_id: 10, name: 'Diversity & Inclusion', description: 'D&I initiatives and metrics', order_index: 3 }
|
||||||
|
];
|
||||||
|
|
||||||
|
kpis.forEach(kpi => {
|
||||||
|
db.run(
|
||||||
|
'INSERT INTO kpis (dimension_id, name, description, order_index) VALUES (?, ?, ?, ?)',
|
||||||
|
[kpi.dimension_id, kpi.name, kpi.description, kpi.order_index]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Insert check types
|
||||||
|
const checkTypes = [
|
||||||
|
{ name: 'text', data_type: 'text', options_json: null, validation_json: null },
|
||||||
|
{
|
||||||
|
name: 'dropdown',
|
||||||
|
data_type: 'string',
|
||||||
|
options_json: JSON.stringify(['Yes', 'Partially', 'No']),
|
||||||
|
validation_json: JSON.stringify({ required: true })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'number',
|
||||||
|
data_type: 'number',
|
||||||
|
options_json: null,
|
||||||
|
validation_json: JSON.stringify({ min: 0, max: Number.MAX_SAFE_INTEGER })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'percentage',
|
||||||
|
data_type: 'number',
|
||||||
|
options_json: null,
|
||||||
|
validation_json: JSON.stringify({ min: 0, max: 100 })
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
checkTypes.forEach(type => {
|
||||||
|
db.run(
|
||||||
|
'INSERT INTO check_types (name, data_type, options_json, validation_json) VALUES (?, ?, ?, ?)',
|
||||||
|
[type.name, type.data_type, type.options_json, type.validation_json]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Insert sample checks for demonstration
|
||||||
|
const sampleChecks = [
|
||||||
|
// Strategy checks
|
||||||
|
{ kpi_id: 1, question: 'Is the company vision clearly defined?', check_type: 'dropdown', current_value: null, expected_value: 'Yes', is_completed: true },
|
||||||
|
{ kpi_id: 1, question: 'Are strategic goals documented and communicated?', check_type: 'dropdown', current_value: null, expected_value: 'Yes', is_completed: false },
|
||||||
|
{ kpi_id: 1, question: 'Strategic alignment score (1-10)', check_type: 'number', expected_value: '8', current_value: '7', is_completed: false },
|
||||||
|
|
||||||
|
// Sales checks
|
||||||
|
{ kpi_id: 7, question: 'Monthly revenue growth rate', check_type: 'percentage', expected_value: '15', current_value: '12', is_completed: false },
|
||||||
|
{ kpi_id: 7, question: 'Sales conversion rate', check_type: 'percentage', expected_value: '25', current_value: '22', is_completed: false },
|
||||||
|
|
||||||
|
// Engineering checks
|
||||||
|
{ kpi_id: 18, question: 'Development methodology', check_type: 'dropdown', expected_value: 'Agile', current_value: 'Agile', is_completed: true },
|
||||||
|
{ kpi_id: 18, question: 'Average sprint velocity (story points)', check_type: 'number', expected_value: '40', current_value: '35', is_completed: false },
|
||||||
|
{ kpi_id: 19, question: 'Code coverage percentage', check_type: 'percentage', expected_value: '80', current_value: '75', is_completed: false }
|
||||||
|
];
|
||||||
|
|
||||||
|
sampleChecks.forEach(check => {
|
||||||
|
db.run(
|
||||||
|
'INSERT INTO checks (kpi_id, question, check_type, current_value, expected_value, is_completed) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
|
[check.kpi_id, check.question, check.check_type, check.current_value || null, check.expected_value || null, check.is_completed ? 1 : 0]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Database seeded successfully');
|
||||||
|
};
|
||||||
3
client/src/index.css
Normal file
3
client/src/index.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
10
client/src/main.tsx
Normal file
10
client/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
313
client/src/services/__tests__/dataService.test.ts
Normal file
313
client/src/services/__tests__/dataService.test.ts
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { DataService } from '../dataService';
|
||||||
|
import { Database } from '../../database/database';
|
||||||
|
|
||||||
|
// Mock database
|
||||||
|
const mockDb = {
|
||||||
|
exec: vi.fn(),
|
||||||
|
prepare: vi.fn(),
|
||||||
|
run: vi.fn(),
|
||||||
|
} as unknown as Database;
|
||||||
|
|
||||||
|
describe('DataService', () => {
|
||||||
|
let dataService: DataService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
dataService = new DataService(mockDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDimensions', () => {
|
||||||
|
it('should return dimensions ordered by order_index', () => {
|
||||||
|
const mockResult = [{
|
||||||
|
columns: ['id', 'name', 'icon', 'color', 'order_index', 'created_at'],
|
||||||
|
values: [
|
||||||
|
[1, 'Strategy', 'Briefcase', '#4F46E5', 1, '2023-01-01'],
|
||||||
|
[2, 'Engineering', 'Code', '#F59E0B', 2, '2023-01-01']
|
||||||
|
]
|
||||||
|
}];
|
||||||
|
|
||||||
|
(mockDb.exec as any).mockReturnValue(mockResult);
|
||||||
|
|
||||||
|
const result = dataService.getDimensions();
|
||||||
|
|
||||||
|
expect(mockDb.exec).toHaveBeenCalledWith('SELECT * FROM dimensions ORDER BY order_index');
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Strategy',
|
||||||
|
icon: 'Briefcase',
|
||||||
|
color: '#4F46E5',
|
||||||
|
order_index: 1,
|
||||||
|
created_at: '2023-01-01'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Engineering',
|
||||||
|
icon: 'Code',
|
||||||
|
color: '#F59E0B',
|
||||||
|
order_index: 2,
|
||||||
|
created_at: '2023-01-01'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when no dimensions exist', () => {
|
||||||
|
(mockDb.exec as any).mockReturnValue([]);
|
||||||
|
|
||||||
|
const result = dataService.getDimensions();
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database errors', () => {
|
||||||
|
(mockDb.exec as any).mockImplementation(() => {
|
||||||
|
throw new Error('Database error');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => dataService.getDimensions()).toThrow('Database error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDimensionsWithStats', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock getDimensions
|
||||||
|
vi.spyOn(dataService, 'getDimensions').mockReturnValue([
|
||||||
|
{ id: 1, name: 'Strategy', icon: 'Briefcase', color: '#4F46E5', order_index: 1 }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate stats correctly', () => {
|
||||||
|
// Mock KPI count query
|
||||||
|
(mockDb.exec as any)
|
||||||
|
.mockReturnValueOnce([{ values: [[3]] }]) // KPI count
|
||||||
|
.mockReturnValueOnce([{ values: [[10, 7]] }]); // total and completed checks
|
||||||
|
|
||||||
|
const result = dataService.getDimensionsWithStats();
|
||||||
|
|
||||||
|
expect(result).toEqual([{
|
||||||
|
dimension: { id: 1, name: 'Strategy', icon: 'Briefcase', color: '#4F46E5', order_index: 1 },
|
||||||
|
kpi_count: 3,
|
||||||
|
total_checks: 10,
|
||||||
|
completed_checks: 7,
|
||||||
|
progress: 70
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero checks correctly', () => {
|
||||||
|
(mockDb.exec as any)
|
||||||
|
.mockReturnValueOnce([{ values: [[2]] }]) // KPI count
|
||||||
|
.mockReturnValueOnce([{ values: [[0, 0]] }]); // total and completed checks
|
||||||
|
|
||||||
|
const result = dataService.getDimensionsWithStats();
|
||||||
|
|
||||||
|
expect(result[0].progress).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null completed checks', () => {
|
||||||
|
(mockDb.exec as any)
|
||||||
|
.mockReturnValueOnce([{ values: [[2]] }]) // KPI count
|
||||||
|
.mockReturnValueOnce([{ values: [[5, null]] }]); // total and completed checks
|
||||||
|
|
||||||
|
const result = dataService.getDimensionsWithStats();
|
||||||
|
|
||||||
|
expect(result[0].completed_checks).toBe(0);
|
||||||
|
expect(result[0].progress).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getKPIsByDimension', () => {
|
||||||
|
it('should return KPIs for a dimension ordered by order_index', () => {
|
||||||
|
const mockResult = [{
|
||||||
|
columns: ['id', 'dimension_id', 'name', 'description', 'order_index', 'created_at'],
|
||||||
|
values: [
|
||||||
|
[1, 1, 'Strategic Alignment', 'Vision clarity', 1, '2023-01-01'],
|
||||||
|
[2, 1, 'Market Position', 'Competitive analysis', 2, '2023-01-01']
|
||||||
|
]
|
||||||
|
}];
|
||||||
|
|
||||||
|
(mockDb.exec as any).mockReturnValue(mockResult);
|
||||||
|
|
||||||
|
const result = dataService.getKPIsByDimension(1);
|
||||||
|
|
||||||
|
expect(mockDb.exec).toHaveBeenCalledWith('SELECT * FROM kpis WHERE dimension_id = 1 ORDER BY order_index');
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].name).toBe('Strategic Alignment');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when no KPIs exist', () => {
|
||||||
|
(mockDb.exec as any).mockReturnValue([]);
|
||||||
|
|
||||||
|
const result = dataService.getKPIsByDimension(999);
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getChecksByKPI', () => {
|
||||||
|
it('should return checks for a KPI ordered by created_at', () => {
|
||||||
|
const mockResult = [{
|
||||||
|
columns: ['id', 'kpi_id', 'question', 'check_type', 'current_value', 'expected_value',
|
||||||
|
'reference_url', 'reference_file_id', 'comment', 'is_completed', 'created_at', 'updated_at'],
|
||||||
|
values: [
|
||||||
|
[1, 1, 'Is vision clear?', 'dropdown', 'Yes', 'Yes', null, null, 'Good', 1, '2023-01-01', '2023-01-01'],
|
||||||
|
[2, 1, 'Goals documented?', 'dropdown', 'No', 'Yes', null, null, 'Needs work', 0, '2023-01-02', '2023-01-02']
|
||||||
|
]
|
||||||
|
}];
|
||||||
|
|
||||||
|
(mockDb.exec as any).mockReturnValue(mockResult);
|
||||||
|
|
||||||
|
const result = dataService.getChecksByKPI(1);
|
||||||
|
|
||||||
|
expect(mockDb.exec).toHaveBeenCalledWith('SELECT * FROM checks WHERE kpi_id = 1 ORDER BY created_at');
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].is_completed).toBe(true);
|
||||||
|
expect(result[1].is_completed).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateCheck', () => {
|
||||||
|
it('should update check with escaped values', () => {
|
||||||
|
const updates = {
|
||||||
|
current_value: "Yes",
|
||||||
|
is_completed: true,
|
||||||
|
comment: "Test's comment"
|
||||||
|
};
|
||||||
|
|
||||||
|
dataService.updateCheck(1, updates);
|
||||||
|
|
||||||
|
expect(mockDb.exec).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("UPDATE checks SET")
|
||||||
|
);
|
||||||
|
expect(mockDb.exec).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("WHERE id = 1")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle boolean values correctly', () => {
|
||||||
|
const updates = { is_completed: true };
|
||||||
|
|
||||||
|
dataService.updateCheck(1, updates);
|
||||||
|
|
||||||
|
const sqlCall = (mockDb.exec as any).mock.calls[0][0];
|
||||||
|
expect(sqlCall).toContain('is_completed = 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should escape string values with quotes', () => {
|
||||||
|
const updates = { comment: "It's working" };
|
||||||
|
|
||||||
|
dataService.updateCheck(1, updates);
|
||||||
|
|
||||||
|
const sqlCall = (mockDb.exec as any).mock.calls[0][0];
|
||||||
|
expect(sqlCall).toContain("comment = 'It''s working'");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createCheck', () => {
|
||||||
|
it('should create new check with proper escaping', () => {
|
||||||
|
const checkData = {
|
||||||
|
kpi_id: 1,
|
||||||
|
question: 'Test question?',
|
||||||
|
check_type: 'text',
|
||||||
|
current_value: 'Test value',
|
||||||
|
expected_value: 'Expected value',
|
||||||
|
reference_url: 'https://example.com',
|
||||||
|
reference_file_id: undefined,
|
||||||
|
comment: 'Test comment',
|
||||||
|
is_completed: false
|
||||||
|
};
|
||||||
|
|
||||||
|
(mockDb.exec as any)
|
||||||
|
.mockReturnValueOnce(undefined) // INSERT
|
||||||
|
.mockReturnValueOnce([{ values: [[123]] }]); // SELECT last_insert_rowid()
|
||||||
|
|
||||||
|
const result = dataService.createCheck(checkData);
|
||||||
|
|
||||||
|
expect(mockDb.exec).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('INSERT INTO checks')
|
||||||
|
);
|
||||||
|
expect(result).toBe(123);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null values correctly', () => {
|
||||||
|
const checkData = {
|
||||||
|
kpi_id: 1,
|
||||||
|
question: 'Test question?',
|
||||||
|
check_type: 'text',
|
||||||
|
current_value: undefined,
|
||||||
|
expected_value: null,
|
||||||
|
reference_url: undefined,
|
||||||
|
reference_file_id: undefined,
|
||||||
|
comment: undefined,
|
||||||
|
is_completed: false
|
||||||
|
};
|
||||||
|
|
||||||
|
(mockDb.exec as any)
|
||||||
|
.mockReturnValueOnce(undefined)
|
||||||
|
.mockReturnValueOnce([{ values: [[456]] }]);
|
||||||
|
|
||||||
|
const result = dataService.createCheck(checkData as any);
|
||||||
|
|
||||||
|
const sqlCall = (mockDb.exec as any).mock.calls[0][0];
|
||||||
|
expect(sqlCall).toContain('NULL');
|
||||||
|
expect(result).toBe(456);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle boolean completion status', () => {
|
||||||
|
const checkData = {
|
||||||
|
kpi_id: 1,
|
||||||
|
question: 'Test?',
|
||||||
|
check_type: 'text',
|
||||||
|
is_completed: true
|
||||||
|
};
|
||||||
|
|
||||||
|
(mockDb.exec as any)
|
||||||
|
.mockReturnValueOnce(undefined)
|
||||||
|
.mockReturnValueOnce([{ values: [[789]] }]);
|
||||||
|
|
||||||
|
dataService.createCheck(checkData as any);
|
||||||
|
|
||||||
|
const sqlCall = (mockDb.exec as any).mock.calls[0][0];
|
||||||
|
expect(sqlCall).toContain(', 1)'); // boolean true as 1
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteCheck', () => {
|
||||||
|
it('should delete check by ID', () => {
|
||||||
|
dataService.deleteCheck(123);
|
||||||
|
|
||||||
|
expect(mockDb.exec).toHaveBeenCalledWith('DELETE FROM checks WHERE id = 123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database errors', () => {
|
||||||
|
(mockDb.exec as any).mockImplementation(() => {
|
||||||
|
throw new Error('Delete failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => dataService.deleteCheck(123)).toThrow('Delete failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases and error handling', () => {
|
||||||
|
it('should handle empty result sets gracefully', () => {
|
||||||
|
(mockDb.exec as any).mockReturnValue([]);
|
||||||
|
|
||||||
|
expect(() => dataService.getDimensions()).not.toThrow();
|
||||||
|
expect(() => dataService.getKPIsByDimension(1)).not.toThrow();
|
||||||
|
expect(() => dataService.getChecksByKPI(1)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle malformed result sets', () => {
|
||||||
|
(mockDb.exec as any).mockReturnValue([{ columns: [], values: [] }]);
|
||||||
|
|
||||||
|
const result = dataService.getDimensions();
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle undefined database responses', () => {
|
||||||
|
(mockDb.exec as any).mockReturnValue(undefined);
|
||||||
|
|
||||||
|
expect(() => dataService.getDimensions()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
175
client/src/services/dataService.ts
Normal file
175
client/src/services/dataService.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { Database } from '../database/database';
|
||||||
|
import { Dimension, KPI, Check, DimensionWithStats } from '../types';
|
||||||
|
|
||||||
|
export class DataService {
|
||||||
|
constructor(private db: Database) {}
|
||||||
|
|
||||||
|
getDimensions(): Dimension[] {
|
||||||
|
const result = this.db.exec('SELECT * FROM dimensions ORDER BY order_index');
|
||||||
|
const dimensions: Dimension[] = [];
|
||||||
|
|
||||||
|
if (result.length > 0) {
|
||||||
|
const columns = result[0].columns;
|
||||||
|
const values = result[0].values;
|
||||||
|
|
||||||
|
values.forEach((row: any[]) => {
|
||||||
|
const dimension: any = {};
|
||||||
|
columns.forEach((column: string, index: number) => {
|
||||||
|
dimension[column] = row[index];
|
||||||
|
});
|
||||||
|
|
||||||
|
dimensions.push({
|
||||||
|
id: dimension.id,
|
||||||
|
name: dimension.name,
|
||||||
|
icon: dimension.icon,
|
||||||
|
color: dimension.color,
|
||||||
|
order_index: dimension.order_index,
|
||||||
|
created_at: dimension.created_at
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return dimensions;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDimensionsWithStats(): DimensionWithStats[] {
|
||||||
|
const dimensions = this.getDimensions();
|
||||||
|
|
||||||
|
return dimensions.map(dimension => {
|
||||||
|
// Get KPI count
|
||||||
|
const kpiResult = this.db.exec('SELECT COUNT(*) as count FROM kpis WHERE dimension_id = ' + dimension.id);
|
||||||
|
const kpi_count = kpiResult.length > 0 ? kpiResult[0].values[0][0] as number : 0;
|
||||||
|
|
||||||
|
// Get total checks and completed checks
|
||||||
|
const checksResult = this.db.exec(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_checks,
|
||||||
|
SUM(CASE WHEN is_completed = 1 THEN 1 ELSE 0 END) as completed_checks
|
||||||
|
FROM checks c
|
||||||
|
JOIN kpis k ON c.kpi_id = k.id
|
||||||
|
WHERE k.dimension_id = ${dimension.id}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const total_checks = checksResult.length > 0 ? (checksResult[0].values[0][0] as number || 0) : 0;
|
||||||
|
const completed_checks = checksResult.length > 0 ? (checksResult[0].values[0][1] as number || 0) : 0;
|
||||||
|
|
||||||
|
const progress = total_checks > 0 ? (completed_checks / total_checks) * 100 : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
dimension,
|
||||||
|
kpi_count,
|
||||||
|
total_checks,
|
||||||
|
completed_checks,
|
||||||
|
progress
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getKPIsByDimension(dimensionId: number): KPI[] {
|
||||||
|
const result = this.db.exec(`SELECT * FROM kpis WHERE dimension_id = ${dimensionId} ORDER BY order_index`);
|
||||||
|
const kpis: KPI[] = [];
|
||||||
|
|
||||||
|
if (result.length > 0) {
|
||||||
|
const columns = result[0].columns;
|
||||||
|
const values = result[0].values;
|
||||||
|
|
||||||
|
values.forEach((row: any[]) => {
|
||||||
|
const kpi: any = {};
|
||||||
|
columns.forEach((column: string, index: number) => {
|
||||||
|
kpi[column] = row[index];
|
||||||
|
});
|
||||||
|
|
||||||
|
kpis.push({
|
||||||
|
id: kpi.id,
|
||||||
|
dimension_id: kpi.dimension_id,
|
||||||
|
name: kpi.name,
|
||||||
|
description: kpi.description,
|
||||||
|
order_index: kpi.order_index,
|
||||||
|
created_at: kpi.created_at
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return kpis;
|
||||||
|
}
|
||||||
|
|
||||||
|
getChecksByKPI(kpiId: number): Check[] {
|
||||||
|
const result = this.db.exec(`SELECT * FROM checks WHERE kpi_id = ${kpiId} ORDER BY created_at`);
|
||||||
|
const checks: Check[] = [];
|
||||||
|
|
||||||
|
if (result.length > 0) {
|
||||||
|
const columns = result[0].columns;
|
||||||
|
const values = result[0].values;
|
||||||
|
|
||||||
|
values.forEach((row: any[]) => {
|
||||||
|
const check: any = {};
|
||||||
|
columns.forEach((column: string, index: number) => {
|
||||||
|
check[column] = row[index];
|
||||||
|
});
|
||||||
|
|
||||||
|
checks.push({
|
||||||
|
id: check.id,
|
||||||
|
kpi_id: check.kpi_id,
|
||||||
|
question: check.question,
|
||||||
|
check_type: check.check_type,
|
||||||
|
current_value: check.current_value,
|
||||||
|
expected_value: check.expected_value,
|
||||||
|
reference_url: check.reference_url,
|
||||||
|
reference_file_id: check.reference_file_id,
|
||||||
|
comment: check.comment,
|
||||||
|
is_completed: Boolean(check.is_completed),
|
||||||
|
created_at: check.created_at,
|
||||||
|
updated_at: check.updated_at
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return checks;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCheck(checkId: number, updates: Partial<Check>): void {
|
||||||
|
const fields = Object.keys(updates).filter(key => key !== 'id');
|
||||||
|
const values = fields.map(key => {
|
||||||
|
const value = updates[key as keyof Check];
|
||||||
|
if (typeof value === 'boolean') return value ? 1 : 0;
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const setClause = fields.map(field => `${field} = ?`).join(', ');
|
||||||
|
const sql = `UPDATE checks SET ${setClause}, updated_at = CURRENT_TIMESTAMP WHERE id = ${checkId}`;
|
||||||
|
|
||||||
|
// For sql.js, we need to use exec with direct values
|
||||||
|
const finalSql = sql.replace(/\?/g, () => {
|
||||||
|
const value = values.shift();
|
||||||
|
return typeof value === 'string' ? `'${value.replace(/'/g, "''")}'` : String(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.db.exec(finalSql);
|
||||||
|
}
|
||||||
|
|
||||||
|
createCheck(check: Omit<Check, 'id' | 'created_at' | 'updated_at'>): number {
|
||||||
|
const escapeString = (str: string | undefined | null) => {
|
||||||
|
if (!str) return 'NULL';
|
||||||
|
return `'${str.replace(/'/g, "''")}'`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
INSERT INTO checks (kpi_id, question, check_type, current_value, expected_value,
|
||||||
|
reference_url, reference_file_id, comment, is_completed)
|
||||||
|
VALUES (${check.kpi_id}, ${escapeString(check.question)}, ${escapeString(check.check_type)},
|
||||||
|
${escapeString(check.current_value)}, ${escapeString(check.expected_value)},
|
||||||
|
${escapeString(check.reference_url)}, ${check.reference_file_id || 'NULL'},
|
||||||
|
${escapeString(check.comment)}, ${check.is_completed ? 1 : 0})
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.db.exec(sql);
|
||||||
|
|
||||||
|
// Return the last inserted row ID
|
||||||
|
const result = this.db.exec('SELECT last_insert_rowid() as id');
|
||||||
|
return result[0].values[0][0] as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCheck(checkId: number): void {
|
||||||
|
this.db.exec(`DELETE FROM checks WHERE id = ${checkId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
224
client/src/test/README.md
Normal file
224
client/src/test/README.md
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
# Testing Documentation
|
||||||
|
|
||||||
|
This directory contains comprehensive automated tests for the Due Diligence Tracking System.
|
||||||
|
|
||||||
|
## Test Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/test/
|
||||||
|
├── setup.ts # Test environment setup
|
||||||
|
├── integration/ # Integration tests
|
||||||
|
│ └── database.test.ts # Database operations and data integrity
|
||||||
|
├── e2e/ # End-to-end tests
|
||||||
|
│ ├── dashboard.spec.ts # Dashboard functionality
|
||||||
|
│ ├── check-management.spec.ts # Check CRUD operations
|
||||||
|
│ ├── export-functionality.spec.ts # Data export features
|
||||||
|
│ └── performance.spec.ts # Performance and Core Web Vitals
|
||||||
|
└── performance/ # Performance and load tests
|
||||||
|
└── load.test.ts # Load testing and performance benchmarks
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component Tests
|
||||||
|
|
||||||
|
Component tests are co-located with their respective components:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/components/
|
||||||
|
├── __tests__/
|
||||||
|
│ ├── ProgressBar.test.tsx
|
||||||
|
│ ├── CheckForm.test.tsx
|
||||||
|
│ ├── CheckItem.test.tsx
|
||||||
|
│ └── DimensionCard.test.tsx
|
||||||
|
└── ...
|
||||||
|
|
||||||
|
src/utils/
|
||||||
|
├── __tests__/
|
||||||
|
│ ├── icons.test.ts
|
||||||
|
│ └── exportUtils.test.ts
|
||||||
|
└── ...
|
||||||
|
|
||||||
|
src/services/
|
||||||
|
├── __tests__/
|
||||||
|
│ └── dataService.test.ts
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Categories
|
||||||
|
|
||||||
|
### 1. Unit Tests
|
||||||
|
- **Utility Functions**: Icon mapping, export utilities
|
||||||
|
- **Data Service**: Database operations, CRUD functionality
|
||||||
|
- **Components**: Individual React component behavior
|
||||||
|
|
||||||
|
### 2. Component Tests
|
||||||
|
- **Rendering**: Component output and prop handling
|
||||||
|
- **Interactions**: User events and state changes
|
||||||
|
- **Form Validation**: Input validation and error handling
|
||||||
|
- **Accessibility**: ARIA labels, keyboard navigation
|
||||||
|
|
||||||
|
### 3. Integration Tests
|
||||||
|
- **Database Operations**: Schema creation, data seeding, referential integrity
|
||||||
|
- **End-to-End Workflows**: Complete user journeys
|
||||||
|
- **Error Handling**: Graceful degradation and recovery
|
||||||
|
|
||||||
|
### 4. E2E Tests
|
||||||
|
- **User Workflows**: Complete application usage scenarios
|
||||||
|
- **Cross-Browser**: Functionality across different browsers
|
||||||
|
- **Responsive Design**: Mobile, tablet, and desktop layouts
|
||||||
|
- **Performance**: Core Web Vitals and load times
|
||||||
|
|
||||||
|
### 5. Performance Tests
|
||||||
|
- **Load Testing**: Large dataset handling
|
||||||
|
- **Memory Usage**: Memory leak detection and optimization
|
||||||
|
- **Stress Testing**: Rapid successive operations
|
||||||
|
- **Browser Constraints**: Simulated resource limitations
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
### All Tests
|
||||||
|
```bash
|
||||||
|
npm test # Run all tests in watch mode
|
||||||
|
npm run test:run # Run all tests once
|
||||||
|
npm run test:coverage # Run tests with coverage report
|
||||||
|
```
|
||||||
|
|
||||||
|
### Unit and Component Tests
|
||||||
|
```bash
|
||||||
|
npm test -- src/utils # Run utility function tests
|
||||||
|
npm test -- src/components # Run component tests
|
||||||
|
npm test -- src/services # Run service tests
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
```bash
|
||||||
|
npm test -- src/test/integration
|
||||||
|
```
|
||||||
|
|
||||||
|
### End-to-End Tests
|
||||||
|
```bash
|
||||||
|
npm run test:e2e # Run E2E tests
|
||||||
|
npm run test:e2e:ui # Run E2E tests with UI
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance Tests
|
||||||
|
```bash
|
||||||
|
npm test -- src/test/performance
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test UI
|
||||||
|
```bash
|
||||||
|
npm run test:ui # Open Vitest UI
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Coverage Goals
|
||||||
|
|
||||||
|
- **Statements**: > 90%
|
||||||
|
- **Branches**: > 85%
|
||||||
|
- **Functions**: > 90%
|
||||||
|
- **Lines**: > 90%
|
||||||
|
|
||||||
|
## Performance Benchmarks
|
||||||
|
|
||||||
|
### Load Times
|
||||||
|
- **Dashboard Load**: < 3 seconds
|
||||||
|
- **Dimension Expansion**: < 2 seconds total
|
||||||
|
- **Form Submissions**: < 500ms each
|
||||||
|
- **Data Export**: < 2 seconds
|
||||||
|
|
||||||
|
### Memory Usage
|
||||||
|
- **Memory Leaks**: < 10MB increase over 1000 operations
|
||||||
|
- **Large Datasets**: Handle 10,000+ items efficiently
|
||||||
|
|
||||||
|
### Core Web Vitals
|
||||||
|
- **LCP (Largest Contentful Paint)**: < 2.5 seconds
|
||||||
|
- **FID (First Input Delay)**: < 100ms
|
||||||
|
- **CLS (Cumulative Layout Shift)**: < 0.1
|
||||||
|
|
||||||
|
## Test Data
|
||||||
|
|
||||||
|
### Mock Data
|
||||||
|
Tests use comprehensive mock data including:
|
||||||
|
- 10 dimensions (Strategy, Sales, Product, etc.)
|
||||||
|
- 50+ KPIs across all dimensions
|
||||||
|
- Various check types (text, dropdown, number, percentage)
|
||||||
|
- Sample checks with different completion states
|
||||||
|
|
||||||
|
### Test Scenarios
|
||||||
|
- **Happy Path**: Normal user workflows
|
||||||
|
- **Edge Cases**: Empty states, invalid inputs, network errors
|
||||||
|
- **Error Conditions**: Database failures, validation errors
|
||||||
|
- **Performance**: Large datasets, rapid interactions
|
||||||
|
|
||||||
|
## Continuous Integration
|
||||||
|
|
||||||
|
Tests are designed to run in CI environments:
|
||||||
|
- **Headless Mode**: E2E tests run without browser UI
|
||||||
|
- **Parallel Execution**: Tests run concurrently for speed
|
||||||
|
- **Cross-Browser**: Tests validate across Chrome, Firefox, Safari
|
||||||
|
- **Performance Monitoring**: Automated performance regression detection
|
||||||
|
|
||||||
|
## Debugging Tests
|
||||||
|
|
||||||
|
### Debug Unit Tests
|
||||||
|
```bash
|
||||||
|
npm test -- --reporter=verbose src/path/to/test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debug E2E Tests
|
||||||
|
```bash
|
||||||
|
npm run test:e2e -- --debug
|
||||||
|
npm run test:e2e:ui # Visual debugging
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Artifacts
|
||||||
|
- **Screenshots**: Automatically captured on E2E test failures
|
||||||
|
- **Videos**: Full test run recordings for debugging
|
||||||
|
- **Traces**: Detailed execution traces for analysis
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Test Writing
|
||||||
|
- **Descriptive Names**: Clear test descriptions
|
||||||
|
- **Arrange-Act-Assert**: Structured test organization
|
||||||
|
- **Independent Tests**: No test dependencies
|
||||||
|
- **Realistic Data**: Use production-like test data
|
||||||
|
|
||||||
|
### Mock Strategy
|
||||||
|
- **Minimal Mocking**: Mock only external dependencies
|
||||||
|
- **Behavior Verification**: Test behavior, not implementation
|
||||||
|
- **Data Isolation**: Each test uses isolated data
|
||||||
|
|
||||||
|
### Performance Testing
|
||||||
|
- **Baseline Metrics**: Establish performance baselines
|
||||||
|
- **Regular Monitoring**: Continuous performance tracking
|
||||||
|
- **Real-World Scenarios**: Test with realistic usage patterns
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
#### Tests Timing Out
|
||||||
|
- Increase timeout values in test configuration
|
||||||
|
- Check for infinite loops or unresolved promises
|
||||||
|
- Verify async/await usage
|
||||||
|
|
||||||
|
#### Flaky Tests
|
||||||
|
- Add proper wait conditions
|
||||||
|
- Use deterministic test data
|
||||||
|
- Avoid time-based assertions
|
||||||
|
|
||||||
|
#### Memory Issues
|
||||||
|
- Clear mocks between tests
|
||||||
|
- Avoid memory leaks in test setup
|
||||||
|
- Use `beforeEach` and `afterEach` properly
|
||||||
|
|
||||||
|
#### E2E Test Failures
|
||||||
|
- Check browser compatibility
|
||||||
|
- Verify element selectors
|
||||||
|
- Add explicit waits for dynamic content
|
||||||
|
|
||||||
|
### Getting Help
|
||||||
|
- Check test logs for detailed error messages
|
||||||
|
- Use browser developer tools for E2E debugging
|
||||||
|
- Review test artifacts (screenshots, videos)
|
||||||
|
- Consult framework documentation (Vitest, Playwright)
|
||||||
217
client/src/test/e2e/check-management.spec.ts
Normal file
217
client/src/test/e2e/check-management.spec.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Check Management E2E Tests', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Wait for dashboard to load and expand Strategy dimension
|
||||||
|
await expect(page.getByText('Due Diligence Tracking System')).toBeVisible();
|
||||||
|
await page.locator('button:has-text("Strategy")').click();
|
||||||
|
await expect(page.getByText('Strategic Alignment')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should create a new check', async ({ page }) => {
|
||||||
|
// Click "Add Check" button for the first KPI
|
||||||
|
await page.getByRole('button', { name: /add check/i }).first().click();
|
||||||
|
|
||||||
|
// Fill out the check form
|
||||||
|
await page.getByLabel(/question\/statement/i).fill('Test new check question?');
|
||||||
|
await page.getByLabel(/check type/i).selectOption('dropdown');
|
||||||
|
await page.getByLabel(/current value/i).selectOption('Yes');
|
||||||
|
await page.getByLabel(/expected value/i).selectOption('Yes');
|
||||||
|
await page.getByLabel(/reference url/i).fill('https://example.com/test');
|
||||||
|
await page.getByLabel(/comment/i).fill('This is a test check');
|
||||||
|
|
||||||
|
// Mark as completed
|
||||||
|
await page.getByLabel(/mark as completed/i).check();
|
||||||
|
|
||||||
|
// Submit the form
|
||||||
|
await page.getByRole('button', { name: /create check/i }).click();
|
||||||
|
|
||||||
|
// Verify the check was created
|
||||||
|
await expect(page.getByText('Test new check question?')).toBeVisible();
|
||||||
|
await expect(page.getByText('This is a test check')).toBeVisible();
|
||||||
|
|
||||||
|
// Verify the completion checkbox is checked
|
||||||
|
const newCheckbox = page.locator('input[type="checkbox"]').last();
|
||||||
|
await expect(newCheckbox).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should edit an existing check', async ({ page }) => {
|
||||||
|
// Find an existing check and click edit
|
||||||
|
const existingCheck = page.locator('div:has-text("Is the company vision clearly defined?")').first();
|
||||||
|
await existingCheck.getByTitle(/edit check/i).click();
|
||||||
|
|
||||||
|
// Modify the form
|
||||||
|
await page.getByLabel(/comment/i).clear();
|
||||||
|
await page.getByLabel(/comment/i).fill('Updated comment for this check');
|
||||||
|
await page.getByLabel(/current value/i).selectOption('Partially');
|
||||||
|
|
||||||
|
// Submit the update
|
||||||
|
await page.getByRole('button', { name: /update check/i }).click();
|
||||||
|
|
||||||
|
// Verify the changes
|
||||||
|
await expect(page.getByText('Updated comment for this check')).toBeVisible();
|
||||||
|
await expect(page.getByText('Partially')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should delete a check with confirmation', async ({ page }) => {
|
||||||
|
// Create a check first to delete
|
||||||
|
await page.getByRole('button', { name: /add check/i }).first().click();
|
||||||
|
await page.getByLabel(/question\/statement/i).fill('Check to be deleted');
|
||||||
|
await page.getByRole('button', { name: /create check/i }).click();
|
||||||
|
|
||||||
|
// Wait for the check to appear
|
||||||
|
await expect(page.getByText('Check to be deleted')).toBeVisible();
|
||||||
|
|
||||||
|
// Find the delete button for this check
|
||||||
|
const checkToDelete = page.locator('div:has-text("Check to be deleted")');
|
||||||
|
const deleteButton = checkToDelete.getByTitle(/delete check/i);
|
||||||
|
|
||||||
|
// First click - should show confirmation
|
||||||
|
await deleteButton.click();
|
||||||
|
await expect(checkToDelete.getByTitle(/click again to confirm deletion/i)).toBeVisible();
|
||||||
|
|
||||||
|
// Second click - should delete
|
||||||
|
await deleteButton.click();
|
||||||
|
|
||||||
|
// Verify the check is deleted
|
||||||
|
await expect(page.getByText('Check to be deleted')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should toggle check completion status', async ({ page }) => {
|
||||||
|
// Find an incomplete check
|
||||||
|
const incompleteCheck = page.locator('div:has-text("Are strategic goals documented?")').first();
|
||||||
|
const checkbox = incompleteCheck.locator('input[type="checkbox"]');
|
||||||
|
|
||||||
|
// Initially should be unchecked
|
||||||
|
await expect(checkbox).not.toBeChecked();
|
||||||
|
|
||||||
|
// Toggle to complete
|
||||||
|
await checkbox.check();
|
||||||
|
|
||||||
|
// Should now be checked and styled as completed
|
||||||
|
await expect(checkbox).toBeChecked();
|
||||||
|
|
||||||
|
// The check should have completion styling
|
||||||
|
await expect(incompleteCheck.locator('h4')).toHaveClass(/line-through/);
|
||||||
|
|
||||||
|
// Toggle back to incomplete
|
||||||
|
await checkbox.uncheck();
|
||||||
|
await expect(checkbox).not.toBeChecked();
|
||||||
|
await expect(incompleteCheck.locator('h4')).not.toHaveClass(/line-through/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle different check types correctly', async ({ page }) => {
|
||||||
|
// Test text check type
|
||||||
|
await page.getByRole('button', { name: /add check/i }).first().click();
|
||||||
|
await page.getByLabel(/question\/statement/i).fill('Text check test');
|
||||||
|
await page.getByLabel(/check type/i).selectOption('text');
|
||||||
|
await page.getByLabel(/current value/i).fill('Free text value');
|
||||||
|
await page.getByRole('button', { name: /create check/i }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('Free text value')).toBeVisible();
|
||||||
|
|
||||||
|
// Test number check type
|
||||||
|
await page.getByRole('button', { name: /add check/i }).first().click();
|
||||||
|
await page.getByLabel(/question\/statement/i).fill('Number check test');
|
||||||
|
await page.getByLabel(/check type/i).selectOption('number');
|
||||||
|
await page.getByLabel(/current value/i).fill('42');
|
||||||
|
await page.getByRole('button', { name: /create check/i }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('42')).toBeVisible();
|
||||||
|
|
||||||
|
// Test percentage check type
|
||||||
|
await page.getByRole('button', { name: /add check/i }).first().click();
|
||||||
|
await page.getByLabel(/question\/statement/i).fill('Percentage check test');
|
||||||
|
await page.getByLabel(/check type/i).selectOption('percentage');
|
||||||
|
await page.getByLabel(/current value/i).fill('75');
|
||||||
|
await page.getByRole('button', { name: /create check/i }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('75%')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should cancel check creation', async ({ page }) => {
|
||||||
|
// Start creating a check
|
||||||
|
await page.getByRole('button', { name: /add check/i }).first().click();
|
||||||
|
await page.getByLabel(/question\/statement/i).fill('This should be cancelled');
|
||||||
|
|
||||||
|
// Cancel the form
|
||||||
|
await page.getByRole('button', { name: /cancel/i }).click();
|
||||||
|
|
||||||
|
// Form should disappear and check should not be created
|
||||||
|
await expect(page.getByLabel(/question\/statement/i)).not.toBeVisible();
|
||||||
|
await expect(page.getByText('This should be cancelled')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle form validation', async ({ page }) => {
|
||||||
|
// Try to submit empty form
|
||||||
|
await page.getByRole('button', { name: /add check/i }).first().click();
|
||||||
|
await page.getByRole('button', { name: /create check/i }).click();
|
||||||
|
|
||||||
|
// Form should not submit (HTML5 validation will prevent it)
|
||||||
|
// The form should still be visible
|
||||||
|
await expect(page.getByLabel(/question\/statement/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should update progress when checks are completed', async ({ page }) => {
|
||||||
|
// Get initial progress
|
||||||
|
const progressElement = page.locator('text=/\\d+% progress/').first();
|
||||||
|
const initialProgress = await progressElement.textContent();
|
||||||
|
|
||||||
|
// Complete an incomplete check
|
||||||
|
const incompleteCheck = page.locator('input[type="checkbox"]:not(:checked)').first();
|
||||||
|
await incompleteCheck.check();
|
||||||
|
|
||||||
|
// Progress should update
|
||||||
|
await expect(progressElement).not.toHaveText(initialProgress || '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle external reference links', async ({ page }) => {
|
||||||
|
// Find a check with a reference URL
|
||||||
|
const checkWithReference = page.locator('div:has-text("Is the company vision clearly defined?")');
|
||||||
|
const referenceLink = checkWithReference.getByRole('link', { name: /reference/i });
|
||||||
|
|
||||||
|
if (await referenceLink.isVisible()) {
|
||||||
|
// Verify link properties
|
||||||
|
await expect(referenceLink).toHaveAttribute('target', '_blank');
|
||||||
|
await expect(referenceLink).toHaveAttribute('rel', 'noopener noreferrer');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should maintain state across dimension toggles', async ({ page }) => {
|
||||||
|
// Create a check
|
||||||
|
await page.getByRole('button', { name: /add check/i }).first().click();
|
||||||
|
await page.getByLabel(/question\/statement/i).fill('State persistence test');
|
||||||
|
await page.getByRole('button', { name: /create check/i }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('State persistence test')).toBeVisible();
|
||||||
|
|
||||||
|
// Collapse the dimension
|
||||||
|
await page.locator('button:has-text("Strategy")').click();
|
||||||
|
|
||||||
|
// Expand it again
|
||||||
|
await page.locator('button:has-text("Strategy")').click();
|
||||||
|
|
||||||
|
// Check should still be there
|
||||||
|
await expect(page.getByText('State persistence test')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show empty state when no checks exist', async ({ page }) => {
|
||||||
|
// Navigate to a KPI with no checks (if any exist)
|
||||||
|
// This test assumes we can find a KPI without checks
|
||||||
|
// If all KPIs have checks, this test would need to delete all checks first
|
||||||
|
|
||||||
|
const addCheckButtons = page.getByRole('button', { name: /add check/i });
|
||||||
|
const count = await addCheckButtons.count();
|
||||||
|
|
||||||
|
// At least one KPI should be visible
|
||||||
|
expect(count).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Check for empty state message if it exists
|
||||||
|
const emptyMessage = page.getByText('No checks yet. Click "Add Check" to get started.');
|
||||||
|
if (await emptyMessage.isVisible()) {
|
||||||
|
await expect(emptyMessage).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
136
client/src/test/e2e/dashboard.spec.ts
Normal file
136
client/src/test/e2e/dashboard.spec.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Dashboard E2E Tests', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Wait for the dashboard to load
|
||||||
|
await expect(page.getByText('Due Diligence Tracking System')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should load and display dashboard with dimensions', async ({ page }) => {
|
||||||
|
// Check main heading
|
||||||
|
await expect(page.getByRole('heading', { name: 'Due Diligence Tracking System' })).toBeVisible();
|
||||||
|
|
||||||
|
// Check export buttons
|
||||||
|
await expect(page.getByRole('button', { name: /export json/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /export csv/i })).toBeVisible();
|
||||||
|
|
||||||
|
// Check dimension cards are loaded
|
||||||
|
await expect(page.getByText('Strategy')).toBeVisible();
|
||||||
|
await expect(page.getByText('Engineering')).toBeVisible();
|
||||||
|
await expect(page.getByText('Sales')).toBeVisible();
|
||||||
|
|
||||||
|
// Check progress indicators
|
||||||
|
await expect(page.locator('text=/\\d+%/')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should expand and collapse dimension cards', async ({ page }) => {
|
||||||
|
const strategyCard = page.locator('button:has-text("Strategy")');
|
||||||
|
|
||||||
|
// Initially collapsed - should show right chevron
|
||||||
|
await expect(strategyCard.locator('[data-lucide="chevron-right"]')).toBeVisible();
|
||||||
|
|
||||||
|
// Expand the strategy dimension
|
||||||
|
await strategyCard.click();
|
||||||
|
|
||||||
|
// Should now show down chevron and expanded content
|
||||||
|
await expect(strategyCard.locator('[data-lucide="chevron-down"]')).toBeVisible();
|
||||||
|
await expect(page.getByText('Key Performance Indicators')).toBeVisible();
|
||||||
|
await expect(page.getByText('Open Items')).toBeVisible();
|
||||||
|
await expect(page.getByText('Completed Items')).toBeVisible();
|
||||||
|
|
||||||
|
// Collapse again
|
||||||
|
await strategyCard.click();
|
||||||
|
await expect(strategyCard.locator('[data-lucide="chevron-right"]')).toBeVisible();
|
||||||
|
await expect(page.getByText('Key Performance Indicators')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display KPIs when dimension is expanded', async ({ page }) => {
|
||||||
|
// Expand Strategy dimension
|
||||||
|
await page.locator('button:has-text("Strategy")').click();
|
||||||
|
|
||||||
|
// Should show KPIs
|
||||||
|
await expect(page.getByText('Strategic Alignment')).toBeVisible();
|
||||||
|
await expect(page.getByText('Market Position')).toBeVisible();
|
||||||
|
|
||||||
|
// Each KPI should have an "Add Check" button
|
||||||
|
await expect(page.getByRole('button', { name: /add check/i }).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show existing checks in KPIs', async ({ page }) => {
|
||||||
|
// Expand Strategy dimension
|
||||||
|
await page.locator('button:has-text("Strategy")').click();
|
||||||
|
|
||||||
|
// Look for existing checks (from seed data)
|
||||||
|
await expect(page.getByText('Is the company vision clearly defined?')).toBeVisible();
|
||||||
|
|
||||||
|
// Check that progress indicators are shown
|
||||||
|
await expect(page.locator('text=/\\d+ checks total/')).toBeVisible();
|
||||||
|
await expect(page.locator('text=/\\d+ completed/')).toBeVisible();
|
||||||
|
await expect(page.locator('text=/\\d+% progress/')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle multiple expanded dimensions', async ({ page }) => {
|
||||||
|
// Expand multiple dimensions
|
||||||
|
await page.locator('button:has-text("Strategy")').click();
|
||||||
|
await page.locator('button:has-text("Engineering")').click();
|
||||||
|
|
||||||
|
// Both should be expanded
|
||||||
|
await expect(page.getByText('Strategic Alignment')).toBeVisible();
|
||||||
|
await expect(page.getByText('Development Practices')).toBeVisible();
|
||||||
|
|
||||||
|
// Both should show KPI content
|
||||||
|
const strategyKpis = page.locator(':text("Strategic Alignment")');
|
||||||
|
const engineeringKpis = page.locator(':text("Development Practices")');
|
||||||
|
|
||||||
|
await expect(strategyKpis).toBeVisible();
|
||||||
|
await expect(engineeringKpis).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display loading state initially', async ({ page }) => {
|
||||||
|
// Navigate to a fresh page to catch loading state
|
||||||
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
|
|
||||||
|
// Should show loading initially (may be very brief)
|
||||||
|
// Note: This test might be flaky due to fast loading times
|
||||||
|
const loadingText = page.getByText('Loading...');
|
||||||
|
|
||||||
|
// Either loading is visible or content loads immediately
|
||||||
|
try {
|
||||||
|
await expect(loadingText).toBeVisible({ timeout: 100 });
|
||||||
|
} catch {
|
||||||
|
// Loading was too fast, check that content is loaded
|
||||||
|
await expect(page.getByText('Due Diligence Tracking System')).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should maintain responsive design on different screen sizes', async ({ page }) => {
|
||||||
|
// Test mobile viewport
|
||||||
|
await page.setViewportSize({ width: 375, height: 667 });
|
||||||
|
await expect(page.getByText('Due Diligence Tracking System')).toBeVisible();
|
||||||
|
|
||||||
|
// Test tablet viewport
|
||||||
|
await page.setViewportSize({ width: 768, height: 1024 });
|
||||||
|
await expect(page.getByText('Due Diligence Tracking System')).toBeVisible();
|
||||||
|
|
||||||
|
// Test desktop viewport
|
||||||
|
await page.setViewportSize({ width: 1920, height: 1080 });
|
||||||
|
await expect(page.getByText('Due Diligence Tracking System')).toBeVisible();
|
||||||
|
|
||||||
|
// Cards should still be clickable on all sizes
|
||||||
|
await page.locator('button:has-text("Strategy")').click();
|
||||||
|
await expect(page.getByText('Strategic Alignment')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle error states gracefully', async ({ page }) => {
|
||||||
|
// Intercept and mock a database error
|
||||||
|
await page.route('**/*', route => {
|
||||||
|
// Let the request through normally (since we're using client-side SQLite)
|
||||||
|
route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The application should still load
|
||||||
|
await expect(page.getByText('Due Diligence Tracking System')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
271
client/src/test/e2e/export-functionality.spec.ts
Normal file
271
client/src/test/e2e/export-functionality.spec.ts
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
test.describe('Export Functionality E2E Tests', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.getByText('Due Diligence Tracking System')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should export data to JSON format', async ({ page }) => {
|
||||||
|
// Set up download handling
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
|
||||||
|
// Click JSON export button
|
||||||
|
await page.getByRole('button', { name: /export json/i }).click();
|
||||||
|
|
||||||
|
// Wait for download
|
||||||
|
const download = await downloadPromise;
|
||||||
|
|
||||||
|
// Verify download properties
|
||||||
|
expect(download.suggestedFilename()).toMatch(/due-diligence-export-\d{4}-\d{2}-\d{2}\.json/);
|
||||||
|
|
||||||
|
// Save and verify file content
|
||||||
|
const downloadPath = path.join(__dirname, 'downloads', download.suggestedFilename());
|
||||||
|
await download.saveAs(downloadPath);
|
||||||
|
|
||||||
|
// Read and parse the JSON file
|
||||||
|
const fileContent = await fs.readFile(downloadPath, 'utf8');
|
||||||
|
const exportData = JSON.parse(fileContent);
|
||||||
|
|
||||||
|
// Verify JSON structure
|
||||||
|
expect(exportData).toHaveProperty('dimensions');
|
||||||
|
expect(exportData).toHaveProperty('exportedAt');
|
||||||
|
expect(Array.isArray(exportData.dimensions)).toBe(true);
|
||||||
|
expect(exportData.dimensions.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Verify dimension structure
|
||||||
|
const firstDimension = exportData.dimensions[0];
|
||||||
|
expect(firstDimension).toHaveProperty('id');
|
||||||
|
expect(firstDimension).toHaveProperty('name');
|
||||||
|
expect(firstDimension).toHaveProperty('kpis');
|
||||||
|
expect(Array.isArray(firstDimension.kpis)).toBe(true);
|
||||||
|
|
||||||
|
// Verify KPI structure if KPIs exist
|
||||||
|
if (firstDimension.kpis.length > 0) {
|
||||||
|
const firstKPI = firstDimension.kpis[0];
|
||||||
|
expect(firstKPI).toHaveProperty('id');
|
||||||
|
expect(firstKPI).toHaveProperty('name');
|
||||||
|
expect(firstKPI).toHaveProperty('checks');
|
||||||
|
expect(Array.isArray(firstKPI.checks)).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await fs.unlink(downloadPath).catch(() => {}); // Ignore errors if file doesn't exist
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should export data to CSV format', async ({ page }) => {
|
||||||
|
// Set up download handling
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
|
||||||
|
// Click CSV export button
|
||||||
|
await page.getByRole('button', { name: /export csv/i }).click();
|
||||||
|
|
||||||
|
// Wait for download
|
||||||
|
const download = await downloadPromise;
|
||||||
|
|
||||||
|
// Verify download properties
|
||||||
|
expect(download.suggestedFilename()).toMatch(/due-diligence-export-\d{4}-\d{2}-\d{2}\.csv/);
|
||||||
|
|
||||||
|
// Save and verify file content
|
||||||
|
const downloadPath = path.join(__dirname, 'downloads', download.suggestedFilename());
|
||||||
|
await download.saveAs(downloadPath);
|
||||||
|
|
||||||
|
// Read and verify CSV content
|
||||||
|
const fileContent = await fs.readFile(downloadPath, 'utf8');
|
||||||
|
const lines = fileContent.split('\n').filter(line => line.trim());
|
||||||
|
|
||||||
|
// Verify CSV structure
|
||||||
|
expect(lines.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Verify header
|
||||||
|
const header = lines[0];
|
||||||
|
expect(header).toContain('Dimension');
|
||||||
|
expect(header).toContain('KPI');
|
||||||
|
expect(header).toContain('Check Question');
|
||||||
|
expect(header).toContain('Check Type');
|
||||||
|
expect(header).toContain('Current Value');
|
||||||
|
expect(header).toContain('Expected Value');
|
||||||
|
expect(header).toContain('Is Completed');
|
||||||
|
expect(header).toContain('Reference URL');
|
||||||
|
expect(header).toContain('Comment');
|
||||||
|
expect(header).toContain('Created At');
|
||||||
|
|
||||||
|
// Verify data rows if they exist
|
||||||
|
if (lines.length > 1) {
|
||||||
|
const dataRow = lines[1];
|
||||||
|
const columns = dataRow.split(',');
|
||||||
|
expect(columns.length).toBeGreaterThanOrEqual(10); // Should have at least 10 columns
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await fs.unlink(downloadPath).catch(() => {}); // Ignore errors if file doesn't exist
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle CSV special characters correctly', async ({ page }) => {
|
||||||
|
// First, create a check with special characters
|
||||||
|
await page.locator('button:has-text("Strategy")').click();
|
||||||
|
await page.getByRole('button', { name: /add check/i }).first().click();
|
||||||
|
|
||||||
|
await page.getByLabel(/question\/statement/i).fill('Test with "quotes" and, commas');
|
||||||
|
await page.getByLabel(/comment/i).fill('Comment with special chars: àáâãäå, "quotes", and newlines\nSecond line');
|
||||||
|
await page.getByRole('button', { name: /create check/i }).click();
|
||||||
|
|
||||||
|
// Export CSV
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: /export csv/i }).click();
|
||||||
|
const download = await downloadPromise;
|
||||||
|
|
||||||
|
// Verify content handling
|
||||||
|
const downloadPath = path.join(__dirname, 'downloads', download.suggestedFilename());
|
||||||
|
await download.saveAs(downloadPath);
|
||||||
|
|
||||||
|
const fileContent = await fs.readFile(downloadPath, 'utf8');
|
||||||
|
|
||||||
|
// Check that special characters are properly escaped
|
||||||
|
expect(fileContent).toContain('"Test with ""quotes"" and, commas"');
|
||||||
|
expect(fileContent).toContain('àáâãäå');
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await fs.unlink(downloadPath).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should export with current timestamp', async ({ page }) => {
|
||||||
|
const exportTime = new Date();
|
||||||
|
|
||||||
|
// Export JSON
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: /export json/i }).click();
|
||||||
|
const download = await downloadPromise;
|
||||||
|
|
||||||
|
const downloadPath = path.join(__dirname, 'downloads', download.suggestedFilename());
|
||||||
|
await download.saveAs(downloadPath);
|
||||||
|
|
||||||
|
const fileContent = await fs.readFile(downloadPath, 'utf8');
|
||||||
|
const exportData = JSON.parse(fileContent);
|
||||||
|
|
||||||
|
// Verify timestamp is recent (within 1 minute)
|
||||||
|
const exportedAt = new Date(exportData.exportedAt);
|
||||||
|
const timeDiff = Math.abs(exportTime.getTime() - exportedAt.getTime());
|
||||||
|
expect(timeDiff).toBeLessThan(60000); // Less than 1 minute
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await fs.unlink(downloadPath).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle empty data gracefully', async ({ page }) => {
|
||||||
|
// This test assumes we have a way to clear all data or test with empty data
|
||||||
|
// For now, we'll just verify that export works even with minimal data
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: /export json/i }).click();
|
||||||
|
const download = await downloadPromise;
|
||||||
|
|
||||||
|
const downloadPath = path.join(__dirname, 'downloads', download.suggestedFilename());
|
||||||
|
await download.saveAs(downloadPath);
|
||||||
|
|
||||||
|
const fileContent = await fs.readFile(downloadPath, 'utf8');
|
||||||
|
const exportData = JSON.parse(fileContent);
|
||||||
|
|
||||||
|
// Should still have valid structure even if empty
|
||||||
|
expect(exportData).toHaveProperty('dimensions');
|
||||||
|
expect(exportData).toHaveProperty('exportedAt');
|
||||||
|
expect(Array.isArray(exportData.dimensions)).toBe(true);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await fs.unlink(downloadPath).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should generate unique filenames for multiple exports', async ({ page }) => {
|
||||||
|
// Export multiple files quickly
|
||||||
|
const downloads = [];
|
||||||
|
|
||||||
|
// First export
|
||||||
|
let downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: /export json/i }).click();
|
||||||
|
downloads.push(await downloadPromise);
|
||||||
|
|
||||||
|
// Small delay to ensure different timestamps if based on seconds
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Second export
|
||||||
|
downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: /export json/i }).click();
|
||||||
|
downloads.push(await downloadPromise);
|
||||||
|
|
||||||
|
// Verify unique filenames
|
||||||
|
const filename1 = downloads[0].suggestedFilename();
|
||||||
|
const filename2 = downloads[1].suggestedFilename();
|
||||||
|
|
||||||
|
// If exports are in the same day, filenames might be the same
|
||||||
|
// This tests that the export mechanism works consistently
|
||||||
|
expect(filename1).toMatch(/due-diligence-export-\d{4}-\d{2}-\d{2}\.json/);
|
||||||
|
expect(filename2).toMatch(/due-diligence-export-\d{4}-\d{2}-\d{2}\.json/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should export complete data hierarchy', async ({ page }) => {
|
||||||
|
// First ensure we have some data by expanding and creating checks
|
||||||
|
await page.locator('button:has-text("Strategy")').click();
|
||||||
|
|
||||||
|
// Export JSON
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: /export json/i }).click();
|
||||||
|
const download = await downloadPromise;
|
||||||
|
|
||||||
|
const downloadPath = path.join(__dirname, 'downloads', download.suggestedFilename());
|
||||||
|
await download.saveAs(downloadPath);
|
||||||
|
|
||||||
|
const fileContent = await fs.readFile(downloadPath, 'utf8');
|
||||||
|
const exportData = JSON.parse(fileContent);
|
||||||
|
|
||||||
|
// Verify complete hierarchy is present
|
||||||
|
let hasCompleteHierarchy = false;
|
||||||
|
|
||||||
|
for (const dimension of exportData.dimensions) {
|
||||||
|
if (dimension.kpis && dimension.kpis.length > 0) {
|
||||||
|
for (const kpi of dimension.kpis) {
|
||||||
|
if (kpi.checks && kpi.checks.length > 0) {
|
||||||
|
hasCompleteHierarchy = true;
|
||||||
|
|
||||||
|
// Verify check structure
|
||||||
|
const check = kpi.checks[0];
|
||||||
|
expect(check).toHaveProperty('question');
|
||||||
|
expect(check).toHaveProperty('check_type');
|
||||||
|
expect(check).toHaveProperty('is_completed');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasCompleteHierarchy) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We should have at least some complete hierarchy from seed data
|
||||||
|
expect(hasCompleteHierarchy).toBe(true);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await fs.unlink(downloadPath).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle browser download restrictions', async ({ page, context }) => {
|
||||||
|
// Test that exports work even with stricter browser settings
|
||||||
|
// This is more of a smoke test since we can't easily simulate all restrictions
|
||||||
|
|
||||||
|
const exportButtons = [
|
||||||
|
page.getByRole('button', { name: /export json/i }),
|
||||||
|
page.getByRole('button', { name: /export csv/i })
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const button of exportButtons) {
|
||||||
|
// Verify button is clickable and doesn't throw errors
|
||||||
|
await expect(button).toBeEnabled();
|
||||||
|
await expect(button).toBeVisible();
|
||||||
|
|
||||||
|
// Click and verify no JavaScript errors occur
|
||||||
|
await button.click();
|
||||||
|
|
||||||
|
// Small delay to allow any potential errors to surface
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
274
client/src/test/e2e/performance.spec.ts
Normal file
274
client/src/test/e2e/performance.spec.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Performance E2E Tests', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.getByText('Due Diligence Tracking System')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should load dashboard within performance budget', async ({ page }) => {
|
||||||
|
// Measure page load performance
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
await page.goto('/', { waitUntil: 'networkidle' });
|
||||||
|
await expect(page.getByText('Due Diligence Tracking System')).toBeVisible();
|
||||||
|
|
||||||
|
const endTime = Date.now();
|
||||||
|
const loadTime = endTime - startTime;
|
||||||
|
|
||||||
|
// Dashboard should load within 3 seconds
|
||||||
|
expect(loadTime).toBeLessThan(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should expand dimensions quickly', async ({ page }) => {
|
||||||
|
const dimensionButtons = page.locator('button:has-text("Strategy"), button:has-text("Engineering"), button:has-text("Sales")');
|
||||||
|
const count = await dimensionButtons.count();
|
||||||
|
|
||||||
|
// Measure expansion performance
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Expand multiple dimensions
|
||||||
|
for (let i = 0; i < Math.min(count, 5); i++) {
|
||||||
|
await dimensionButtons.nth(i).click();
|
||||||
|
|
||||||
|
// Wait for content to appear
|
||||||
|
await expect(page.getByText('Key Performance Indicators')).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
const endTime = Date.now();
|
||||||
|
const expansionTime = endTime - startTime;
|
||||||
|
|
||||||
|
// All expansions should complete within 2 seconds
|
||||||
|
expect(expansionTime).toBeLessThan(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle rapid user interactions', async ({ page }) => {
|
||||||
|
// Expand Strategy dimension
|
||||||
|
await page.locator('button:has-text("Strategy")').click();
|
||||||
|
await expect(page.getByText('Strategic Alignment')).toBeVisible();
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Perform rapid interactions
|
||||||
|
const addCheckButton = page.getByRole('button', { name: /add check/i }).first();
|
||||||
|
|
||||||
|
// Rapidly open and close form
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await addCheckButton.click();
|
||||||
|
await expect(page.getByLabel(/question\/statement/i)).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /cancel/i }).click();
|
||||||
|
await expect(page.getByLabel(/question\/statement/i)).not.toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
const endTime = Date.now();
|
||||||
|
const interactionTime = endTime - startTime;
|
||||||
|
|
||||||
|
// Rapid interactions should complete within 3 seconds
|
||||||
|
expect(interactionTime).toBeLessThan(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle form submissions efficiently', async ({ page }) => {
|
||||||
|
await page.locator('button:has-text("Strategy")').click();
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Create multiple checks rapidly
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await page.getByRole('button', { name: /add check/i }).first().click();
|
||||||
|
|
||||||
|
await page.getByLabel(/question\/statement/i).fill(`Performance test check ${i + 1}`);
|
||||||
|
await page.getByRole('button', { name: /create check/i }).click();
|
||||||
|
|
||||||
|
// Wait for check to appear
|
||||||
|
await expect(page.getByText(`Performance test check ${i + 1}`)).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
const endTime = Date.now();
|
||||||
|
const submissionTime = endTime - startTime;
|
||||||
|
|
||||||
|
// Multiple form submissions should complete within 5 seconds
|
||||||
|
expect(submissionTime).toBeLessThan(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should export data efficiently', async ({ page }) => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Test JSON export performance
|
||||||
|
const jsonDownloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: /export json/i }).click();
|
||||||
|
const jsonDownload = await jsonDownloadPromise;
|
||||||
|
|
||||||
|
const jsonExportTime = Date.now() - startTime;
|
||||||
|
|
||||||
|
// JSON export should complete within 2 seconds
|
||||||
|
expect(jsonExportTime).toBeLessThan(2000);
|
||||||
|
expect(jsonDownload.suggestedFilename()).toMatch(/\.json$/);
|
||||||
|
|
||||||
|
// Test CSV export performance
|
||||||
|
const csvStartTime = Date.now();
|
||||||
|
const csvDownloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: /export csv/i }).click();
|
||||||
|
const csvDownload = await csvDownloadPromise;
|
||||||
|
|
||||||
|
const csvExportTime = Date.now() - csvStartTime;
|
||||||
|
|
||||||
|
// CSV export should complete within 2 seconds
|
||||||
|
expect(csvExportTime).toBeLessThan(2000);
|
||||||
|
expect(csvDownload.suggestedFilename()).toMatch(/\.csv$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should maintain responsiveness with many interactions', async ({ page }) => {
|
||||||
|
// Expand multiple dimensions
|
||||||
|
await page.locator('button:has-text("Strategy")').click();
|
||||||
|
await page.locator('button:has-text("Engineering")').click();
|
||||||
|
|
||||||
|
// Create checks in multiple KPIs
|
||||||
|
const addCheckButtons = page.getByRole('button', { name: /add check/i });
|
||||||
|
const buttonCount = await addCheckButtons.count();
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Interact with multiple KPI sections
|
||||||
|
for (let i = 0; i < Math.min(buttonCount, 3); i++) {
|
||||||
|
await addCheckButtons.nth(i).click();
|
||||||
|
await page.getByLabel(/question\/statement/i).fill(`Responsiveness test ${i + 1}`);
|
||||||
|
await page.getByRole('button', { name: /create check/i }).click();
|
||||||
|
|
||||||
|
// Toggle completion status
|
||||||
|
const checkbox = page.locator(`div:has-text("Responsiveness test ${i + 1}") input[type="checkbox"]`);
|
||||||
|
await checkbox.check();
|
||||||
|
await checkbox.uncheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
const endTime = Date.now();
|
||||||
|
const totalTime = endTime - startTime;
|
||||||
|
|
||||||
|
// Multiple complex interactions should complete within 8 seconds
|
||||||
|
expect(totalTime).toBeLessThan(8000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle browser resource constraints', async ({ page, context }) => {
|
||||||
|
// Simulate slower browser conditions
|
||||||
|
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Perform comprehensive workflow
|
||||||
|
await page.locator('button:has-text("Strategy")').click();
|
||||||
|
await page.getByRole('button', { name: /add check/i }).first().click();
|
||||||
|
await page.getByLabel(/question\/statement/i).fill('Resource constraint test');
|
||||||
|
await page.getByLabel(/check type/i).selectOption('dropdown');
|
||||||
|
await page.getByLabel(/current value/i).selectOption('Yes');
|
||||||
|
await page.getByRole('button', { name: /create check/i }).click();
|
||||||
|
|
||||||
|
// Export data
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: /export json/i }).click();
|
||||||
|
await downloadPromise;
|
||||||
|
|
||||||
|
const endTime = Date.now();
|
||||||
|
const workflowTime = endTime - startTime;
|
||||||
|
|
||||||
|
// Complete workflow should finish within 6 seconds even with constraints
|
||||||
|
expect(workflowTime).toBeLessThan(6000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should measure Core Web Vitals', async ({ page }) => {
|
||||||
|
// Navigate to page and measure performance metrics
|
||||||
|
await page.goto('/', { waitUntil: 'networkidle' });
|
||||||
|
|
||||||
|
// Measure Largest Contentful Paint (LCP)
|
||||||
|
const lcp = await page.evaluate(() => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
new PerformanceObserver((entryList) => {
|
||||||
|
const entries = entryList.getEntries();
|
||||||
|
const lastEntry = entries[entries.length - 1];
|
||||||
|
resolve(lastEntry.startTime);
|
||||||
|
}).observe({ entryTypes: ['largest-contentful-paint'] });
|
||||||
|
|
||||||
|
// Fallback timeout
|
||||||
|
setTimeout(() => resolve(0), 5000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// LCP should be under 2.5 seconds
|
||||||
|
expect(lcp).toBeLessThan(2500);
|
||||||
|
|
||||||
|
// Measure First Input Delay (FID) simulation
|
||||||
|
const startTime = Date.now();
|
||||||
|
await page.locator('button:has-text("Strategy")').click();
|
||||||
|
const inputDelay = Date.now() - startTime;
|
||||||
|
|
||||||
|
// Simulated FID should be under 100ms
|
||||||
|
expect(inputDelay).toBeLessThan(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle memory-intensive operations', async ({ page }) => {
|
||||||
|
// Perform operations that might consume memory
|
||||||
|
const dimensionButtons = page.locator('button[class*="hover:bg-gray-100"]');
|
||||||
|
const count = await dimensionButtons.count();
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Expand all dimensions
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
await dimensionButtons.nth(i).click();
|
||||||
|
|
||||||
|
// Wait briefly to ensure rendering
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create multiple checks
|
||||||
|
const addCheckButtons = page.getByRole('button', { name: /add check/i });
|
||||||
|
const checkButtonCount = await addCheckButtons.count();
|
||||||
|
|
||||||
|
for (let i = 0; i < Math.min(checkButtonCount, 5); i++) {
|
||||||
|
await addCheckButtons.nth(i).click();
|
||||||
|
await page.getByLabel(/question\/statement/i).fill(`Memory test check ${i + 1}`);
|
||||||
|
await page.getByRole('button', { name: /create check/i }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapse all dimensions
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
await dimensionButtons.nth(i).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
const endTime = Date.now();
|
||||||
|
const memoryOperationTime = endTime - startTime;
|
||||||
|
|
||||||
|
// Memory-intensive operations should complete within 10 seconds
|
||||||
|
expect(memoryOperationTime).toBeLessThan(10000);
|
||||||
|
|
||||||
|
// Page should remain responsive
|
||||||
|
await expect(page.getByText('Due Diligence Tracking System')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should maintain performance with large datasets', async ({ page }) => {
|
||||||
|
// This test simulates having many checks by creating them
|
||||||
|
await page.locator('button:has-text("Strategy")').click();
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Create several checks to simulate larger dataset
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await page.getByRole('button', { name: /add check/i }).first().click();
|
||||||
|
await page.getByLabel(/question\/statement/i).fill(`Large dataset test ${i + 1}?`);
|
||||||
|
await page.getByRole('button', { name: /create check/i }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Measure time to toggle all checks
|
||||||
|
const checkboxes = page.locator('input[type="checkbox"]');
|
||||||
|
const checkboxCount = await checkboxes.count();
|
||||||
|
|
||||||
|
for (let i = 0; i < checkboxCount; i++) {
|
||||||
|
await checkboxes.nth(i).check();
|
||||||
|
}
|
||||||
|
|
||||||
|
const endTime = Date.now();
|
||||||
|
const largeDatasetTime = endTime - startTime;
|
||||||
|
|
||||||
|
// Operations with larger dataset should complete within 15 seconds
|
||||||
|
expect(largeDatasetTime).toBeLessThan(15000);
|
||||||
|
});
|
||||||
|
});
|
||||||
304
client/src/test/integration/database.test.ts
Normal file
304
client/src/test/integration/database.test.ts
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { initDatabase, Database } from '../../database/database';
|
||||||
|
import { seedDatabase } from '../../database/seedData';
|
||||||
|
import { DataService } from '../../services/dataService';
|
||||||
|
|
||||||
|
// Mock sql.js for integration tests
|
||||||
|
const mockDatabase = {
|
||||||
|
exec: vi.fn(),
|
||||||
|
prepare: vi.fn(),
|
||||||
|
run: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('sql.js', () => ({
|
||||||
|
default: vi.fn(() => Promise.resolve({
|
||||||
|
Database: vi.fn().mockImplementation(() => mockDatabase),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Database Integration Tests', () => {
|
||||||
|
let db: Database;
|
||||||
|
let dataService: DataService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Mock successful database initialization
|
||||||
|
mockDatabase.exec.mockReturnValue([]);
|
||||||
|
|
||||||
|
db = await initDatabase();
|
||||||
|
dataService = new DataService(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Database Schema Creation', () => {
|
||||||
|
it('should create all required tables', async () => {
|
||||||
|
await initDatabase();
|
||||||
|
|
||||||
|
expect(mockDatabase.exec).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('CREATE TABLE IF NOT EXISTS dimensions')
|
||||||
|
);
|
||||||
|
expect(mockDatabase.exec).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('CREATE TABLE IF NOT EXISTS kpis')
|
||||||
|
);
|
||||||
|
expect(mockDatabase.exec).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('CREATE TABLE IF NOT EXISTS checks')
|
||||||
|
);
|
||||||
|
expect(mockDatabase.exec).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('CREATE TABLE IF NOT EXISTS files')
|
||||||
|
);
|
||||||
|
expect(mockDatabase.exec).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('CREATE TABLE IF NOT EXISTS check_types')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create foreign key constraints', async () => {
|
||||||
|
await initDatabase();
|
||||||
|
|
||||||
|
const schemaCall = mockDatabase.exec.mock.calls[0][0];
|
||||||
|
expect(schemaCall).toContain('FOREIGN KEY (dimension_id) REFERENCES dimensions(id)');
|
||||||
|
expect(schemaCall).toContain('FOREIGN KEY (kpi_id) REFERENCES kpis(id)');
|
||||||
|
expect(schemaCall).toContain('FOREIGN KEY (reference_file_id) REFERENCES files(id)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle database initialization errors', async () => {
|
||||||
|
mockDatabase.exec.mockImplementation(() => {
|
||||||
|
throw new Error('Database initialization failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(initDatabase()).rejects.toThrow('Database initialization failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Data Seeding', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock successful seed operations
|
||||||
|
mockDatabase.run = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should seed dimensions correctly', () => {
|
||||||
|
seedDatabase(db);
|
||||||
|
|
||||||
|
expect(mockDatabase.run).toHaveBeenCalledWith(
|
||||||
|
'INSERT INTO dimensions (name, icon, color, order_index) VALUES (?, ?, ?, ?)',
|
||||||
|
['Strategy', 'Briefcase', '#4F46E5', 1]
|
||||||
|
);
|
||||||
|
expect(mockDatabase.run).toHaveBeenCalledWith(
|
||||||
|
'INSERT INTO dimensions (name, icon, color, order_index) VALUES (?, ?, ?, ?)',
|
||||||
|
['Engineering', 'Code', '#F59E0B', 5]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should seed KPIs with proper relationships', () => {
|
||||||
|
seedDatabase(db);
|
||||||
|
|
||||||
|
expect(mockDatabase.run).toHaveBeenCalledWith(
|
||||||
|
'INSERT INTO kpis (dimension_id, name, description, order_index) VALUES (?, ?, ?, ?)',
|
||||||
|
[1, 'Strategic Alignment', 'Vision and mission clarity', 1]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should seed check types', () => {
|
||||||
|
seedDatabase(db);
|
||||||
|
|
||||||
|
expect(mockDatabase.run).toHaveBeenCalledWith(
|
||||||
|
'INSERT INTO check_types (name, data_type, options_json, validation_json) VALUES (?, ?, ?, ?)',
|
||||||
|
['dropdown', 'string', JSON.stringify(['Yes', 'Partially', 'No']), JSON.stringify({ required: true })]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should seed sample checks', () => {
|
||||||
|
seedDatabase(db);
|
||||||
|
|
||||||
|
expect(mockDatabase.run).toHaveBeenCalledWith(
|
||||||
|
'INSERT INTO checks (kpi_id, question, check_type, current_value, expected_value, is_completed) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
|
[1, 'Is the company vision clearly defined?', 'dropdown', undefined, 'Yes', true]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('End-to-End Data Operations', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Setup mock data for integration tests
|
||||||
|
const mockDimensions = [
|
||||||
|
[1, 'Strategy', 'Briefcase', '#4F46E5', 1, '2023-01-01']
|
||||||
|
];
|
||||||
|
const mockKPIs = [
|
||||||
|
[1, 1, 'Strategic Alignment', 'Vision clarity', 1, '2023-01-01']
|
||||||
|
];
|
||||||
|
const mockChecks = [
|
||||||
|
[1, 1, 'Test question?', 'dropdown', 'Yes', 'Yes', null, null, 'Test', 1, '2023-01-01', '2023-01-01']
|
||||||
|
];
|
||||||
|
|
||||||
|
mockDatabase.exec
|
||||||
|
.mockReturnValueOnce([{ columns: ['id', 'name', 'icon', 'color', 'order_index', 'created_at'], values: mockDimensions }]) // getDimensions
|
||||||
|
.mockReturnValueOnce([{ values: [[1]] }]) // KPI count
|
||||||
|
.mockReturnValueOnce([{ values: [[1, 1]] }]) // checks count
|
||||||
|
.mockReturnValueOnce([{ columns: ['id', 'dimension_id', 'name', 'description', 'order_index', 'created_at'], values: mockKPIs }]) // getKPIsByDimension
|
||||||
|
.mockReturnValueOnce([{ columns: ['id', 'kpi_id', 'question', 'check_type', 'current_value', 'expected_value', 'reference_url', 'reference_file_id', 'comment', 'is_completed', 'created_at', 'updated_at'], values: mockChecks }]); // getChecksByKPI
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should perform complete dimension statistics calculation', () => {
|
||||||
|
const dimensionsWithStats = dataService.getDimensionsWithStats();
|
||||||
|
|
||||||
|
expect(dimensionsWithStats).toHaveLength(1);
|
||||||
|
expect(dimensionsWithStats[0]).toEqual({
|
||||||
|
dimension: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Strategy',
|
||||||
|
icon: 'Briefcase',
|
||||||
|
color: '#4F46E5',
|
||||||
|
order_index: 1,
|
||||||
|
created_at: '2023-01-01'
|
||||||
|
},
|
||||||
|
kpi_count: 1,
|
||||||
|
total_checks: 1,
|
||||||
|
completed_checks: 1,
|
||||||
|
progress: 100
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle complete CRUD workflow for checks', () => {
|
||||||
|
// Create
|
||||||
|
mockDatabase.exec
|
||||||
|
.mockReturnValueOnce(undefined) // INSERT
|
||||||
|
.mockReturnValueOnce([{ values: [[123]] }]); // SELECT last_insert_rowid()
|
||||||
|
|
||||||
|
const newCheckId = dataService.createCheck({
|
||||||
|
kpi_id: 1,
|
||||||
|
question: 'New test question?',
|
||||||
|
check_type: 'text',
|
||||||
|
current_value: 'Test value',
|
||||||
|
expected_value: 'Expected',
|
||||||
|
is_completed: false
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(newCheckId).toBe(123);
|
||||||
|
|
||||||
|
// Update
|
||||||
|
dataService.updateCheck(123, {
|
||||||
|
current_value: 'Updated value',
|
||||||
|
is_completed: true
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockDatabase.exec).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('UPDATE checks SET')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
dataService.deleteCheck(123);
|
||||||
|
|
||||||
|
expect(mockDatabase.exec).toHaveBeenCalledWith(
|
||||||
|
'DELETE FROM checks WHERE id = 123'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Data Integrity and Constraints', () => {
|
||||||
|
it('should maintain referential integrity between tables', () => {
|
||||||
|
// Test that we can't create a KPI without a valid dimension
|
||||||
|
const invalidKPI = {
|
||||||
|
dimension_id: 999, // Non-existent dimension
|
||||||
|
name: 'Invalid KPI',
|
||||||
|
description: 'Should fail'
|
||||||
|
};
|
||||||
|
|
||||||
|
mockDatabase.run.mockImplementation(() => {
|
||||||
|
throw new Error('FOREIGN KEY constraint failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
mockDatabase.run(
|
||||||
|
'INSERT INTO kpis (dimension_id, name, description) VALUES (?, ?, ?)',
|
||||||
|
[invalidKPI.dimension_id, invalidKPI.name, invalidKPI.description]
|
||||||
|
);
|
||||||
|
}).toThrow('FOREIGN KEY constraint failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle SQL injection attempts safely', () => {
|
||||||
|
const maliciousInput = "'; DROP TABLE dimensions; --";
|
||||||
|
|
||||||
|
dataService.updateCheck(1, {
|
||||||
|
comment: maliciousInput
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify that the input is properly escaped
|
||||||
|
const sqlCall = mockDatabase.exec.mock.calls[0][0];
|
||||||
|
expect(sqlCall).toContain("comment = 'O''; DROP TABLE dimensions; --'");
|
||||||
|
expect(sqlCall).not.toContain('DROP TABLE dimensions');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle special characters in data', () => {
|
||||||
|
const specialChars = "Test with 'quotes' and \"double quotes\" and ñoñó";
|
||||||
|
|
||||||
|
dataService.updateCheck(1, {
|
||||||
|
comment: specialChars
|
||||||
|
});
|
||||||
|
|
||||||
|
const sqlCall = mockDatabase.exec.mock.calls[0][0];
|
||||||
|
expect(sqlCall).toContain("'Test with ''quotes'' and \"double quotes\" and ñoñó'");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Performance and Scalability', () => {
|
||||||
|
it('should handle large datasets efficiently', () => {
|
||||||
|
// Mock a large dataset
|
||||||
|
const largeDimensionSet = Array.from({ length: 100 }, (_, i) => [
|
||||||
|
i + 1, `Dimension ${i + 1}`, 'Circle', '#000000', i + 1, '2023-01-01'
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockDatabase.exec.mockReturnValue([{
|
||||||
|
columns: ['id', 'name', 'icon', 'color', 'order_index', 'created_at'],
|
||||||
|
values: largeDimensionSet
|
||||||
|
}]);
|
||||||
|
|
||||||
|
const dimensions = dataService.getDimensions();
|
||||||
|
|
||||||
|
expect(dimensions).toHaveLength(100);
|
||||||
|
expect(mockDatabase.exec).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should batch operations efficiently', () => {
|
||||||
|
// Test that multiple operations don't cause excessive database calls
|
||||||
|
const operations = [
|
||||||
|
() => dataService.updateCheck(1, { is_completed: true }),
|
||||||
|
() => dataService.updateCheck(2, { is_completed: true }),
|
||||||
|
() => dataService.updateCheck(3, { is_completed: true })
|
||||||
|
];
|
||||||
|
|
||||||
|
operations.forEach(op => op());
|
||||||
|
|
||||||
|
// Each update should result in one database call
|
||||||
|
expect(mockDatabase.exec).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error Recovery and Resilience', () => {
|
||||||
|
it('should handle database connection failures gracefully', () => {
|
||||||
|
mockDatabase.exec.mockImplementation(() => {
|
||||||
|
throw new Error('Database connection lost');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => dataService.getDimensions()).toThrow('Database connection lost');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle partial data corruption', () => {
|
||||||
|
// Mock corrupted data response
|
||||||
|
mockDatabase.exec.mockReturnValue([{
|
||||||
|
columns: ['id', 'name'], // Missing expected columns
|
||||||
|
values: [[1, 'Strategy']] // Incomplete data
|
||||||
|
}]);
|
||||||
|
|
||||||
|
const dimensions = dataService.getDimensions();
|
||||||
|
|
||||||
|
expect(dimensions).toHaveLength(1);
|
||||||
|
expect(dimensions[0].icon).toBeUndefined();
|
||||||
|
expect(dimensions[0].color).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle malformed database responses', () => {
|
||||||
|
mockDatabase.exec.mockReturnValue(null);
|
||||||
|
|
||||||
|
expect(() => dataService.getDimensions()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
312
client/src/test/performance/load.test.ts
Normal file
312
client/src/test/performance/load.test.ts
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { DataService } from '../../services/dataService';
|
||||||
|
import { Database } from '../../database/database';
|
||||||
|
|
||||||
|
const mockDb = {
|
||||||
|
exec: vi.fn(),
|
||||||
|
prepare: vi.fn(),
|
||||||
|
run: vi.fn(),
|
||||||
|
} as unknown as Database;
|
||||||
|
|
||||||
|
describe('Performance and Load Tests', () => {
|
||||||
|
let dataService: DataService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
dataService = new DataService(mockDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Large Dataset Performance', () => {
|
||||||
|
it('should handle 1000+ dimensions efficiently', () => {
|
||||||
|
const largeDimensionSet = Array.from({ length: 1000 }, (_, i) => [
|
||||||
|
i + 1, `Dimension ${i + 1}`, 'Circle', '#000000', i + 1, '2023-01-01'
|
||||||
|
]);
|
||||||
|
|
||||||
|
(mockDb.exec as any).mockReturnValue([{
|
||||||
|
columns: ['id', 'name', 'icon', 'color', 'order_index', 'created_at'],
|
||||||
|
values: largeDimensionSet
|
||||||
|
}]);
|
||||||
|
|
||||||
|
const start = performance.now();
|
||||||
|
const dimensions = dataService.getDimensions();
|
||||||
|
const end = performance.now();
|
||||||
|
|
||||||
|
expect(dimensions).toHaveLength(1000);
|
||||||
|
expect(end - start).toBeLessThan(100); // Should complete in less than 100ms
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle 10000+ checks efficiently', () => {
|
||||||
|
const largeCheckSet = Array.from({ length: 10000 }, (_, i) => [
|
||||||
|
i + 1, 1, `Check ${i + 1}?`, 'text', 'Current', 'Expected',
|
||||||
|
null, null, 'Comment', 0, '2023-01-01', '2023-01-01'
|
||||||
|
]);
|
||||||
|
|
||||||
|
(mockDb.exec as any).mockReturnValue([{
|
||||||
|
columns: ['id', 'kpi_id', 'question', 'check_type', 'current_value', 'expected_value',
|
||||||
|
'reference_url', 'reference_file_id', 'comment', 'is_completed', 'created_at', 'updated_at'],
|
||||||
|
values: largeCheckSet
|
||||||
|
}]);
|
||||||
|
|
||||||
|
const start = performance.now();
|
||||||
|
const checks = dataService.getChecksByKPI(1);
|
||||||
|
const end = performance.now();
|
||||||
|
|
||||||
|
expect(checks).toHaveLength(10000);
|
||||||
|
expect(end - start).toBeLessThan(500); // Should complete in less than 500ms
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate statistics for large datasets efficiently', () => {
|
||||||
|
// Mock 100 dimensions with comprehensive stats
|
||||||
|
const dimensions = Array.from({ length: 100 }, (_, i) => [
|
||||||
|
i + 1, `Dimension ${i + 1}`, 'Circle', '#000000', i + 1, '2023-01-01'
|
||||||
|
]);
|
||||||
|
|
||||||
|
vi.spyOn(dataService, 'getDimensions').mockReturnValue(
|
||||||
|
dimensions.map(d => ({
|
||||||
|
id: d[0] as number,
|
||||||
|
name: d[1] as string,
|
||||||
|
icon: d[2] as string,
|
||||||
|
color: d[3] as string,
|
||||||
|
order_index: d[4] as number,
|
||||||
|
created_at: d[5] as string
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mock stats queries - each dimension returns random stats
|
||||||
|
(mockDb.exec as any).mockImplementation((sql: string) => {
|
||||||
|
if (sql.includes('COUNT(*) as count FROM kpis')) {
|
||||||
|
return [{ values: [[Math.floor(Math.random() * 10) + 1]] }];
|
||||||
|
}
|
||||||
|
if (sql.includes('COUNT(*) as total_checks')) {
|
||||||
|
const total = Math.floor(Math.random() * 50) + 10;
|
||||||
|
const completed = Math.floor(Math.random() * total);
|
||||||
|
return [{ values: [[total, completed]] }];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const start = performance.now();
|
||||||
|
const dimensionsWithStats = dataService.getDimensionsWithStats();
|
||||||
|
const end = performance.now();
|
||||||
|
|
||||||
|
expect(dimensionsWithStats).toHaveLength(100);
|
||||||
|
expect(end - start).toBeLessThan(1000); // Should complete in less than 1 second
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Memory Usage Optimization', () => {
|
||||||
|
it('should not leak memory during repeated operations', () => {
|
||||||
|
const mockData = Array.from({ length: 100 }, (_, i) => [
|
||||||
|
i + 1, `Item ${i + 1}`, 'data', '2023-01-01'
|
||||||
|
]);
|
||||||
|
|
||||||
|
(mockDb.exec as any).mockReturnValue([{
|
||||||
|
columns: ['id', 'name', 'data', 'created_at'],
|
||||||
|
values: mockData
|
||||||
|
}]);
|
||||||
|
|
||||||
|
// Simulate repeated operations
|
||||||
|
const initialMemory = process.memoryUsage().heapUsed;
|
||||||
|
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
dataService.getDimensions();
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalMemory = process.memoryUsage().heapUsed;
|
||||||
|
const memoryIncrease = finalMemory - initialMemory;
|
||||||
|
|
||||||
|
// Memory increase should be reasonable (less than 10MB)
|
||||||
|
expect(memoryIncrease).toBeLessThan(10 * 1024 * 1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle concurrent operations efficiently', async () => {
|
||||||
|
(mockDb.exec as any).mockReturnValue([{
|
||||||
|
columns: ['id', 'name'],
|
||||||
|
values: [[1, 'Test']]
|
||||||
|
}]);
|
||||||
|
|
||||||
|
const operations = Array.from({ length: 100 }, () =>
|
||||||
|
Promise.resolve(dataService.getDimensions())
|
||||||
|
);
|
||||||
|
|
||||||
|
const start = performance.now();
|
||||||
|
const results = await Promise.all(operations);
|
||||||
|
const end = performance.now();
|
||||||
|
|
||||||
|
expect(results).toHaveLength(100);
|
||||||
|
expect(end - start).toBeLessThan(200); // Should complete in less than 200ms
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Database Query Optimization', () => {
|
||||||
|
it('should minimize database calls for complex operations', () => {
|
||||||
|
const dimensions = [{ id: 1, name: 'Test', icon: 'Circle', color: '#000', order_index: 1 }];
|
||||||
|
vi.spyOn(dataService, 'getDimensions').mockReturnValue(dimensions);
|
||||||
|
|
||||||
|
(mockDb.exec as any)
|
||||||
|
.mockReturnValueOnce([{ values: [[5]] }]) // KPI count
|
||||||
|
.mockReturnValueOnce([{ values: [[20, 15]] }]); // checks stats
|
||||||
|
|
||||||
|
dataService.getDimensionsWithStats();
|
||||||
|
|
||||||
|
// Should make exactly 3 database calls: 1 for dimensions, 2 for stats per dimension
|
||||||
|
expect(mockDb.exec).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should batch operations when possible', () => {
|
||||||
|
const updates = [
|
||||||
|
{ id: 1, is_completed: true },
|
||||||
|
{ id: 2, is_completed: true },
|
||||||
|
{ id: 3, is_completed: false }
|
||||||
|
];
|
||||||
|
|
||||||
|
updates.forEach(update => {
|
||||||
|
dataService.updateCheck(update.id, { is_completed: update.is_completed });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Each update should result in one database call
|
||||||
|
expect(mockDb.exec).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Stress Testing', () => {
|
||||||
|
it('should handle rapid successive operations', () => {
|
||||||
|
(mockDb.exec as any).mockReturnValue([{ columns: ['id'], values: [[1]] }]);
|
||||||
|
|
||||||
|
const start = performance.now();
|
||||||
|
|
||||||
|
// Perform 1000 rapid operations
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
dataService.getDimensions();
|
||||||
|
}
|
||||||
|
|
||||||
|
const end = performance.now();
|
||||||
|
|
||||||
|
expect(end - start).toBeLessThan(1000); // Should complete in less than 1 second
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle complex nested data structures', () => {
|
||||||
|
const complexData = Array.from({ length: 50 }, (_, dimIndex) => ({
|
||||||
|
dimension: {
|
||||||
|
id: dimIndex + 1,
|
||||||
|
name: `Dimension ${dimIndex + 1}`,
|
||||||
|
kpis: Array.from({ length: 10 }, (_, kpiIndex) => ({
|
||||||
|
id: kpiIndex + 1,
|
||||||
|
name: `KPI ${kpiIndex + 1}`,
|
||||||
|
checks: Array.from({ length: 20 }, (_, checkIndex) => ({
|
||||||
|
id: checkIndex + 1,
|
||||||
|
question: `Check ${checkIndex + 1}?`,
|
||||||
|
check_type: 'text',
|
||||||
|
current_value: `Value ${checkIndex + 1}`,
|
||||||
|
is_completed: Math.random() > 0.5
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const start = performance.now();
|
||||||
|
|
||||||
|
// Process complex nested structure
|
||||||
|
let totalChecks = 0;
|
||||||
|
complexData.forEach(data => {
|
||||||
|
data.dimension.kpis.forEach(kpi => {
|
||||||
|
totalChecks += kpi.checks.length;
|
||||||
|
// Simulate processing each check
|
||||||
|
kpi.checks.forEach(check => {
|
||||||
|
expect(check.question).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const end = performance.now();
|
||||||
|
|
||||||
|
expect(totalChecks).toBe(10000); // 50 * 10 * 20
|
||||||
|
expect(end - start).toBeLessThan(100); // Should complete in less than 100ms
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge Case Performance', () => {
|
||||||
|
it('should handle empty result sets efficiently', () => {
|
||||||
|
(mockDb.exec as any).mockReturnValue([]);
|
||||||
|
|
||||||
|
const start = performance.now();
|
||||||
|
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const result = dataService.getDimensions();
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const end = performance.now();
|
||||||
|
|
||||||
|
expect(end - start).toBeLessThan(50); // Should be very fast for empty results
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle malformed data gracefully without performance impact', () => {
|
||||||
|
(mockDb.exec as any).mockReturnValue([{
|
||||||
|
columns: ['id'], // Missing expected columns
|
||||||
|
values: [[1], [2], [3]] // Incomplete data
|
||||||
|
}]);
|
||||||
|
|
||||||
|
const start = performance.now();
|
||||||
|
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const result = dataService.getDimensions();
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
const end = performance.now();
|
||||||
|
|
||||||
|
expect(end - start).toBeLessThan(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle very long strings efficiently', () => {
|
||||||
|
const longString = 'A'.repeat(10000); // 10KB string
|
||||||
|
const dataWithLongStrings = Array.from({ length: 100 }, (_, i) => [
|
||||||
|
i + 1, longString, longString, longString, i + 1, '2023-01-01'
|
||||||
|
]);
|
||||||
|
|
||||||
|
(mockDb.exec as any).mockReturnValue([{
|
||||||
|
columns: ['id', 'name', 'icon', 'color', 'order_index', 'created_at'],
|
||||||
|
values: dataWithLongStrings
|
||||||
|
}]);
|
||||||
|
|
||||||
|
const start = performance.now();
|
||||||
|
const result = dataService.getDimensions();
|
||||||
|
const end = performance.now();
|
||||||
|
|
||||||
|
expect(result).toHaveLength(100);
|
||||||
|
expect(result[0].name).toHaveLength(10000);
|
||||||
|
expect(end - start).toBeLessThan(200); // Should handle large strings reasonably
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Browser Performance Simulation', () => {
|
||||||
|
it('should perform well with simulated browser constraints', () => {
|
||||||
|
// Simulate slower operations as might occur in browser
|
||||||
|
const slowDbExec = vi.fn().mockImplementation(() => {
|
||||||
|
// Simulate 1ms database delay
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < 1) {} // Busy wait
|
||||||
|
|
||||||
|
return [{
|
||||||
|
columns: ['id', 'name'],
|
||||||
|
values: Array.from({ length: 10 }, (_, i) => [i + 1, `Item ${i + 1}`])
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
|
||||||
|
(mockDb.exec as any) = slowDbExec;
|
||||||
|
|
||||||
|
const start = performance.now();
|
||||||
|
|
||||||
|
// Perform operations that would be common in browser
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
dataService.getDimensions();
|
||||||
|
}
|
||||||
|
|
||||||
|
const end = performance.now();
|
||||||
|
|
||||||
|
// Even with simulated delays, should complete reasonably quickly
|
||||||
|
expect(end - start).toBeLessThan(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
40
client/src/test/setup.ts
Normal file
40
client/src/test/setup.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||
|
// Mock sql.js since it's not available in test environment
|
||||||
|
vi.mock('sql.js', () => ({
|
||||||
|
default: vi.fn(() => Promise.resolve({
|
||||||
|
Database: vi.fn().mockImplementation(() => ({
|
||||||
|
exec: vi.fn(() => []),
|
||||||
|
prepare: vi.fn(() => ({
|
||||||
|
step: vi.fn(() => false),
|
||||||
|
getAsObject: vi.fn(() => ({})),
|
||||||
|
free: vi.fn(),
|
||||||
|
bind: vi.fn(),
|
||||||
|
run: vi.fn(),
|
||||||
|
})),
|
||||||
|
run: vi.fn(),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock URL.createObjectURL for file exports
|
||||||
|
Object.defineProperty(window, 'URL', {
|
||||||
|
value: {
|
||||||
|
createObjectURL: vi.fn(() => 'mock-url'),
|
||||||
|
revokeObjectURL: vi.fn(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock document.createElement for download links
|
||||||
|
const originalCreateElement = document.createElement;
|
||||||
|
document.createElement = vi.fn((tagName) => {
|
||||||
|
if (tagName === 'a') {
|
||||||
|
return {
|
||||||
|
href: '',
|
||||||
|
download: '',
|
||||||
|
click: vi.fn(),
|
||||||
|
style: {},
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
return originalCreateElement.call(document, tagName);
|
||||||
|
});
|
||||||
18
client/src/test/test-utils.tsx
Normal file
18
client/src/test/test-utils.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, RenderOptions } from '@testing-library/react';
|
||||||
|
|
||||||
|
const AllTheProviders = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const customRender = (
|
||||||
|
ui: React.ReactElement,
|
||||||
|
options?: Omit<RenderOptions, 'wrapper'>
|
||||||
|
) => render(ui, { wrapper: AllTheProviders, ...options });
|
||||||
|
|
||||||
|
export * from '@testing-library/react';
|
||||||
|
export { customRender as render };
|
||||||
57
client/src/types/index.ts
Normal file
57
client/src/types/index.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
export interface Dimension {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
icon?: string;
|
||||||
|
color?: string;
|
||||||
|
order_index?: number;
|
||||||
|
created_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KPI {
|
||||||
|
id: number;
|
||||||
|
dimension_id: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
order_index?: number;
|
||||||
|
created_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Check {
|
||||||
|
id: number;
|
||||||
|
kpi_id: number;
|
||||||
|
question: string;
|
||||||
|
check_type: string;
|
||||||
|
current_value?: string;
|
||||||
|
expected_value?: string;
|
||||||
|
reference_url?: string;
|
||||||
|
reference_file_id?: number;
|
||||||
|
comment?: string;
|
||||||
|
is_completed: boolean;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileRecord {
|
||||||
|
id: number;
|
||||||
|
original_name: string;
|
||||||
|
stored_path: string;
|
||||||
|
mime_type?: string;
|
||||||
|
size_bytes?: number;
|
||||||
|
uploaded_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckType {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
data_type: string;
|
||||||
|
options_json?: string;
|
||||||
|
validation_json?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DimensionWithStats {
|
||||||
|
dimension: Dimension;
|
||||||
|
kpi_count: number;
|
||||||
|
total_checks: number;
|
||||||
|
completed_checks: number;
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
247
client/src/utils/__tests__/exportUtils.test.ts
Normal file
247
client/src/utils/__tests__/exportUtils.test.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { exportToJSON, exportToCSV } from '../exportUtils';
|
||||||
|
import { DataService } from '../../services/dataService';
|
||||||
|
|
||||||
|
// Mock DataService
|
||||||
|
const mockDataService = {
|
||||||
|
getDimensions: vi.fn(),
|
||||||
|
getKPIsByDimension: vi.fn(),
|
||||||
|
getChecksByKPI: vi.fn(),
|
||||||
|
} as unknown as DataService;
|
||||||
|
|
||||||
|
// Mock data
|
||||||
|
const mockDimensions = [
|
||||||
|
{ id: 1, name: 'Strategy', icon: 'Briefcase', color: '#4F46E5' },
|
||||||
|
{ id: 2, name: 'Engineering', icon: 'Code', color: '#F59E0B' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockKPIs = [
|
||||||
|
{ id: 1, dimension_id: 1, name: 'Strategic Alignment', description: 'Vision clarity' },
|
||||||
|
{ id: 2, dimension_id: 1, name: 'Market Position', description: 'Competitive analysis' },
|
||||||
|
{ id: 3, dimension_id: 2, name: 'Development Practices', description: 'Coding standards' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockChecks = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
kpi_id: 1,
|
||||||
|
question: 'Is vision clearly defined?',
|
||||||
|
check_type: 'dropdown',
|
||||||
|
current_value: 'Yes',
|
||||||
|
expected_value: 'Yes',
|
||||||
|
is_completed: true,
|
||||||
|
reference_url: 'https://example.com',
|
||||||
|
comment: 'Well documented',
|
||||||
|
created_at: '2023-01-01T00:00:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
kpi_id: 1,
|
||||||
|
question: 'Strategic goals documented?',
|
||||||
|
check_type: 'dropdown',
|
||||||
|
current_value: 'Partially',
|
||||||
|
expected_value: 'Yes',
|
||||||
|
is_completed: false,
|
||||||
|
reference_url: null,
|
||||||
|
comment: 'Needs improvement',
|
||||||
|
created_at: '2023-01-02T00:00:00Z'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('exportUtils', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Setup DOM mocks
|
||||||
|
const mockLink = {
|
||||||
|
href: '',
|
||||||
|
download: '',
|
||||||
|
click: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.spyOn(document, 'createElement').mockImplementation((tagName) => {
|
||||||
|
if (tagName === 'a') return mockLink as any;
|
||||||
|
return document.createElement(tagName);
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.spyOn(document.body, 'appendChild').mockImplementation(() => mockLink as any);
|
||||||
|
vi.spyOn(document.body, 'removeChild').mockImplementation(() => mockLink as any);
|
||||||
|
|
||||||
|
// Setup URL mock
|
||||||
|
global.URL.createObjectURL = vi.fn(() => 'mock-blob-url');
|
||||||
|
global.URL.revokeObjectURL = vi.fn();
|
||||||
|
|
||||||
|
// Setup Blob mock
|
||||||
|
global.Blob = vi.fn().mockImplementation((content, options) => ({
|
||||||
|
content,
|
||||||
|
options,
|
||||||
|
size: content[0].length,
|
||||||
|
type: options?.type || ''
|
||||||
|
})) as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exportToJSON', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
(mockDataService.getDimensions as any).mockReturnValue(mockDimensions);
|
||||||
|
(mockDataService.getKPIsByDimension as any).mockImplementation((dimensionId: number) => {
|
||||||
|
return mockKPIs.filter(kpi => kpi.dimension_id === dimensionId);
|
||||||
|
});
|
||||||
|
(mockDataService.getChecksByKPI as any).mockImplementation((kpiId: number) => {
|
||||||
|
return mockChecks.filter(check => check.kpi_id === kpiId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should export data to JSON format', () => {
|
||||||
|
exportToJSON(mockDataService);
|
||||||
|
|
||||||
|
expect(mockDataService.getDimensions).toHaveBeenCalledOnce();
|
||||||
|
expect(mockDataService.getKPIsByDimension).toHaveBeenCalledWith(1);
|
||||||
|
expect(mockDataService.getKPIsByDimension).toHaveBeenCalledWith(2);
|
||||||
|
expect(mockDataService.getChecksByKPI).toHaveBeenCalledWith(1);
|
||||||
|
expect(mockDataService.getChecksByKPI).toHaveBeenCalledWith(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a blob with correct JSON content', () => {
|
||||||
|
exportToJSON(mockDataService);
|
||||||
|
|
||||||
|
expect(global.Blob).toHaveBeenCalledWith(
|
||||||
|
[expect.stringContaining('"dimensions"')],
|
||||||
|
{ type: 'application/json' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trigger file download', () => {
|
||||||
|
exportToJSON(mockDataService);
|
||||||
|
|
||||||
|
expect(document.createElement).toHaveBeenCalledWith('a');
|
||||||
|
expect(global.URL.createObjectURL).toHaveBeenCalled();
|
||||||
|
expect(document.body.appendChild).toHaveBeenCalled();
|
||||||
|
expect(document.body.removeChild).toHaveBeenCalled();
|
||||||
|
expect(global.URL.revokeObjectURL).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include exportedAt timestamp', () => {
|
||||||
|
const dateSpy = vi.spyOn(Date.prototype, 'toISOString').mockReturnValue('2023-01-01T00:00:00.000Z');
|
||||||
|
|
||||||
|
exportToJSON(mockDataService);
|
||||||
|
|
||||||
|
const blobCall = (global.Blob as any).mock.calls[0];
|
||||||
|
const jsonContent = blobCall[0][0];
|
||||||
|
expect(jsonContent).toContain('"exportedAt":"2023-01-01T00:00:00.000Z"');
|
||||||
|
|
||||||
|
dateSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exportToCSV', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
(mockDataService.getDimensions as any).mockReturnValue(mockDimensions);
|
||||||
|
(mockDataService.getKPIsByDimension as any).mockImplementation((dimensionId: number) => {
|
||||||
|
return mockKPIs.filter(kpi => kpi.dimension_id === dimensionId);
|
||||||
|
});
|
||||||
|
(mockDataService.getChecksByKPI as any).mockImplementation((kpiId: number) => {
|
||||||
|
return mockChecks.filter(check => check.kpi_id === kpiId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should export data to CSV format', () => {
|
||||||
|
exportToCSV(mockDataService);
|
||||||
|
|
||||||
|
expect(mockDataService.getDimensions).toHaveBeenCalledOnce();
|
||||||
|
expect(global.Blob).toHaveBeenCalledWith(
|
||||||
|
[expect.stringContaining('Dimension,KPI,Check Question')],
|
||||||
|
{ type: 'text/csv' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include CSV headers', () => {
|
||||||
|
exportToCSV(mockDataService);
|
||||||
|
|
||||||
|
const blobCall = (global.Blob as any).mock.calls[0];
|
||||||
|
const csvContent = blobCall[0][0];
|
||||||
|
expect(csvContent).toContain('Dimension,KPI,Check Question,Check Type,Current Value,Expected Value,Is Completed,Reference URL,Comment,Created At');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should properly escape CSV values with commas', () => {
|
||||||
|
const checkWithComma = {
|
||||||
|
...mockChecks[0],
|
||||||
|
question: 'Is vision, mission clearly defined?',
|
||||||
|
comment: 'Well documented, needs review'
|
||||||
|
};
|
||||||
|
|
||||||
|
(mockDataService.getChecksByKPI as any).mockReturnValue([checkWithComma]);
|
||||||
|
|
||||||
|
exportToCSV(mockDataService);
|
||||||
|
|
||||||
|
const blobCall = (global.Blob as any).mock.calls[0];
|
||||||
|
const csvContent = blobCall[0][0];
|
||||||
|
expect(csvContent).toContain('"Is vision, mission clearly defined?"');
|
||||||
|
expect(csvContent).toContain('"Well documented, needs review"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should properly escape CSV values with quotes', () => {
|
||||||
|
const checkWithQuotes = {
|
||||||
|
...mockChecks[0],
|
||||||
|
question: 'Is "vision" clearly defined?',
|
||||||
|
comment: 'Well "documented"'
|
||||||
|
};
|
||||||
|
|
||||||
|
(mockDataService.getChecksByKPI as any).mockReturnValue([checkWithQuotes]);
|
||||||
|
|
||||||
|
exportToCSV(mockDataService);
|
||||||
|
|
||||||
|
const blobCall = (global.Blob as any).mock.calls[0];
|
||||||
|
const csvContent = blobCall[0][0];
|
||||||
|
expect(csvContent).toContain('"Is ""vision"" clearly defined?"');
|
||||||
|
expect(csvContent).toContain('"Well ""documented"""');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null and undefined values', () => {
|
||||||
|
const checkWithNulls = {
|
||||||
|
...mockChecks[0],
|
||||||
|
current_value: null,
|
||||||
|
reference_url: undefined,
|
||||||
|
comment: null
|
||||||
|
};
|
||||||
|
|
||||||
|
(mockDataService.getChecksByKPI as any).mockReturnValue([checkWithNulls]);
|
||||||
|
|
||||||
|
exportToCSV(mockDataService);
|
||||||
|
|
||||||
|
const blobCall = (global.Blob as any).mock.calls[0];
|
||||||
|
const csvContent = blobCall[0][0];
|
||||||
|
expect(csvContent).toContain(',,Yes,');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert boolean completion status to Yes/No', () => {
|
||||||
|
exportToCSV(mockDataService);
|
||||||
|
|
||||||
|
const blobCall = (global.Blob as any).mock.calls[0];
|
||||||
|
const csvContent = blobCall[0][0];
|
||||||
|
expect(csvContent).toContain(',Yes,');
|
||||||
|
expect(csvContent).toContain(',No,');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('should handle DataService errors gracefully in JSON export', () => {
|
||||||
|
(mockDataService.getDimensions as any).mockImplementation(() => {
|
||||||
|
throw new Error('Database error');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => exportToJSON(mockDataService)).toThrow('Database error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle DataService errors gracefully in CSV export', () => {
|
||||||
|
(mockDataService.getDimensions as any).mockImplementation(() => {
|
||||||
|
throw new Error('Database error');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => exportToCSV(mockDataService)).toThrow('Database error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
45
client/src/utils/__tests__/icons.test.ts
Normal file
45
client/src/utils/__tests__/icons.test.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { getIconComponent } from '../icons';
|
||||||
|
import {
|
||||||
|
Briefcase,
|
||||||
|
TrendingUp,
|
||||||
|
Target,
|
||||||
|
Building,
|
||||||
|
Code,
|
||||||
|
Shield,
|
||||||
|
DollarSign,
|
||||||
|
Users,
|
||||||
|
Heart,
|
||||||
|
Circle
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
describe('getIconComponent', () => {
|
||||||
|
it('should return the correct icon component for valid icon names', () => {
|
||||||
|
expect(getIconComponent('Briefcase')).toBe(Briefcase);
|
||||||
|
expect(getIconComponent('TrendingUp')).toBe(TrendingUp);
|
||||||
|
expect(getIconComponent('Target')).toBe(Target);
|
||||||
|
expect(getIconComponent('Building')).toBe(Building);
|
||||||
|
expect(getIconComponent('Code')).toBe(Code);
|
||||||
|
expect(getIconComponent('Shield')).toBe(Shield);
|
||||||
|
expect(getIconComponent('DollarSign')).toBe(DollarSign);
|
||||||
|
expect(getIconComponent('Users')).toBe(Users);
|
||||||
|
expect(getIconComponent('Heart')).toBe(Heart);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return Circle as fallback for invalid icon names', () => {
|
||||||
|
expect(getIconComponent('InvalidIcon')).toBe(Circle);
|
||||||
|
expect(getIconComponent('')).toBe(Circle);
|
||||||
|
expect(getIconComponent('undefined')).toBe(Circle);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null and undefined inputs', () => {
|
||||||
|
expect(getIconComponent(null as any)).toBe(Circle);
|
||||||
|
expect(getIconComponent(undefined as any)).toBe(Circle);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be case-sensitive', () => {
|
||||||
|
expect(getIconComponent('briefcase')).toBe(Circle);
|
||||||
|
expect(getIconComponent('BRIEFCASE')).toBe(Circle);
|
||||||
|
expect(getIconComponent('Briefcase')).toBe(Briefcase);
|
||||||
|
});
|
||||||
|
});
|
||||||
76
client/src/utils/exportUtils.ts
Normal file
76
client/src/utils/exportUtils.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { DataService } from '../services/dataService';
|
||||||
|
|
||||||
|
export const exportToJSON = (dataService: DataService): void => {
|
||||||
|
const dimensions = dataService.getDimensions();
|
||||||
|
const exportData = {
|
||||||
|
dimensions: dimensions.map(dimension => ({
|
||||||
|
...dimension,
|
||||||
|
kpis: dataService.getKPIsByDimension(dimension.id).map(kpi => ({
|
||||||
|
...kpi,
|
||||||
|
checks: dataService.getChecksByKPI(kpi.id)
|
||||||
|
}))
|
||||||
|
})),
|
||||||
|
exportedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
|
||||||
|
type: 'application/json'
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `due-diligence-export-${new Date().toISOString().split('T')[0]}.json`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const exportToCSV = (dataService: DataService): void => {
|
||||||
|
const dimensions = dataService.getDimensions();
|
||||||
|
const rows: string[] = [
|
||||||
|
'Dimension,KPI,Check Question,Check Type,Current Value,Expected Value,Is Completed,Reference URL,Comment,Created At'
|
||||||
|
];
|
||||||
|
|
||||||
|
dimensions.forEach(dimension => {
|
||||||
|
const kpis = dataService.getKPIsByDimension(dimension.id);
|
||||||
|
kpis.forEach(kpi => {
|
||||||
|
const checks = dataService.getChecksByKPI(kpi.id);
|
||||||
|
checks.forEach(check => {
|
||||||
|
const row = [
|
||||||
|
escapeCSV(dimension.name),
|
||||||
|
escapeCSV(kpi.name),
|
||||||
|
escapeCSV(check.question),
|
||||||
|
escapeCSV(check.check_type),
|
||||||
|
escapeCSV(check.current_value || ''),
|
||||||
|
escapeCSV(check.expected_value || ''),
|
||||||
|
check.is_completed ? 'Yes' : 'No',
|
||||||
|
escapeCSV(check.reference_url || ''),
|
||||||
|
escapeCSV(check.comment || ''),
|
||||||
|
escapeCSV(check.created_at || '')
|
||||||
|
].join(',');
|
||||||
|
rows.push(row);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const csvContent = rows.join('\n');
|
||||||
|
const blob = new Blob([csvContent], { type: 'text/csv' });
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `due-diligence-export-${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const escapeCSV = (value: string): string => {
|
||||||
|
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
|
||||||
|
return `"${value.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
30
client/src/utils/icons.ts
Normal file
30
client/src/utils/icons.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import {
|
||||||
|
Briefcase,
|
||||||
|
TrendingUp,
|
||||||
|
Target,
|
||||||
|
Building,
|
||||||
|
Code,
|
||||||
|
Shield,
|
||||||
|
DollarSign,
|
||||||
|
Users,
|
||||||
|
Heart,
|
||||||
|
Circle,
|
||||||
|
LucideIcon
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
const iconMap: Record<string, LucideIcon> = {
|
||||||
|
Briefcase,
|
||||||
|
TrendingUp,
|
||||||
|
Target,
|
||||||
|
Building,
|
||||||
|
Code,
|
||||||
|
Shield,
|
||||||
|
DollarSign,
|
||||||
|
Users,
|
||||||
|
Heart,
|
||||||
|
Circle
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getIconComponent = (iconName: string): LucideIcon => {
|
||||||
|
return iconMap[iconName] || Circle;
|
||||||
|
};
|
||||||
1
client/src/vite-env.d.ts
vendored
Normal file
1
client/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
11
client/tailwind.config.js
Normal file
11
client/tailwind.config.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
116
client/test-results/.last-run.json
Normal file
116
client/test-results/.last-run.json
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"failedTests": [
|
||||||
|
"7e00f922d46c41134293-f58281d14b245aa51bab",
|
||||||
|
"7e00f922d46c41134293-0c7c2890095398cc9310",
|
||||||
|
"7e00f922d46c41134293-8b84f965d5ce2d56d2b5",
|
||||||
|
"7e00f922d46c41134293-0163bd20e3cc02b904b5",
|
||||||
|
"7e00f922d46c41134293-b0571510c3c7afaf5477",
|
||||||
|
"7e00f922d46c41134293-f6a68a87078cc2e6d430",
|
||||||
|
"7e00f922d46c41134293-f72972f7fafde080c106",
|
||||||
|
"7e00f922d46c41134293-f008b365744759d1dfa5",
|
||||||
|
"7e00f922d46c41134293-25efbba3087038ae8a4a",
|
||||||
|
"7e00f922d46c41134293-ac347bbbf9390fe9b672",
|
||||||
|
"7e00f922d46c41134293-e8c9bce0553599fb1a10",
|
||||||
|
"90cda532ab82d274b30b-1009189270a2425ae491",
|
||||||
|
"90cda532ab82d274b30b-cf8cbf42f3ac19b2817f",
|
||||||
|
"90cda532ab82d274b30b-980be027682d11c166a4",
|
||||||
|
"90cda532ab82d274b30b-0cad1a62809d92f98311",
|
||||||
|
"90cda532ab82d274b30b-da796b0c43cfe81d8e53",
|
||||||
|
"90cda532ab82d274b30b-be337429dd7e6ff0756b",
|
||||||
|
"90cda532ab82d274b30b-d6f9ab1cd703509a1b51",
|
||||||
|
"90cda532ab82d274b30b-75f967fe22d699ceaadc",
|
||||||
|
"12632e2f23590a06441f-52c9aece4befd9597d7e",
|
||||||
|
"12632e2f23590a06441f-a5aeb7e140fbb81973aa",
|
||||||
|
"12632e2f23590a06441f-206f69fa982c695f218b",
|
||||||
|
"12632e2f23590a06441f-5c8922293ed9f03e242d",
|
||||||
|
"12632e2f23590a06441f-b88726f4224f3a4d2690",
|
||||||
|
"12632e2f23590a06441f-8680629aa30cf8f10db3",
|
||||||
|
"12632e2f23590a06441f-9511f0169bc36a6814a8",
|
||||||
|
"12632e2f23590a06441f-0793299b6873c6dd8849",
|
||||||
|
"b15d6361b2fc38288977-d90839824693c63642d0",
|
||||||
|
"b15d6361b2fc38288977-216724d80870892f8267",
|
||||||
|
"b15d6361b2fc38288977-37b7b011e3ecd523572d",
|
||||||
|
"b15d6361b2fc38288977-65d8bbe89ff1c0a360cd",
|
||||||
|
"b15d6361b2fc38288977-41a1b94658731e85fac8",
|
||||||
|
"b15d6361b2fc38288977-b3ef1ff1fffd500bc052",
|
||||||
|
"b15d6361b2fc38288977-25381e0f667e487d0f57",
|
||||||
|
"b15d6361b2fc38288977-52dd6623a29b330aae6c",
|
||||||
|
"b15d6361b2fc38288977-093799a943a1614c9790",
|
||||||
|
"b15d6361b2fc38288977-2a130d3226604f9b2821",
|
||||||
|
"7e00f922d46c41134293-63a942745dd6b3c86f86",
|
||||||
|
"7e00f922d46c41134293-73a9e80e91fb2f9510ec",
|
||||||
|
"7e00f922d46c41134293-926821663cdef922f956",
|
||||||
|
"7e00f922d46c41134293-b793129d5dae453290e5",
|
||||||
|
"7e00f922d46c41134293-92d1806fddfb79b7d378",
|
||||||
|
"7e00f922d46c41134293-a4b2aa076b68c119ed2d",
|
||||||
|
"7e00f922d46c41134293-6f2abdffdadfed7be253",
|
||||||
|
"7e00f922d46c41134293-6f378fef1cb559fd280b",
|
||||||
|
"7e00f922d46c41134293-e3ee26dddaa3db56dfd4",
|
||||||
|
"7e00f922d46c41134293-4c3695441f267059f1e3",
|
||||||
|
"7e00f922d46c41134293-2769fe56e2415d4d1742",
|
||||||
|
"90cda532ab82d274b30b-90e00aa1eb7f6b662c27",
|
||||||
|
"90cda532ab82d274b30b-2282e98cc6cec79349b8",
|
||||||
|
"90cda532ab82d274b30b-67efa245a3e4fad4426c",
|
||||||
|
"90cda532ab82d274b30b-ce1e5dcc3fc4afbe64d6",
|
||||||
|
"90cda532ab82d274b30b-967425240a26c7c4fed5",
|
||||||
|
"90cda532ab82d274b30b-28e9a812aab71144dd2c",
|
||||||
|
"90cda532ab82d274b30b-47774e3cc09cad5f5997",
|
||||||
|
"90cda532ab82d274b30b-85e5ed8a658c35acf3e7",
|
||||||
|
"12632e2f23590a06441f-af784571129d0bea99f1",
|
||||||
|
"12632e2f23590a06441f-2ff1b16c4af9478480eb",
|
||||||
|
"12632e2f23590a06441f-b946cbf3d63a9f299b1a",
|
||||||
|
"12632e2f23590a06441f-b941bb71c364ea4623e0",
|
||||||
|
"12632e2f23590a06441f-f60f47d31917848bb7a6",
|
||||||
|
"12632e2f23590a06441f-2b4e43af157e8be0cd0c",
|
||||||
|
"12632e2f23590a06441f-44c38017f2f73b38b307",
|
||||||
|
"12632e2f23590a06441f-185d3bd57908fee78af0",
|
||||||
|
"b15d6361b2fc38288977-1808c196bf92e4c3a814",
|
||||||
|
"b15d6361b2fc38288977-4320cca9f329835ef017",
|
||||||
|
"b15d6361b2fc38288977-964f94fc47c75de7aaec",
|
||||||
|
"b15d6361b2fc38288977-10d79efac8b63aee3793",
|
||||||
|
"b15d6361b2fc38288977-bd18847cb81bc0af98d8",
|
||||||
|
"b15d6361b2fc38288977-8ebad6aee174ceeebc53",
|
||||||
|
"b15d6361b2fc38288977-96f51b0135b51b4ab4bb",
|
||||||
|
"b15d6361b2fc38288977-b4b7d9aed9b2eed1fbf6",
|
||||||
|
"b15d6361b2fc38288977-0159e1fef62e283d06c7",
|
||||||
|
"b15d6361b2fc38288977-876b4da831cdeda13847",
|
||||||
|
"7e00f922d46c41134293-b19edcf68f8e25cda503",
|
||||||
|
"7e00f922d46c41134293-2d7a07f71fc8124de88c",
|
||||||
|
"7e00f922d46c41134293-6fd9011d4009ee8ba7f6",
|
||||||
|
"7e00f922d46c41134293-dfecc4c1d039d28263b4",
|
||||||
|
"7e00f922d46c41134293-56614efb2378a94f18e8",
|
||||||
|
"7e00f922d46c41134293-758ce465607c2fbca7b2",
|
||||||
|
"7e00f922d46c41134293-79b48e246d630628d93c",
|
||||||
|
"7e00f922d46c41134293-667a94b8a6ec9413ba78",
|
||||||
|
"7e00f922d46c41134293-8d568103a281e0dc97bd",
|
||||||
|
"7e00f922d46c41134293-8adc58382af877ca5b04",
|
||||||
|
"7e00f922d46c41134293-159eb58c62c71620b0a6",
|
||||||
|
"90cda532ab82d274b30b-14e0ffe5df41f266e262",
|
||||||
|
"90cda532ab82d274b30b-ce4b0b34c18622d774e9",
|
||||||
|
"90cda532ab82d274b30b-07b0f606396cd806bf12",
|
||||||
|
"90cda532ab82d274b30b-ced0882778d458127e36",
|
||||||
|
"90cda532ab82d274b30b-074d9dff313ee1140e3e",
|
||||||
|
"90cda532ab82d274b30b-faca2f1bf622c9db2e7c",
|
||||||
|
"90cda532ab82d274b30b-506006e1346eeacbb4c4",
|
||||||
|
"90cda532ab82d274b30b-d2db419d05bc6958110a",
|
||||||
|
"12632e2f23590a06441f-57ebb5c9118f0b23fd52",
|
||||||
|
"12632e2f23590a06441f-bd77c89db0f6d50f1497",
|
||||||
|
"12632e2f23590a06441f-b3f422bab23230330c48",
|
||||||
|
"12632e2f23590a06441f-48589d4cd72a6edcda1a",
|
||||||
|
"12632e2f23590a06441f-dbba0120bf644c5cfc92",
|
||||||
|
"12632e2f23590a06441f-8890aa08cee6d78d7ad7",
|
||||||
|
"12632e2f23590a06441f-1c3be8cc93685201106b",
|
||||||
|
"12632e2f23590a06441f-988e1acc31dea0550049",
|
||||||
|
"b15d6361b2fc38288977-1ef8cb577b364bb3ca66",
|
||||||
|
"b15d6361b2fc38288977-9d5c799925dbb683b6a5",
|
||||||
|
"b15d6361b2fc38288977-15cdf819fe109e7cfbb6",
|
||||||
|
"b15d6361b2fc38288977-1aa432a9128f2bb61899",
|
||||||
|
"b15d6361b2fc38288977-4e7989b6913f7be0be1a",
|
||||||
|
"b15d6361b2fc38288977-3ffc3dbaf036dd52d60a",
|
||||||
|
"b15d6361b2fc38288977-24e152aa8f20515d2e2c",
|
||||||
|
"b15d6361b2fc38288977-2102dee55420791c03ce",
|
||||||
|
"b15d6361b2fc38288977-d0c6c45a17a267206352",
|
||||||
|
"b15d6361b2fc38288977-03d98487190d473d3dda"
|
||||||
|
]
|
||||||
|
}
|
||||||
28
client/tsconfig.app.json
Normal file
28
client/tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": false,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": false,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"erasableSyntaxOnly": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["**/*.test.*", "**/__tests__/**", "**/test/**"]
|
||||||
|
}
|
||||||
7
client/tsconfig.json
Normal file
7
client/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
25
client/tsconfig.node.json
Normal file
25
client/tsconfig.node.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
18
client/vite.config.ts
Normal file
18
client/vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/// <reference types="vitest" />
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: true,
|
||||||
|
allowedHosts: 'all'
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: './src/test/setup.ts',
|
||||||
|
exclude: ['**/e2e/**', '**/node_modules/**'],
|
||||||
|
},
|
||||||
|
})
|
||||||
231
mockup.tsx
Normal file
231
mockup.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { ChevronDown, ChevronRight, Target, Users, Building, Code, Shield, DollarSign, Briefcase, Heart, TrendingUp } from 'lucide-react';
|
||||||
|
|
||||||
|
const ExecutiveDashboard = () => {
|
||||||
|
const [expandedDimension, setExpandedDimension] = useState(null);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
strategy: { kpis: 6, open: 15, completed: 15, progress: 50 },
|
||||||
|
operational: {
|
||||||
|
sales: { kpis: 3, open: 5, completed: 3, progress: 38, icon: TrendingUp, shade: '#10B981' },
|
||||||
|
product: { kpis: 5, open: 19, completed: 5, progress: 21, icon: Target, shade: '#3B82F6' },
|
||||||
|
organization: { kpis: 5, open: 5, completed: 5, progress: 50, icon: Building, shade: '#8B5CF6' },
|
||||||
|
engineering: { kpis: 5, open: 28, completed: 4, progress: 13, icon: Code, shade: '#F59E0B' },
|
||||||
|
devops: { kpis: 3, open: 6, completed: 3, progress: 33, icon: Shield, shade: '#EF4444' },
|
||||||
|
cyber: { kpis: 6, open: 29, completed: 6, progress: 17, icon: Shield, shade: '#6B7280' },
|
||||||
|
finance: { kpis: 13, open: 3, completed: 13, progress: 81, icon: DollarSign, shade: '#059669' }
|
||||||
|
},
|
||||||
|
foundation: {
|
||||||
|
people: { kpis: 5, open: 7, completed: 5, progress: 42, icon: Users, shade: '#DC2626' },
|
||||||
|
culture: { kpis: 3, open: 2, completed: 3, progress: 60, icon: Heart, shade: '#7C3AED' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDimension = (dimension) => {
|
||||||
|
setExpandedDimension(expandedDimension === dimension ? null : dimension);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProgressBar = ({ progress, shade = '#333333', height = '8px' }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bg-gray-200 rounded-full relative overflow-hidden"
|
||||||
|
style={{ height, width: '96px' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-300 ease-in-out"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(progress, 100)}%`,
|
||||||
|
backgroundColor: shade
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DetailedView = () => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Strategy Section */}
|
||||||
|
<div className="bg-white rounded-xl border-2 border-gray-300 p-6 shadow-sm">
|
||||||
|
<div className="border-2 border-gray-400 rounded-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleDimension('strategy')}
|
||||||
|
className="w-full p-4 flex items-center justify-between hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="p-2 border-2 border-gray-400 rounded-full bg-gray-100">
|
||||||
|
<Briefcase className="w-5 h-5 text-gray-700" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<h4 className="font-bold text-gray-900 text-lg">Strategy</h4>
|
||||||
|
<p className="text-sm text-gray-700 font-medium">{data.strategy.kpis} KPIs • {data.strategy.open + data.strategy.completed} total items</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="font-bold text-xl text-gray-900">{Math.round(data.strategy.progress)}%</div>
|
||||||
|
<div className="w-24">
|
||||||
|
<ProgressBar progress={data.strategy.progress} shade="#4F46E5" height="6px" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{expandedDimension === 'strategy' ? <ChevronDown className="w-5 h-5 text-gray-600" /> : <ChevronRight className="w-5 h-5 text-gray-600" />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expandedDimension === 'strategy' && (
|
||||||
|
<div className="px-4 pb-4 border-t-2 border-gray-300 bg-gray-50 rounded-b-lg">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
|
||||||
|
<div className="text-center p-4 bg-white rounded-lg border-2 border-gray-300">
|
||||||
|
<div className="text-3xl font-bold text-gray-900">{data.strategy.kpis}</div>
|
||||||
|
<div className="text-sm text-gray-700 font-medium">Key Performance Indicators</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-white rounded-lg border-2 border-gray-300">
|
||||||
|
<div className="text-3xl font-bold text-gray-700">{data.strategy.open}</div>
|
||||||
|
<div className="text-sm text-gray-700 font-medium">Open Items</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-white rounded-lg border-2 border-gray-300">
|
||||||
|
<div className="text-3xl font-bold text-gray-900">{data.strategy.completed}</div>
|
||||||
|
<div className="text-sm text-gray-700 font-medium">Completed Items</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* All Operational Dimensions */}
|
||||||
|
<div className="bg-white rounded-xl border-2 border-gray-300 p-6 shadow-sm">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Object.entries(data.operational).map(([name, dim]) => {
|
||||||
|
const Icon = dim.icon;
|
||||||
|
const isExpanded = expandedDimension === name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={name} className="border-2 border-gray-400 rounded-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleDimension(name)}
|
||||||
|
className="w-full p-4 flex items-center justify-between hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="p-2 border-2 border-gray-400 rounded-full bg-gray-100">
|
||||||
|
<Icon className="w-5 h-5" style={{ color: dim.shade }} />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<h4 className="font-bold text-gray-900 capitalize">{name}</h4>
|
||||||
|
<p className="text-sm text-gray-700 font-medium">{dim.kpis} KPIs • {dim.open + dim.completed} total items</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="font-bold text-lg" style={{ color: dim.shade }}>{Math.round(dim.progress)}%</div>
|
||||||
|
<div className="w-24">
|
||||||
|
<ProgressBar progress={dim.progress} shade={dim.shade} height="4px" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isExpanded ? <ChevronDown className="w-5 h-5 text-gray-600" /> : <ChevronRight className="w-5 h-5 text-gray-600" />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="px-4 pb-4 border-t-2 border-gray-300 bg-gray-50 rounded-b-lg">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
|
||||||
|
<div className="text-center p-4 bg-white rounded-lg border-2 border-gray-300">
|
||||||
|
<div className="text-3xl font-bold" style={{ color: dim.shade }}>{dim.kpis}</div>
|
||||||
|
<div className="text-sm text-gray-700 font-medium">Key Performance Indicators</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-white rounded-lg border-2 border-gray-300">
|
||||||
|
<div className="text-3xl font-bold text-gray-700">{dim.open}</div>
|
||||||
|
<div className="text-sm text-gray-700 font-medium">Open Items</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-white rounded-lg border-2 border-gray-300">
|
||||||
|
<div className="text-3xl font-bold text-gray-900">{dim.completed}</div>
|
||||||
|
<div className="text-sm text-gray-700 font-medium">Completed Items</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Foundation Dimensions */}
|
||||||
|
<div className="bg-white rounded-xl border-2 border-gray-300 p-6 shadow-sm">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Object.entries(data.foundation).map(([name, dim]) => {
|
||||||
|
const Icon = dim.icon;
|
||||||
|
const isExpanded = expandedDimension === name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={name} className="border-2 border-gray-400 rounded-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleDimension(name)}
|
||||||
|
className="w-full p-4 flex items-center justify-between hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="p-2 border-2 border-gray-400 rounded-full bg-gray-100">
|
||||||
|
<Icon className="w-5 h-5" style={{ color: dim.shade }} />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<h4 className="font-bold text-gray-900 capitalize">{name}</h4>
|
||||||
|
<p className="text-sm text-gray-700 font-medium">{dim.kpis} KPIs • {dim.open + dim.completed} total items</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="font-bold text-lg" style={{ color: dim.shade }}>{Math.round(dim.progress)}%</div>
|
||||||
|
<div className="w-24">
|
||||||
|
<ProgressBar progress={dim.progress} shade={dim.shade} height="4px" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isExpanded ? <ChevronDown className="w-5 h-5 text-gray-600" /> : <ChevronRight className="w-5 h-5 text-gray-600" />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="px-4 pb-4 border-t-2 border-gray-300 bg-gray-50 rounded-b-lg">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
|
||||||
|
<div className="text-center p-4 bg-white rounded-lg border-2 border-gray-300">
|
||||||
|
<div className="text-3xl font-bold" style={{ color: dim.shade }}>{dim.kpis}</div>
|
||||||
|
<div className="text-sm text-gray-700 font-medium">Key Performance Indicators</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-white rounded-lg border-2 border-gray-300">
|
||||||
|
<div className="text-3xl font-bold text-gray-700">{dim.open}</div>
|
||||||
|
<div className="text-sm text-gray-700 font-medium">Open Items</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-white rounded-lg border-2 border-gray-300">
|
||||||
|
<div className="text-3xl font-bold text-gray-900">{dim.completed}</div>
|
||||||
|
<div className="text-sm text-gray-700 font-medium">Completed Items</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 p-6 print:bg-white print:p-4">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<style jsx>{`
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust: exact; }
|
||||||
|
.shadow-sm { box-shadow: none !important; }
|
||||||
|
.shadow-lg { box-shadow: none !important; }
|
||||||
|
.hover\\:bg-gray-100:hover { background-color: transparent !important; }
|
||||||
|
button { cursor: default !important; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<DetailedView />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExecutiveDashboard;
|
||||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "due-diligence-tracker",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
389
prd.md
Normal file
389
prd.md
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
# Due Diligence Tracking System
|
||||||
|
## Product Requirements Document (PRD)
|
||||||
|
|
||||||
|
### 1. Executive Summary
|
||||||
|
|
||||||
|
A web-based due diligence tracking system that enables systematic evaluation of corporate dimensions through KPIs and checks. The system provides a dashboard overview of progress across multiple dimensions with drill-down capabilities to individual checks.
|
||||||
|
|
||||||
|
### 2. System Architecture
|
||||||
|
|
||||||
|
#### 2.1 Technology Stack
|
||||||
|
- **Frontend**: React with TypeScript
|
||||||
|
- **Styling**: Tailwind CSS
|
||||||
|
- **Database**: SQLite (via sql.js for browser compatibility)
|
||||||
|
- **File Storage**: Local file system (server-side)
|
||||||
|
- **Build Tool**: Vite
|
||||||
|
- **Deployment**: Self-hosted on dedicated server
|
||||||
|
|
||||||
|
#### 2.2 Data Architecture
|
||||||
|
```
|
||||||
|
Database: SQLite
|
||||||
|
├── dimensions (id, name, order, created_at)
|
||||||
|
├── kpis (id, dimension_id, name, order, created_at)
|
||||||
|
├── checks (id, kpi_id, question, check_type, current_value, expected_value,
|
||||||
|
│ reference_url, reference_file_id, comment, is_completed, created_at, updated_at)
|
||||||
|
├── check_types (id, name, data_type, options_json)
|
||||||
|
└── files (id, original_name, stored_path, mime_type, size_bytes, uploaded_at)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Data Model
|
||||||
|
|
||||||
|
#### 3.1 Hierarchy
|
||||||
|
- **Dimension** → **KPIs** → **Checks** (strict 3-level hierarchy)
|
||||||
|
- One-to-many relationships throughout
|
||||||
|
- No cross-references between checks and KPIs
|
||||||
|
|
||||||
|
#### 3.2 Check Structure
|
||||||
|
Each check contains:
|
||||||
|
1. **Question/Statement** (text): The evaluation criteria
|
||||||
|
2. **Current Value** (dynamic type based on check_type)
|
||||||
|
3. **Expected Value** (dynamic type based on check_type)
|
||||||
|
4. **Reference** (URL or file upload)
|
||||||
|
5. **Comment** (text): Additional notes
|
||||||
|
6. **Is Completed** (boolean): Manual completion flag
|
||||||
|
|
||||||
|
#### 3.3 Check Types
|
||||||
|
- **Text**: Free-form text input
|
||||||
|
- **Dropdown**: Predefined options stored in JSON
|
||||||
|
- **Number**: Numeric input with optional min/max
|
||||||
|
- **Percentage**: 0-100 with % display
|
||||||
|
|
||||||
|
Example check_types configuration:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"development_method": {
|
||||||
|
"type": "dropdown",
|
||||||
|
"options": ["Traditional", "Agile", "Waterfall", "Hybrid"]
|
||||||
|
},
|
||||||
|
"team_size": {
|
||||||
|
"type": "number",
|
||||||
|
"min": 1,
|
||||||
|
"max": 1000
|
||||||
|
},
|
||||||
|
"completion_rate": {
|
||||||
|
"type": "percentage"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Core Features
|
||||||
|
|
||||||
|
#### 4.1 Dashboard View
|
||||||
|
- **Overview Cards**: Display each dimension with:
|
||||||
|
- Icon and name
|
||||||
|
- KPI count
|
||||||
|
- Total items (open + completed)
|
||||||
|
- Progress percentage
|
||||||
|
- Visual progress bar
|
||||||
|
- **Expandable Sections**: Click to reveal KPI details
|
||||||
|
- **Progress Calculation**: (Completed checks / Total checks) × 100%
|
||||||
|
|
||||||
|
#### 4.2 Check Management
|
||||||
|
- **Add Check**: Form with dynamic fields based on check type
|
||||||
|
- **Edit Check**: In-place editing with validation
|
||||||
|
- **Complete Check**: Checkbox to mark as done
|
||||||
|
- **Bulk Actions**: Select multiple checks for completion/deletion
|
||||||
|
|
||||||
|
#### 4.3 File Handling
|
||||||
|
- **Upload**: Drag-and-drop or click to upload
|
||||||
|
- **File Types**: All types accepted (max 100MB)
|
||||||
|
- **Storage**: Server file system with database reference
|
||||||
|
- **Preview**: Quick preview for common formats (PDF, images)
|
||||||
|
|
||||||
|
### 5. User Interface
|
||||||
|
|
||||||
|
#### 5.1 Design System Options
|
||||||
|
|
||||||
|
**Option A: Professional Blue**
|
||||||
|
- Primary: #3B82F6 (Blue)
|
||||||
|
- Success: #10B981 (Green)
|
||||||
|
- Warning: #F59E0B (Amber)
|
||||||
|
- Error: #EF4444 (Red)
|
||||||
|
- Neutral: Gray scale
|
||||||
|
|
||||||
|
**Option B: Modern Purple**
|
||||||
|
- Primary: #8B5CF6 (Purple)
|
||||||
|
- Success: #10B981 (Green)
|
||||||
|
- Warning: #F59E0B (Amber)
|
||||||
|
- Error: #EF4444 (Red)
|
||||||
|
- Neutral: Slate scale
|
||||||
|
|
||||||
|
**Option C: Corporate Dark**
|
||||||
|
- Primary: #1F2937 (Dark Gray)
|
||||||
|
- Success: #059669 (Emerald)
|
||||||
|
- Warning: #D97706 (Amber)
|
||||||
|
- Error: #DC2626 (Red)
|
||||||
|
- Neutral: Gray scale
|
||||||
|
|
||||||
|
#### 5.2 Layout Structure
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Header (Logo, Title, Actions) │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ ┌─────────┐ ┌─────────┐ ┌─────────┐│
|
||||||
|
│ │Strategy │ │ Sales │ │Product ││ <- Dimension Cards
|
||||||
|
│ └─────────┘ └─────────┘ └─────────┘│
|
||||||
|
│ ┌─────────┐ ┌─────────┐ ┌─────────┐│
|
||||||
|
│ │Engineer │ │ DevOps │ │ Cyber ││
|
||||||
|
│ └─────────┘ └─────────┘ └─────────┘│
|
||||||
|
│ ┌─────────┐ ┌─────────┐ │
|
||||||
|
│ │ People │ │Culture │ │
|
||||||
|
│ └─────────┘ └─────────┘ │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
|
||||||
|
Expanded View:
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ ▼ Engineering │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ KPI: Development Efficiency │
|
||||||
|
│ ├─ Check 1: Dev methodology │
|
||||||
|
│ ├─ Check 2: Sprint velocity │
|
||||||
|
│ └─ Check 3: Code coverage │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Functional Requirements
|
||||||
|
|
||||||
|
#### 6.1 Phase 1 (MVP)
|
||||||
|
- [ ] Dashboard with all dimensions
|
||||||
|
- [ ] Expandable dimension views
|
||||||
|
- [ ] Add/Edit/Complete checks
|
||||||
|
- [ ] File upload and reference
|
||||||
|
- [ ] Progress tracking
|
||||||
|
- [ ] Data persistence in SQLite
|
||||||
|
- [ ] Export data to JSON/CSV
|
||||||
|
|
||||||
|
#### 6.2 Phase 2 (Future)
|
||||||
|
- [ ] Configurable dimensions
|
||||||
|
- [ ] Advanced queries and filters
|
||||||
|
- [ ] PDF report generation
|
||||||
|
- [ ] Historical progress tracking
|
||||||
|
- [ ] Multi-user support
|
||||||
|
- [ ] Check templates
|
||||||
|
- [ ] Automated reminders
|
||||||
|
|
||||||
|
### 7. Technical Implementation
|
||||||
|
|
||||||
|
#### 7.1 Database Schema
|
||||||
|
```sql
|
||||||
|
-- Dimensions table
|
||||||
|
CREATE TABLE dimensions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
icon TEXT,
|
||||||
|
color TEXT,
|
||||||
|
order_index INTEGER,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- KPIs table
|
||||||
|
CREATE TABLE kpis (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
dimension_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
order_index INTEGER,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (dimension_id) REFERENCES dimensions(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Checks table
|
||||||
|
CREATE TABLE checks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
kpi_id INTEGER NOT NULL,
|
||||||
|
question TEXT NOT NULL,
|
||||||
|
check_type TEXT NOT NULL,
|
||||||
|
current_value TEXT,
|
||||||
|
expected_value TEXT,
|
||||||
|
reference_url TEXT,
|
||||||
|
reference_file_id INTEGER,
|
||||||
|
comment TEXT,
|
||||||
|
is_completed BOOLEAN DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (kpi_id) REFERENCES kpis(id),
|
||||||
|
FOREIGN KEY (reference_file_id) REFERENCES files(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Files table
|
||||||
|
CREATE TABLE files (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
original_name TEXT NOT NULL,
|
||||||
|
stored_path TEXT NOT NULL,
|
||||||
|
mime_type TEXT,
|
||||||
|
size_bytes INTEGER,
|
||||||
|
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Check types configuration
|
||||||
|
CREATE TABLE check_types (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
data_type TEXT NOT NULL,
|
||||||
|
options_json TEXT,
|
||||||
|
validation_json TEXT
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 7.2 API Endpoints
|
||||||
|
```
|
||||||
|
GET /api/dimensions
|
||||||
|
GET /api/dimensions/:id/kpis
|
||||||
|
GET /api/kpis/:id/checks
|
||||||
|
POST /api/checks
|
||||||
|
PUT /api/checks/:id
|
||||||
|
DELETE /api/checks/:id
|
||||||
|
POST /api/files/upload
|
||||||
|
GET /api/files/:id
|
||||||
|
GET /api/export/:format (json|csv)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Non-Functional Requirements
|
||||||
|
|
||||||
|
#### 8.1 Performance
|
||||||
|
- Dashboard load time < 2 seconds
|
||||||
|
- Check updates < 500ms
|
||||||
|
- Support 10,000+ checks without degradation
|
||||||
|
|
||||||
|
#### 8.2 Security
|
||||||
|
- File upload validation
|
||||||
|
- SQL injection prevention
|
||||||
|
- XSS protection
|
||||||
|
- File size limits (100MB)
|
||||||
|
|
||||||
|
#### 8.3 Usability
|
||||||
|
- Responsive design (mobile, tablet, desktop)
|
||||||
|
- Keyboard navigation support
|
||||||
|
- Print-friendly views
|
||||||
|
- Intuitive drag-and-drop
|
||||||
|
|
||||||
|
### 9. Initial Data Structure
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dimensions": [
|
||||||
|
{
|
||||||
|
"name": "Strategy",
|
||||||
|
"icon": "Briefcase",
|
||||||
|
"kpis": [
|
||||||
|
{
|
||||||
|
"name": "Strategic Alignment",
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"question": "Is the company vision clearly defined?",
|
||||||
|
"check_type": "dropdown",
|
||||||
|
"options": ["Yes", "Partially", "No"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sales",
|
||||||
|
"icon": "TrendingUp",
|
||||||
|
"kpis": [
|
||||||
|
{
|
||||||
|
"name": "Sales Performance",
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"question": "Monthly revenue growth rate",
|
||||||
|
"check_type": "percentage"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Engineering",
|
||||||
|
"icon": "Code",
|
||||||
|
"kpis": [
|
||||||
|
{
|
||||||
|
"name": "Development Practices",
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"question": "Development methodology",
|
||||||
|
"check_type": "dropdown",
|
||||||
|
"options": ["Traditional", "Agile", "Waterfall", "Hybrid"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "Average sprint velocity",
|
||||||
|
"check_type": "number"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10. Development Roadmap
|
||||||
|
|
||||||
|
#### Week 1-2: Foundation
|
||||||
|
- Set up project structure
|
||||||
|
- Implement SQLite integration
|
||||||
|
- Create basic UI components
|
||||||
|
- Dashboard layout
|
||||||
|
|
||||||
|
#### Week 3-4: Core Features
|
||||||
|
- Check CRUD operations
|
||||||
|
- File upload system
|
||||||
|
- Progress calculations
|
||||||
|
- Expand/collapse functionality
|
||||||
|
|
||||||
|
#### Week 5-6: Polish & Testing
|
||||||
|
- Validation rules
|
||||||
|
- Export functionality
|
||||||
|
- Responsive design
|
||||||
|
- Performance optimization
|
||||||
|
|
||||||
|
### 11. Success Metrics
|
||||||
|
|
||||||
|
- **Efficiency**: 50% reduction in due diligence tracking time
|
||||||
|
- **Completeness**: 100% of checks documented
|
||||||
|
- **Visibility**: Real-time progress tracking
|
||||||
|
- **Usability**: < 5 minute onboarding time
|
||||||
|
|
||||||
|
### 12. Appendix
|
||||||
|
|
||||||
|
#### Sample Check Types Configuration
|
||||||
|
```javascript
|
||||||
|
const checkTypes = {
|
||||||
|
text: {
|
||||||
|
component: 'TextInput',
|
||||||
|
validation: null
|
||||||
|
},
|
||||||
|
dropdown: {
|
||||||
|
component: 'SelectInput',
|
||||||
|
validation: 'requiredOption'
|
||||||
|
},
|
||||||
|
number: {
|
||||||
|
component: 'NumberInput',
|
||||||
|
validation: 'numeric'
|
||||||
|
},
|
||||||
|
percentage: {
|
||||||
|
component: 'PercentageInput',
|
||||||
|
validation: 'range:0:100'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### File Structure
|
||||||
|
```
|
||||||
|
due-diligence-tracker/
|
||||||
|
├── client/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ ├── pages/
|
||||||
|
│ │ ├── hooks/
|
||||||
|
│ │ ├── utils/
|
||||||
|
│ │ └── types/
|
||||||
|
│ └── public/
|
||||||
|
├── server/
|
||||||
|
│ ├── api/
|
||||||
|
│ ├── db/
|
||||||
|
│ ├── uploads/
|
||||||
|
│ └── utils/
|
||||||
|
└── shared/
|
||||||
|
└── types/
|
||||||
|
```
|
||||||
164
prompt.md
Normal file
164
prompt.md
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
Due Diligence Tracking System
|
||||||
|
I need you to build a due diligence tracking system based on the attached PRD and mockup. This is a web application for tracking corporate evaluation progress across multiple dimensions.
|
||||||
|
|
||||||
|
Project Overview
|
||||||
|
Build a React/TypeScript application with:
|
||||||
|
|
||||||
|
Dashboard showing dimensions (Strategy, Sales, Product, Engineering, etc.) with progress tracking
|
||||||
|
Expandable cards revealing KPIs and their associated checks
|
||||||
|
SQLite database for data persistence
|
||||||
|
Local file storage for document references
|
||||||
|
Clean, professional UI matching the attached mockup
|
||||||
|
Technical Requirements
|
||||||
|
Stack:
|
||||||
|
|
||||||
|
Frontend: React 18+ with TypeScript
|
||||||
|
Styling: Tailwind CSS
|
||||||
|
Database: SQLite (use sql.js for browser or better-sqlite3 for Node)
|
||||||
|
Build: Vite
|
||||||
|
Backend: Express.js (minimal API)
|
||||||
|
Key Features to Implement:
|
||||||
|
|
||||||
|
Dashboard Layout
|
||||||
|
Dimension cards with icon, name, KPI count, and progress bars
|
||||||
|
Expandable sections showing KPI details
|
||||||
|
Progress calculated as (completed checks / total checks) × 100%
|
||||||
|
Responsive design with print support
|
||||||
|
Data Model
|
||||||
|
Dimensions → KPIs → Checks (strict hierarchy)
|
||||||
|
Check structure: question, current_value, expected_value, reference, comment, is_completed
|
||||||
|
Check types: text, dropdown, number, percentage
|
||||||
|
File uploads up to 100MB
|
||||||
|
Core Functionality
|
||||||
|
CRUD operations for checks
|
||||||
|
Mark checks as complete/incomplete
|
||||||
|
File upload with drag-and-drop
|
||||||
|
Export data to JSON/CSV
|
||||||
|
Progress tracking per dimension/KPI
|
||||||
|
Implementation Steps
|
||||||
|
Project Setup
|
||||||
|
bash
|
||||||
|
# Create the project structure
|
||||||
|
due-diligence-tracker/
|
||||||
|
├── client/ # React frontend
|
||||||
|
├── server/ # Express backend
|
||||||
|
└── shared/ # Shared types
|
||||||
|
Database Schema
|
||||||
|
Create SQLite database with tables: dimensions, kpis, checks, files, check_types
|
||||||
|
Seed with initial data (Strategy, Sales, Product, etc.)
|
||||||
|
Set up migrations
|
||||||
|
Frontend Components
|
||||||
|
components/
|
||||||
|
├── Dashboard/
|
||||||
|
│ ├── DimensionCard.tsx
|
||||||
|
│ ├── ProgressBar.tsx
|
||||||
|
│ └── KPIExpanded.tsx
|
||||||
|
├── Checks/
|
||||||
|
│ ├── CheckForm.tsx
|
||||||
|
│ ├── CheckList.tsx
|
||||||
|
│ └── CheckItem.tsx
|
||||||
|
└── Common/
|
||||||
|
├── FileUpload.tsx
|
||||||
|
└── DynamicInput.tsx
|
||||||
|
API Endpoints
|
||||||
|
GET /api/dimensions # With nested KPIs and check counts
|
||||||
|
GET /api/kpis/:id/checks # All checks for a KPI
|
||||||
|
POST /api/checks # Create check
|
||||||
|
PUT /api/checks/:id # Update check
|
||||||
|
POST /api/files/upload # Handle file uploads
|
||||||
|
GET /api/export/:format # Export data
|
||||||
|
UI/UX Requirements
|
||||||
|
Match the gray theme from mockup with customizable accent colors
|
||||||
|
Smooth expand/collapse animations
|
||||||
|
Loading states and error handling
|
||||||
|
Keyboard navigation support
|
||||||
|
Specific Implementation Details
|
||||||
|
Check Type System:
|
||||||
|
|
||||||
|
typescript
|
||||||
|
interface CheckType {
|
||||||
|
type: 'text' | 'dropdown' | 'number' | 'percentage';
|
||||||
|
options?: string[]; // For dropdowns
|
||||||
|
validation?: {
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
required?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic rendering based on check type
|
||||||
|
const DynamicInput = ({ checkType, value, onChange }) => {
|
||||||
|
switch(checkType.type) {
|
||||||
|
case 'dropdown': return <Select options={checkType.options} ... />
|
||||||
|
case 'percentage': return <PercentageInput ... />
|
||||||
|
// etc.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Progress Calculation:
|
||||||
|
|
||||||
|
typescript
|
||||||
|
// For each dimension
|
||||||
|
const calculateProgress = (checks: Check[]) => {
|
||||||
|
const completed = checks.filter(c => c.is_completed).length;
|
||||||
|
return (completed / checks.length) * 100;
|
||||||
|
}
|
||||||
|
File Handling:
|
||||||
|
|
||||||
|
Store files in server/uploads/ with UUID filenames
|
||||||
|
Save metadata in database
|
||||||
|
Support drag-and-drop and click-to-upload
|
||||||
|
Show file preview for common types
|
||||||
|
Initial Data Structure
|
||||||
|
Please use this seed data for dimensions:
|
||||||
|
|
||||||
|
javascript
|
||||||
|
const seedData = {
|
||||||
|
dimensions: [
|
||||||
|
{ name: 'Strategy', icon: 'Briefcase', color: '#4F46E5' },
|
||||||
|
{ name: 'Sales', icon: 'TrendingUp', color: '#10B981' },
|
||||||
|
{ name: 'Product', icon: 'Target', color: '#3B82F6' },
|
||||||
|
{ name: 'Organization', icon: 'Building', color: '#8B5CF6' },
|
||||||
|
{ name: 'Engineering', icon: 'Code', color: '#F59E0B' },
|
||||||
|
{ name: 'DevOps', icon: 'Shield', color: '#EF4444' },
|
||||||
|
{ name: 'Cyber', icon: 'Shield', color: '#6B7280' },
|
||||||
|
{ name: 'Finance', icon: 'DollarSign', color: '#059669' },
|
||||||
|
{ name: 'People', icon: 'Users', color: '#DC2626' },
|
||||||
|
{ name: 'Culture', icon: 'Heart', color: '#7C3AED' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
Code Quality Requirements
|
||||||
|
TypeScript: Strict mode, proper interfaces for all data structures
|
||||||
|
Error Handling: Try-catch blocks, user-friendly error messages
|
||||||
|
Performance: Lazy loading for checks, debounced search
|
||||||
|
Accessibility: ARIA labels, keyboard navigation
|
||||||
|
Testing: Basic unit tests for critical functions
|
||||||
|
Styling Guidelines
|
||||||
|
Use Tailwind classes matching the mockup:
|
||||||
|
|
||||||
|
Cards: bg-white rounded-xl border-2 border-gray-300 p-6 shadow-sm
|
||||||
|
Buttons: hover:bg-gray-100 transition-colors
|
||||||
|
Progress bars: Custom component with dynamic color based on dimension
|
||||||
|
Icons: Use lucide-react icons as shown in mockup
|
||||||
|
Development Priorities
|
||||||
|
Phase 1: Get the dashboard working with static data
|
||||||
|
Phase 2: Add database and CRUD operations
|
||||||
|
Phase 3: Implement file uploads and references
|
||||||
|
Phase 4: Add export functionality
|
||||||
|
Phase 5: Polish UI and add validation
|
||||||
|
Important Notes
|
||||||
|
Keep the UI clean and professional - avoid over-designing
|
||||||
|
The mockup's expand/collapse functionality is critical
|
||||||
|
Progress bars should update in real-time as checks are completed
|
||||||
|
Make it easy to add new dimensions/KPIs/checks in the future
|
||||||
|
File storage is local - no cloud integration needed
|
||||||
|
Focus on desktop experience first, then make responsive
|
||||||
|
Example Usage Flow
|
||||||
|
User sees dashboard with all dimensions
|
||||||
|
Clicks on "Engineering" dimension to expand
|
||||||
|
Sees list of KPIs with their checks
|
||||||
|
Clicks on a check to edit
|
||||||
|
Updates current value from "Traditional" to "Agile"
|
||||||
|
Uploads a PDF reference document
|
||||||
|
Marks check as complete
|
||||||
|
Progress bar updates automatically
|
||||||
|
Please start by setting up the project structure and creating the dashboard with expandable dimension cards. Let me know if you need clarification on any requirements!
|
||||||
Reference in New Issue
Block a user