Initial commit: ZiRA Classification Tool for Zuyderland CMDB
This commit is contained in:
10
.claude/settings.local.json
Normal file
10
.claude/settings.local.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(/opt/homebrew/bin/npm run build)",
|
||||
"Bash(export PATH=\"/opt/homebrew/bin:$PATH\")",
|
||||
"Bash(npx tsc:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
49
.env.example
Normal file
49
.env.example
Normal file
@@ -0,0 +1,49 @@
|
||||
# Jira Assets Configuration
|
||||
JIRA_HOST=https://jira.zuyderland.nl
|
||||
JIRA_PAT=your_personal_access_token_here
|
||||
JIRA_SCHEMA_ID=your_schema_id
|
||||
|
||||
JIRA_API_BATCH_SIZE=20
|
||||
|
||||
# Object Type IDs (retrieve via API)
|
||||
JIRA_APPLICATION_COMPONENT_TYPE_ID=your_type_id
|
||||
JIRA_APPLICATION_FUNCTION_TYPE_ID=your_function_type_id
|
||||
JIRA_DYNAMICS_FACTOR_TYPE_ID=your_dynamics_factor_type_id
|
||||
JIRA_COMPLEXITY_FACTOR_TYPE_ID=your_complexity_factor_type_id
|
||||
JIRA_NUMBER_OF_USERS_TYPE_ID=your_number_of_users_type_id
|
||||
JIRA_GOVERNANCE_MODEL_TYPE_ID=your_governance_model_type_id
|
||||
JIRA_APPLICATION_CLUSTER_TYPE_ID=your_application_cluster_type_id
|
||||
JIRA_APPLICATION_TYPE_TYPE_ID=your_application_type_type_id
|
||||
JIRA_BUSINESS_IMPACT_ANALYSE_TYPE_ID=your_business_impact_analyse_type_id
|
||||
JIRA_HOSTING_TYPE_TYPE_ID=your_hosting_type_type_id
|
||||
JIRA_HOSTING_TYPE_ID=your_hosting_type_id
|
||||
JIRA_TAM_TYPE_ID=your_tam_type_id
|
||||
|
||||
# Attribute IDs (retrieve via API - needed for updates)
|
||||
JIRA_ATTR_APPLICATION_FUNCTION=attribute_id
|
||||
JIRA_ATTR_DYNAMICS_FACTOR=attribute_id
|
||||
JIRA_ATTR_COMPLEXITY_FACTOR=attribute_id
|
||||
JIRA_ATTR_NUMBER_OF_USERS=attribute_id
|
||||
JIRA_ATTR_GOVERNANCE_MODEL=attribute_id
|
||||
JIRA_ATTR_APPLICATION_CLUSTER=attribute_id
|
||||
JIRA_ATTR_APPLICATION_TYPE=attribute_id
|
||||
JIRA_ATTR_PLATFORM=attribute_id
|
||||
JIRA_ATTR_BUSINESS_IMPACT_ANALYSE=attribute_id
|
||||
JIRA_ATTR_HOSTING_TYPE=attribute_id
|
||||
JIRA_ATTR_TECHNISCHE_ARCHITECTUUR=attribute_id
|
||||
JIRA_ATTR_HOSTING=attribute_id
|
||||
JIRA_ATTR_TAM=attribute_id
|
||||
|
||||
|
||||
# Claude API
|
||||
ANTHROPIC_API_KEY=your_anthropic_api_key_here
|
||||
|
||||
# Tavily API Key (verkrijgbaar via https://tavily.com)
|
||||
TAVILY_API_KEY=your_tavily_api_key_here
|
||||
|
||||
# OpenAI API
|
||||
OPENAI_API_KEY=your_openai_api_key_here
|
||||
|
||||
# Application
|
||||
PORT=3001
|
||||
NODE_ENV=development
|
||||
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Test coverage
|
||||
coverage/
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
197
CLAUDE.md
Normal file
197
CLAUDE.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# CLAUDE.md - ZiRA Classificatie Tool
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Project:** ZiRA Classificatie Tool (Zuyderland CMDB Editor)
|
||||
**Organization:** Zuyderland Medisch Centrum - ICMT
|
||||
**Purpose:** Interactive tool for classifying ~500 application components into ZiRA (Ziekenhuis Referentie Architectuur) application functions with Jira Assets CMDB integration.
|
||||
|
||||
## Current Status
|
||||
|
||||
**Phase:** v1.0 - First Implementation Complete
|
||||
|
||||
The project has a working implementation with:
|
||||
- Full backend API with Express + TypeScript
|
||||
- React frontend with Dashboard, Application List, and Detail views
|
||||
- Mock data service for development (can be switched to Jira Assets)
|
||||
- AI classification integration with Claude API
|
||||
- SQLite database for classification history
|
||||
|
||||
Key files:
|
||||
- `zira-classificatie-tool-specificatie.md` - Complete technical specification
|
||||
- `zira-taxonomy.json` - ZiRA taxonomy with 90+ application functions across 10 domains
|
||||
- `management-parameters.json` - Reference data for dynamics, complexity, users, governance models
|
||||
|
||||
## Technology Stack
|
||||
|
||||
### Frontend
|
||||
- React + TypeScript
|
||||
- Vite (build tool)
|
||||
- TailwindCSS
|
||||
|
||||
### Backend
|
||||
- Node.js + Express + TypeScript
|
||||
- SQLite (local caching)
|
||||
|
||||
### External Integrations
|
||||
- **Jira Data Center REST API** (Assets CMDB) - source of truth for application data
|
||||
- **Anthropic Claude API** (claude-sonnet-4-20250514) - AI classification suggestions
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Backend development
|
||||
cd backend && npm install && npm run dev
|
||||
|
||||
# Frontend development
|
||||
cd frontend && npm install && npm run dev
|
||||
|
||||
# Docker (full stack)
|
||||
docker-compose up
|
||||
|
||||
# Build for production
|
||||
cd backend && npm run build
|
||||
cd frontend && npm run build
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
zira-classificatie-tool/
|
||||
├── package.json # Root workspace package
|
||||
├── docker-compose.yml # Docker development setup
|
||||
├── .env.example # Environment template
|
||||
├── backend/
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.json
|
||||
│ ├── Dockerfile
|
||||
│ └── src/
|
||||
│ ├── index.ts # Express server entry
|
||||
│ ├── config/env.ts # Environment configuration
|
||||
│ ├── services/
|
||||
│ │ ├── jiraAssets.ts # Jira Assets API client
|
||||
│ │ ├── claude.ts # Claude AI integration
|
||||
│ │ ├── mockData.ts # Mock data for development
|
||||
│ │ ├── database.ts # SQLite database service
|
||||
│ │ └── logger.ts # Winston logger
|
||||
│ ├── routes/
|
||||
│ │ ├── applications.ts # Application CRUD endpoints
|
||||
│ │ ├── classifications.ts # AI classification endpoints
|
||||
│ │ ├── referenceData.ts # Reference data endpoints
|
||||
│ │ └── dashboard.ts # Dashboard statistics
|
||||
│ ├── data/
|
||||
│ │ ├── zira-taxonomy.json
|
||||
│ │ └── management-parameters.json
|
||||
│ └── types/index.ts # TypeScript interfaces
|
||||
├── frontend/
|
||||
│ ├── package.json
|
||||
│ ├── vite.config.ts
|
||||
│ ├── tailwind.config.js
|
||||
│ ├── Dockerfile
|
||||
│ └── src/
|
||||
│ ├── main.tsx # React entry point
|
||||
│ ├── App.tsx # Main component with routing
|
||||
│ ├── index.css # Tailwind CSS imports
|
||||
│ ├── components/
|
||||
│ │ ├── Dashboard.tsx # Overview statistics
|
||||
│ │ ├── ApplicationList.tsx # Search & filter view
|
||||
│ │ └── ApplicationDetail.tsx # Edit & AI classify
|
||||
│ ├── services/api.ts # API client
|
||||
│ ├── stores/
|
||||
│ │ ├── searchStore.ts # Filter state (Zustand)
|
||||
│ │ └── navigationStore.ts # Navigation state
|
||||
│ └── types/index.ts # TypeScript interfaces
|
||||
└── data/
|
||||
├── zira-taxonomy.json
|
||||
└── management-parameters.json
|
||||
```
|
||||
|
||||
## Key Domain Concepts
|
||||
|
||||
### ZiRA (Ziekenhuis Referentie Architectuur)
|
||||
Dutch hospital reference architecture with 90+ application functions organized in 10 domains:
|
||||
- Sturing (Governance)
|
||||
- Onderzoek (Research)
|
||||
- Zorg-SAM, Zorg-CON, Zorg-AOZ, Zorg-ZON (Care delivery)
|
||||
- Onderwijs (Education)
|
||||
- Bedrijfsondersteuning (Business support)
|
||||
- Generieke ICT (IT infrastructure)
|
||||
|
||||
### Editable Classification Fields
|
||||
- **ApplicationFunction** - ZiRA taxonomy match
|
||||
- **Dynamics Factor** - 1-4 scale (Stabiel to Zeer hoog)
|
||||
- **Complexity Factor** - 1-4 scale (Laag to Zeer hoog)
|
||||
- **Number of Users** - 7 ranges (< 100 to > 15.000)
|
||||
- **Governance Model** - A-E (Centraal to Volledig Decentraal)
|
||||
|
||||
### AI Classification Confidence
|
||||
- HOOG (high) - Strong match, can auto-apply
|
||||
- MIDDEN (medium) - Reasonable match, needs review
|
||||
- LAAG (low) - Uncertain, requires manual classification
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```env
|
||||
# Jira Data Center
|
||||
JIRA_HOST=https://jira.zuyderland.nl
|
||||
JIRA_PAT=<personal_access_token>
|
||||
JIRA_SCHEMA_ID=<schema_id>
|
||||
|
||||
# Jira Object Type IDs
|
||||
JIRA_APPLICATION_COMPONENT_TYPE_ID=<type_id>
|
||||
JIRA_APPLICATION_FUNCTION_TYPE_ID=<type_id>
|
||||
JIRA_DYNAMICS_FACTOR_TYPE_ID=<type_id>
|
||||
JIRA_COMPLEXITY_FACTOR_TYPE_ID=<type_id>
|
||||
JIRA_NUMBER_OF_USERS_TYPE_ID=<type_id>
|
||||
JIRA_GOVERNANCE_MODEL_TYPE_ID=<type_id>
|
||||
JIRA_APPLICATION_CLUSTER_TYPE_ID=<type_id>
|
||||
JIRA_APPLICATION_TYPE_TYPE_ID=<type_id>
|
||||
|
||||
# Jira Attribute IDs
|
||||
JIRA_ATTR_APPLICATION_FUNCTION=<attr_id>
|
||||
JIRA_ATTR_DYNAMICS_FACTOR=<attr_id>
|
||||
JIRA_ATTR_COMPLEXITY_FACTOR=<attr_id>
|
||||
JIRA_ATTR_NUMBER_OF_USERS=<attr_id>
|
||||
JIRA_ATTR_GOVERNANCE_MODEL=<attr_id>
|
||||
JIRA_ATTR_APPLICATION_CLUSTER=<attr_id>
|
||||
JIRA_ATTR_APPLICATION_TYPE=<attr_id>
|
||||
|
||||
# Claude AI
|
||||
ANTHROPIC_API_KEY=<claude_api_key>
|
||||
|
||||
# Server
|
||||
PORT=3001
|
||||
NODE_ENV=development
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
1. **Never commit PAT tokens** - Always use .env files (add to .gitignore)
|
||||
2. **Jira Assets is source of truth** - SQLite is for local caching only
|
||||
3. **Rate limiting** - Implement exponential backoff for Jira API calls
|
||||
4. **Validation** - Verify ApplicationFunction objects exist before updating
|
||||
5. **Audit trail** - Comprehensive logging for all classifications
|
||||
6. **Reference data sync** - Fetch from Jira Assets on startup
|
||||
7. **Navigation state** - Maintain filter state when navigating between screens
|
||||
|
||||
## Development Roadmap
|
||||
|
||||
1. **Phase 1 - Setup:** Project initialization, Vite + Express, Jira API connection test
|
||||
2. **Phase 2 - Backend:** Services, API endpoints, error handling
|
||||
3. **Phase 3 - Frontend:** Dashboard, classification workflow, state management
|
||||
4. **Phase 4 - Integration:** E2E testing, bulk operations, reporting/export
|
||||
5. **Phase 5 - Deployment:** Tests, documentation, deployment setup
|
||||
|
||||
## Key Files Reference
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `zira-classificatie-tool-specificatie.md` | Complete technical specification |
|
||||
| `zira-taxonomy.json` | 90+ ZiRA application functions |
|
||||
| `management-parameters.json` | Dropdown options and reference data |
|
||||
|
||||
## Language
|
||||
|
||||
- Code: English
|
||||
- UI/Documentation: Dutch (user-facing content is in Dutch)
|
||||
- Comments: English preferred
|
||||
19
backend/Dockerfile
Normal file
19
backend/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p data
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3001
|
||||
|
||||
# Start development server
|
||||
CMD ["npm", "run", "dev"]
|
||||
BIN
backend/data/BIA.xlsx
Normal file
BIN
backend/data/BIA.xlsx
Normal file
Binary file not shown.
902
backend/data/effort-calculation-config.json
Normal file
902
backend/data/effort-calculation-config.json
Normal file
@@ -0,0 +1,902 @@
|
||||
{
|
||||
"governanceModelRules": [
|
||||
{
|
||||
"governanceModel": "Regiemodel A",
|
||||
"applicationTypeRules": {
|
||||
"Applicatie": {
|
||||
"applicationTypes": [
|
||||
"Applicatie",
|
||||
"Connected Device"
|
||||
],
|
||||
"businessImpactRules": {
|
||||
"F": [
|
||||
{
|
||||
"result": 0.3,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"Azure (IaaS)",
|
||||
"Azure (PaaS)",
|
||||
"PoC (Saas)",
|
||||
"SaaS"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.5,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"On-premises",
|
||||
"PoC (On-premises)"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.3
|
||||
}
|
||||
],
|
||||
"E": [
|
||||
{
|
||||
"result": 0.2,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"Azure (IaaS)",
|
||||
"Azure (PaaS)",
|
||||
"PoC (Saas)",
|
||||
"SaaS"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.3,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"On-premises",
|
||||
"PoC (On-premises)"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.2
|
||||
}
|
||||
],
|
||||
"D": [
|
||||
{
|
||||
"result": 0.1,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"Azure (IaaS)",
|
||||
"Azure (PaaS)",
|
||||
"PoC (Saas)",
|
||||
"SaaS"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.15,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"On-premises",
|
||||
"PoC (On-premises)"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.1
|
||||
}
|
||||
],
|
||||
"C": [
|
||||
{
|
||||
"result": 0.1,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"Azure (IaaS)",
|
||||
"Azure (PaaS)",
|
||||
"PoC (Saas)",
|
||||
"SaaS"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.15,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"On-premises",
|
||||
"PoC (On-premises)"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.1
|
||||
}
|
||||
],
|
||||
"B": [
|
||||
{
|
||||
"result": 0.1,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"Azure (IaaS)",
|
||||
"Azure (PaaS)",
|
||||
"PoC (Saas)",
|
||||
"SaaS"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.15,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"On-premises",
|
||||
"PoC (On-premises)"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.1
|
||||
}
|
||||
],
|
||||
"A": [
|
||||
{
|
||||
"result": 0.1,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"Azure (IaaS)",
|
||||
"Azure (PaaS)",
|
||||
"PoC (Saas)",
|
||||
"SaaS"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.15,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"On-premises",
|
||||
"PoC (On-premises)"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.1
|
||||
}
|
||||
]
|
||||
},
|
||||
"default": [
|
||||
{
|
||||
"result": 0.1,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"Azure (IaaS)",
|
||||
"Azure (PaaS)",
|
||||
"PoC (Saas)",
|
||||
"SaaS"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.15,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"On-premises",
|
||||
"PoC (On-premises)"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.1
|
||||
}
|
||||
]
|
||||
},
|
||||
"Platform": {
|
||||
"applicationTypes": "Platform",
|
||||
"businessImpactRules": {
|
||||
"F": [
|
||||
{
|
||||
"result": 0.6,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"Azure (IaaS)",
|
||||
"Azure (PaaS)",
|
||||
"PoC (Saas)",
|
||||
"SaaS"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 1,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"On-premises",
|
||||
"PoC (On-premises)"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 1
|
||||
}
|
||||
],
|
||||
"E": [
|
||||
{
|
||||
"result": 0.25,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"Azure (IaaS)",
|
||||
"Azure (PaaS)",
|
||||
"PoC (Saas)",
|
||||
"SaaS"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.4,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"On-premises",
|
||||
"PoC (On-premises)"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.25
|
||||
}
|
||||
],
|
||||
"D": [
|
||||
{
|
||||
"result": 0.15,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"Azure (IaaS)",
|
||||
"Azure (PaaS)",
|
||||
"PoC (Saas)",
|
||||
"SaaS"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.25,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"On-premises",
|
||||
"PoC (On-premises)"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.15
|
||||
}
|
||||
],
|
||||
"C": [
|
||||
{
|
||||
"result": 0.15,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"Azure (IaaS)",
|
||||
"Azure (PaaS)",
|
||||
"PoC (Saas)",
|
||||
"SaaS"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.25,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"On-premises",
|
||||
"PoC (On-premises)"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.15
|
||||
}
|
||||
],
|
||||
"B": [
|
||||
{
|
||||
"result": 0.15,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"Azure (IaaS)",
|
||||
"Azure (PaaS)",
|
||||
"PoC (Saas)",
|
||||
"SaaS"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.25,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"On-premises",
|
||||
"PoC (On-premises)"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.15
|
||||
}
|
||||
],
|
||||
"A": [
|
||||
{
|
||||
"result": 0.15,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"Azure (IaaS)",
|
||||
"Azure (PaaS)",
|
||||
"PoC (Saas)",
|
||||
"SaaS"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.25,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"On-premises",
|
||||
"PoC (On-premises)"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.15
|
||||
}
|
||||
]
|
||||
},
|
||||
"default": [
|
||||
{
|
||||
"result": 0.15,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"Azure (IaaS)",
|
||||
"Azure (PaaS)",
|
||||
"PoC (Saas)",
|
||||
"SaaS"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.25,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"On-premises",
|
||||
"PoC (On-premises)"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.15
|
||||
}
|
||||
]
|
||||
},
|
||||
"Workload": {
|
||||
"applicationTypes": "Workload",
|
||||
"businessImpactRules": {
|
||||
"F": {
|
||||
"result": 0.2
|
||||
},
|
||||
"E": {
|
||||
"result": 0.12
|
||||
},
|
||||
"D": {
|
||||
"result": 0.08
|
||||
},
|
||||
"C": {
|
||||
"result": 0.04
|
||||
},
|
||||
"B": {
|
||||
"result": 0.04
|
||||
},
|
||||
"A": {
|
||||
"result": 0.04
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"result": 0.04
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"governanceModel": "Regiemodel B",
|
||||
"applicationTypeRules": {
|
||||
"Applicatie": {
|
||||
"applicationTypes": [
|
||||
"Applicatie",
|
||||
"Connected Device"
|
||||
],
|
||||
"businessImpactRules": {
|
||||
"F": [
|
||||
{
|
||||
"result": 0.1,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"Azure (IaaS)",
|
||||
"Azure (PaaS)",
|
||||
"PoC (Saas)",
|
||||
"SaaS"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.15,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"On-premises",
|
||||
"PoC (On-premises)"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.1
|
||||
}
|
||||
],
|
||||
"E": [
|
||||
{
|
||||
"result": 0.1,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"Azure (IaaS)",
|
||||
"Azure (PaaS)",
|
||||
"PoC (Saas)",
|
||||
"SaaS"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.15,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"On-premises",
|
||||
"PoC (On-premises)"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.1
|
||||
}
|
||||
],
|
||||
"D": [
|
||||
{
|
||||
"result": 0.08,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"Azure (IaaS)",
|
||||
"Azure (PaaS)",
|
||||
"PoC (Saas)",
|
||||
"SaaS"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.1,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"On-premises",
|
||||
"PoC (On-premises)"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.08
|
||||
}
|
||||
],
|
||||
"C": {
|
||||
"result": 0.04,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"Azure (IaaS)",
|
||||
"Azure (PaaS)",
|
||||
"PoC (Saas)",
|
||||
"SaaS"
|
||||
]
|
||||
}
|
||||
},
|
||||
"B": {
|
||||
"result": 0.04,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"Azure (IaaS)",
|
||||
"Azure (PaaS)",
|
||||
"PoC (Saas)",
|
||||
"SaaS"
|
||||
]
|
||||
}
|
||||
},
|
||||
"A": {
|
||||
"result": 0.04,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"Azure (IaaS)",
|
||||
"Azure (PaaS)",
|
||||
"PoC (Saas)",
|
||||
"SaaS"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": [
|
||||
{
|
||||
"result": 0.04,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"Azure (IaaS)",
|
||||
"Azure (PaaS)",
|
||||
"PoC (Saas)",
|
||||
"SaaS"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.04,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"On-premises",
|
||||
"PoC (On-premises)"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.04
|
||||
}
|
||||
]
|
||||
},
|
||||
"Platform": {
|
||||
"applicationTypes": "Platform",
|
||||
"businessImpactRules": {
|
||||
"F": [
|
||||
{
|
||||
"result": 0.15,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"Azure (IaaS)",
|
||||
"Azure (PaaS)",
|
||||
"PoC (Saas)",
|
||||
"SaaS"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.2,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"On-premises",
|
||||
"PoC (On-premises)"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.15
|
||||
}
|
||||
],
|
||||
"E": [
|
||||
{
|
||||
"result": 0.15,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"Azure (IaaS)",
|
||||
"Azure (PaaS)",
|
||||
"PoC (Saas)",
|
||||
"SaaS"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.2,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"On-premises",
|
||||
"PoC (On-premises)"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.15
|
||||
}
|
||||
],
|
||||
"D": [
|
||||
{
|
||||
"result": 0.1,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"Azure (IaaS)",
|
||||
"Azure (PaaS)",
|
||||
"PoC (Saas)",
|
||||
"SaaS"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.15,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"On-premises",
|
||||
"PoC (On-premises)"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.1
|
||||
}
|
||||
],
|
||||
"C": [
|
||||
{
|
||||
"result": 0.06,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"Azure (IaaS)",
|
||||
"Azure (PaaS)",
|
||||
"PoC (Saas)",
|
||||
"SaaS"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.08,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"On-premises",
|
||||
"PoC (On-premises)"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.06
|
||||
}
|
||||
],
|
||||
"B": [
|
||||
{
|
||||
"result": 0.06,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"Azure (IaaS)",
|
||||
"Azure (PaaS)",
|
||||
"PoC (Saas)",
|
||||
"SaaS"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.08,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"On-premises",
|
||||
"PoC (On-premises)"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.06
|
||||
}
|
||||
],
|
||||
"A": [
|
||||
{
|
||||
"result": 0.06,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"Azure (IaaS)",
|
||||
"Azure (PaaS)",
|
||||
"PoC (Saas)",
|
||||
"SaaS"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.08,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"On-premises",
|
||||
"PoC (On-premises)"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.06
|
||||
}
|
||||
]
|
||||
},
|
||||
"default": [
|
||||
{
|
||||
"result": 0.06,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"Azure (IaaS)",
|
||||
"Azure (PaaS)",
|
||||
"PoC (Saas)",
|
||||
"SaaS"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.08,
|
||||
"conditions": {
|
||||
"hostingType": [
|
||||
"On-premises",
|
||||
"PoC (On-premises)"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": 0.06
|
||||
}
|
||||
]
|
||||
},
|
||||
"Workload": {
|
||||
"applicationTypes": "Workload",
|
||||
"businessImpactRules": {
|
||||
"F": {
|
||||
"result": 0.08
|
||||
},
|
||||
"E": {
|
||||
"result": 0.08
|
||||
},
|
||||
"D": {
|
||||
"result": 0.05
|
||||
},
|
||||
"C": {
|
||||
"result": 0.02
|
||||
},
|
||||
"B": {
|
||||
"result": 0.02
|
||||
},
|
||||
"A": {
|
||||
"result": 0.02
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"result": 0.02
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"governanceModel": "Regiemodel C",
|
||||
"applicationTypeRules": {
|
||||
"Applicatie": {
|
||||
"applicationTypes": [
|
||||
"Applicatie",
|
||||
"Connected Device"
|
||||
],
|
||||
"businessImpactRules": {
|
||||
"F": {
|
||||
"result": 0.25
|
||||
},
|
||||
"E": {
|
||||
"result": 0.15
|
||||
},
|
||||
"D": {
|
||||
"result": 0.08
|
||||
},
|
||||
"C": {
|
||||
"result": 0.04
|
||||
},
|
||||
"B": {
|
||||
"result": 0.04
|
||||
},
|
||||
"A": {
|
||||
"result": 0.04
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"result": 0.04
|
||||
}
|
||||
},
|
||||
"Platform": {
|
||||
"applicationTypes": "Platform",
|
||||
"businessImpactRules": {
|
||||
"F": {
|
||||
"result": 0.35
|
||||
},
|
||||
"E": {
|
||||
"result": 0.2
|
||||
},
|
||||
"D": {
|
||||
"result": 0.12
|
||||
},
|
||||
"C": {
|
||||
"result": 0.06
|
||||
},
|
||||
"B": {
|
||||
"result": 0.06
|
||||
},
|
||||
"A": {
|
||||
"result": 0.06
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"result": 0.6
|
||||
}
|
||||
},
|
||||
"Workload": {
|
||||
"applicationTypes": "Workload",
|
||||
"businessImpactRules": {
|
||||
"F": {
|
||||
"result": 0.15
|
||||
},
|
||||
"E": {
|
||||
"result": 0.1
|
||||
},
|
||||
"D": {
|
||||
"result": 0.06
|
||||
},
|
||||
"C": {
|
||||
"result": 0.03
|
||||
},
|
||||
"B": {
|
||||
"result": 0.03
|
||||
},
|
||||
"A": {
|
||||
"result": 0.03
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"result": 0.03
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"governanceModel": "Regiemodel D",
|
||||
"applicationTypeRules": {
|
||||
"Applicatie": {
|
||||
"applicationTypes": [
|
||||
"Applicatie",
|
||||
"Connected Device"
|
||||
],
|
||||
"businessImpactRules": {},
|
||||
"default": {
|
||||
"result": 0.01
|
||||
}
|
||||
},
|
||||
"Platform": {
|
||||
"applicationTypes": "Platform",
|
||||
"businessImpactRules": {},
|
||||
"default": {
|
||||
"result": 0.02
|
||||
}
|
||||
},
|
||||
"Workload": {
|
||||
"applicationTypes": "Workload",
|
||||
"businessImpactRules": {},
|
||||
"default": {
|
||||
"result": 0.01
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"governanceModel": "Regiemodel E",
|
||||
"applicationTypeRules": {},
|
||||
"default": {
|
||||
"result": 0.01
|
||||
}
|
||||
},
|
||||
{
|
||||
"governanceModel": "Regiemodel B+",
|
||||
"applicationTypeRules": {
|
||||
"New Application Type 1": {
|
||||
"applicationTypes": [
|
||||
"New Type",
|
||||
"Connected Device",
|
||||
"Applicatie"
|
||||
],
|
||||
"businessImpactRules": {}
|
||||
},
|
||||
"New Application Type 2": {
|
||||
"applicationTypes": [
|
||||
"New Type",
|
||||
"Platform"
|
||||
],
|
||||
"businessImpactRules": {}
|
||||
},
|
||||
"New Application Type 3": {
|
||||
"applicationTypes": [
|
||||
"New Type",
|
||||
"Workload"
|
||||
],
|
||||
"businessImpactRules": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"default": {
|
||||
"result": 0.05
|
||||
}
|
||||
}
|
||||
32
backend/package.json
Normal file
32
backend/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "zira-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "ZiRA Classificatie Tool Backend",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.32.1",
|
||||
"better-sqlite3": "^11.6.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.1",
|
||||
"express-rate-limit": "^7.4.1",
|
||||
"helmet": "^8.0.0",
|
||||
"openai": "^6.15.0",
|
||||
"winston": "^3.17.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/xlsx": "^0.0.35",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
720
backend/src/config/effortCalculation.ts
Normal file
720
backend/src/config/effortCalculation.ts
Normal file
@@ -0,0 +1,720 @@
|
||||
/**
|
||||
* Configuration for Required Effort Application Management calculation (v25)
|
||||
* Based on Dienstencatalogus Applicatiebeheer v25
|
||||
*
|
||||
* Hierarchy:
|
||||
* - ICT Governance Model (niveau 1)
|
||||
* > Application Type (niveau 2) - Application Management - Application Type
|
||||
* > Business Impact (niveau 3) - Business Impact Analyse
|
||||
* > Hosting (niveau 4) - Application Management - Hosting
|
||||
*
|
||||
* Each level can have a default rule if no specific configuration matches.
|
||||
*
|
||||
* Hosting values (Application Management - Hosting):
|
||||
* - ON-PREM: On-Premises (Azure - Eigen beheer → mapped to ON-PREM for ICMT beheer)
|
||||
* - AZURE: Azure - Eigen beheer
|
||||
* - AZURE-DM: Azure - Delegated Management
|
||||
* - EXTERN: Extern (SaaS)
|
||||
*/
|
||||
|
||||
// FTE range with min/max values
|
||||
export interface FTERange {
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
// Hosting rule with multiselect hosting values
|
||||
export interface HostingRule {
|
||||
hostingValues: string[]; // e.g., ['ON-PREM', 'AZURE'] or ['AZURE-DM', 'EXTERN']
|
||||
fte: FTERange;
|
||||
}
|
||||
|
||||
// BIA level configuration
|
||||
export interface BIALevelConfig {
|
||||
description?: string;
|
||||
defaultFte?: FTERange;
|
||||
hosting: {
|
||||
[key: string]: HostingRule; // e.g., 'OnPrem', 'SaaS', '_all'
|
||||
};
|
||||
}
|
||||
|
||||
// Application Type configuration
|
||||
export interface ApplicationTypeConfig {
|
||||
defaultFte?: FTERange;
|
||||
note?: string;
|
||||
requiresManualAssessment?: boolean;
|
||||
fixedFte?: boolean;
|
||||
notRecommended?: boolean;
|
||||
biaLevels: {
|
||||
[key: string]: BIALevelConfig; // e.g., 'F', 'E', 'D', 'C', 'B', 'A', '_all'
|
||||
};
|
||||
}
|
||||
|
||||
// Governance Model (Regiemodel) configuration
|
||||
export interface GovernanceModelConfig {
|
||||
name: string;
|
||||
description?: string;
|
||||
allowedBia: string[]; // Allowed BIA levels for this regiemodel
|
||||
defaultFte: FTERange;
|
||||
note?: string;
|
||||
applicationTypes: {
|
||||
[key: string]: ApplicationTypeConfig; // e.g., 'Applicatie', 'Platform', 'Workload', 'Connected Device'
|
||||
};
|
||||
}
|
||||
|
||||
// Complete configuration structure
|
||||
export interface EffortCalculationConfigV25 {
|
||||
metadata: {
|
||||
version: string;
|
||||
description: string;
|
||||
date: string;
|
||||
formula: string;
|
||||
};
|
||||
regiemodellen: {
|
||||
[key: string]: GovernanceModelConfig; // e.g., 'A', 'B', 'B+', 'C', 'D', 'E'
|
||||
};
|
||||
validationRules: {
|
||||
biaRegieModelConstraints: {
|
||||
[regiemodel: string]: string[]; // e.g., 'A': ['D', 'E', 'F']
|
||||
};
|
||||
platformRestrictions: Array<{
|
||||
regiemodel: string;
|
||||
applicationType: string;
|
||||
warning: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy types for backward compatibility
|
||||
export interface EffortRule {
|
||||
result: number;
|
||||
conditions?: {
|
||||
businessImpactAnalyse?: string | string[];
|
||||
applicationManagementHosting?: string | string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApplicationTypeRule {
|
||||
applicationTypes: string | string[];
|
||||
businessImpactRules: {
|
||||
[key: string]: EffortRule | EffortRule[];
|
||||
};
|
||||
default?: EffortRule | EffortRule[];
|
||||
}
|
||||
|
||||
export interface GovernanceModelRule {
|
||||
governanceModel: string;
|
||||
applicationTypeRules: {
|
||||
[key: string]: ApplicationTypeRule | EffortRule;
|
||||
};
|
||||
default?: EffortRule;
|
||||
}
|
||||
|
||||
export interface EffortCalculationConfig {
|
||||
governanceModelRules: GovernanceModelRule[];
|
||||
default: EffortRule;
|
||||
}
|
||||
|
||||
/**
|
||||
* New configuration structure (v25)
|
||||
* Based on Dienstencatalogus Applicatiebeheer v25
|
||||
*/
|
||||
export const EFFORT_CALCULATION_CONFIG_V25: EffortCalculationConfigV25 = {
|
||||
metadata: {
|
||||
version: '25',
|
||||
description: 'FTE-configuratie Dienstencatalogus Applicatiebeheer v25',
|
||||
date: '2025-12-23',
|
||||
formula: 'Werkelijke FTE = basis_fte_avg * schaalfactor * complexiteit * dynamiek',
|
||||
},
|
||||
|
||||
regiemodellen: {
|
||||
'A': {
|
||||
name: 'Centraal Beheer ICMT',
|
||||
description: 'ICMT voert volledig beheer uit (TAB + FAB + IM)',
|
||||
allowedBia: ['D', 'E', 'F'],
|
||||
defaultFte: { min: 0.15, max: 0.30 },
|
||||
applicationTypes: {
|
||||
'Applicatie': {
|
||||
defaultFte: { min: 0.15, max: 0.30 },
|
||||
biaLevels: {
|
||||
'F': {
|
||||
description: 'Zeer kritiek - Levensbedreigende impact',
|
||||
defaultFte: { min: 0.30, max: 0.50 },
|
||||
hosting: {
|
||||
'OnPrem': { hostingValues: ['On-Premises', 'Azure - Eigen beheer'], fte: { min: 0.50, max: 1.00 } },
|
||||
'SaaS': { hostingValues: ['Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.30, max: 0.50 } },
|
||||
},
|
||||
},
|
||||
'E': {
|
||||
description: 'Kritiek - Grote impact op zorgprocessen',
|
||||
defaultFte: { min: 0.20, max: 0.30 },
|
||||
hosting: {
|
||||
'OnPrem': { hostingValues: ['On-Premises', 'Azure - Eigen beheer'], fte: { min: 0.30, max: 0.50 } },
|
||||
'SaaS': { hostingValues: ['Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.20, max: 0.30 } },
|
||||
},
|
||||
},
|
||||
'D': {
|
||||
description: 'Belangrijk - Significante impact',
|
||||
defaultFte: { min: 0.10, max: 0.20 },
|
||||
hosting: {
|
||||
'OnPrem': { hostingValues: ['On-Premises', 'Azure - Eigen beheer'], fte: { min: 0.15, max: 0.30 } },
|
||||
'SaaS': { hostingValues: ['Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.10, max: 0.20 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'Platform': {
|
||||
defaultFte: { min: 0.20, max: 0.40 },
|
||||
biaLevels: {
|
||||
'F': {
|
||||
description: 'Zeer kritiek - Levensbedreigende impact',
|
||||
defaultFte: { min: 0.40, max: 0.60 },
|
||||
hosting: {
|
||||
'OnPrem': { hostingValues: ['On-Premises', 'Azure - Eigen beheer'], fte: { min: 0.60, max: 1.00 } },
|
||||
'SaaS': { hostingValues: ['Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.40, max: 0.60 } },
|
||||
},
|
||||
},
|
||||
'E': {
|
||||
description: 'Kritiek - Grote impact op zorgprocessen',
|
||||
defaultFte: { min: 0.25, max: 0.40 },
|
||||
hosting: {
|
||||
'OnPrem': { hostingValues: ['On-Premises', 'Azure - Eigen beheer'], fte: { min: 0.40, max: 0.60 } },
|
||||
'SaaS': { hostingValues: ['Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.25, max: 0.40 } },
|
||||
},
|
||||
},
|
||||
'D': {
|
||||
description: 'Belangrijk - Significante impact',
|
||||
defaultFte: { min: 0.15, max: 0.25 },
|
||||
hosting: {
|
||||
'OnPrem': { hostingValues: ['On-Premises', 'Azure - Eigen beheer'], fte: { min: 0.25, max: 0.40 } },
|
||||
'SaaS': { hostingValues: ['Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.15, max: 0.25 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'Workload': {
|
||||
defaultFte: { min: 0.08, max: 0.15 },
|
||||
biaLevels: {
|
||||
'F': {
|
||||
description: 'Zeer kritiek - Levensbedreigende impact',
|
||||
defaultFte: { min: 0.20, max: 0.35 },
|
||||
hosting: {
|
||||
'_all': { hostingValues: ['On-Premises', 'Azure - Eigen beheer', 'Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.20, max: 0.35 } },
|
||||
},
|
||||
},
|
||||
'E': {
|
||||
description: 'Kritiek - Grote impact op zorgprocessen',
|
||||
defaultFte: { min: 0.12, max: 0.20 },
|
||||
hosting: {
|
||||
'_all': { hostingValues: ['On-Premises', 'Azure - Eigen beheer', 'Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.12, max: 0.20 } },
|
||||
},
|
||||
},
|
||||
'D': {
|
||||
description: 'Belangrijk - Significante impact',
|
||||
defaultFte: { min: 0.08, max: 0.15 },
|
||||
hosting: {
|
||||
'_all': { hostingValues: ['On-Premises', 'Azure - Eigen beheer', 'Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.08, max: 0.15 } },
|
||||
},
|
||||
},
|
||||
'C': {
|
||||
description: 'Standaard - Gemiddelde impact',
|
||||
defaultFte: { min: 0.04, max: 0.08 },
|
||||
hosting: {
|
||||
'_all': { hostingValues: ['On-Premises', 'Azure - Eigen beheer', 'Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.04, max: 0.08 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'Connected Device': {
|
||||
note: 'Handmatige beoordeling vereist conform Beheer Readiness Checklist sectie J',
|
||||
requiresManualAssessment: true,
|
||||
defaultFte: { min: 0.05, max: 0.15 },
|
||||
biaLevels: {
|
||||
'_all': {
|
||||
hosting: {
|
||||
'_all': { hostingValues: ['On-Premises', 'Azure - Eigen beheer', 'Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.05, max: 0.15 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
'B': {
|
||||
name: 'Federatief Beheer',
|
||||
description: 'ICMT doet TAB + IM, business doet FAB met ICMT-coaching',
|
||||
allowedBia: ['C', 'D', 'E'],
|
||||
defaultFte: { min: 0.05, max: 0.15 },
|
||||
applicationTypes: {
|
||||
'Applicatie': {
|
||||
defaultFte: { min: 0.05, max: 0.15 },
|
||||
biaLevels: {
|
||||
'E': {
|
||||
description: 'Kritiek - Grote impact op zorgprocessen',
|
||||
defaultFte: { min: 0.10, max: 0.20 },
|
||||
hosting: {
|
||||
'OnPrem': { hostingValues: ['On-Premises', 'Azure - Eigen beheer'], fte: { min: 0.15, max: 0.30 } },
|
||||
'SaaS': { hostingValues: ['Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.10, max: 0.20 } },
|
||||
},
|
||||
},
|
||||
'D': {
|
||||
description: 'Belangrijk - Significante impact',
|
||||
defaultFte: { min: 0.08, max: 0.15 },
|
||||
hosting: {
|
||||
'OnPrem': { hostingValues: ['On-Premises', 'Azure - Eigen beheer'], fte: { min: 0.10, max: 0.20 } },
|
||||
'SaaS': { hostingValues: ['Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.08, max: 0.15 } },
|
||||
},
|
||||
},
|
||||
'C': {
|
||||
description: 'Standaard - Gemiddelde impact',
|
||||
defaultFte: { min: 0.04, max: 0.08 },
|
||||
hosting: {
|
||||
'OnPrem': { hostingValues: ['On-Premises', 'Azure - Eigen beheer'], fte: { min: 0.05, max: 0.10 } },
|
||||
'SaaS': { hostingValues: ['Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.04, max: 0.08 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'Platform': {
|
||||
defaultFte: { min: 0.08, max: 0.18 },
|
||||
biaLevels: {
|
||||
'E': {
|
||||
description: 'Kritiek - Grote impact op zorgprocessen',
|
||||
defaultFte: { min: 0.15, max: 0.25 },
|
||||
hosting: {
|
||||
'OnPrem': { hostingValues: ['On-Premises', 'Azure - Eigen beheer'], fte: { min: 0.20, max: 0.35 } },
|
||||
'SaaS': { hostingValues: ['Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.15, max: 0.25 } },
|
||||
},
|
||||
},
|
||||
'D': {
|
||||
description: 'Belangrijk - Significante impact',
|
||||
defaultFte: { min: 0.10, max: 0.18 },
|
||||
hosting: {
|
||||
'OnPrem': { hostingValues: ['On-Premises', 'Azure - Eigen beheer'], fte: { min: 0.15, max: 0.25 } },
|
||||
'SaaS': { hostingValues: ['Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.10, max: 0.18 } },
|
||||
},
|
||||
},
|
||||
'C': {
|
||||
description: 'Standaard - Gemiddelde impact',
|
||||
defaultFte: { min: 0.06, max: 0.12 },
|
||||
hosting: {
|
||||
'OnPrem': { hostingValues: ['On-Premises', 'Azure - Eigen beheer'], fte: { min: 0.08, max: 0.15 } },
|
||||
'SaaS': { hostingValues: ['Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.06, max: 0.12 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'Workload': {
|
||||
note: 'ICMT-aandeel; business levert aanvullend eigen FTE voor FAB',
|
||||
defaultFte: { min: 0.03, max: 0.08 },
|
||||
biaLevels: {
|
||||
'E': {
|
||||
description: 'Kritiek - Grote impact op zorgprocessen',
|
||||
defaultFte: { min: 0.08, max: 0.12 },
|
||||
hosting: {
|
||||
'_all': { hostingValues: ['On-Premises', 'Azure - Eigen beheer', 'Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.08, max: 0.12 } },
|
||||
},
|
||||
},
|
||||
'D': {
|
||||
description: 'Belangrijk - Significante impact',
|
||||
defaultFte: { min: 0.05, max: 0.08 },
|
||||
hosting: {
|
||||
'_all': { hostingValues: ['On-Premises', 'Azure - Eigen beheer', 'Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.05, max: 0.08 } },
|
||||
},
|
||||
},
|
||||
'C': {
|
||||
description: 'Standaard - Gemiddelde impact',
|
||||
defaultFte: { min: 0.02, max: 0.05 },
|
||||
hosting: {
|
||||
'_all': { hostingValues: ['On-Premises', 'Azure - Eigen beheer', 'Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.02, max: 0.05 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'Connected Device': {
|
||||
note: 'Handmatige beoordeling vereist conform Beheer Readiness Checklist sectie J',
|
||||
requiresManualAssessment: true,
|
||||
defaultFte: { min: 0.03, max: 0.10 },
|
||||
biaLevels: {
|
||||
'_all': {
|
||||
hosting: {
|
||||
'_all': { hostingValues: ['On-Premises', 'Azure - Eigen beheer', 'Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.03, max: 0.10 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
'B+': {
|
||||
name: 'Gescheiden Beheer',
|
||||
description: 'ICMT doet TAB + IM, business doet FAB zelfstandig (zonder coaching)',
|
||||
allowedBia: ['C', 'D', 'E'],
|
||||
defaultFte: { min: 0.04, max: 0.12 },
|
||||
note: 'FTE-waarden zijn circa 20-30% lager dan Model B (geen coaching)',
|
||||
applicationTypes: {
|
||||
'Applicatie': {
|
||||
defaultFte: { min: 0.04, max: 0.12 },
|
||||
biaLevels: {
|
||||
'E': {
|
||||
description: 'Kritiek - Grote impact op zorgprocessen',
|
||||
defaultFte: { min: 0.08, max: 0.15 },
|
||||
hosting: {
|
||||
'OnPrem': { hostingValues: ['On-Premises', 'Azure - Eigen beheer'], fte: { min: 0.12, max: 0.22 } },
|
||||
'SaaS': { hostingValues: ['Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.08, max: 0.15 } },
|
||||
},
|
||||
},
|
||||
'D': {
|
||||
description: 'Belangrijk - Significante impact',
|
||||
defaultFte: { min: 0.06, max: 0.11 },
|
||||
hosting: {
|
||||
'OnPrem': { hostingValues: ['On-Premises', 'Azure - Eigen beheer'], fte: { min: 0.08, max: 0.15 } },
|
||||
'SaaS': { hostingValues: ['Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.06, max: 0.11 } },
|
||||
},
|
||||
},
|
||||
'C': {
|
||||
description: 'Standaard - Gemiddelde impact',
|
||||
defaultFte: { min: 0.03, max: 0.06 },
|
||||
hosting: {
|
||||
'OnPrem': { hostingValues: ['On-Premises', 'Azure - Eigen beheer'], fte: { min: 0.04, max: 0.08 } },
|
||||
'SaaS': { hostingValues: ['Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.03, max: 0.06 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'Platform': {
|
||||
defaultFte: { min: 0.06, max: 0.14 },
|
||||
biaLevels: {
|
||||
'E': {
|
||||
description: 'Kritiek - Grote impact op zorgprocessen',
|
||||
defaultFte: { min: 0.12, max: 0.19 },
|
||||
hosting: {
|
||||
'OnPrem': { hostingValues: ['On-Premises', 'Azure - Eigen beheer'], fte: { min: 0.15, max: 0.26 } },
|
||||
'SaaS': { hostingValues: ['Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.12, max: 0.19 } },
|
||||
},
|
||||
},
|
||||
'D': {
|
||||
description: 'Belangrijk - Significante impact',
|
||||
defaultFte: { min: 0.08, max: 0.14 },
|
||||
hosting: {
|
||||
'OnPrem': { hostingValues: ['On-Premises', 'Azure - Eigen beheer'], fte: { min: 0.12, max: 0.19 } },
|
||||
'SaaS': { hostingValues: ['Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.08, max: 0.14 } },
|
||||
},
|
||||
},
|
||||
'C': {
|
||||
description: 'Standaard - Gemiddelde impact',
|
||||
defaultFte: { min: 0.05, max: 0.09 },
|
||||
hosting: {
|
||||
'OnPrem': { hostingValues: ['On-Premises', 'Azure - Eigen beheer'], fte: { min: 0.06, max: 0.11 } },
|
||||
'SaaS': { hostingValues: ['Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.05, max: 0.09 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'Workload': {
|
||||
note: 'ICMT-aandeel; business levert volledig eigen FTE voor FAB (geen coaching)',
|
||||
defaultFte: { min: 0.02, max: 0.05 },
|
||||
biaLevels: {
|
||||
'E': {
|
||||
description: 'Kritiek - Grote impact op zorgprocessen',
|
||||
defaultFte: { min: 0.05, max: 0.08 },
|
||||
hosting: {
|
||||
'_all': { hostingValues: ['On-Premises', 'Azure - Eigen beheer', 'Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.05, max: 0.08 } },
|
||||
},
|
||||
},
|
||||
'D': {
|
||||
description: 'Belangrijk - Significante impact',
|
||||
defaultFte: { min: 0.03, max: 0.05 },
|
||||
hosting: {
|
||||
'_all': { hostingValues: ['On-Premises', 'Azure - Eigen beheer', 'Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.03, max: 0.05 } },
|
||||
},
|
||||
},
|
||||
'C': {
|
||||
description: 'Standaard - Gemiddelde impact',
|
||||
defaultFte: { min: 0.01, max: 0.03 },
|
||||
hosting: {
|
||||
'_all': { hostingValues: ['On-Premises', 'Azure - Eigen beheer', 'Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.01, max: 0.03 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'Connected Device': {
|
||||
note: 'Handmatige beoordeling vereist conform Beheer Readiness Checklist sectie J',
|
||||
requiresManualAssessment: true,
|
||||
defaultFte: { min: 0.02, max: 0.08 },
|
||||
biaLevels: {
|
||||
'_all': {
|
||||
hosting: {
|
||||
'_all': { hostingValues: ['On-Premises', 'Azure - Eigen beheer', 'Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.02, max: 0.08 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
'C': {
|
||||
name: 'Uitbesteed met ICMT-Regie',
|
||||
description: 'Leverancier doet TAB, ICMT doet IM/regie + FAB (BIA-afhankelijk)',
|
||||
allowedBia: ['C', 'D', 'E', 'F'],
|
||||
defaultFte: { min: 0.06, max: 0.15 },
|
||||
note: 'FAB-niveau: Volledig (E-F), Uitgebreid (D), Basis (C)',
|
||||
applicationTypes: {
|
||||
'Applicatie': {
|
||||
defaultFte: { min: 0.06, max: 0.15 },
|
||||
biaLevels: {
|
||||
'F': {
|
||||
description: 'Zeer kritiek - Levensbedreigende impact (FAB-niveau: Volledig)',
|
||||
defaultFte: { min: 0.25, max: 0.50 },
|
||||
hosting: {
|
||||
'_all': { hostingValues: ['On-Premises', 'Azure - Eigen beheer', 'Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.25, max: 0.50 } },
|
||||
},
|
||||
},
|
||||
'E': {
|
||||
description: 'Kritiek - Grote impact op zorgprocessen (FAB-niveau: Volledig)',
|
||||
defaultFte: { min: 0.15, max: 0.25 },
|
||||
hosting: {
|
||||
'_all': { hostingValues: ['On-Premises', 'Azure - Eigen beheer', 'Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.15, max: 0.25 } },
|
||||
},
|
||||
},
|
||||
'D': {
|
||||
description: 'Belangrijk - Significante impact (FAB-niveau: Uitgebreid)',
|
||||
defaultFte: { min: 0.08, max: 0.15 },
|
||||
hosting: {
|
||||
'_all': { hostingValues: ['On-Premises', 'Azure - Eigen beheer', 'Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.08, max: 0.15 } },
|
||||
},
|
||||
},
|
||||
'C': {
|
||||
description: 'Standaard - Gemiddelde impact (FAB-niveau: Basis)',
|
||||
defaultFte: { min: 0.04, max: 0.08 },
|
||||
hosting: {
|
||||
'_all': { hostingValues: ['On-Premises', 'Azure - Eigen beheer', 'Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.04, max: 0.08 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'Platform': {
|
||||
defaultFte: { min: 0.10, max: 0.25 },
|
||||
biaLevels: {
|
||||
'F': {
|
||||
description: 'Zeer kritiek - Levensbedreigende impact (IM/Regie focus: Intensief)',
|
||||
defaultFte: { min: 0.35, max: 0.50 },
|
||||
hosting: {
|
||||
'_all': { hostingValues: ['On-Premises', 'Azure - Eigen beheer', 'Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.35, max: 0.50 } },
|
||||
},
|
||||
},
|
||||
'E': {
|
||||
description: 'Kritiek - Grote impact op zorgprocessen (IM/Regie focus: Hoog)',
|
||||
defaultFte: { min: 0.20, max: 0.35 },
|
||||
hosting: {
|
||||
'_all': { hostingValues: ['On-Premises', 'Azure - Eigen beheer', 'Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.20, max: 0.35 } },
|
||||
},
|
||||
},
|
||||
'D': {
|
||||
description: 'Belangrijk - Significante impact (IM/Regie focus: Standaard)',
|
||||
defaultFte: { min: 0.12, max: 0.20 },
|
||||
hosting: {
|
||||
'_all': { hostingValues: ['On-Premises', 'Azure - Eigen beheer', 'Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.12, max: 0.20 } },
|
||||
},
|
||||
},
|
||||
'C': {
|
||||
description: 'Standaard - Gemiddelde impact (IM/Regie focus: Basis)',
|
||||
defaultFte: { min: 0.06, max: 0.12 },
|
||||
hosting: {
|
||||
'_all': { hostingValues: ['On-Premises', 'Azure - Eigen beheer', 'Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.06, max: 0.12 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'Workload': {
|
||||
defaultFte: { min: 0.04, max: 0.10 },
|
||||
biaLevels: {
|
||||
'F': {
|
||||
description: 'Zeer kritiek - Levensbedreigende impact',
|
||||
defaultFte: { min: 0.15, max: 0.25 },
|
||||
hosting: {
|
||||
'_all': { hostingValues: ['On-Premises', 'Azure - Eigen beheer', 'Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.15, max: 0.25 } },
|
||||
},
|
||||
},
|
||||
'E': {
|
||||
description: 'Kritiek - Grote impact op zorgprocessen',
|
||||
defaultFte: { min: 0.08, max: 0.15 },
|
||||
hosting: {
|
||||
'_all': { hostingValues: ['On-Premises', 'Azure - Eigen beheer', 'Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.08, max: 0.15 } },
|
||||
},
|
||||
},
|
||||
'D': {
|
||||
description: 'Belangrijk - Significante impact',
|
||||
defaultFte: { min: 0.05, max: 0.10 },
|
||||
hosting: {
|
||||
'_all': { hostingValues: ['On-Premises', 'Azure - Eigen beheer', 'Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.05, max: 0.10 } },
|
||||
},
|
||||
},
|
||||
'C': {
|
||||
description: 'Standaard - Gemiddelde impact',
|
||||
defaultFte: { min: 0.03, max: 0.06 },
|
||||
hosting: {
|
||||
'_all': { hostingValues: ['On-Premises', 'Azure - Eigen beheer', 'Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.03, max: 0.06 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'Connected Device': {
|
||||
note: 'Handmatige beoordeling vereist conform Beheer Readiness Checklist sectie J',
|
||||
requiresManualAssessment: true,
|
||||
defaultFte: { min: 0.03, max: 0.10 },
|
||||
biaLevels: {
|
||||
'_all': {
|
||||
hosting: {
|
||||
'_all': { hostingValues: ['On-Premises', 'Azure - Eigen beheer', 'Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.03, max: 0.10 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
'D': {
|
||||
name: 'Decentraal met Business-Regie',
|
||||
description: 'Business of decentrale IT regisseert, leverancier doet TAB, ICMT alleen CMDB + advies',
|
||||
allowedBia: ['A', 'B', 'C'],
|
||||
defaultFte: { min: 0.01, max: 0.02 },
|
||||
note: 'Vaste FTE ongeacht BIA en Hosting - alleen CMDB-registratie en review',
|
||||
applicationTypes: {
|
||||
'Applicatie': {
|
||||
fixedFte: true,
|
||||
defaultFte: { min: 0.01, max: 0.02 },
|
||||
biaLevels: {
|
||||
'_all': {
|
||||
description: 'Alle BIA-niveaus (A, B, C)',
|
||||
hosting: {
|
||||
'_all': { hostingValues: ['On-Premises', 'Azure - Eigen beheer', 'Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.01, max: 0.02 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'Platform': {
|
||||
fixedFte: true,
|
||||
notRecommended: true,
|
||||
note: 'Niet aanbevolen voor Platforms vanwege governance-risico',
|
||||
defaultFte: { min: 0.02, max: 0.04 },
|
||||
biaLevels: {
|
||||
'_all': {
|
||||
description: 'Alle BIA-niveaus (A, B, C)',
|
||||
hosting: {
|
||||
'_all': { hostingValues: ['On-Premises', 'Azure - Eigen beheer', 'Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.02, max: 0.04 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'Workload': {
|
||||
fixedFte: true,
|
||||
defaultFte: { min: 0.01, max: 0.02 },
|
||||
biaLevels: {
|
||||
'_all': {
|
||||
description: 'Alle BIA-niveaus (A, B, C)',
|
||||
hosting: {
|
||||
'_all': { hostingValues: ['On-Premises', 'Azure - Eigen beheer', 'Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.01, max: 0.02 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'Connected Device': {
|
||||
fixedFte: true,
|
||||
defaultFte: { min: 0.01, max: 0.02 },
|
||||
biaLevels: {
|
||||
'_all': {
|
||||
hosting: {
|
||||
'_all': { hostingValues: ['On-Premises', 'Azure - Eigen beheer', 'Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.01, max: 0.02 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
'E': {
|
||||
name: 'Volledig Decentraal Beheer',
|
||||
description: 'Business voert volledig beheer uit, ICMT alleen CMDB + jaarlijkse review',
|
||||
allowedBia: ['A', 'B'],
|
||||
defaultFte: { min: 0.01, max: 0.02 },
|
||||
note: 'Vaste FTE ongeacht BIA en Hosting - alleen CMDB-registratie en jaarlijkse review',
|
||||
applicationTypes: {
|
||||
'Applicatie': {
|
||||
fixedFte: true,
|
||||
defaultFte: { min: 0.01, max: 0.02 },
|
||||
biaLevels: {
|
||||
'_all': {
|
||||
description: 'Alle BIA-niveaus (A, B)',
|
||||
hosting: {
|
||||
'_all': { hostingValues: ['On-Premises', 'Azure - Eigen beheer', 'Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.01, max: 0.02 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'Platform': {
|
||||
fixedFte: true,
|
||||
notRecommended: true,
|
||||
note: 'Model E is niet geschikt voor Platforms',
|
||||
defaultFte: { min: 0.01, max: 0.02 },
|
||||
biaLevels: {
|
||||
'_all': {
|
||||
hosting: {
|
||||
'_all': { hostingValues: ['On-Premises', 'Azure - Eigen beheer', 'Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.01, max: 0.02 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'Workload': {
|
||||
fixedFte: true,
|
||||
notRecommended: true,
|
||||
note: 'Model E is niet geschikt voor Workloads (vereist Platform)',
|
||||
defaultFte: { min: 0.01, max: 0.02 },
|
||||
biaLevels: {
|
||||
'_all': {
|
||||
hosting: {
|
||||
'_all': { hostingValues: ['On-Premises', 'Azure - Eigen beheer', 'Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.01, max: 0.02 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'Connected Device': {
|
||||
fixedFte: true,
|
||||
defaultFte: { min: 0.01, max: 0.02 },
|
||||
biaLevels: {
|
||||
'_all': {
|
||||
hosting: {
|
||||
'_all': { hostingValues: ['On-Premises', 'Azure - Eigen beheer', 'Azure - Delegated Management', 'Extern (SaaS)'], fte: { min: 0.01, max: 0.02 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
validationRules: {
|
||||
biaRegieModelConstraints: {
|
||||
'A': ['D', 'E', 'F'],
|
||||
'B': ['C', 'D', 'E'],
|
||||
'B+': ['C', 'D', 'E'],
|
||||
'C': ['C', 'D', 'E', 'F'],
|
||||
'D': ['A', 'B', 'C'],
|
||||
'E': ['A', 'B'],
|
||||
},
|
||||
platformRestrictions: [
|
||||
{ regiemodel: 'D', applicationType: 'Platform', warning: 'Niet aanbevolen vanwege governance-risico' },
|
||||
{ regiemodel: 'E', applicationType: 'Platform', warning: 'Niet geschikt voor Platforms' },
|
||||
{ regiemodel: 'E', applicationType: 'Workload', warning: 'Niet geschikt voor Workloads' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Legacy configuration for backward compatibility
|
||||
* This is used by the existing calculation logic until fully migrated
|
||||
*/
|
||||
export const EFFORT_CALCULATION_CONFIG: EffortCalculationConfig = {
|
||||
governanceModelRules: [],
|
||||
default: { result: 0.01 },
|
||||
};
|
||||
144
backend/src/config/env.ts
Normal file
144
backend/src/config/env.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
|
||||
// Load .env from project root
|
||||
dotenv.config({ path: path.resolve(__dirname, '../../../.env') });
|
||||
|
||||
interface Config {
|
||||
// Jira Assets
|
||||
jiraHost: string;
|
||||
jiraPat: string;
|
||||
jiraSchemaId: string;
|
||||
|
||||
// Object Type IDs
|
||||
jiraApplicationComponentTypeId: string;
|
||||
jiraApplicationFunctionTypeId: string;
|
||||
jiraDynamicsFactorTypeId: string;
|
||||
jiraComplexityFactorTypeId: string;
|
||||
jiraNumberOfUsersTypeId: string;
|
||||
jiraGovernanceModelTypeId: string;
|
||||
jiraApplicationClusterTypeId: string;
|
||||
jiraApplicationTypeTypeId: string;
|
||||
jiraHostingTypeTypeId: string;
|
||||
jiraBusinessImpactAnalyseTypeId: string;
|
||||
jiraApplicationManagementHostingTypeId: string; // Object Type ID for "Application Management - Hosting"
|
||||
jiraApplicationManagementTAMTypeId: string; // Object Type ID for "Application Management - TAM"
|
||||
|
||||
// Attribute IDs
|
||||
jiraAttrApplicationFunction: string;
|
||||
jiraAttrDynamicsFactor: string;
|
||||
jiraAttrComplexityFactor: string;
|
||||
jiraAttrNumberOfUsers: string;
|
||||
jiraAttrGovernanceModel: string;
|
||||
jiraAttrApplicationCluster: string;
|
||||
jiraAttrApplicationType: string;
|
||||
jiraAttrPlatform: string;
|
||||
jiraAttrHostingType: string;
|
||||
jiraAttrBusinessImpactAnalyse: string;
|
||||
jiraAttrTechnischeArchitectuur: string; // Attribute ID for "Technische Architectuur (TA)"
|
||||
jiraAttrTechnicalApplicationManagementPrimary: string; // Attribute ID for "Technical Application Management Primary"
|
||||
jiraAttrTechnicalApplicationManagementSecondary: string; // Attribute ID for "Technical Application Management Secondary"
|
||||
jiraAttrOverrideFTE: string; // Attribute ID for "Application Management - Override FTE"
|
||||
jiraAttrApplicationManagementHosting: string; // Attribute ID for "Application Management - Hosting" (4939)
|
||||
jiraAttrApplicationManagementTAM: string; // Attribute ID for "Application Management - TAM" (4945)
|
||||
|
||||
// AI API Keys
|
||||
anthropicApiKey: string;
|
||||
openaiApiKey: string;
|
||||
defaultAIProvider: 'claude' | 'openai';
|
||||
|
||||
// Web Search API (Tavily)
|
||||
tavilyApiKey: string;
|
||||
enableWebSearch: boolean;
|
||||
|
||||
// Application
|
||||
port: number;
|
||||
nodeEnv: string;
|
||||
isDevelopment: boolean;
|
||||
isProduction: boolean;
|
||||
|
||||
// API Configuration
|
||||
jiraApiBatchSize: number;
|
||||
}
|
||||
|
||||
function getEnvVar(name: string, defaultValue?: string): string {
|
||||
const value = process.env[name] || defaultValue;
|
||||
if (!value) {
|
||||
throw new Error(`Environment variable ${name} is required but not set`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function getOptionalEnvVar(name: string, defaultValue: string = ''): string {
|
||||
return process.env[name] || defaultValue;
|
||||
}
|
||||
|
||||
export const config: Config = {
|
||||
// Jira Assets
|
||||
jiraHost: getOptionalEnvVar('JIRA_HOST', 'https://jira.zuyderland.nl'),
|
||||
jiraPat: getOptionalEnvVar('JIRA_PAT'),
|
||||
jiraSchemaId: getOptionalEnvVar('JIRA_SCHEMA_ID'),
|
||||
|
||||
// Object Type IDs
|
||||
jiraApplicationComponentTypeId: getOptionalEnvVar('JIRA_APPLICATION_COMPONENT_TYPE_ID'),
|
||||
jiraApplicationFunctionTypeId: getOptionalEnvVar('JIRA_APPLICATION_FUNCTION_TYPE_ID'),
|
||||
jiraDynamicsFactorTypeId: getOptionalEnvVar('JIRA_DYNAMICS_FACTOR_TYPE_ID'),
|
||||
jiraComplexityFactorTypeId: getOptionalEnvVar('JIRA_COMPLEXITY_FACTOR_TYPE_ID'),
|
||||
jiraNumberOfUsersTypeId: getOptionalEnvVar('JIRA_NUMBER_OF_USERS_TYPE_ID'),
|
||||
jiraGovernanceModelTypeId: getOptionalEnvVar('JIRA_GOVERNANCE_MODEL_TYPE_ID'),
|
||||
jiraApplicationClusterTypeId: getOptionalEnvVar('JIRA_APPLICATION_CLUSTER_TYPE_ID'),
|
||||
jiraApplicationTypeTypeId: getOptionalEnvVar('JIRA_APPLICATION_TYPE_TYPE_ID'),
|
||||
jiraHostingTypeTypeId: getOptionalEnvVar('JIRA_HOSTING_TYPE_TYPE_ID', '39'),
|
||||
jiraBusinessImpactAnalyseTypeId: getOptionalEnvVar('JIRA_BUSINESS_IMPACT_ANALYSE_TYPE_ID', '41'),
|
||||
jiraApplicationManagementHostingTypeId: getOptionalEnvVar('JIRA_APPLICATION_MANAGEMENT_HOSTING_TYPE_ID', '438'),
|
||||
jiraApplicationManagementTAMTypeId: getOptionalEnvVar('JIRA_APPLICATION_MANAGEMENT_TAM_TYPE_ID', '439'),
|
||||
|
||||
// Attribute IDs
|
||||
jiraAttrApplicationFunction: getOptionalEnvVar('JIRA_ATTR_APPLICATION_FUNCTION'),
|
||||
jiraAttrDynamicsFactor: getOptionalEnvVar('JIRA_ATTR_DYNAMICS_FACTOR'),
|
||||
jiraAttrComplexityFactor: getOptionalEnvVar('JIRA_ATTR_COMPLEXITY_FACTOR'),
|
||||
jiraAttrNumberOfUsers: getOptionalEnvVar('JIRA_ATTR_NUMBER_OF_USERS'),
|
||||
jiraAttrGovernanceModel: getOptionalEnvVar('JIRA_ATTR_GOVERNANCE_MODEL'),
|
||||
jiraAttrApplicationCluster: getOptionalEnvVar('JIRA_ATTR_APPLICATION_CLUSTER'),
|
||||
jiraAttrApplicationType: getOptionalEnvVar('JIRA_ATTR_APPLICATION_TYPE'),
|
||||
jiraAttrPlatform: getOptionalEnvVar('JIRA_ATTR_PLATFORM'),
|
||||
jiraAttrHostingType: getOptionalEnvVar('JIRA_ATTR_HOSTING_TYPE', '355'),
|
||||
jiraAttrBusinessImpactAnalyse: getOptionalEnvVar('JIRA_ATTR_BUSINESS_IMPACT_ANALYSE', '368'),
|
||||
jiraAttrTechnischeArchitectuur: getOptionalEnvVar('JIRA_ATTR_TECHNISCHE_ARCHITECTUUR', '572'),
|
||||
jiraAttrTechnicalApplicationManagementPrimary: getOptionalEnvVar('JIRA_ATTR_TECHNICAL_APPLICATION_MANAGEMENT_PRIMARY', '377'),
|
||||
jiraAttrTechnicalApplicationManagementSecondary: getOptionalEnvVar('JIRA_ATTR_TECHNICAL_APPLICATION_MANAGEMENT_SECONDARY', '1330'),
|
||||
jiraAttrOverrideFTE: getOptionalEnvVar('JIRA_ATTR_OVERRIDE_FTE', '4932'),
|
||||
jiraAttrApplicationManagementHosting: getOptionalEnvVar('JIRA_ATTR_APPLICATION_MANAGEMENT_HOSTING', '4939'),
|
||||
jiraAttrApplicationManagementTAM: getOptionalEnvVar('JIRA_ATTR_APPLICATION_MANAGEMENT_TAM', '4945'),
|
||||
|
||||
// AI API Keys
|
||||
anthropicApiKey: getOptionalEnvVar('ANTHROPIC_API_KEY'),
|
||||
openaiApiKey: getOptionalEnvVar('OPENAI_API_KEY'),
|
||||
defaultAIProvider: (getOptionalEnvVar('DEFAULT_AI_PROVIDER', 'claude') as 'claude' | 'openai'),
|
||||
|
||||
// Web Search API (Tavily)
|
||||
tavilyApiKey: getOptionalEnvVar('TAVILY_API_KEY'),
|
||||
enableWebSearch: getOptionalEnvVar('ENABLE_WEB_SEARCH', 'false').toLowerCase() === 'true',
|
||||
|
||||
// Application
|
||||
port: parseInt(getOptionalEnvVar('PORT', '3001'), 10),
|
||||
nodeEnv: getOptionalEnvVar('NODE_ENV', 'development'),
|
||||
isDevelopment: getOptionalEnvVar('NODE_ENV', 'development') === 'development',
|
||||
isProduction: getOptionalEnvVar('NODE_ENV', 'development') === 'production',
|
||||
|
||||
// API Configuration
|
||||
jiraApiBatchSize: parseInt(getOptionalEnvVar('JIRA_API_BATCH_SIZE', '15'), 10),
|
||||
};
|
||||
|
||||
export function validateConfig(): void {
|
||||
const missingVars: string[] = [];
|
||||
|
||||
if (!config.jiraPat) missingVars.push('JIRA_PAT');
|
||||
if (!config.jiraSchemaId) missingVars.push('JIRA_SCHEMA_ID');
|
||||
if (!config.anthropicApiKey) missingVars.push('ANTHROPIC_API_KEY');
|
||||
|
||||
if (missingVars.length > 0) {
|
||||
console.warn(`Warning: Missing environment variables: ${missingVars.join(', ')}`);
|
||||
console.warn('Some features may not work correctly. Using mock data where possible.');
|
||||
}
|
||||
}
|
||||
284
backend/src/data/management-parameters.json
Normal file
284
backend/src/data/management-parameters.json
Normal file
@@ -0,0 +1,284 @@
|
||||
{
|
||||
"version": "2024.1",
|
||||
"source": "Zuyderland ICMT - Application Management Framework",
|
||||
"lastUpdated": "2024-12-19",
|
||||
"referenceData": {
|
||||
"applicationStatuses": [
|
||||
{
|
||||
"key": "status",
|
||||
"name": "Status",
|
||||
"description": "Algemene status",
|
||||
"order": 0,
|
||||
"color": "#6b7280",
|
||||
"includeInFilter": true
|
||||
},
|
||||
{
|
||||
"key": "prod",
|
||||
"name": "In Production",
|
||||
"description": "Productie - actief in gebruik",
|
||||
"order": 1,
|
||||
"color": "#22c55e",
|
||||
"includeInFilter": true
|
||||
},
|
||||
{
|
||||
"key": "impl",
|
||||
"name": "Implementation",
|
||||
"description": "In implementatie",
|
||||
"order": 2,
|
||||
"color": "#3b82f6",
|
||||
"includeInFilter": true
|
||||
},
|
||||
{
|
||||
"key": "poc",
|
||||
"name": "Proof of Concept",
|
||||
"description": "Proefproject",
|
||||
"order": 3,
|
||||
"color": "#8b5cf6",
|
||||
"includeInFilter": true
|
||||
},
|
||||
{
|
||||
"key": "eos",
|
||||
"name": "End of support",
|
||||
"description": "Geen ondersteuning meer van leverancier",
|
||||
"order": 4,
|
||||
"color": "#f97316",
|
||||
"includeInFilter": true
|
||||
},
|
||||
{
|
||||
"key": "eol",
|
||||
"name": "End of life",
|
||||
"description": "Einde levensduur, wordt uitgefaseerd",
|
||||
"order": 5,
|
||||
"color": "#ef4444",
|
||||
"includeInFilter": true
|
||||
},
|
||||
{
|
||||
"key": "deprecated",
|
||||
"name": "Deprecated",
|
||||
"description": "Verouderd, wordt uitgefaseerd",
|
||||
"order": 6,
|
||||
"color": "#f97316",
|
||||
"includeInFilter": true
|
||||
},
|
||||
{
|
||||
"key": "shadow",
|
||||
"name": "Shadow IT",
|
||||
"description": "Niet-geautoriseerde IT",
|
||||
"order": 7,
|
||||
"color": "#eab308",
|
||||
"includeInFilter": true
|
||||
},
|
||||
{
|
||||
"key": "closed",
|
||||
"name": "Closed",
|
||||
"description": "Afgesloten",
|
||||
"order": 8,
|
||||
"color": "#6b7280",
|
||||
"includeInFilter": true
|
||||
},
|
||||
{
|
||||
"key": "undefined",
|
||||
"name": "Undefined",
|
||||
"description": "Niet gedefinieerd",
|
||||
"order": 9,
|
||||
"color": "#9ca3af",
|
||||
"includeInFilter": true
|
||||
}
|
||||
],
|
||||
"dynamicsFactors": [
|
||||
{
|
||||
"key": "1",
|
||||
"name": "Stabiel",
|
||||
"description": "Weinig wijzigingen, uitgekristalliseerd systeem, < 2 releases/jaar",
|
||||
"order": 1,
|
||||
"color": "#22c55e"
|
||||
},
|
||||
{
|
||||
"key": "2",
|
||||
"name": "Gemiddeld",
|
||||
"description": "Regelmatige wijzigingen, 2-4 releases/jaar, incidentele projecten",
|
||||
"order": 2,
|
||||
"color": "#eab308"
|
||||
},
|
||||
{
|
||||
"key": "3",
|
||||
"name": "Hoog",
|
||||
"description": "Veel wijzigingen, > 4 releases/jaar, continue doorontwikkeling",
|
||||
"order": 3,
|
||||
"color": "#f97316"
|
||||
},
|
||||
{
|
||||
"key": "4",
|
||||
"name": "Zeer hoog",
|
||||
"description": "Continu in beweging, grote transformatieprojecten, veel nieuwe functionaliteit",
|
||||
"order": 4,
|
||||
"color": "#ef4444"
|
||||
}
|
||||
],
|
||||
"complexityFactors": [
|
||||
{
|
||||
"key": "1",
|
||||
"name": "Laag",
|
||||
"description": "Standalone applicatie, geen/weinig integraties, standaard configuratie",
|
||||
"order": 1,
|
||||
"color": "#22c55e"
|
||||
},
|
||||
{
|
||||
"key": "2",
|
||||
"name": "Gemiddeld",
|
||||
"description": "Enkele integraties, beperkt maatwerk, standaard governance",
|
||||
"order": 2,
|
||||
"color": "#eab308"
|
||||
},
|
||||
{
|
||||
"key": "3",
|
||||
"name": "Hoog",
|
||||
"description": "Veel integraties, significant maatwerk, meerdere stakeholdergroepen",
|
||||
"order": 3,
|
||||
"color": "#f97316"
|
||||
},
|
||||
{
|
||||
"key": "4",
|
||||
"name": "Zeer hoog",
|
||||
"description": "Platform met meerdere workloads, uitgebreide governance, veel maatwerk",
|
||||
"order": 4,
|
||||
"color": "#ef4444"
|
||||
}
|
||||
],
|
||||
"numberOfUsers": [
|
||||
{
|
||||
"key": "1",
|
||||
"name": "< 100",
|
||||
"minUsers": 0,
|
||||
"maxUsers": 99,
|
||||
"order": 1
|
||||
},
|
||||
{
|
||||
"key": "2",
|
||||
"name": "100 - 500",
|
||||
"minUsers": 100,
|
||||
"maxUsers": 500,
|
||||
"order": 2
|
||||
},
|
||||
{
|
||||
"key": "3",
|
||||
"name": "500 - 2.000",
|
||||
"minUsers": 500,
|
||||
"maxUsers": 2000,
|
||||
"order": 3
|
||||
},
|
||||
{
|
||||
"key": "4",
|
||||
"name": "2.000 - 5.000",
|
||||
"minUsers": 2000,
|
||||
"maxUsers": 5000,
|
||||
"order": 4
|
||||
},
|
||||
{
|
||||
"key": "5",
|
||||
"name": "5.000 - 10.000",
|
||||
"minUsers": 5000,
|
||||
"maxUsers": 10000,
|
||||
"order": 5
|
||||
},
|
||||
{
|
||||
"key": "6",
|
||||
"name": "10.000 - 15.000",
|
||||
"minUsers": 10000,
|
||||
"maxUsers": 15000,
|
||||
"order": 6
|
||||
},
|
||||
{
|
||||
"key": "7",
|
||||
"name": "> 15.000",
|
||||
"minUsers": 15000,
|
||||
"maxUsers": null,
|
||||
"order": 7
|
||||
}
|
||||
],
|
||||
"governanceModels": [
|
||||
{
|
||||
"key": "A",
|
||||
"name": "Centraal Beheer",
|
||||
"shortDescription": "ICMT voert volledig beheer uit",
|
||||
"description": "Volledige dienstverlening door ICMT. Dit is het standaardmodel voor kernapplicaties.",
|
||||
"applicability": "Kernapplicaties met BIA-classificatie D, E of F (belangrijk tot zeer kritiek). Voorbeelden: EPD (HiX), ERP (AFAS), Microsoft 365, kritieke zorgapplicaties.",
|
||||
"icmtInvolvement": "Volledig",
|
||||
"businessInvolvement": "Minimaal",
|
||||
"supplierInvolvement": "Via ICMT",
|
||||
"order": 1,
|
||||
"color": "#3b82f6"
|
||||
},
|
||||
{
|
||||
"key": "B",
|
||||
"name": "Federatief Beheer",
|
||||
"shortDescription": "ICMT + business delen beheer",
|
||||
"description": "ICMT en business delen de verantwoordelijkheid. Geschikt voor applicaties met een sterke key user organisatie.",
|
||||
"applicability": "Kernapplicaties met BIA-classificatie D, E of F (belangrijk tot zeer kritiek). Voorbeelden: EPD (HiX), ERP (AFAS), Microsoft 365, kritieke zorgapplicaties.",
|
||||
"icmtInvolvement": "Gedeeld",
|
||||
"businessInvolvement": "Gedeeld",
|
||||
"supplierInvolvement": "Via ICMT/Business",
|
||||
"order": 2,
|
||||
"color": "#8b5cf6"
|
||||
},
|
||||
{
|
||||
"key": "C",
|
||||
"name": "Uitbesteed met ICMT-Regie",
|
||||
"shortDescription": "Leverancier beheert, ICMT regisseert",
|
||||
"description": "Leverancier voert beheer uit, ICMT houdt regie. Dit is het standaardmodel voor SaaS waar ICMT contractpartij is.",
|
||||
"applicability": "SaaS-applicaties waar ICMT het contract beheert. Voorbeelden: AFAS, diverse zorg-SaaS oplossingen. De mate van FAB-dienstverlening hangt af van de BIA-classificatie.",
|
||||
"icmtInvolvement": "Regie",
|
||||
"businessInvolvement": "Gebruiker",
|
||||
"supplierInvolvement": "Volledig beheer",
|
||||
"contractHolder": "ICMT",
|
||||
"order": 3,
|
||||
"color": "#06b6d4"
|
||||
},
|
||||
{
|
||||
"key": "D",
|
||||
"name": "Uitbesteed met Business-Regie",
|
||||
"shortDescription": "Leverancier beheert, business regisseert",
|
||||
"description": "Business onderhoudt de leveranciersrelatie. ICMT heeft beperkte betrokkenheid.",
|
||||
"applicability": "SaaS-applicaties waar de business zelf het contract en de leveranciersrelatie beheert. Voorbeelden: niche SaaS tools, afdelingsspecifieke oplossingen, tools waar de business expertise heeft die ICMT niet heeft.",
|
||||
"icmtInvolvement": "Beperkt",
|
||||
"businessInvolvement": "Regie",
|
||||
"supplierInvolvement": "Volledig beheer",
|
||||
"contractHolder": "Business",
|
||||
"order": 4,
|
||||
"color": "#14b8a6"
|
||||
},
|
||||
{
|
||||
"key": "E",
|
||||
"name": "Volledig Decentraal Beheer",
|
||||
"shortDescription": "Business voert volledig beheer uit",
|
||||
"description": "Business voert zelf beheer uit. ICMT heeft minimale betrokkenheid.",
|
||||
"applicability": "Afdelingsspecifieke tools met beperkte impact, Shadow IT die in kaart is gebracht. Voorbeelden: standalone afdelingstools, pilotapplicaties, persoonlijke productiviteitstools.",
|
||||
"icmtInvolvement": "Minimaal",
|
||||
"businessInvolvement": "Volledig",
|
||||
"supplierInvolvement": "Direct met business",
|
||||
"order": 5,
|
||||
"color": "#6b7280"
|
||||
}
|
||||
]
|
||||
},
|
||||
"visualizations": {
|
||||
"capacityMatrix": {
|
||||
"description": "Matrix voor capaciteitsplanning gebaseerd op Dynamiek x Complexiteit",
|
||||
"formula": "Beheerlast = Dynamiek * Complexiteit * log(Gebruikers)",
|
||||
"weightings": {
|
||||
"dynamics": 1.0,
|
||||
"complexity": 1.2,
|
||||
"users": 0.3
|
||||
}
|
||||
},
|
||||
"governanceDecisionTree": {
|
||||
"description": "Beslisboom voor keuze regiemodel",
|
||||
"factors": [
|
||||
"BIA-classificatie",
|
||||
"Hosting type (SaaS/On-prem)",
|
||||
"Contracthouder",
|
||||
"Key user maturity"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
649
backend/src/data/zira-taxonomy.json
Normal file
649
backend/src/data/zira-taxonomy.json
Normal file
@@ -0,0 +1,649 @@
|
||||
{
|
||||
"version": "2024.1",
|
||||
"source": "ZiRA - Ziekenhuis Referentie Architectuur (Nictiz)",
|
||||
"lastUpdated": "2024-12-19",
|
||||
"domains": [
|
||||
{
|
||||
"code": "STU",
|
||||
"name": "Sturing",
|
||||
"description": "Applicatiefuncties ter ondersteuning van besturing en management",
|
||||
"functions": [
|
||||
{
|
||||
"code": "STU-001",
|
||||
"name": "Beleid & Innovatie",
|
||||
"description": "Functionaliteit voor ondersteuning van het bepalen en beheren van beleid, ontwikkeling producten & diensten, planning & control cyclus en ondersteunende managementinformatie",
|
||||
"keywords": ["beleid", "innovatie", "strategie", "planning", "control", "managementinformatie", "BI", "business intelligence"]
|
||||
},
|
||||
{
|
||||
"code": "STU-002",
|
||||
"name": "Proces & Architectuur",
|
||||
"description": "Functionaliteit voor het ontwikkelen en beheren van de enterprise architectuur (organisatie, processen, informatie, applicatie, techniek)",
|
||||
"keywords": ["architectuur", "proces", "enterprise", "TOGAF", "ArchiMate", "modellering", "BPM"]
|
||||
},
|
||||
{
|
||||
"code": "STU-003",
|
||||
"name": "Project & Portfoliomanagement",
|
||||
"description": "Functionaliteit voor het beheren van projecten en programma's",
|
||||
"keywords": ["project", "portfolio", "programma", "PMO", "planning", "resource", "Jira", "MS Project"]
|
||||
},
|
||||
{
|
||||
"code": "STU-004",
|
||||
"name": "Kwaliteitsinformatiemanagement",
|
||||
"description": "Functionaliteit voor de ondersteuning van het maken, verwerken en beheren van kwaliteitsdocumenten (inclusief protocollen)",
|
||||
"keywords": ["kwaliteit", "protocol", "procedure", "document", "QMS", "ISO", "accreditatie", "Zenya"]
|
||||
},
|
||||
{
|
||||
"code": "STU-005",
|
||||
"name": "Performance & Verantwoording",
|
||||
"description": "Functionaliteit voor het beheren van productieafspraken, KPI's inclusief beheer van de verantwoording in het kader van wet & regelgeving alsmede prestaties en maatschappelijk verantwoordschap",
|
||||
"keywords": ["KPI", "dashboard", "verantwoording", "rapportage", "compliance", "prestatie", "IGJ"]
|
||||
},
|
||||
{
|
||||
"code": "STU-006",
|
||||
"name": "Marketing & Contractmanagement",
|
||||
"description": "Functionaliteit voor ondersteuning van marktanalyses en contractmanagement",
|
||||
"keywords": ["marketing", "contract", "leverancier", "SLA", "marktanalyse", "CRM"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "ONZ",
|
||||
"name": "Onderzoek",
|
||||
"description": "Applicatiefuncties ter ondersteuning van wetenschappelijk onderzoek",
|
||||
"functions": [
|
||||
{
|
||||
"code": "ONZ-001",
|
||||
"name": "Onderzoek ontwikkeling",
|
||||
"description": "Functionaliteit voor de administratieve ondersteuning voor het indienen van een onderzoeksaanvraag, het opstellen van een onderzoeksprotocol, het opstellen van een onderzoeksvoorstel en de medisch etische keuring",
|
||||
"keywords": ["onderzoek", "protocol", "METC", "ethiek", "aanvraag", "voorstel"]
|
||||
},
|
||||
{
|
||||
"code": "ONZ-002",
|
||||
"name": "Onderzoekvoorbereiding",
|
||||
"description": "Functionaliteit voor de administratieve voorbereiding van het onderzoek als aanvraag van vergunningen en financieringen",
|
||||
"keywords": ["vergunning", "financiering", "subsidie", "grant", "voorbereiding"]
|
||||
},
|
||||
{
|
||||
"code": "ONZ-003",
|
||||
"name": "Onderzoeksmanagement",
|
||||
"description": "Functionaliteit voor de administratieve uitvoering van het onderzoek als aanvraag patientenselectie, verkrijgen consent",
|
||||
"keywords": ["consent", "inclusie", "patientselectie", "trial", "studie", "CTMS"]
|
||||
},
|
||||
{
|
||||
"code": "ONZ-004",
|
||||
"name": "Researchdatamanagement",
|
||||
"description": "Functionaliteit voor het verzamelen, bewerken, analyseren en publiceren van onderzoeksdata",
|
||||
"keywords": ["research", "data", "analyse", "statistiek", "SPSS", "R", "Castor", "REDCap"]
|
||||
},
|
||||
{
|
||||
"code": "ONZ-005",
|
||||
"name": "Onderzoekpublicatie",
|
||||
"description": "Functionaliteit voor de opslag van publicaties van onderzoeksresultaten",
|
||||
"keywords": ["publicatie", "artikel", "repository", "Pure", "bibliografie"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-SAM",
|
||||
"name": "Zorg - Samenwerking",
|
||||
"description": "Applicatiefuncties ter ondersteuning van samenwerking met patiënt en ketenpartners",
|
||||
"functions": [
|
||||
{
|
||||
"code": "ZRG-SAM-001",
|
||||
"name": "Dossier inzage",
|
||||
"description": "Functionaliteit die het mogelijk maakt voor patiënten om digitale inzage te krijgen in medische dossiers die de zorgverleners over hen bijhouden",
|
||||
"keywords": ["portaal", "inzage", "dossier", "patient", "MijnZuyderland", "toegang"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-SAM-002",
|
||||
"name": "Behandelondersteuning",
|
||||
"description": "Functionaliteit voor het voorlichten en coachen van en communiceren met de patiënt over zijn zorg met als doel de patiënt te helpen bij het bereiken van de behandeldoelen en (mede)verantwoordelijkheid te geven voor behandelkeuzes en behandeling (patientempowerment)",
|
||||
"keywords": ["voorlichting", "coaching", "empowerment", "educatie", "patient", "zelfmanagement"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-SAM-003",
|
||||
"name": "Interactie PGO",
|
||||
"description": "Functionaliteit voor ondersteuning en integraties met een persoonlijke gezondheidsomgeving",
|
||||
"keywords": ["PGO", "PHR", "persoonlijk", "gezondheidsomgeving", "MedMij"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-SAM-004",
|
||||
"name": "Patientenforum",
|
||||
"description": "Functionaliteit voor het aanbieden van een online omgeving voor patienten (bv discussieforum voor patienten onderling)",
|
||||
"keywords": ["forum", "community", "patient", "discussie", "lotgenoten"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-SAM-005",
|
||||
"name": "Preventie",
|
||||
"description": "Functionaliteit ter bevordering van de gezondheid en ter voorkoming van klachten en problemen",
|
||||
"keywords": ["preventie", "screening", "gezondheid", "vroegdetectie", "risico"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-SAM-006",
|
||||
"name": "Gezondheidsvragen",
|
||||
"description": "Functionaliteit voor het on-line invullen van vragenlijsten bijvoorbeeld anamnestische vragenlijsten of gezondheidsvragenlijsten",
|
||||
"keywords": ["vragenlijst", "anamnese", "intake", "PROM", "ePRO", "formulier"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-SAM-007",
|
||||
"name": "Kwaliteit en tevredenheidsmeting",
|
||||
"description": "Functionaliteit om de effecten van behandelingen en de patiënttevredenheid te kunnen meten en vaststellen",
|
||||
"keywords": ["tevredenheid", "kwaliteit", "PREM", "CQI", "NPS", "enquete", "feedback"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-SAM-008",
|
||||
"name": "Tele-consultatie",
|
||||
"description": "Functionaliteit om een zorgprofessional remote (niet in elkaars fysieke aanwezigheid) te raadplegen in het kader van een gezondheidsvraag",
|
||||
"keywords": ["teleconsultatie", "videoconsult", "beeldbellen", "remote", "consult"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-SAM-009",
|
||||
"name": "Zelfmonitoring",
|
||||
"description": "Functionaliteit om de eigen gezondheidstoestand te bewaken",
|
||||
"keywords": ["zelfmonitoring", "thuismeten", "wearable", "app", "meten"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-SAM-010",
|
||||
"name": "Tele-monitoring",
|
||||
"description": "Functionaliteit waarmee de patient op afstand (tele) gevolgd en begeleid (monitoring) wordt door de zorgverlener met behulp van bij de patient aanwezige meetapparatuur",
|
||||
"keywords": ["telemonitoring", "remote", "monitoring", "thuiszorg", "hartfalen", "COPD"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-SAM-011",
|
||||
"name": "On-line afspraken",
|
||||
"description": "Functionaliteit voor het on-line maken van afspraken",
|
||||
"keywords": ["afspraak", "online", "boeken", "reserveren", "planning"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-SAM-012",
|
||||
"name": "Dossieruitwisseling",
|
||||
"description": "Functionaliteit voor het versturen en ontvangen en verwerken van dossierinformatie door bijvoorbeeld verwijzer, overdragende of consulterend arts",
|
||||
"keywords": ["uitwisseling", "overdracht", "verwijzing", "XDS", "LSP", "Zorgplatform"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-SAM-013",
|
||||
"name": "Interactie externe bronnen",
|
||||
"description": "Functionaliteit voor informatieuitwisseling met derden voor het verzamelen van additionele gegevens",
|
||||
"keywords": ["extern", "koppeling", "integratie", "bron", "register"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-SAM-014",
|
||||
"name": "Samenwerking betrokken zorgverleners",
|
||||
"description": "Functionaliteit voor het coördineren van zorg met andere zorgverleners en het documenteren daarvan",
|
||||
"keywords": ["samenwerking", "keten", "MDO", "multidisciplinair", "consult"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-CON",
|
||||
"name": "Zorg - Consultatie & Behandeling",
|
||||
"description": "Applicatiefuncties ter ondersteuning van het primaire zorgproces",
|
||||
"functions": [
|
||||
{
|
||||
"code": "ZRG-CON-001",
|
||||
"name": "Dossierraadpleging",
|
||||
"description": "Functionaliteit voor het raadplegen van het dossier via verschillende views als patiëntgeschiedenis, decursus, samenvatting, problemen, diagnoses en allergieën",
|
||||
"keywords": ["dossier", "raadplegen", "EPD", "decursus", "samenvatting", "overzicht"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-CON-002",
|
||||
"name": "Dossiervoering",
|
||||
"description": "Functionaliteit voor het bijwerken van het dossier aan de hand van gegevens uit consult, behandeling en input vanuit andere bronnen",
|
||||
"keywords": ["dossier", "registratie", "EPD", "notitie", "verslag", "brief"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-CON-003",
|
||||
"name": "Medicatie",
|
||||
"description": "Functionaliteit van de ondersteuning van de medicamenteuze behandeling",
|
||||
"keywords": ["medicatie", "voorschrijven", "EVS", "apotheek", "recept", "CPOE"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-CON-004",
|
||||
"name": "Operatie",
|
||||
"description": "Functionaliteit voor de ondersteuning van het operatieve proces",
|
||||
"keywords": ["OK", "operatie", "chirurgie", "planning", "anesthesie", "perioperatief"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-CON-005",
|
||||
"name": "Patientbewaking",
|
||||
"description": "Functionaliteit voor bewaking van de patienten (bv medische alarmering, monitoring, dwaaldetectie, valdetectie)",
|
||||
"keywords": ["monitoring", "bewaking", "alarm", "IC", "telemetrie", "vitale functies"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-CON-006",
|
||||
"name": "Beslissingsondersteuning",
|
||||
"description": "Functionaliteit voor de ondersteuning van besluiten van de zorgverlener",
|
||||
"keywords": ["CDSS", "beslissing", "advies", "alert", "waarschuwing", "protocol"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-CON-007",
|
||||
"name": "Verzorgingondersteuning",
|
||||
"description": "Functionaliteit voor de ondersteuning van het verzorgingsproces als aanvragen van verzorgingsdiensten",
|
||||
"keywords": ["verzorging", "verpleging", "zorgplan", "ADL", "voeding"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-CON-008",
|
||||
"name": "Ordermanagement",
|
||||
"description": "Functionaliteit voor de uitvoering van de closed order loop van onderzoeken (aanvraag, planning, oplevering, acceptatie)",
|
||||
"keywords": ["order", "aanvraag", "lab", "onderzoek", "workflow", "ORM"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-CON-009",
|
||||
"name": "Resultaat afhandeling",
|
||||
"description": "Functionaliteit voor de analyse en rapportage van resultaten en notificatie naar zorgverleners en/of patient",
|
||||
"keywords": ["resultaat", "uitslag", "notificatie", "rapport", "bevinding"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-CON-010",
|
||||
"name": "Kwaliteitsbewaking",
|
||||
"description": "Functionaliteit voor de bewaking en signalering van (mogelijke) fouten (verkeerde patient, verkeerde dosis, verkeerde tijd, verkeerde vervolgstap)",
|
||||
"keywords": ["kwaliteit", "veiligheid", "controle", "check", "alert", "CDSS"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-AOZ",
|
||||
"name": "Zorg - Aanvullend onderzoek",
|
||||
"description": "Applicatiefuncties ter ondersteuning van diagnostisch onderzoek",
|
||||
"functions": [
|
||||
{
|
||||
"code": "ZRG-AOZ-001",
|
||||
"name": "Laboratoriumonderzoek",
|
||||
"description": "Functionaliteit voor de ondersteuning van processen op laboratoria (kcl, microbiologie, pathologie, klinische genetica, apotheeklab, etc)",
|
||||
"keywords": ["lab", "LIMS", "laboratorium", "KCL", "microbiologie", "pathologie", "genetica"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-AOZ-002",
|
||||
"name": "Beeldvormend onderzoek",
|
||||
"description": "Functionaliteit voor de ondersteuning van Beeldvormend onderzoek voor bijvoorbeeld Radiologie, Nucleair, Cardologie inclusief beeldmanagement (zoals VNA)",
|
||||
"keywords": ["PACS", "RIS", "radiologie", "CT", "MRI", "echo", "VNA", "DICOM"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-AOZ-003",
|
||||
"name": "Functieonderzoek",
|
||||
"description": "Functionaliteit voor de ondersteuning van Functieonderzoek (voorbeelden ECG, Longfunctie, Audiologie)",
|
||||
"keywords": ["ECG", "longfunctie", "audiologie", "functie", "EEG", "EMG"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-ZON",
|
||||
"name": "Zorg - Zorgondersteuning",
|
||||
"description": "Applicatiefuncties ter ondersteuning van de zorglogistiek",
|
||||
"functions": [
|
||||
{
|
||||
"code": "ZRG-ZON-001",
|
||||
"name": "Zorgrelatiebeheer",
|
||||
"description": "Functionaliteit voor beheren van alle gegevens van zorgrelaties (zorgaanbieders, zorgverleners, zorgverzekeraars e.d.)",
|
||||
"keywords": ["AGB", "zorgverlener", "verwijzer", "huisarts", "verzekeraar", "register"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-ZON-002",
|
||||
"name": "Zorgplanning",
|
||||
"description": "Functionaliteit voor het maken en beheren van afspraken, opnames, overplaatsingen, ontslag en verwijzing",
|
||||
"keywords": ["planning", "afspraak", "agenda", "opname", "ontslag", "bed"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-ZON-003",
|
||||
"name": "Resource planning",
|
||||
"description": "Functionaliteit voor het plannen van resources (personen, zorgverleners) en middelen",
|
||||
"keywords": ["resource", "capaciteit", "rooster", "personeel", "middelen"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-ZON-004",
|
||||
"name": "Patiëntadministratie",
|
||||
"description": "Functionaliteit voor beheer van demografie, contactpersonen en alle andere (niet medische) informatie nodig voor het ondersteunen van het consult en de behandeling",
|
||||
"keywords": ["ZIS", "administratie", "demografie", "patient", "registratie", "NAW"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-ZON-005",
|
||||
"name": "Patiëntenlogistiek",
|
||||
"description": "Functionaliteit voor de ondersteuning van het verplaatsen van mensen en middelen (bv transportlogistiek, route ondersteuning, track & tracing, aanmeldregistratie, wachtrijmanagement, oproep)",
|
||||
"keywords": ["logistiek", "transport", "wachtrij", "aanmeldzuil", "tracking", "routing"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-ZON-006",
|
||||
"name": "Zorgfacturering",
|
||||
"description": "Functionaliteit voor de vastlegging van de verrichting en factureren van het zorgproduct",
|
||||
"keywords": ["facturatie", "DBC", "DOT", "declaratie", "verrichting", "tarief"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "OND",
|
||||
"name": "Onderwijs",
|
||||
"description": "Applicatiefuncties ter ondersteuning van medisch onderwijs",
|
||||
"functions": [
|
||||
{
|
||||
"code": "OND-001",
|
||||
"name": "Onderwijsportfolio",
|
||||
"description": "Functionaliteit voor creatie en beheer van het onderwijsportfolio",
|
||||
"keywords": ["portfolio", "EPA", "competentie", "voortgang", "student"]
|
||||
},
|
||||
{
|
||||
"code": "OND-002",
|
||||
"name": "Learning Content Management",
|
||||
"description": "Functionaliteit creatie en beheer van onderwijscontent",
|
||||
"keywords": ["LMS", "content", "cursus", "module", "e-learning"]
|
||||
},
|
||||
{
|
||||
"code": "OND-003",
|
||||
"name": "Educatie",
|
||||
"description": "Functionaliteit voor het geven van educatie dmv digitale middelen",
|
||||
"keywords": ["educatie", "training", "scholing", "e-learning", "webinar"]
|
||||
},
|
||||
{
|
||||
"code": "OND-004",
|
||||
"name": "Toetsing",
|
||||
"description": "Functionaliteit voor het geven en beoordelen van toetsen",
|
||||
"keywords": ["toets", "examen", "beoordeling", "assessment", "evaluatie"]
|
||||
},
|
||||
{
|
||||
"code": "OND-005",
|
||||
"name": "Student Informatie",
|
||||
"description": "Functionaliteit voor het beheren van alle informatie van en over de student",
|
||||
"keywords": ["SIS", "student", "opleiding", "registratie", "inschrijving"]
|
||||
},
|
||||
{
|
||||
"code": "OND-006",
|
||||
"name": "Onderwijs rooster & planning",
|
||||
"description": "Functionaliteit voor het roosteren en plannen van het onderwijsprogramma",
|
||||
"keywords": ["rooster", "planning", "stage", "coschap", "onderwijs"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "BED",
|
||||
"name": "Bedrijfsondersteuning",
|
||||
"description": "Applicatiefuncties ter ondersteuning van bedrijfsvoering",
|
||||
"functions": [
|
||||
{
|
||||
"code": "BED-001",
|
||||
"name": "Vastgoed",
|
||||
"description": "Functionaliteit die beheer, bouw en exploitatie van gebouwen en de daaraan verbonden faciliteiten en goederenstromen ondersteunt",
|
||||
"keywords": ["vastgoed", "gebouw", "facilitair", "onderhoud", "FMIS"]
|
||||
},
|
||||
{
|
||||
"code": "BED-002",
|
||||
"name": "Inkoop",
|
||||
"description": "Functionaliteit die inkopen van producten en diensten alsook het beheren van leveranciers en contracten ondersteunt",
|
||||
"keywords": ["inkoop", "procurement", "leverancier", "bestelling", "contract"]
|
||||
},
|
||||
{
|
||||
"code": "BED-003",
|
||||
"name": "Voorraadbeheer",
|
||||
"description": "Beheren/beheersen van de in- en uitgaande goederenstroom (door middel van planningtools) inclusief supply chain",
|
||||
"keywords": ["voorraad", "magazijn", "supply chain", "logistiek", "inventaris"]
|
||||
},
|
||||
{
|
||||
"code": "BED-004",
|
||||
"name": "Kennismanagement",
|
||||
"description": "Functionaliteit die het creëeren en delen van gezamenlijke kennis ondersteunt",
|
||||
"keywords": ["kennis", "wiki", "intranet", "SharePoint", "documentatie"]
|
||||
},
|
||||
{
|
||||
"code": "BED-005",
|
||||
"name": "Datamanagement",
|
||||
"description": "Functionaliteit voor ondersteunen van datamanagement, inclusief reference & master datamangement, metadatamanagement, dataanalytics",
|
||||
"keywords": ["data", "master data", "metadata", "analytics", "datawarehouse", "BI"]
|
||||
},
|
||||
{
|
||||
"code": "BED-006",
|
||||
"name": "Voorlichting",
|
||||
"description": "Functionaliteit die het geven van voorlichting via verschillende kanalen ondersteunt",
|
||||
"keywords": ["website", "CMS", "communicatie", "voorlichting", "publicatie"]
|
||||
},
|
||||
{
|
||||
"code": "BED-007",
|
||||
"name": "Hotelservice",
|
||||
"description": "Functionaliteit die de hotelfunctie ondersteunt, hierbij inbegrepen zijn parkeren, catering, kassa",
|
||||
"keywords": ["catering", "restaurant", "parkeren", "kassa", "hotel"]
|
||||
},
|
||||
{
|
||||
"code": "BED-008",
|
||||
"name": "Klachtenafhandeling",
|
||||
"description": "Functionaliteit die de afhandeling van klachten ondersteunt",
|
||||
"keywords": ["klacht", "melding", "incident", "feedback", "MIC", "MIM"]
|
||||
},
|
||||
{
|
||||
"code": "BED-009",
|
||||
"name": "Personeelbeheer",
|
||||
"description": "Functionaliteit die het administreren en managen van medewerkers ondersteunt",
|
||||
"keywords": ["HR", "HRM", "personeel", "medewerker", "werving", "talent"]
|
||||
},
|
||||
{
|
||||
"code": "BED-010",
|
||||
"name": "Tijdsregistratie",
|
||||
"description": "Functionaliteit waarmee het registreren van de bestede tijd van individuen wordt ondersteund",
|
||||
"keywords": ["tijd", "uren", "registratie", "klokken", "rooster"]
|
||||
},
|
||||
{
|
||||
"code": "BED-011",
|
||||
"name": "Financieel beheer",
|
||||
"description": "Functionaliteit waarmee de financiële administratie en verwerking van financiële stromen wordt ondersteund",
|
||||
"keywords": ["financieel", "boekhouding", "factuur", "budget", "ERP", "SAP"]
|
||||
},
|
||||
{
|
||||
"code": "BED-012",
|
||||
"name": "Salarisverwerking",
|
||||
"description": "Functionaliteit waarmee het uitbetalen van salarissen aan medewerkers wordt ondersteund",
|
||||
"keywords": ["salaris", "loon", "payroll", "verloning"]
|
||||
},
|
||||
{
|
||||
"code": "BED-013",
|
||||
"name": "Beheren medische technologie",
|
||||
"description": "Functionaliteit die beheer, onderhoud en gebruik van diverse medische apparatuur ondersteunt",
|
||||
"keywords": ["MT", "medische techniek", "apparatuur", "onderhoud", "kalibratie"]
|
||||
},
|
||||
{
|
||||
"code": "BED-014",
|
||||
"name": "Beveiliging",
|
||||
"description": "Functionaliteit die ondersteunt bij het uitvoeren van de veiligheid, kwaliteit en milieu taken en verplichtingen",
|
||||
"keywords": ["beveiliging", "VGM", "ARBO", "milieu", "veiligheid"]
|
||||
},
|
||||
{
|
||||
"code": "BED-015",
|
||||
"name": "Relatiebeheer",
|
||||
"description": "Functionaliteit ter ondersteuning van relatiebeheer in brede zin",
|
||||
"keywords": ["CRM", "relatie", "stakeholder", "contact", "netwerk"]
|
||||
},
|
||||
{
|
||||
"code": "BED-016",
|
||||
"name": "ICT-change en servicemanagement",
|
||||
"description": "Functies voor het faciliteren van hulpvragen en oplossingen",
|
||||
"keywords": ["ITSM", "servicedesk", "incident", "change", "TOPdesk", "ServiceNow"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "GEN-WRK",
|
||||
"name": "Generieke ICT - Werkplek en samenwerken",
|
||||
"description": "Generieke ICT-functies voor werkplek en samenwerking",
|
||||
"functions": [
|
||||
{
|
||||
"code": "GEN-WRK-001",
|
||||
"name": "Beheren werkplek",
|
||||
"description": "Functionaliteit voor beheren hardware (PC, monitor, mobile device, printers, scanners, bedside, tv e.d.) en software op de werkplek of bed-site (LCM, CMDB, deployment, virtual desktop)",
|
||||
"keywords": ["werkplek", "PC", "laptop", "VDI", "Citrix", "deployment", "SCCM", "Intune"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-WRK-002",
|
||||
"name": "Printing & scanning",
|
||||
"description": "Functionaliteit voor het afdrukken en scannen",
|
||||
"keywords": ["print", "scan", "printer", "MFP", "document"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-WRK-003",
|
||||
"name": "Kantoorautomatisering",
|
||||
"description": "Functionaliteit voor standaard kantoorondersteuning (tekstverwerking, spreadsheet, e-mail en agenda)",
|
||||
"keywords": ["Office", "Microsoft 365", "Word", "Excel", "Outlook", "email", "agenda"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-WRK-004",
|
||||
"name": "Unified communications",
|
||||
"description": "Functionaliteit voor de (geïntegreerde) communicatie tussen mensen via verschillende kanalen (spraak, instant messaging, video)",
|
||||
"keywords": ["Teams", "telefonie", "video", "chat", "communicatie", "VoIP"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-WRK-005",
|
||||
"name": "Document & Beeld beheer",
|
||||
"description": "Functionaliteit voor het beheren van documenten en beelden",
|
||||
"keywords": ["DMS", "document", "archief", "SharePoint", "OneDrive"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-WRK-006",
|
||||
"name": "Content management",
|
||||
"description": "Functionaliteit voor het verzamelen, managen en publiceren van (niet-patientgebonden) informatie in elke vorm of medium",
|
||||
"keywords": ["CMS", "website", "intranet", "publicatie", "content"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-WRK-007",
|
||||
"name": "Publieke ICT services",
|
||||
"description": "Functionaliteit voor het aanbieden van bv radio en tv, internet, e-books, netflix",
|
||||
"keywords": ["gastnetwerk", "wifi", "entertainment", "internet", "publiek"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "GEN-IAM",
|
||||
"name": "Generieke ICT - Identiteit, toegang en beveiliging",
|
||||
"description": "Generieke ICT-functies voor identity en access management",
|
||||
"functions": [
|
||||
{
|
||||
"code": "GEN-IAM-001",
|
||||
"name": "Identiteit & Authenticatie",
|
||||
"description": "Functionaliteit voor het identificeren en authenticeren van individuen in systemen",
|
||||
"keywords": ["IAM", "identiteit", "authenticatie", "SSO", "MFA", "Active Directory", "Entra"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-IAM-002",
|
||||
"name": "Autorisatie management",
|
||||
"description": "Functionaliteit voor beheren van rechten en toegang",
|
||||
"keywords": ["autorisatie", "RBAC", "rechten", "toegang", "rollen"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-IAM-003",
|
||||
"name": "Auditing & monitoring",
|
||||
"description": "Functionaliteit voor audits en monitoring in het kader van rechtmatig gebruik en toegang",
|
||||
"keywords": ["audit", "logging", "SIEM", "compliance", "NEN7513"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-IAM-004",
|
||||
"name": "Certificate service",
|
||||
"description": "Functionaliteit voor uitgifte en beheer van certificaten",
|
||||
"keywords": ["certificaat", "PKI", "SSL", "TLS", "signing"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-IAM-005",
|
||||
"name": "ICT Preventie en protectie",
|
||||
"description": "Functionaliteit voor beheersen van kwetsbaarheden en penetraties",
|
||||
"keywords": ["security", "antivirus", "EDR", "firewall", "vulnerability", "pentest"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "GEN-DC",
|
||||
"name": "Generieke ICT - Datacenter",
|
||||
"description": "Generieke ICT-functies voor datacenter en hosting",
|
||||
"functions": [
|
||||
{
|
||||
"code": "GEN-DC-001",
|
||||
"name": "Hosting servercapaciteit",
|
||||
"description": "Functionaliteit voor het leveren van serverinfrastructuur (CPU power)",
|
||||
"keywords": ["server", "hosting", "VM", "compute", "cloud", "Azure"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-DC-002",
|
||||
"name": "Datacenter housing",
|
||||
"description": "Functionaliteit voor beheren van het datacenter, bijvoorbeeld fysieke toegang, cooling",
|
||||
"keywords": ["datacenter", "housing", "colocation", "rack", "cooling"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-DC-003",
|
||||
"name": "Hosting data storage",
|
||||
"description": "Functionaliteit voor data opslag",
|
||||
"keywords": ["storage", "SAN", "NAS", "opslag", "disk"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-DC-004",
|
||||
"name": "Data archiving",
|
||||
"description": "Functionaliteit voor het archiveren van gegevens",
|
||||
"keywords": ["archief", "archivering", "retentie", "backup", "cold storage"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-DC-005",
|
||||
"name": "Backup & recovery",
|
||||
"description": "Functionaliteit voor back-up en herstel",
|
||||
"keywords": ["backup", "restore", "recovery", "DR", "disaster recovery"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-DC-006",
|
||||
"name": "Database management",
|
||||
"description": "Functionaliteit voor het beheren van databases",
|
||||
"keywords": ["database", "SQL", "Oracle", "DBA", "DBMS"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-DC-007",
|
||||
"name": "Provisioning & automation service",
|
||||
"description": "Functionaliteit voor het distribueren en automatiseren van diensten/applicaties",
|
||||
"keywords": ["automation", "provisioning", "deployment", "DevOps", "CI/CD"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-DC-008",
|
||||
"name": "Monitoring & alerting",
|
||||
"description": "Functionaliteit voor het monitoren en analyseren van het datacentrum",
|
||||
"keywords": ["monitoring", "APM", "alerting", "Zabbix", "Splunk", "observability"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-DC-009",
|
||||
"name": "Servermanagement",
|
||||
"description": "Functionaliteit voor het beheren van servers",
|
||||
"keywords": ["server", "beheer", "patching", "configuratie", "lifecycle"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "GEN-CON",
|
||||
"name": "Generieke ICT - Connectiviteit",
|
||||
"description": "Generieke ICT-functies voor netwerk en connectiviteit",
|
||||
"functions": [
|
||||
{
|
||||
"code": "GEN-CON-001",
|
||||
"name": "Netwerkmanagement",
|
||||
"description": "Functionaliteit voor het beheren van het netwerk zoals bijv. acceptatie van hardware op netwerk/DC-LAN, Campus-LAN, WAN",
|
||||
"keywords": ["netwerk", "LAN", "WAN", "switch", "router", "wifi"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-CON-002",
|
||||
"name": "Locatiebepaling",
|
||||
"description": "Functies voor het traceren en volgen van items of eigendom, nu of in het verleden. Bijvoorbeeld RFID-toepassingen",
|
||||
"keywords": ["RFID", "RTLS", "tracking", "locatie", "asset tracking"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-CON-003",
|
||||
"name": "DNS & IP Adress management",
|
||||
"description": "Functionaliteit voor het beheren van DNS en IP adressen",
|
||||
"keywords": ["DNS", "DHCP", "IP", "IPAM", "domain"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-CON-004",
|
||||
"name": "Remote Access",
|
||||
"description": "Functionaliteit voor toegang op afstand zoals inbelfaciliteiten",
|
||||
"keywords": ["VPN", "remote", "thuiswerken", "toegang", "DirectAccess"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-CON-005",
|
||||
"name": "Load Balancing",
|
||||
"description": "Functionaliteit voor beheren van server en netwerkbelasting",
|
||||
"keywords": ["load balancer", "F5", "HAProxy", "traffic", "availability"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-CON-006",
|
||||
"name": "Gegevensuitwisseling",
|
||||
"description": "Functionaliteit voor de ondersteuning van het gegevensuitwisseling (ESB, Message broker)",
|
||||
"keywords": ["integratie", "ESB", "API", "HL7", "FHIR", "message broker", "MuleSoft"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
102
backend/src/index.ts
Normal file
102
backend/src/index.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { config, validateConfig } from './config/env.js';
|
||||
import { logger } from './services/logger.js';
|
||||
import { dataService } from './services/dataService.js';
|
||||
import applicationsRouter from './routes/applications.js';
|
||||
import classificationsRouter from './routes/classifications.js';
|
||||
import referenceDataRouter from './routes/referenceData.js';
|
||||
import dashboardRouter from './routes/dashboard.js';
|
||||
import configurationRouter from './routes/configuration.js';
|
||||
|
||||
// Validate configuration
|
||||
validateConfig();
|
||||
|
||||
const app = express();
|
||||
|
||||
// Security middleware
|
||||
app.use(helmet());
|
||||
app.use(cors({
|
||||
origin: config.isDevelopment ? '*' : ['http://localhost:5173', 'http://localhost:3000'],
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
// Rate limiting
|
||||
const limiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 1000, // Limit each IP to 1000 requests per windowMs
|
||||
message: 'Too many requests from this IP, please try again later.',
|
||||
});
|
||||
app.use(limiter);
|
||||
|
||||
// Body parsing
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Request logging
|
||||
app.use((req, res, next) => {
|
||||
logger.debug(`${req.method} ${req.path}`);
|
||||
next();
|
||||
});
|
||||
|
||||
// Health check
|
||||
app.get('/health', async (req, res) => {
|
||||
const jiraConnected = await dataService.testConnection();
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
dataSource: dataService.isUsingJiraAssets() ? 'jira-assets' : 'mock-data',
|
||||
jiraConnected: dataService.isUsingJiraAssets() ? jiraConnected : null,
|
||||
aiConfigured: !!config.anthropicApiKey,
|
||||
});
|
||||
});
|
||||
|
||||
// Config endpoint
|
||||
app.get('/api/config', (req, res) => {
|
||||
res.json({
|
||||
jiraHost: config.jiraHost,
|
||||
});
|
||||
});
|
||||
|
||||
// API routes
|
||||
app.use('/api/applications', applicationsRouter);
|
||||
app.use('/api/classifications', classificationsRouter);
|
||||
app.use('/api/reference-data', referenceDataRouter);
|
||||
app.use('/api/dashboard', dashboardRouter);
|
||||
app.use('/api/configuration', configurationRouter);
|
||||
|
||||
// Error handling
|
||||
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
logger.error('Unhandled error:', err);
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: config.isDevelopment ? err.message : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
// 404 handler
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
});
|
||||
|
||||
// Start server
|
||||
const PORT = config.port;
|
||||
app.listen(PORT, () => {
|
||||
logger.info(`Server running on http://localhost:${PORT}`);
|
||||
logger.info(`Environment: ${config.nodeEnv}`);
|
||||
logger.info(`AI Classification: ${config.anthropicApiKey ? 'Configured' : 'Not configured'}`);
|
||||
logger.info(`Jira Assets: ${config.jiraPat ? 'Configured' : 'Using mock data'}`);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
logger.info('SIGTERM signal received: closing HTTP server');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('SIGINT signal received: closing HTTP server');
|
||||
process.exit(0);
|
||||
});
|
||||
217
backend/src/routes/applications.ts
Normal file
217
backend/src/routes/applications.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { dataService } from '../services/dataService.js';
|
||||
import { databaseService } from '../services/database.js';
|
||||
import { logger } from '../services/logger.js';
|
||||
import { calculateRequiredEffortApplicationManagement, calculateRequiredEffortApplicationManagementWithBreakdown } from '../services/effortCalculation.js';
|
||||
import type { SearchFilters, ApplicationUpdateRequest, ReferenceValue, ClassificationResult, ApplicationDetails, ApplicationStatus } from '../types/index.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Search applications with filters
|
||||
router.post('/search', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { filters, page = 1, pageSize = 25 } = req.body as {
|
||||
filters: SearchFilters;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
const result = await dataService.searchApplications(filters, page, pageSize);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Failed to search applications', error);
|
||||
res.status(500).json({ error: 'Failed to search applications' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get team dashboard data
|
||||
router.get('/team-dashboard', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const excludedStatusesParam = req.query.excludedStatuses as string | undefined;
|
||||
let excludedStatuses: ApplicationStatus[] = [];
|
||||
|
||||
if (excludedStatusesParam && excludedStatusesParam.trim().length > 0) {
|
||||
// Parse comma-separated statuses
|
||||
excludedStatuses = excludedStatusesParam
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0) as ApplicationStatus[];
|
||||
} else {
|
||||
// Default to excluding 'Closed' and 'Deprecated' if not specified
|
||||
excludedStatuses = ['Closed', 'Deprecated'];
|
||||
}
|
||||
|
||||
const data = await dataService.getTeamDashboardData(excludedStatuses);
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get team dashboard data', error);
|
||||
res.status(500).json({ error: 'Failed to get team dashboard data' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get application by ID
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Don't treat special routes as application IDs
|
||||
if (id === 'team-dashboard' || id === 'calculate-effort' || id === 'search') {
|
||||
res.status(404).json({ error: 'Route not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const application = await dataService.getApplicationById(id);
|
||||
|
||||
if (!application) {
|
||||
res.status(404).json({ error: 'Application not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(application);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get application', error);
|
||||
res.status(500).json({ error: 'Failed to get application' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update application
|
||||
router.put('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updates = req.body as {
|
||||
applicationFunctions?: ReferenceValue[];
|
||||
dynamicsFactor?: ReferenceValue;
|
||||
complexityFactor?: ReferenceValue;
|
||||
numberOfUsers?: ReferenceValue;
|
||||
governanceModel?: ReferenceValue;
|
||||
source?: 'AI_ACCEPTED' | 'AI_MODIFIED' | 'MANUAL';
|
||||
};
|
||||
|
||||
const application = await dataService.getApplicationById(id);
|
||||
if (!application) {
|
||||
res.status(404).json({ error: 'Application not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Build changes object for history
|
||||
const changes: ClassificationResult['changes'] = {};
|
||||
if (updates.applicationFunctions) {
|
||||
changes.applicationFunctions = {
|
||||
from: application.applicationFunctions,
|
||||
to: updates.applicationFunctions,
|
||||
};
|
||||
}
|
||||
if (updates.dynamicsFactor) {
|
||||
changes.dynamicsFactor = {
|
||||
from: application.dynamicsFactor,
|
||||
to: updates.dynamicsFactor,
|
||||
};
|
||||
}
|
||||
if (updates.complexityFactor) {
|
||||
changes.complexityFactor = {
|
||||
from: application.complexityFactor,
|
||||
to: updates.complexityFactor,
|
||||
};
|
||||
}
|
||||
if (updates.numberOfUsers) {
|
||||
changes.numberOfUsers = {
|
||||
from: application.numberOfUsers,
|
||||
to: updates.numberOfUsers,
|
||||
};
|
||||
}
|
||||
if (updates.governanceModel) {
|
||||
changes.governanceModel = {
|
||||
from: application.governanceModel,
|
||||
to: updates.governanceModel,
|
||||
};
|
||||
}
|
||||
|
||||
const success = await dataService.updateApplication(id, updates);
|
||||
|
||||
if (success) {
|
||||
// Save to classification history
|
||||
const classificationResult: ClassificationResult = {
|
||||
applicationId: id,
|
||||
applicationName: application.name,
|
||||
changes,
|
||||
source: updates.source || 'MANUAL',
|
||||
timestamp: new Date(),
|
||||
};
|
||||
databaseService.saveClassificationResult(classificationResult);
|
||||
|
||||
const updatedApp = await dataService.getApplicationById(id);
|
||||
res.json(updatedApp);
|
||||
} else {
|
||||
res.status(500).json({ error: 'Failed to update application' });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to update application', error);
|
||||
res.status(500).json({ error: 'Failed to update application' });
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate FTE effort for an application (real-time calculation without saving)
|
||||
router.post('/calculate-effort', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const applicationData = req.body as Partial<ApplicationDetails>;
|
||||
|
||||
// Build a complete ApplicationDetails object with defaults
|
||||
const application: ApplicationDetails = {
|
||||
id: applicationData.id || '',
|
||||
key: applicationData.key || '',
|
||||
name: applicationData.name || '',
|
||||
searchReference: applicationData.searchReference || null,
|
||||
description: applicationData.description || null,
|
||||
supplierProduct: applicationData.supplierProduct || null,
|
||||
organisation: applicationData.organisation || null,
|
||||
hostingType: applicationData.hostingType || null,
|
||||
status: applicationData.status || null,
|
||||
businessImportance: applicationData.businessImportance || null,
|
||||
businessImpactAnalyse: applicationData.businessImpactAnalyse || null,
|
||||
systemOwner: applicationData.systemOwner || null,
|
||||
businessOwner: applicationData.businessOwner || null,
|
||||
functionalApplicationManagement: applicationData.functionalApplicationManagement || null,
|
||||
technicalApplicationManagement: applicationData.technicalApplicationManagement || null,
|
||||
technicalApplicationManagementPrimary: applicationData.technicalApplicationManagementPrimary || null,
|
||||
technicalApplicationManagementSecondary: applicationData.technicalApplicationManagementSecondary || null,
|
||||
medischeTechniek: applicationData.medischeTechniek || false,
|
||||
applicationFunctions: applicationData.applicationFunctions || [],
|
||||
dynamicsFactor: applicationData.dynamicsFactor || null,
|
||||
complexityFactor: applicationData.complexityFactor || null,
|
||||
numberOfUsers: applicationData.numberOfUsers || null,
|
||||
governanceModel: applicationData.governanceModel || null,
|
||||
applicationCluster: applicationData.applicationCluster || null,
|
||||
applicationType: applicationData.applicationType || null,
|
||||
platform: applicationData.platform || null,
|
||||
requiredEffortApplicationManagement: null,
|
||||
overrideFTE: applicationData.overrideFTE || null,
|
||||
applicationManagementHosting: applicationData.applicationManagementHosting || null,
|
||||
applicationManagementTAM: applicationData.applicationManagementTAM || null,
|
||||
technischeArchitectuur: applicationData.technischeArchitectuur || null,
|
||||
};
|
||||
|
||||
const result = calculateRequiredEffortApplicationManagementWithBreakdown(application);
|
||||
|
||||
res.json({
|
||||
requiredEffortApplicationManagement: result.finalEffort,
|
||||
breakdown: result.breakdown,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to calculate effort', error);
|
||||
res.status(500).json({ error: 'Failed to calculate effort' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get application classification history
|
||||
router.get('/:id/history', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const history = databaseService.getClassificationsByApplicationId(id);
|
||||
res.json(history);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get classification history', error);
|
||||
res.status(500).json({ error: 'Failed to get classification history' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
203
backend/src/routes/classifications.ts
Normal file
203
backend/src/routes/classifications.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { aiService, AIProvider } from '../services/claude.js';
|
||||
import { dataService } from '../services/dataService.js';
|
||||
import { databaseService } from '../services/database.js';
|
||||
import { logger } from '../services/logger.js';
|
||||
import { config } from '../config/env.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Get AI classification for an application
|
||||
router.post('/suggest/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
// Get provider from query parameter or request body, default to config
|
||||
const provider = (req.query.provider as AIProvider) || (req.body.provider as AIProvider) || config.defaultAIProvider;
|
||||
|
||||
if (!aiService.isConfigured(provider)) {
|
||||
res.status(503).json({
|
||||
error: 'AI classification not available',
|
||||
message: `${provider === 'claude' ? 'Claude (Anthropic)' : 'OpenAI'} API is not configured. Please set ${provider === 'claude' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY'}.`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const application = await dataService.getApplicationById(id);
|
||||
if (!application) {
|
||||
res.status(404).json({ error: 'Application not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Generating AI classification for: ${application.name} using ${provider}`);
|
||||
const suggestion = await aiService.classifyApplication(application, provider);
|
||||
|
||||
res.json(suggestion);
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate AI classification', error);
|
||||
res.status(500).json({ error: 'Failed to generate AI classification' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get ZiRA taxonomy
|
||||
router.get('/taxonomy', (req: Request, res: Response) => {
|
||||
try {
|
||||
const taxonomy = aiService.getTaxonomy();
|
||||
res.json(taxonomy);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get taxonomy', error);
|
||||
res.status(500).json({ error: 'Failed to get taxonomy' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get function by code
|
||||
router.get('/function/:code', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { code } = req.params;
|
||||
const func = aiService.getFunctionByCode(code);
|
||||
|
||||
if (!func) {
|
||||
res.status(404).json({ error: 'Function not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(func);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get function', error);
|
||||
res.status(500).json({ error: 'Failed to get function' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get classification history
|
||||
router.get('/history', (req: Request, res: Response) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit as string) || 50;
|
||||
const history = databaseService.getClassificationHistory(limit);
|
||||
res.json(history);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get classification history', error);
|
||||
res.status(500).json({ error: 'Failed to get classification history' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get classification stats
|
||||
router.get('/stats', (req: Request, res: Response) => {
|
||||
try {
|
||||
const dbStats = databaseService.getStats();
|
||||
res.json(dbStats);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get classification stats', error);
|
||||
res.status(500).json({ error: 'Failed to get classification stats' });
|
||||
}
|
||||
});
|
||||
|
||||
// Check if AI is available - returns available providers
|
||||
router.get('/ai-status', (req: Request, res: Response) => {
|
||||
const availableProviders = aiService.getAvailableProviders();
|
||||
res.json({
|
||||
available: availableProviders.length > 0,
|
||||
providers: availableProviders,
|
||||
defaultProvider: config.defaultAIProvider,
|
||||
claude: {
|
||||
available: aiService.isProviderConfigured('claude'),
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
},
|
||||
openai: {
|
||||
available: aiService.isProviderConfigured('openai'),
|
||||
model: 'gpt-4o',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Get the AI prompt for an application (for debugging/copy-paste)
|
||||
router.get('/prompt/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const application = await dataService.getApplicationById(id);
|
||||
if (!application) {
|
||||
res.status(404).json({ error: 'Application not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const prompt = await aiService.getPromptForApplication(application);
|
||||
res.json({ prompt });
|
||||
} catch (error) {
|
||||
logger.error('Failed to get AI prompt', error);
|
||||
res.status(500).json({ error: 'Failed to get AI prompt' });
|
||||
}
|
||||
});
|
||||
|
||||
// Chat with AI about an application
|
||||
router.post('/chat/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { message, conversationId, provider: requestProvider } = req.body;
|
||||
|
||||
if (!message || typeof message !== 'string' || message.trim().length === 0) {
|
||||
res.status(400).json({ error: 'Message is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = (requestProvider as AIProvider) || config.defaultAIProvider;
|
||||
|
||||
if (!aiService.isConfigured(provider)) {
|
||||
res.status(503).json({
|
||||
error: 'AI chat not available',
|
||||
message: `${provider === 'claude' ? 'Claude (Anthropic)' : 'OpenAI'} API is not configured.`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const application = await dataService.getApplicationById(id);
|
||||
if (!application) {
|
||||
res.status(404).json({ error: 'Application not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Chat message for: ${application.name} using ${provider}`);
|
||||
const response = await aiService.chat(application, message.trim(), conversationId, provider);
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
logger.error('Failed to process chat message', error);
|
||||
res.status(500).json({ error: 'Failed to process chat message' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get conversation history
|
||||
router.get('/chat/conversation/:conversationId', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { conversationId } = req.params;
|
||||
const messages = aiService.getConversationHistory(conversationId);
|
||||
|
||||
if (messages.length === 0) {
|
||||
res.status(404).json({ error: 'Conversation not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ conversationId, messages });
|
||||
} catch (error) {
|
||||
logger.error('Failed to get conversation history', error);
|
||||
res.status(500).json({ error: 'Failed to get conversation history' });
|
||||
}
|
||||
});
|
||||
|
||||
// Clear a conversation
|
||||
router.delete('/chat/conversation/:conversationId', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { conversationId } = req.params;
|
||||
const deleted = aiService.clearConversation(conversationId);
|
||||
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Conversation not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to clear conversation', error);
|
||||
res.status(500).json({ error: 'Failed to clear conversation' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
121
backend/src/routes/configuration.ts
Normal file
121
backend/src/routes/configuration.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { readFile, writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { logger } from '../services/logger.js';
|
||||
import { clearEffortCalculationConfigCache, getEffortCalculationConfigV25 } from '../services/effortCalculation.js';
|
||||
import type { EffortCalculationConfig, EffortCalculationConfigV25 } from '../config/effortCalculation.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Path to the configuration files
|
||||
const CONFIG_FILE_PATH = join(__dirname, '../../data/effort-calculation-config.json');
|
||||
const CONFIG_FILE_PATH_V25 = join(__dirname, '../../data/effort-calculation-config-v25.json');
|
||||
|
||||
/**
|
||||
* Get the current effort calculation configuration (legacy)
|
||||
*/
|
||||
router.get('/effort-calculation', async (req: Request, res: Response) => {
|
||||
try {
|
||||
// Try to read from JSON file, fallback to default config
|
||||
try {
|
||||
const fileContent = await readFile(CONFIG_FILE_PATH, 'utf-8');
|
||||
const config = JSON.parse(fileContent) as EffortCalculationConfig;
|
||||
res.json(config);
|
||||
} catch (fileError) {
|
||||
// If file doesn't exist, return default config from code
|
||||
const { EFFORT_CALCULATION_CONFIG } = await import('../config/effortCalculation.js');
|
||||
res.json(EFFORT_CALCULATION_CONFIG);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to get effort calculation configuration', error);
|
||||
res.status(500).json({ error: 'Failed to get configuration' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Update the effort calculation configuration (legacy)
|
||||
*/
|
||||
router.put('/effort-calculation', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const config = req.body as EffortCalculationConfig;
|
||||
|
||||
// Validate the configuration structure
|
||||
if (!config.governanceModelRules || !Array.isArray(config.governanceModelRules)) {
|
||||
res.status(400).json({ error: 'Invalid configuration: governanceModelRules must be an array' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.default || typeof config.default.result !== 'number') {
|
||||
res.status(400).json({ error: 'Invalid configuration: default.result must be a number' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Write to JSON file
|
||||
await writeFile(CONFIG_FILE_PATH, JSON.stringify(config, null, 2), 'utf-8');
|
||||
|
||||
// Clear the cache so the new config is loaded on next request
|
||||
clearEffortCalculationConfigCache();
|
||||
|
||||
logger.info('Effort calculation configuration updated');
|
||||
res.json({ success: true, message: 'Configuration saved successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Failed to update effort calculation configuration', error);
|
||||
res.status(500).json({ error: 'Failed to save configuration' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the v25 effort calculation configuration
|
||||
*/
|
||||
router.get('/effort-calculation-v25', async (req: Request, res: Response) => {
|
||||
try {
|
||||
// Try to read from JSON file, fallback to default config
|
||||
try {
|
||||
const fileContent = await readFile(CONFIG_FILE_PATH_V25, 'utf-8');
|
||||
const config = JSON.parse(fileContent) as EffortCalculationConfigV25;
|
||||
res.json(config);
|
||||
} catch (fileError) {
|
||||
// If file doesn't exist, return default config from code
|
||||
const config = getEffortCalculationConfigV25();
|
||||
res.json(config);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to get effort calculation configuration v25', error);
|
||||
res.status(500).json({ error: 'Failed to get configuration' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Update the v25 effort calculation configuration
|
||||
*/
|
||||
router.put('/effort-calculation-v25', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const config = req.body as EffortCalculationConfigV25;
|
||||
|
||||
// Validate the configuration structure
|
||||
if (!config.regiemodellen || typeof config.regiemodellen !== 'object') {
|
||||
res.status(400).json({ error: 'Invalid configuration: regiemodellen must be an object' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.validationRules || typeof config.validationRules !== 'object') {
|
||||
res.status(400).json({ error: 'Invalid configuration: validationRules must be an object' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Write to JSON file
|
||||
await writeFile(CONFIG_FILE_PATH_V25, JSON.stringify(config, null, 2), 'utf-8');
|
||||
|
||||
// Clear the cache so the new config is loaded on next request
|
||||
clearEffortCalculationConfigCache();
|
||||
|
||||
logger.info('Effort calculation configuration v25 updated');
|
||||
res.json({ success: true, message: 'Configuration v25 saved successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Failed to update effort calculation configuration v25', error);
|
||||
res.status(500).json({ error: 'Failed to save configuration' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
79
backend/src/routes/dashboard.ts
Normal file
79
backend/src/routes/dashboard.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { dataService } from '../services/dataService.js';
|
||||
import { databaseService } from '../services/database.js';
|
||||
import { logger } from '../services/logger.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Simple in-memory cache for dashboard stats
|
||||
interface CachedStats {
|
||||
data: any;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
let statsCache: CachedStats | null = null;
|
||||
const CACHE_TTL_MS = 3 * 60 * 1000; // 3 minutes cache (longer since jiraAssets also caches)
|
||||
|
||||
// Get dashboard statistics
|
||||
router.get('/stats', async (req: Request, res: Response) => {
|
||||
try {
|
||||
// Allow force refresh via query param
|
||||
const forceRefresh = req.query.refresh === 'true';
|
||||
|
||||
// Check cache first (unless force refresh)
|
||||
const now = Date.now();
|
||||
if (!forceRefresh && statsCache && (now - statsCache.timestamp) < CACHE_TTL_MS) {
|
||||
logger.debug('Returning cached dashboard stats');
|
||||
return res.json(statsCache.data);
|
||||
}
|
||||
|
||||
logger.info('Dashboard: Fetching fresh stats...');
|
||||
|
||||
// Default to true to include distributions, but allow disabling for performance
|
||||
const includeDistributions = req.query.distributions !== 'false';
|
||||
const stats = await dataService.getStats(includeDistributions);
|
||||
const dbStats = databaseService.getStats();
|
||||
|
||||
const responseData = {
|
||||
...stats,
|
||||
classificationStats: dbStats,
|
||||
};
|
||||
|
||||
// Update cache
|
||||
statsCache = {
|
||||
data: responseData,
|
||||
timestamp: now,
|
||||
};
|
||||
|
||||
logger.info('Dashboard: Stats fetched and cached successfully');
|
||||
res.json(responseData);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get dashboard stats', error);
|
||||
|
||||
// Return cached data if available (even if expired)
|
||||
if (statsCache) {
|
||||
logger.info('Dashboard: Returning stale cached data due to error');
|
||||
return res.json({
|
||||
...statsCache.data,
|
||||
stale: true,
|
||||
error: 'Using cached data due to API timeout',
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({ error: 'Failed to get dashboard stats' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get recent classifications
|
||||
router.get('/recent', (req: Request, res: Response) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit as string) || 10;
|
||||
const history = databaseService.getClassificationHistory(limit);
|
||||
res.json(history);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get recent classifications', error);
|
||||
res.status(500).json({ error: 'Failed to get recent classifications' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
203
backend/src/routes/referenceData.ts
Normal file
203
backend/src/routes/referenceData.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { dataService } from '../services/dataService.js';
|
||||
import { logger } from '../services/logger.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Get all reference data
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const [
|
||||
dynamicsFactors,
|
||||
complexityFactors,
|
||||
numberOfUsers,
|
||||
governanceModels,
|
||||
organisations,
|
||||
hostingTypes,
|
||||
applicationFunctions,
|
||||
applicationClusters,
|
||||
applicationTypes,
|
||||
businessImportance,
|
||||
businessImpactAnalyses,
|
||||
applicationManagementHosting,
|
||||
applicationManagementTAM,
|
||||
] = await Promise.all([
|
||||
dataService.getDynamicsFactors(),
|
||||
dataService.getComplexityFactors(),
|
||||
dataService.getNumberOfUsers(),
|
||||
dataService.getGovernanceModels(),
|
||||
dataService.getOrganisations(),
|
||||
dataService.getHostingTypes(),
|
||||
dataService.getApplicationFunctions(),
|
||||
dataService.getApplicationClusters(),
|
||||
dataService.getApplicationTypes(),
|
||||
dataService.getBusinessImportance(),
|
||||
dataService.getBusinessImpactAnalyses(),
|
||||
dataService.getApplicationManagementHosting(),
|
||||
dataService.getApplicationManagementTAM(),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
dynamicsFactors,
|
||||
complexityFactors,
|
||||
numberOfUsers,
|
||||
governanceModels,
|
||||
organisations,
|
||||
hostingTypes,
|
||||
applicationFunctions,
|
||||
applicationClusters,
|
||||
applicationTypes,
|
||||
businessImportance,
|
||||
businessImpactAnalyses,
|
||||
applicationManagementHosting,
|
||||
applicationManagementTAM,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get reference data', error);
|
||||
res.status(500).json({ error: 'Failed to get reference data' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get dynamics factors
|
||||
router.get('/dynamics-factors', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const factors = await dataService.getDynamicsFactors();
|
||||
res.json(factors);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get dynamics factors', error);
|
||||
res.status(500).json({ error: 'Failed to get dynamics factors' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get complexity factors
|
||||
router.get('/complexity-factors', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const factors = await dataService.getComplexityFactors();
|
||||
res.json(factors);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get complexity factors', error);
|
||||
res.status(500).json({ error: 'Failed to get complexity factors' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get number of users options
|
||||
router.get('/number-of-users', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const options = await dataService.getNumberOfUsers();
|
||||
res.json(options);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get number of users', error);
|
||||
res.status(500).json({ error: 'Failed to get number of users' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get governance models
|
||||
router.get('/governance-models', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const models = await dataService.getGovernanceModels();
|
||||
res.json(models);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get governance models', error);
|
||||
res.status(500).json({ error: 'Failed to get governance models' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get organisations
|
||||
router.get('/organisations', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const orgs = await dataService.getOrganisations();
|
||||
res.json(orgs);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get organisations', error);
|
||||
res.status(500).json({ error: 'Failed to get organisations' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get hosting types
|
||||
router.get('/hosting-types', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const types = await dataService.getHostingTypes();
|
||||
res.json(types);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get hosting types', error);
|
||||
res.status(500).json({ error: 'Failed to get hosting types' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get application functions (from Jira Assets)
|
||||
router.get('/application-functions', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const functions = await dataService.getApplicationFunctions();
|
||||
res.json(functions);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get application functions', error);
|
||||
res.status(500).json({ error: 'Failed to get application functions' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get application clusters (from Jira Assets)
|
||||
router.get('/application-clusters', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const clusters = await dataService.getApplicationClusters();
|
||||
res.json(clusters);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get application clusters', error);
|
||||
res.status(500).json({ error: 'Failed to get application clusters' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get application types (from Jira Assets)
|
||||
router.get('/application-types', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const types = await dataService.getApplicationTypes();
|
||||
res.json(types);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get application types', error);
|
||||
res.status(500).json({ error: 'Failed to get application types' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/business-importance', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const importance = await dataService.getBusinessImportance();
|
||||
res.json(importance);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get business importance', error);
|
||||
res.status(500).json({ error: 'Failed to get business importance' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get business impact analyses
|
||||
router.get('/business-impact-analyses', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const analyses = await dataService.getBusinessImpactAnalyses();
|
||||
res.json(analyses);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get business impact analyses', error);
|
||||
res.status(500).json({ error: 'Failed to get business impact analyses' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get application management hosting
|
||||
router.get('/application-management-hosting', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const hosting = await dataService.getApplicationManagementHosting();
|
||||
res.json(hosting);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get application management hosting', error);
|
||||
res.status(500).json({ error: 'Failed to get application management hosting' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get application management TAM
|
||||
router.get('/application-management-tam', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const tam = await dataService.getApplicationManagementTAM();
|
||||
res.json(tam);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get application management TAM', error);
|
||||
res.status(500).json({ error: 'Failed to get application management TAM' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
1410
backend/src/services/claude.ts
Normal file
1410
backend/src/services/claude.ts
Normal file
File diff suppressed because it is too large
Load Diff
207
backend/src/services/dataService.ts
Normal file
207
backend/src/services/dataService.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { config } from '../config/env.js';
|
||||
import { jiraAssetsService } from './jiraAssets.js';
|
||||
import { mockDataService } from './mockData.js';
|
||||
import { logger } from './logger.js';
|
||||
import type {
|
||||
ApplicationDetails,
|
||||
ApplicationStatus,
|
||||
ApplicationUpdateRequest,
|
||||
ReferenceValue,
|
||||
SearchFilters,
|
||||
SearchResult,
|
||||
TeamDashboardData,
|
||||
} from '../types/index.js';
|
||||
|
||||
// Determine if we should use real Jira Assets or mock data
|
||||
const useJiraAssets = !!(config.jiraPat && config.jiraSchemaId);
|
||||
|
||||
if (useJiraAssets) {
|
||||
logger.info('Using Jira Assets API for data');
|
||||
} else {
|
||||
logger.info('Using mock data (Jira credentials not configured)');
|
||||
}
|
||||
|
||||
export const dataService = {
|
||||
async searchApplications(
|
||||
filters: SearchFilters,
|
||||
page: number = 1,
|
||||
pageSize: number = 25
|
||||
): Promise<SearchResult> {
|
||||
if (useJiraAssets) {
|
||||
return jiraAssetsService.searchApplications(filters, page, pageSize);
|
||||
}
|
||||
return mockDataService.searchApplications(filters, page, pageSize);
|
||||
},
|
||||
|
||||
async getApplicationById(id: string): Promise<ApplicationDetails | null> {
|
||||
if (useJiraAssets) {
|
||||
return jiraAssetsService.getApplicationById(id);
|
||||
}
|
||||
return mockDataService.getApplicationById(id);
|
||||
},
|
||||
|
||||
async updateApplication(
|
||||
id: string,
|
||||
updates: {
|
||||
applicationFunctions?: ReferenceValue[];
|
||||
dynamicsFactor?: ReferenceValue;
|
||||
complexityFactor?: ReferenceValue;
|
||||
numberOfUsers?: ReferenceValue;
|
||||
governanceModel?: ReferenceValue;
|
||||
applicationCluster?: ReferenceValue;
|
||||
applicationType?: ReferenceValue;
|
||||
hostingType?: ReferenceValue;
|
||||
businessImpactAnalyse?: ReferenceValue;
|
||||
overrideFTE?: number | null;
|
||||
applicationManagementHosting?: string;
|
||||
applicationManagementTAM?: string;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
logger.info(`dataService.updateApplication called for ${id}`);
|
||||
logger.info(`Updates from frontend: ${JSON.stringify(updates)}`);
|
||||
|
||||
if (useJiraAssets) {
|
||||
// Convert ReferenceValues to keys for Jira update
|
||||
const jiraUpdates: ApplicationUpdateRequest = {
|
||||
applicationFunctions: updates.applicationFunctions?.map((f) => f.key),
|
||||
dynamicsFactor: updates.dynamicsFactor?.key,
|
||||
complexityFactor: updates.complexityFactor?.key,
|
||||
numberOfUsers: updates.numberOfUsers?.key,
|
||||
governanceModel: updates.governanceModel?.key,
|
||||
applicationCluster: updates.applicationCluster?.key,
|
||||
applicationType: updates.applicationType?.key,
|
||||
hostingType: updates.hostingType?.key,
|
||||
businessImpactAnalyse: updates.businessImpactAnalyse?.key,
|
||||
overrideFTE: updates.overrideFTE,
|
||||
applicationManagementHosting: updates.applicationManagementHosting,
|
||||
applicationManagementTAM: updates.applicationManagementTAM,
|
||||
};
|
||||
logger.info(`Converted to Jira format: ${JSON.stringify(jiraUpdates)}`);
|
||||
return jiraAssetsService.updateApplication(id, jiraUpdates);
|
||||
}
|
||||
return mockDataService.updateApplication(id, updates);
|
||||
},
|
||||
|
||||
async getDynamicsFactors(): Promise<ReferenceValue[]> {
|
||||
if (useJiraAssets) {
|
||||
return jiraAssetsService.getDynamicsFactors();
|
||||
}
|
||||
return mockDataService.getDynamicsFactors();
|
||||
},
|
||||
|
||||
async getComplexityFactors(): Promise<ReferenceValue[]> {
|
||||
if (useJiraAssets) {
|
||||
return jiraAssetsService.getComplexityFactors();
|
||||
}
|
||||
return mockDataService.getComplexityFactors();
|
||||
},
|
||||
|
||||
async getNumberOfUsers(): Promise<ReferenceValue[]> {
|
||||
if (useJiraAssets) {
|
||||
return jiraAssetsService.getNumberOfUsers();
|
||||
}
|
||||
return mockDataService.getNumberOfUsers();
|
||||
},
|
||||
|
||||
async getGovernanceModels(): Promise<ReferenceValue[]> {
|
||||
if (useJiraAssets) {
|
||||
return jiraAssetsService.getGovernanceModels();
|
||||
}
|
||||
return mockDataService.getGovernanceModels();
|
||||
},
|
||||
|
||||
async getOrganisations(): Promise<ReferenceValue[]> {
|
||||
if (useJiraAssets) {
|
||||
return jiraAssetsService.getOrganisations();
|
||||
}
|
||||
return mockDataService.getOrganisations();
|
||||
},
|
||||
|
||||
async getHostingTypes(): Promise<ReferenceValue[]> {
|
||||
if (useJiraAssets) {
|
||||
return jiraAssetsService.getHostingTypes();
|
||||
}
|
||||
return mockDataService.getHostingTypes();
|
||||
},
|
||||
|
||||
async getBusinessImpactAnalyses(): Promise<ReferenceValue[]> {
|
||||
if (useJiraAssets) {
|
||||
return jiraAssetsService.getBusinessImpactAnalyses();
|
||||
}
|
||||
return mockDataService.getBusinessImpactAnalyses();
|
||||
},
|
||||
|
||||
async getApplicationManagementHosting(): Promise<ReferenceValue[]> {
|
||||
if (useJiraAssets) {
|
||||
return jiraAssetsService.getApplicationManagementHosting();
|
||||
}
|
||||
return mockDataService.getApplicationManagementHosting();
|
||||
},
|
||||
|
||||
async getApplicationManagementTAM(): Promise<ReferenceValue[]> {
|
||||
if (useJiraAssets) {
|
||||
return jiraAssetsService.getApplicationManagementTAM();
|
||||
}
|
||||
return mockDataService.getApplicationManagementTAM();
|
||||
},
|
||||
|
||||
async getStats(includeDistributions: boolean = true) {
|
||||
if (useJiraAssets) {
|
||||
return jiraAssetsService.getStats(includeDistributions);
|
||||
}
|
||||
return mockDataService.getStats();
|
||||
},
|
||||
|
||||
async getApplicationFunctions(): Promise<ReferenceValue[]> {
|
||||
if (useJiraAssets) {
|
||||
return jiraAssetsService.getApplicationFunctions();
|
||||
}
|
||||
return mockDataService.getApplicationFunctions();
|
||||
},
|
||||
|
||||
async getApplicationFunctionCategories(): Promise<ReferenceValue[]> {
|
||||
if (useJiraAssets) {
|
||||
return jiraAssetsService.getApplicationFunctionCategories();
|
||||
}
|
||||
return mockDataService.getApplicationFunctionCategories();
|
||||
},
|
||||
|
||||
async getApplicationClusters(): Promise<ReferenceValue[]> {
|
||||
if (useJiraAssets) {
|
||||
return jiraAssetsService.getApplicationClusters();
|
||||
}
|
||||
return mockDataService.getApplicationClusters();
|
||||
},
|
||||
|
||||
async getApplicationTypes(): Promise<ReferenceValue[]> {
|
||||
if (useJiraAssets) {
|
||||
return jiraAssetsService.getApplicationTypes();
|
||||
}
|
||||
return mockDataService.getApplicationTypes();
|
||||
},
|
||||
|
||||
async getBusinessImportance(): Promise<ReferenceValue[]> {
|
||||
if (useJiraAssets) {
|
||||
return jiraAssetsService.getBusinessImportance();
|
||||
}
|
||||
return mockDataService.getBusinessImportance();
|
||||
},
|
||||
|
||||
isUsingJiraAssets(): boolean {
|
||||
return useJiraAssets;
|
||||
},
|
||||
|
||||
async testConnection(): Promise<boolean> {
|
||||
if (useJiraAssets) {
|
||||
return jiraAssetsService.testConnection();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
async getTeamDashboardData(excludedStatuses: ApplicationStatus[] = []): Promise<TeamDashboardData> {
|
||||
if (useJiraAssets) {
|
||||
return jiraAssetsService.getTeamDashboardData(excludedStatuses);
|
||||
}
|
||||
return mockDataService.getTeamDashboardData(excludedStatuses);
|
||||
},
|
||||
};
|
||||
154
backend/src/services/database.ts
Normal file
154
backend/src/services/database.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { logger } from './logger.js';
|
||||
import type { ClassificationResult } from '../types/index.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const DB_PATH = join(__dirname, '../../data/classifications.db');
|
||||
|
||||
class DatabaseService {
|
||||
private db: Database.Database;
|
||||
|
||||
constructor() {
|
||||
this.db = new Database(DB_PATH);
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
private initialize(): void {
|
||||
// Create tables if they don't exist
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS classification_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
application_id TEXT NOT NULL,
|
||||
application_name TEXT NOT NULL,
|
||||
changes TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
user_id TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_classification_app_id ON classification_history(application_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_classification_timestamp ON classification_history(timestamp);
|
||||
`);
|
||||
|
||||
logger.info('Database initialized');
|
||||
}
|
||||
|
||||
saveClassificationResult(result: ClassificationResult): void {
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO classification_history (application_id, application_name, changes, source, timestamp, user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
result.applicationId,
|
||||
result.applicationName,
|
||||
JSON.stringify(result.changes),
|
||||
result.source,
|
||||
result.timestamp.toISOString(),
|
||||
result.userId || null
|
||||
);
|
||||
}
|
||||
|
||||
getClassificationHistory(limit: number = 50): ClassificationResult[] {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT * FROM classification_history
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
|
||||
const rows = stmt.all(limit) as any[];
|
||||
|
||||
return rows.map((row) => ({
|
||||
applicationId: row.application_id,
|
||||
applicationName: row.application_name,
|
||||
changes: JSON.parse(row.changes),
|
||||
source: row.source,
|
||||
timestamp: new Date(row.timestamp),
|
||||
userId: row.user_id,
|
||||
}));
|
||||
}
|
||||
|
||||
getClassificationsByApplicationId(applicationId: string): ClassificationResult[] {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT * FROM classification_history
|
||||
WHERE application_id = ?
|
||||
ORDER BY timestamp DESC
|
||||
`);
|
||||
|
||||
const rows = stmt.all(applicationId) as any[];
|
||||
|
||||
return rows.map((row) => ({
|
||||
applicationId: row.application_id,
|
||||
applicationName: row.application_name,
|
||||
changes: JSON.parse(row.changes),
|
||||
source: row.source,
|
||||
timestamp: new Date(row.timestamp),
|
||||
userId: row.user_id,
|
||||
}));
|
||||
}
|
||||
|
||||
saveSessionState(key: string, value: any): void {
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO session_state (key, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = ?
|
||||
`);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const valueStr = JSON.stringify(value);
|
||||
stmt.run(key, valueStr, now, valueStr, now);
|
||||
}
|
||||
|
||||
getSessionState<T>(key: string): T | null {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT value FROM session_state WHERE key = ?
|
||||
`);
|
||||
|
||||
const row = stmt.get(key) as { value: string } | undefined;
|
||||
if (row) {
|
||||
return JSON.parse(row.value) as T;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
clearSessionState(key: string): void {
|
||||
const stmt = this.db.prepare(`
|
||||
DELETE FROM session_state WHERE key = ?
|
||||
`);
|
||||
stmt.run(key);
|
||||
}
|
||||
|
||||
getStats(): { totalClassifications: number; bySource: Record<string, number> } {
|
||||
const totalStmt = this.db.prepare(`
|
||||
SELECT COUNT(*) as count FROM classification_history
|
||||
`);
|
||||
const total = (totalStmt.get() as { count: number }).count;
|
||||
|
||||
const bySourceStmt = this.db.prepare(`
|
||||
SELECT source, COUNT(*) as count FROM classification_history GROUP BY source
|
||||
`);
|
||||
const bySourceRows = bySourceStmt.all() as { source: string; count: number }[];
|
||||
const bySource: Record<string, number> = {};
|
||||
bySourceRows.forEach((row) => {
|
||||
bySource[row.source] = row.count;
|
||||
});
|
||||
|
||||
return { totalClassifications: total, bySource };
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export const databaseService = new DatabaseService();
|
||||
577
backend/src/services/effortCalculation.ts
Normal file
577
backend/src/services/effortCalculation.ts
Normal file
@@ -0,0 +1,577 @@
|
||||
import {
|
||||
EFFORT_CALCULATION_CONFIG_V25,
|
||||
EffortCalculationConfigV25,
|
||||
FTERange,
|
||||
GovernanceModelConfig,
|
||||
ApplicationTypeConfig,
|
||||
BIALevelConfig,
|
||||
HostingRule,
|
||||
} from '../config/effortCalculation.js';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { logger } from './logger.js';
|
||||
import type { ApplicationDetails, EffortCalculationBreakdown } from '../types/index.js';
|
||||
|
||||
// Path to the configuration file (v25)
|
||||
const CONFIG_FILE_PATH_V25 = join(__dirname, '../../data/effort-calculation-config-v25.json');
|
||||
|
||||
// Cache for loaded configuration
|
||||
let cachedConfigV25: EffortCalculationConfigV25 | null = null;
|
||||
|
||||
// FTE to hours constants
|
||||
const HOURS_PER_WEEK = 36;
|
||||
const NET_WORK_WEEKS = 46;
|
||||
const DECLARABLE_PERCENTAGE = 0.75;
|
||||
|
||||
/**
|
||||
* Load effort calculation configuration v25 from file or use default (synchronous)
|
||||
*/
|
||||
function loadEffortCalculationConfigV25(): EffortCalculationConfigV25 {
|
||||
if (cachedConfigV25) {
|
||||
return cachedConfigV25;
|
||||
}
|
||||
|
||||
try {
|
||||
if (existsSync(CONFIG_FILE_PATH_V25)) {
|
||||
const fileContent = readFileSync(CONFIG_FILE_PATH_V25, 'utf-8');
|
||||
cachedConfigV25 = JSON.parse(fileContent) as EffortCalculationConfigV25;
|
||||
logger.info('Loaded effort calculation configuration v25 from file');
|
||||
return cachedConfigV25;
|
||||
} else {
|
||||
logger.info('Configuration file v25 not found, using default from code');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to load configuration file v25, using default from code', error);
|
||||
}
|
||||
|
||||
// Use default config
|
||||
cachedConfigV25 = EFFORT_CALCULATION_CONFIG_V25;
|
||||
return cachedConfigV25;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the configuration cache (call after updating config)
|
||||
*/
|
||||
export function clearEffortCalculationConfigCache(): void {
|
||||
cachedConfigV25 = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current configuration
|
||||
*/
|
||||
export function getEffortCalculationConfigV25(): EffortCalculationConfigV25 {
|
||||
return loadEffortCalculationConfigV25();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract BIA class letter from various formats
|
||||
* Handles: "BIA-2024-0042 (Klasse E)", "E", "Klasse E", etc.
|
||||
*/
|
||||
function extractBIAClass(value: string | null): string | null {
|
||||
if (!value) return null;
|
||||
|
||||
// Try to match "(Klasse X)" format
|
||||
const klasseMatch = value.match(/\(Klasse\s+([A-F])\)/i);
|
||||
if (klasseMatch) return klasseMatch[1].toUpperCase();
|
||||
|
||||
// Try to match "Klasse X" format
|
||||
const klasseMatch2 = value.match(/Klasse\s+([A-F])/i);
|
||||
if (klasseMatch2) return klasseMatch2[1].toUpperCase();
|
||||
|
||||
// Try single letter
|
||||
const singleMatch = value.trim().match(/^([A-F])$/i);
|
||||
if (singleMatch) return singleMatch[1].toUpperCase();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract regiemodel code from name
|
||||
* Handles: "Regiemodel A", "Model A", "A", etc.
|
||||
*/
|
||||
function extractRegieModelCode(value: string | null): string | null {
|
||||
if (!value) return null;
|
||||
|
||||
// Try to match "Regiemodel X" or "Model X" format
|
||||
const modelMatch = value.match(/(?:Regiemodel|Model)\s+([A-E]\+?)/i);
|
||||
if (modelMatch) return modelMatch[1].toUpperCase();
|
||||
|
||||
// Try single letter/code
|
||||
const singleMatch = value.trim().match(/^([A-E]\+?)$/i);
|
||||
if (singleMatch) return singleMatch[1].toUpperCase();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate average FTE from min/max range
|
||||
*/
|
||||
function calculateAverageFTE(range: FTERange): number {
|
||||
return (range.min + range.max) / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert FTE to hours per year (declarable hours)
|
||||
*/
|
||||
function calculateHoursPerYear(fte: number): number {
|
||||
return HOURS_PER_WEEK * NET_WORK_WEEKS * fte * DECLARABLE_PERCENTAGE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find matching hosting rule for a given hosting value
|
||||
*/
|
||||
function findMatchingHostingRule(
|
||||
hosting: { [key: string]: HostingRule },
|
||||
hostingValue: string | null
|
||||
): { rule: HostingRule | null; ruleKey: string | null; usedDefault: boolean } {
|
||||
if (!hostingValue) {
|
||||
// No hosting value - look for _all or use first available
|
||||
if (hosting['_all']) {
|
||||
return { rule: hosting['_all'], ruleKey: '_all', usedDefault: true };
|
||||
}
|
||||
// Use first available as default
|
||||
const keys = Object.keys(hosting);
|
||||
if (keys.length > 0) {
|
||||
return { rule: hosting[keys[0]], ruleKey: keys[0], usedDefault: true };
|
||||
}
|
||||
return { rule: null, ruleKey: null, usedDefault: true };
|
||||
}
|
||||
|
||||
// Search for a rule that contains the hosting value
|
||||
for (const [key, rule] of Object.entries(hosting)) {
|
||||
if (rule.hostingValues.some(hv =>
|
||||
hv.toLowerCase() === hostingValue.toLowerCase() ||
|
||||
hostingValue.toLowerCase().includes(hv.toLowerCase()) ||
|
||||
hv.toLowerCase().includes(hostingValue.toLowerCase())
|
||||
)) {
|
||||
return { rule, ruleKey: key, usedDefault: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to _all if exists
|
||||
if (hosting['_all']) {
|
||||
return { rule: hosting['_all'], ruleKey: '_all', usedDefault: true };
|
||||
}
|
||||
|
||||
// Use first available as default
|
||||
const keys = Object.keys(hosting);
|
||||
if (keys.length > 0) {
|
||||
return { rule: hosting[keys[0]], ruleKey: keys[0], usedDefault: true };
|
||||
}
|
||||
|
||||
return { rule: null, ruleKey: null, usedDefault: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate BIA against regiemodel constraints
|
||||
*/
|
||||
function validateBIAForRegieModel(
|
||||
regieModelCode: string,
|
||||
biaClass: string | null,
|
||||
config: EffortCalculationConfigV25
|
||||
): { isValid: boolean; warning: string | null } {
|
||||
if (!biaClass) {
|
||||
return { isValid: true, warning: null };
|
||||
}
|
||||
|
||||
const allowedBia = config.validationRules.biaRegieModelConstraints[regieModelCode];
|
||||
if (!allowedBia) {
|
||||
return { isValid: true, warning: null };
|
||||
}
|
||||
|
||||
if (!allowedBia.includes(biaClass)) {
|
||||
const errorMessages: Record<string, string> = {
|
||||
'A': `BIA ${biaClass} te laag voor Regiemodel A. Minimaal BIA D vereist.`,
|
||||
'B': `BIA ${biaClass} niet toegestaan voor Regiemodel B. Toegestaan: C, D, E.`,
|
||||
'B+': `BIA ${biaClass} niet toegestaan voor Regiemodel B+. Toegestaan: C, D, E.`,
|
||||
'C': `BIA ${biaClass} te laag voor Regiemodel C. Minimaal BIA C vereist.`,
|
||||
'D': `BIA ${biaClass} te hoog voor Regiemodel D. Maximaal BIA C toegestaan.`,
|
||||
'E': `BIA ${biaClass} te hoog voor Regiemodel E. Maximaal BIA B toegestaan.`,
|
||||
};
|
||||
return {
|
||||
isValid: false,
|
||||
warning: errorMessages[regieModelCode] || `BIA ${biaClass} niet toegestaan voor Regiemodel ${regieModelCode}. Toegestaan: ${allowedBia.join(', ')}.`
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true, warning: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for platform restrictions
|
||||
*/
|
||||
function checkPlatformRestrictions(
|
||||
regieModelCode: string,
|
||||
applicationType: string | null,
|
||||
config: EffortCalculationConfigV25
|
||||
): string | null {
|
||||
if (!applicationType) return null;
|
||||
|
||||
const restriction = config.validationRules.platformRestrictions.find(
|
||||
r => r.regiemodel === regieModelCode &&
|
||||
r.applicationType.toLowerCase() === applicationType.toLowerCase()
|
||||
);
|
||||
|
||||
return restriction ? restriction.warning : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Required Effort Application Management with full breakdown (v25)
|
||||
*/
|
||||
export function calculateRequiredEffortApplicationManagementV25(
|
||||
application: ApplicationDetails
|
||||
): {
|
||||
finalEffort: number | null;
|
||||
breakdown: EffortCalculationBreakdown;
|
||||
} {
|
||||
const config = loadEffortCalculationConfigV25();
|
||||
|
||||
// Initialize breakdown
|
||||
const breakdown: EffortCalculationBreakdown = {
|
||||
baseEffort: 0,
|
||||
baseEffortMin: 0,
|
||||
baseEffortMax: 0,
|
||||
governanceModel: null,
|
||||
governanceModelName: null,
|
||||
applicationType: null,
|
||||
businessImpactAnalyse: null,
|
||||
applicationManagementHosting: null,
|
||||
numberOfUsersFactor: { value: 1.0, name: null },
|
||||
dynamicsFactor: { value: 1.0, name: null },
|
||||
complexityFactor: { value: 1.0, name: null },
|
||||
usedDefaults: [],
|
||||
warnings: [],
|
||||
errors: [],
|
||||
requiresManualAssessment: false,
|
||||
isFixedFte: false,
|
||||
notRecommended: false,
|
||||
hoursPerYear: 0,
|
||||
hoursPerMonth: 0,
|
||||
hoursPerWeek: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
// Extract values from application
|
||||
const governanceModelRaw = application.governanceModel?.name || null;
|
||||
const regieModelCode = extractRegieModelCode(governanceModelRaw);
|
||||
const applicationType = application.applicationType?.name || null;
|
||||
const businessImpactAnalyseRaw = typeof application.businessImpactAnalyse === 'string'
|
||||
? application.businessImpactAnalyse
|
||||
: application.businessImpactAnalyse?.name || null;
|
||||
const biaClass = extractBIAClass(businessImpactAnalyseRaw);
|
||||
const applicationManagementHosting = typeof application.applicationManagementHosting === 'string'
|
||||
? application.applicationManagementHosting
|
||||
: application.applicationManagementHosting?.name || null;
|
||||
|
||||
// Store extracted values in breakdown
|
||||
breakdown.governanceModel = regieModelCode;
|
||||
breakdown.governanceModelName = governanceModelRaw;
|
||||
breakdown.applicationType = applicationType;
|
||||
breakdown.businessImpactAnalyse = biaClass;
|
||||
breakdown.applicationManagementHosting = applicationManagementHosting;
|
||||
|
||||
logger.debug(`=== Effort Calculation v25 ===`);
|
||||
logger.debug(`Regiemodel: ${regieModelCode} (${governanceModelRaw})`);
|
||||
logger.debug(`Application Type: ${applicationType}`);
|
||||
logger.debug(`BIA: ${biaClass} (${businessImpactAnalyseRaw})`);
|
||||
logger.debug(`Hosting: ${applicationManagementHosting}`);
|
||||
|
||||
// Level 1: Find Regiemodel configuration
|
||||
if (!regieModelCode || !config.regiemodellen[regieModelCode]) {
|
||||
breakdown.errors.push(`Geen configuratie gevonden voor regiemodel: ${governanceModelRaw || 'niet ingesteld'}`);
|
||||
breakdown.usedDefaults.push('regiemodel');
|
||||
return { finalEffort: null, breakdown };
|
||||
}
|
||||
|
||||
const regieModelConfig = config.regiemodellen[regieModelCode];
|
||||
breakdown.governanceModelName = regieModelConfig.name;
|
||||
|
||||
// Validate BIA against regiemodel
|
||||
const biaValidation = validateBIAForRegieModel(regieModelCode, biaClass, config);
|
||||
if (!biaValidation.isValid && biaValidation.warning) {
|
||||
breakdown.errors.push(biaValidation.warning);
|
||||
}
|
||||
|
||||
// Level 2: Find Application Type configuration
|
||||
let appTypeConfig: ApplicationTypeConfig | null = null;
|
||||
let usedAppTypeDefault = false;
|
||||
|
||||
if (applicationType && regieModelConfig.applicationTypes[applicationType]) {
|
||||
appTypeConfig = regieModelConfig.applicationTypes[applicationType];
|
||||
} else {
|
||||
// Use default from regiemodel
|
||||
usedAppTypeDefault = true;
|
||||
breakdown.usedDefaults.push('applicationType');
|
||||
// Try to find a default application type or use regiemodel default
|
||||
const appTypes = Object.keys(regieModelConfig.applicationTypes);
|
||||
if (appTypes.includes('Applicatie')) {
|
||||
appTypeConfig = regieModelConfig.applicationTypes['Applicatie'];
|
||||
} else if (appTypes.length > 0) {
|
||||
appTypeConfig = regieModelConfig.applicationTypes[appTypes[0]];
|
||||
}
|
||||
|
||||
if (!appTypeConfig) {
|
||||
// Use regiemodel default FTE
|
||||
breakdown.baseEffortMin = regieModelConfig.defaultFte.min;
|
||||
breakdown.baseEffortMax = regieModelConfig.defaultFte.max;
|
||||
breakdown.baseEffort = calculateAverageFTE(regieModelConfig.defaultFte);
|
||||
breakdown.warnings.push(`Geen specifieke configuratie voor applicatietype: ${applicationType || 'niet ingesteld'}. Default regiemodel waarde gebruikt.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for special flags
|
||||
if (appTypeConfig) {
|
||||
if (appTypeConfig.requiresManualAssessment) {
|
||||
breakdown.requiresManualAssessment = true;
|
||||
breakdown.warnings.push('⚠️ Handmatige beoordeling vereist - zie Beheer Readiness Checklist sectie J');
|
||||
}
|
||||
if (appTypeConfig.fixedFte) {
|
||||
breakdown.isFixedFte = true;
|
||||
breakdown.warnings.push(`ℹ️ Vaste FTE waarde voor dit regiemodel (alleen CMDB + review)`);
|
||||
}
|
||||
if (appTypeConfig.notRecommended) {
|
||||
breakdown.notRecommended = true;
|
||||
const restriction = checkPlatformRestrictions(regieModelCode, applicationType, config);
|
||||
if (restriction) {
|
||||
breakdown.warnings.push(`⚠️ ${restriction}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Level 3: Find BIA configuration
|
||||
let biaConfig: BIALevelConfig | null = null;
|
||||
let usedBiaDefault = false;
|
||||
|
||||
if (appTypeConfig) {
|
||||
if (biaClass && appTypeConfig.biaLevels[biaClass]) {
|
||||
biaConfig = appTypeConfig.biaLevels[biaClass];
|
||||
} else if (appTypeConfig.biaLevels['_all']) {
|
||||
biaConfig = appTypeConfig.biaLevels['_all'];
|
||||
usedBiaDefault = true;
|
||||
} else {
|
||||
// Use application type default
|
||||
usedBiaDefault = true;
|
||||
breakdown.usedDefaults.push('businessImpact');
|
||||
|
||||
if (appTypeConfig.defaultFte) {
|
||||
breakdown.baseEffortMin = appTypeConfig.defaultFte.min;
|
||||
breakdown.baseEffortMax = appTypeConfig.defaultFte.max;
|
||||
breakdown.baseEffort = calculateAverageFTE(appTypeConfig.defaultFte);
|
||||
breakdown.warnings.push(`Geen specifieke configuratie voor BIA ${biaClass || 'niet ingesteld'}. Default applicatietype waarde gebruikt.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Level 4: Find Hosting configuration
|
||||
if (biaConfig) {
|
||||
const hostingResult = findMatchingHostingRule(biaConfig.hosting, applicationManagementHosting);
|
||||
|
||||
if (hostingResult.rule) {
|
||||
breakdown.baseEffortMin = hostingResult.rule.fte.min;
|
||||
breakdown.baseEffortMax = hostingResult.rule.fte.max;
|
||||
breakdown.baseEffort = calculateAverageFTE(hostingResult.rule.fte);
|
||||
|
||||
if (hostingResult.usedDefault) {
|
||||
breakdown.usedDefaults.push('hosting');
|
||||
breakdown.warnings.push(`Geen specifieke configuratie voor hosting: ${applicationManagementHosting || 'niet ingesteld'}. Default waarde gebruikt.`);
|
||||
}
|
||||
} else if (biaConfig.defaultFte) {
|
||||
breakdown.baseEffortMin = biaConfig.defaultFte.min;
|
||||
breakdown.baseEffortMax = biaConfig.defaultFte.max;
|
||||
breakdown.baseEffort = calculateAverageFTE(biaConfig.defaultFte);
|
||||
breakdown.usedDefaults.push('hosting');
|
||||
}
|
||||
}
|
||||
|
||||
// Get factors
|
||||
breakdown.numberOfUsersFactor = {
|
||||
value: application.numberOfUsers?.factor ?? 1.0,
|
||||
name: application.numberOfUsers?.name || null,
|
||||
};
|
||||
breakdown.dynamicsFactor = {
|
||||
value: application.dynamicsFactor?.factor ?? 1.0,
|
||||
name: application.dynamicsFactor?.name || null,
|
||||
};
|
||||
breakdown.complexityFactor = {
|
||||
value: application.complexityFactor?.factor ?? 1.0,
|
||||
name: application.complexityFactor?.name || null,
|
||||
};
|
||||
|
||||
// Calculate final effort
|
||||
const finalEffort = breakdown.baseEffort *
|
||||
breakdown.numberOfUsersFactor.value *
|
||||
breakdown.dynamicsFactor.value *
|
||||
breakdown.complexityFactor.value;
|
||||
|
||||
// Calculate hours
|
||||
breakdown.hoursPerYear = calculateHoursPerYear(finalEffort);
|
||||
breakdown.hoursPerMonth = breakdown.hoursPerYear / 12;
|
||||
breakdown.hoursPerWeek = breakdown.hoursPerYear / NET_WORK_WEEKS;
|
||||
|
||||
logger.debug(`Base FTE: ${breakdown.baseEffort} (${breakdown.baseEffortMin} - ${breakdown.baseEffortMax})`);
|
||||
logger.debug(`Final FTE: ${finalEffort}`);
|
||||
logger.debug(`Hours/year: ${breakdown.hoursPerYear}`);
|
||||
|
||||
return { finalEffort, breakdown };
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error calculating required effort application management v25', error);
|
||||
breakdown.errors.push('Er is een fout opgetreden bij de berekening');
|
||||
return { finalEffort: null, breakdown };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Required Effort Application Management based on application details
|
||||
* Main entry point - uses v25 configuration
|
||||
*/
|
||||
export function calculateRequiredEffortApplicationManagement(
|
||||
application: ApplicationDetails
|
||||
): number | null {
|
||||
const result = calculateRequiredEffortApplicationManagementV25(application);
|
||||
return result.finalEffort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Required Effort Application Management with breakdown
|
||||
* Returns both the final value and the detailed breakdown
|
||||
*/
|
||||
export function calculateRequiredEffortApplicationManagementWithBreakdown(
|
||||
application: ApplicationDetails
|
||||
): {
|
||||
baseEffort: number | null;
|
||||
numberOfUsersFactor: number;
|
||||
dynamicsFactor: number;
|
||||
complexityFactor: number;
|
||||
finalEffort: number | null;
|
||||
breakdown?: EffortCalculationBreakdown;
|
||||
} {
|
||||
const result = calculateRequiredEffortApplicationManagementV25(application);
|
||||
|
||||
return {
|
||||
baseEffort: result.breakdown.baseEffort || null,
|
||||
numberOfUsersFactor: result.breakdown.numberOfUsersFactor.value,
|
||||
dynamicsFactor: result.breakdown.dynamicsFactor.value,
|
||||
complexityFactor: result.breakdown.complexityFactor.value,
|
||||
finalEffort: result.finalEffort,
|
||||
breakdown: result.breakdown,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Required Effort with min/max FTE values
|
||||
* Returns the final effort plus the min and max based on configuration ranges
|
||||
*/
|
||||
export function calculateRequiredEffortWithMinMax(
|
||||
application: ApplicationDetails
|
||||
): {
|
||||
finalEffort: number | null;
|
||||
minFTE: number | null;
|
||||
maxFTE: number | null;
|
||||
} {
|
||||
const result = calculateRequiredEffortApplicationManagementV25(application);
|
||||
const breakdown = result.breakdown;
|
||||
|
||||
if (breakdown.baseEffortMin === 0 && breakdown.baseEffortMax === 0) {
|
||||
return {
|
||||
finalEffort: result.finalEffort,
|
||||
minFTE: null,
|
||||
maxFTE: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Apply the same factors to min and max
|
||||
const factorMultiplier =
|
||||
breakdown.numberOfUsersFactor.value *
|
||||
breakdown.dynamicsFactor.value *
|
||||
breakdown.complexityFactor.value;
|
||||
|
||||
const minFTE = breakdown.baseEffortMin * factorMultiplier;
|
||||
const maxFTE = breakdown.baseEffortMax * factorMultiplier;
|
||||
|
||||
return {
|
||||
finalEffort: result.finalEffort,
|
||||
minFTE: Math.round(minFTE * 100) / 100, // Round to 2 decimals
|
||||
maxFTE: Math.round(maxFTE * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the base effort for a given application (for real-time calculation without saving)
|
||||
* This is a simplified version that returns just the base FTE
|
||||
*/
|
||||
export function calculateRequiredEffortApplicationManagementBase(
|
||||
application: ApplicationDetails
|
||||
): number | null {
|
||||
const result = calculateRequiredEffortApplicationManagementV25(application);
|
||||
return result.breakdown.baseEffort || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full breakdown including hours calculation
|
||||
*/
|
||||
export function getEffortCalculationBreakdown(
|
||||
application: ApplicationDetails
|
||||
): EffortCalculationBreakdown {
|
||||
const result = calculateRequiredEffortApplicationManagementV25(application);
|
||||
return result.breakdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an application's configuration
|
||||
* Returns warnings and errors without calculating FTE
|
||||
*/
|
||||
export function validateApplicationConfiguration(
|
||||
application: ApplicationDetails
|
||||
): {
|
||||
isValid: boolean;
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
} {
|
||||
const result = calculateRequiredEffortApplicationManagementV25(application);
|
||||
|
||||
return {
|
||||
isValid: result.breakdown.errors.length === 0,
|
||||
warnings: result.breakdown.warnings,
|
||||
errors: result.breakdown.errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available regiemodellen from configuration
|
||||
*/
|
||||
export function getAvailableRegieModellen(): Array<{
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
allowedBia: string[];
|
||||
}> {
|
||||
const config = loadEffortCalculationConfigV25();
|
||||
|
||||
return Object.entries(config.regiemodellen).map(([code, model]) => ({
|
||||
code,
|
||||
name: model.name,
|
||||
description: model.description,
|
||||
allowedBia: model.allowedBia,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available application types for a regiemodel
|
||||
*/
|
||||
export function getApplicationTypesForRegieModel(regieModelCode: string): string[] {
|
||||
const config = loadEffortCalculationConfigV25();
|
||||
const regieModel = config.regiemodellen[regieModelCode];
|
||||
|
||||
if (!regieModel) return [];
|
||||
|
||||
return Object.keys(regieModel.applicationTypes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get allowed BIA levels for a regiemodel
|
||||
*/
|
||||
export function getAllowedBIAForRegieModel(regieModelCode: string): string[] {
|
||||
const config = loadEffortCalculationConfigV25();
|
||||
return config.validationRules.biaRegieModelConstraints[regieModelCode] || [];
|
||||
}
|
||||
2092
backend/src/services/jiraAssets.ts
Normal file
2092
backend/src/services/jiraAssets.ts
Normal file
File diff suppressed because it is too large
Load Diff
40
backend/src/services/logger.ts
Normal file
40
backend/src/services/logger.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import winston from 'winston';
|
||||
import { config } from '../config/env.js';
|
||||
|
||||
const { combine, timestamp, printf, colorize, errors } = winston.format;
|
||||
|
||||
const logFormat = printf(({ level, message, timestamp, stack }) => {
|
||||
return `${timestamp} [${level}]: ${stack || message}`;
|
||||
});
|
||||
|
||||
export const logger = winston.createLogger({
|
||||
level: config.isDevelopment ? 'debug' : 'info',
|
||||
format: combine(
|
||||
errors({ stack: true }),
|
||||
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
logFormat
|
||||
),
|
||||
transports: [
|
||||
new winston.transports.Console({
|
||||
format: combine(
|
||||
colorize(),
|
||||
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
logFormat
|
||||
),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
if (config.isProduction) {
|
||||
logger.add(
|
||||
new winston.transports.File({
|
||||
filename: 'logs/error.log',
|
||||
level: 'error',
|
||||
})
|
||||
);
|
||||
logger.add(
|
||||
new winston.transports.File({
|
||||
filename: 'logs/combined.log',
|
||||
})
|
||||
);
|
||||
}
|
||||
859
backend/src/services/mockData.ts
Normal file
859
backend/src/services/mockData.ts
Normal file
@@ -0,0 +1,859 @@
|
||||
import { calculateRequiredEffortApplicationManagement } from './effortCalculation.js';
|
||||
import type {
|
||||
ApplicationDetails,
|
||||
ApplicationListItem,
|
||||
ReferenceValue,
|
||||
SearchFilters,
|
||||
SearchResult,
|
||||
ClassificationResult,
|
||||
TeamDashboardData,
|
||||
ApplicationStatus,
|
||||
} from '../types/index.js';
|
||||
|
||||
// Mock application data for development/demo
|
||||
const mockApplications: ApplicationDetails[] = [
|
||||
{
|
||||
id: '1',
|
||||
key: 'APP-001',
|
||||
name: 'Epic Hyperspace',
|
||||
searchReference: 'EPIC-HS',
|
||||
description: 'Elektronisch Patiëntendossier module voor klinische documentatie en workflow. Ondersteunt de volledige patiëntenzorg van intake tot ontslag.',
|
||||
supplierProduct: 'Epic Systems / Hyperspace',
|
||||
organisation: 'Zorg',
|
||||
hostingType: { objectId: '1', key: 'HOST-1', name: 'On-premises' },
|
||||
status: 'In Production',
|
||||
businessImportance: 'Kritiek',
|
||||
businessImpactAnalyse: { objectId: '1', key: 'BIA-1', name: 'BIA-2024-0042 (Klasse E)' },
|
||||
systemOwner: 'J. Janssen',
|
||||
businessOwner: 'Dr. A. van der Berg',
|
||||
functionalApplicationManagement: 'Team EPD',
|
||||
technicalApplicationManagement: 'Team Zorgapplicaties',
|
||||
technicalApplicationManagementPrimary: 'Jan Jansen',
|
||||
technicalApplicationManagementSecondary: 'Piet Pietersen',
|
||||
medischeTechniek: false,
|
||||
applicationFunctions: [],
|
||||
dynamicsFactor: { objectId: '3', key: 'DYN-3', name: '3 - Hoog' },
|
||||
complexityFactor: { objectId: '4', key: 'CMP-4', name: '4 - Zeer hoog' },
|
||||
numberOfUsers: null,
|
||||
governanceModel: { objectId: 'A', key: 'GOV-A', name: 'Centraal Beheer' },
|
||||
applicationCluster: null,
|
||||
applicationType: null,
|
||||
platform: null,
|
||||
requiredEffortApplicationManagement: null,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
key: 'APP-002',
|
||||
name: 'SAP Finance',
|
||||
searchReference: 'SAP-FIN',
|
||||
description: 'Enterprise Resource Planning systeem voor financiële administratie, budgettering en controlling.',
|
||||
supplierProduct: 'SAP SE / SAP S/4HANA',
|
||||
organisation: 'Bedrijfsvoering',
|
||||
hostingType: { objectId: '3', key: 'HOST-3', name: 'Cloud' },
|
||||
status: 'In Production',
|
||||
businessImportance: 'Kritiek',
|
||||
businessImpactAnalyse: { objectId: '2', key: 'BIA-2', name: 'BIA-2024-0015 (Klasse D)' },
|
||||
systemOwner: 'M. de Groot',
|
||||
businessOwner: 'P. Bakker',
|
||||
functionalApplicationManagement: 'Team ERP',
|
||||
technicalApplicationManagement: 'Team Bedrijfsapplicaties',
|
||||
medischeTechniek: false,
|
||||
applicationFunctions: [],
|
||||
dynamicsFactor: { objectId: '2', key: 'DYN-2', name: '2 - Gemiddeld' },
|
||||
complexityFactor: { objectId: '3', key: 'CMP-3', name: '3 - Hoog' },
|
||||
numberOfUsers: null,
|
||||
governanceModel: null,
|
||||
applicationCluster: null,
|
||||
applicationType: null,
|
||||
platform: null,
|
||||
requiredEffortApplicationManagement: null,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
key: 'APP-003',
|
||||
name: 'Philips IntelliSpace PACS',
|
||||
searchReference: 'PACS',
|
||||
description: 'Picture Archiving and Communication System voor opslag en weergave van medische beelden inclusief radiologie, CT en MRI.',
|
||||
supplierProduct: 'Philips Healthcare / IntelliSpace PACS',
|
||||
organisation: 'Zorg',
|
||||
hostingType: { objectId: '1', key: 'HOST-1', name: 'On-premises' },
|
||||
status: 'In Production',
|
||||
businessImportance: 'Hoog',
|
||||
businessImpactAnalyse: { objectId: '3', key: 'BIA-3', name: 'BIA-2024-0028 (Klasse D)' },
|
||||
systemOwner: 'R. Hermans',
|
||||
businessOwner: 'Dr. K. Smit',
|
||||
functionalApplicationManagement: 'Team Beeldvorming',
|
||||
technicalApplicationManagement: 'Team Zorgapplicaties',
|
||||
medischeTechniek: true,
|
||||
applicationFunctions: [],
|
||||
dynamicsFactor: null,
|
||||
complexityFactor: null,
|
||||
numberOfUsers: null,
|
||||
governanceModel: { objectId: 'C', key: 'GOV-C', name: 'Uitbesteed met ICMT-Regie' },
|
||||
applicationCluster: null,
|
||||
applicationType: null,
|
||||
platform: null,
|
||||
requiredEffortApplicationManagement: null,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
key: 'APP-004',
|
||||
name: 'ChipSoft HiX',
|
||||
searchReference: 'HIX',
|
||||
description: 'Ziekenhuisinformatiesysteem en EPD voor patiëntregistratie, zorgplanning en klinische workflow.',
|
||||
supplierProduct: 'ChipSoft / HiX',
|
||||
organisation: 'Zorg',
|
||||
hostingType: { objectId: '1', key: 'HOST-1', name: 'On-premises' },
|
||||
status: 'In Production',
|
||||
businessImportance: 'Kritiek',
|
||||
businessImpactAnalyse: { objectId: '5', key: 'BIA-5', name: 'BIA-2024-0001 (Klasse F)' },
|
||||
systemOwner: 'T. van Dijk',
|
||||
businessOwner: 'Dr. L. Mulder',
|
||||
functionalApplicationManagement: 'Team ZIS',
|
||||
technicalApplicationManagement: 'Team Zorgapplicaties',
|
||||
medischeTechniek: false,
|
||||
applicationFunctions: [],
|
||||
dynamicsFactor: { objectId: '4', key: 'DYN-4', name: '4 - Zeer hoog' },
|
||||
complexityFactor: { objectId: '4', key: 'CMP-4', name: '4 - Zeer hoog' },
|
||||
numberOfUsers: null,
|
||||
governanceModel: { objectId: 'A', key: 'GOV-A', name: 'Centraal Beheer' },
|
||||
applicationCluster: null,
|
||||
applicationType: null,
|
||||
platform: null,
|
||||
requiredEffortApplicationManagement: null,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
key: 'APP-005',
|
||||
name: 'TOPdesk',
|
||||
searchReference: 'TOPDESK',
|
||||
description: 'IT Service Management platform voor incident, problem en change management.',
|
||||
supplierProduct: 'TOPdesk / TOPdesk Enterprise',
|
||||
organisation: 'ICMT',
|
||||
hostingType: { objectId: '2', key: 'HOST-2', name: 'SaaS' },
|
||||
status: 'In Production',
|
||||
businessImportance: 'Hoog',
|
||||
businessImpactAnalyse: { objectId: '6', key: 'BIA-6', name: 'BIA-2024-0055 (Klasse C)' },
|
||||
systemOwner: 'B. Willems',
|
||||
businessOwner: 'H. Claessen',
|
||||
functionalApplicationManagement: 'Team Servicedesk',
|
||||
technicalApplicationManagement: 'Team ICT Beheer',
|
||||
medischeTechniek: false,
|
||||
applicationFunctions: [],
|
||||
dynamicsFactor: { objectId: '2', key: 'DYN-2', name: '2 - Gemiddeld' },
|
||||
complexityFactor: { objectId: '2', key: 'CMP-2', name: '2 - Gemiddeld' },
|
||||
numberOfUsers: null,
|
||||
governanceModel: null,
|
||||
applicationCluster: null,
|
||||
applicationType: null,
|
||||
platform: null,
|
||||
requiredEffortApplicationManagement: null,
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
key: 'APP-006',
|
||||
name: 'Microsoft 365',
|
||||
searchReference: 'M365',
|
||||
description: 'Kantoorautomatisering suite met Teams, Outlook, SharePoint, OneDrive en Office applicaties.',
|
||||
supplierProduct: 'Microsoft / Microsoft 365 E5',
|
||||
organisation: 'ICMT',
|
||||
hostingType: { objectId: '2', key: 'HOST-2', name: 'SaaS' },
|
||||
status: 'In Production',
|
||||
businessImportance: 'Kritiek',
|
||||
businessImpactAnalyse: { objectId: '1', key: 'BIA-1', name: 'BIA-2024-0042 (Klasse E)' },
|
||||
systemOwner: 'S. Jansen',
|
||||
businessOwner: 'N. Peters',
|
||||
functionalApplicationManagement: 'Team Werkplek',
|
||||
technicalApplicationManagement: 'Team Cloud',
|
||||
medischeTechniek: false,
|
||||
applicationFunctions: [],
|
||||
dynamicsFactor: { objectId: '3', key: 'DYN-3', name: '3 - Hoog' },
|
||||
complexityFactor: { objectId: '3', key: 'CMP-3', name: '3 - Hoog' },
|
||||
numberOfUsers: { objectId: '7', key: 'USR-7', name: '> 15.000' },
|
||||
governanceModel: { objectId: 'A', key: 'GOV-A', name: 'Centraal Beheer' },
|
||||
applicationCluster: null,
|
||||
applicationType: null,
|
||||
platform: null,
|
||||
requiredEffortApplicationManagement: null,
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
key: 'APP-007',
|
||||
name: 'Carestream Vue PACS',
|
||||
searchReference: 'VUE-PACS',
|
||||
description: 'Enterprise imaging platform voor radiologie en cardiologie beeldvorming.',
|
||||
supplierProduct: 'Carestream Health / Vue PACS',
|
||||
organisation: 'Zorg',
|
||||
hostingType: { objectId: '1', key: 'HOST-1', name: 'On-premises' },
|
||||
status: 'End of life',
|
||||
businessImportance: 'Gemiddeld',
|
||||
businessImpactAnalyse: { objectId: '9', key: 'BIA-9', name: 'BIA-2022-0089 (Klasse C)' },
|
||||
systemOwner: 'R. Hermans',
|
||||
businessOwner: 'Dr. K. Smit',
|
||||
functionalApplicationManagement: 'Team Beeldvorming',
|
||||
technicalApplicationManagement: 'Team Zorgapplicaties',
|
||||
medischeTechniek: true,
|
||||
applicationFunctions: [],
|
||||
dynamicsFactor: { objectId: '1', key: 'DYN-1', name: '1 - Stabiel' },
|
||||
complexityFactor: { objectId: '2', key: 'CMP-2', name: '2 - Gemiddeld' },
|
||||
numberOfUsers: null,
|
||||
governanceModel: null,
|
||||
applicationCluster: null,
|
||||
applicationType: null,
|
||||
platform: null,
|
||||
requiredEffortApplicationManagement: null,
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
key: 'APP-008',
|
||||
name: 'AFAS Profit',
|
||||
searchReference: 'AFAS',
|
||||
description: 'HR en salarisadministratie systeem voor personeelsbeheer, tijdregistratie en verloning.',
|
||||
supplierProduct: 'AFAS Software / Profit',
|
||||
organisation: 'Bedrijfsvoering',
|
||||
hostingType: { objectId: '2', key: 'HOST-2', name: 'SaaS' },
|
||||
status: 'In Production',
|
||||
businessImportance: 'Hoog',
|
||||
businessImpactAnalyse: { objectId: '7', key: 'BIA-7', name: 'BIA-2024-0022 (Klasse D)' },
|
||||
systemOwner: 'E. Hendriks',
|
||||
businessOwner: 'C. van Leeuwen',
|
||||
functionalApplicationManagement: 'Team HR',
|
||||
technicalApplicationManagement: 'Team Bedrijfsapplicaties',
|
||||
medischeTechniek: false,
|
||||
applicationFunctions: [],
|
||||
dynamicsFactor: { objectId: '2', key: 'DYN-2', name: '2 - Gemiddeld' },
|
||||
complexityFactor: { objectId: '2', key: 'CMP-2', name: '2 - Gemiddeld' },
|
||||
numberOfUsers: { objectId: '6', key: 'USR-6', name: '10.000 - 15.000' },
|
||||
governanceModel: { objectId: 'C', key: 'GOV-C', name: 'Uitbesteed met ICMT-Regie' },
|
||||
applicationCluster: null,
|
||||
applicationType: null,
|
||||
platform: null,
|
||||
requiredEffortApplicationManagement: null,
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
key: 'APP-009',
|
||||
name: 'Zenya',
|
||||
searchReference: 'ZENYA',
|
||||
description: 'Kwaliteitsmanagementsysteem voor protocollen, procedures en incidentmeldingen.',
|
||||
supplierProduct: 'Infoland / Zenya',
|
||||
organisation: 'Kwaliteit',
|
||||
hostingType: { objectId: '2', key: 'HOST-2', name: 'SaaS' },
|
||||
status: 'In Production',
|
||||
businessImportance: 'Hoog',
|
||||
businessImpactAnalyse: { objectId: '8', key: 'BIA-8', name: 'BIA-2024-0067 (Klasse C)' },
|
||||
systemOwner: 'F. Bos',
|
||||
businessOwner: 'I. Dekker',
|
||||
functionalApplicationManagement: 'Team Kwaliteit',
|
||||
technicalApplicationManagement: 'Team Bedrijfsapplicaties',
|
||||
medischeTechniek: false,
|
||||
applicationFunctions: [],
|
||||
dynamicsFactor: { objectId: '2', key: 'DYN-2', name: '2 - Gemiddeld' },
|
||||
complexityFactor: { objectId: '1', key: 'CMP-1', name: '1 - Laag' },
|
||||
numberOfUsers: { objectId: '4', key: 'USR-4', name: '2.000 - 5.000' },
|
||||
governanceModel: { objectId: 'C', key: 'GOV-C', name: 'Uitbesteed met ICMT-Regie' },
|
||||
applicationCluster: null,
|
||||
applicationType: null,
|
||||
platform: null,
|
||||
requiredEffortApplicationManagement: null,
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
key: 'APP-010',
|
||||
name: 'Castor EDC',
|
||||
searchReference: 'CASTOR',
|
||||
description: 'Electronic Data Capture platform voor klinisch wetenschappelijk onderzoek en trials.',
|
||||
supplierProduct: 'Castor / Castor EDC',
|
||||
organisation: 'Onderzoek',
|
||||
hostingType: { objectId: '2', key: 'HOST-2', name: 'SaaS' },
|
||||
status: 'In Production',
|
||||
businessImportance: 'Gemiddeld',
|
||||
businessImpactAnalyse: null, // BIA-2024-0078 (Klasse B) not in mock list
|
||||
systemOwner: 'G. Vos',
|
||||
businessOwner: 'Prof. Dr. W. Maas',
|
||||
functionalApplicationManagement: 'Team Onderzoek',
|
||||
technicalApplicationManagement: null,
|
||||
medischeTechniek: false,
|
||||
applicationFunctions: [],
|
||||
dynamicsFactor: { objectId: '1', key: 'DYN-1', name: '1 - Stabiel' },
|
||||
complexityFactor: { objectId: '1', key: 'CMP-1', name: '1 - Laag' },
|
||||
numberOfUsers: { objectId: '1', key: 'USR-1', name: '< 100' },
|
||||
governanceModel: { objectId: 'D', key: 'GOV-D', name: 'Uitbesteed met Business-Regie' },
|
||||
applicationCluster: null,
|
||||
applicationType: null,
|
||||
platform: null,
|
||||
requiredEffortApplicationManagement: null,
|
||||
},
|
||||
];
|
||||
|
||||
// Mock reference data
|
||||
const mockDynamicsFactors: ReferenceValue[] = [
|
||||
{ objectId: '1', key: 'DYN-1', name: '1 - Stabiel', summary: 'Weinig wijzigingen, < 2 releases/jaar', description: 'Weinig wijzigingen, < 2 releases/jaar', factor: 0.8 },
|
||||
{ objectId: '2', key: 'DYN-2', name: '2 - Gemiddeld', summary: 'Regelmatige wijzigingen, 2-4 releases/jaar', description: 'Regelmatige wijzigingen, 2-4 releases/jaar', factor: 1.0 },
|
||||
{ objectId: '3', key: 'DYN-3', name: '3 - Hoog', summary: 'Veel wijzigingen, > 4 releases/jaar', description: 'Veel wijzigingen, > 4 releases/jaar', factor: 1.2 },
|
||||
{ objectId: '4', key: 'DYN-4', name: '4 - Zeer hoog', summary: 'Continu in beweging, grote transformaties', description: 'Continu in beweging, grote transformaties', factor: 1.5 },
|
||||
];
|
||||
|
||||
const mockComplexityFactors: ReferenceValue[] = [
|
||||
{ objectId: '1', key: 'CMP-1', name: '1 - Laag', summary: 'Standalone, weinig integraties', description: 'Standalone, weinig integraties', factor: 0.8 },
|
||||
{ objectId: '2', key: 'CMP-2', name: '2 - Gemiddeld', summary: 'Enkele integraties, beperkt maatwerk', description: 'Enkele integraties, beperkt maatwerk', factor: 1.0 },
|
||||
{ objectId: '3', key: 'CMP-3', name: '3 - Hoog', summary: 'Veel integraties, significant maatwerk', description: 'Veel integraties, significant maatwerk', factor: 1.3 },
|
||||
{ objectId: '4', key: 'CMP-4', name: '4 - Zeer hoog', summary: 'Platform, uitgebreide governance', description: 'Platform, uitgebreide governance', factor: 1.6 },
|
||||
];
|
||||
|
||||
const mockNumberOfUsers: ReferenceValue[] = [
|
||||
{ objectId: '1', key: 'USR-1', name: '< 100', order: 1, factor: 0.5 },
|
||||
{ objectId: '2', key: 'USR-2', name: '100 - 500', order: 2, factor: 0.7 },
|
||||
{ objectId: '3', key: 'USR-3', name: '500 - 2.000', order: 3, factor: 1.0 },
|
||||
{ objectId: '4', key: 'USR-4', name: '2.000 - 5.000', order: 4, factor: 1.2 },
|
||||
{ objectId: '5', key: 'USR-5', name: '5.000 - 10.000', order: 5, factor: 1.4 },
|
||||
{ objectId: '6', key: 'USR-6', name: '10.000 - 15.000', order: 6, factor: 1.6 },
|
||||
{ objectId: '7', key: 'USR-7', name: '> 15.000', order: 7, factor: 2.0 },
|
||||
];
|
||||
|
||||
const mockGovernanceModels: ReferenceValue[] = [
|
||||
{ objectId: 'A', key: 'GOV-A', name: 'Centraal Beheer', summary: 'ICMT voert volledig beheer uit', description: 'ICMT voert volledig beheer uit' },
|
||||
{ objectId: 'B', key: 'GOV-B', name: 'Federatief Beheer', summary: 'ICMT + business delen beheer', description: 'ICMT + business delen beheer' },
|
||||
{ objectId: 'C', key: 'GOV-C', name: 'Uitbesteed met ICMT-Regie', summary: 'Leverancier beheert, ICMT regisseert', description: 'Leverancier beheert, ICMT regisseert' },
|
||||
{ objectId: 'D', key: 'GOV-D', name: 'Uitbesteed met Business-Regie', summary: 'Leverancier beheert, business regisseert', description: 'Leverancier beheert, business regisseert' },
|
||||
{ objectId: 'E', key: 'GOV-E', name: 'Volledig Decentraal Beheer', summary: 'Business voert volledig beheer uit', description: 'Business voert volledig beheer uit' },
|
||||
];
|
||||
|
||||
const mockOrganisations: ReferenceValue[] = [
|
||||
{ objectId: '1', key: 'ORG-1', name: 'Zorg' },
|
||||
{ objectId: '2', key: 'ORG-2', name: 'Bedrijfsvoering' },
|
||||
{ objectId: '3', key: 'ORG-3', name: 'ICMT' },
|
||||
{ objectId: '4', key: 'ORG-4', name: 'Kwaliteit' },
|
||||
{ objectId: '5', key: 'ORG-5', name: 'Onderzoek' },
|
||||
{ objectId: '6', key: 'ORG-6', name: 'Onderwijs' },
|
||||
];
|
||||
|
||||
const mockHostingTypes: ReferenceValue[] = [
|
||||
{ objectId: '1', key: 'HOST-1', name: 'On-premises' },
|
||||
{ objectId: '2', key: 'HOST-2', name: 'SaaS' },
|
||||
{ objectId: '3', key: 'HOST-3', name: 'Cloud' },
|
||||
{ objectId: '4', key: 'HOST-4', name: 'Hybrid' },
|
||||
];
|
||||
|
||||
const mockBusinessImpactAnalyses: ReferenceValue[] = [
|
||||
{ objectId: '1', key: 'BIA-1', name: 'BIA-2024-0042 (Klasse E)' },
|
||||
{ objectId: '2', key: 'BIA-2', name: 'BIA-2024-0015 (Klasse D)' },
|
||||
{ objectId: '3', key: 'BIA-3', name: 'BIA-2024-0028 (Klasse D)' },
|
||||
{ objectId: '4', key: 'BIA-4', name: 'BIA-2024-0035 (Klasse C)' },
|
||||
{ objectId: '5', key: 'BIA-5', name: 'BIA-2024-0001 (Klasse F)' },
|
||||
{ objectId: '6', key: 'BIA-6', name: 'BIA-2024-0055 (Klasse C)' },
|
||||
{ objectId: '7', key: 'BIA-7', name: 'BIA-2024-0022 (Klasse D)' },
|
||||
{ objectId: '8', key: 'BIA-8', name: 'BIA-2024-0067 (Klasse C)' },
|
||||
{ objectId: '9', key: 'BIA-9', name: 'BIA-2022-0089 (Klasse C)' },
|
||||
];
|
||||
|
||||
const mockApplicationClusters: ReferenceValue[] = [
|
||||
{ objectId: '1', key: 'CLUSTER-1', name: 'Zorgapplicaties' },
|
||||
{ objectId: '2', key: 'CLUSTER-2', name: 'Bedrijfsvoering' },
|
||||
{ objectId: '3', key: 'CLUSTER-3', name: 'Infrastructuur' },
|
||||
];
|
||||
|
||||
const mockApplicationTypes: ReferenceValue[] = [
|
||||
{ objectId: '1', key: 'TYPE-1', name: 'Applicatie' },
|
||||
{ objectId: '2', key: 'TYPE-2', name: 'Platform' },
|
||||
{ objectId: '3', key: 'TYPE-3', name: 'Workload' },
|
||||
];
|
||||
|
||||
// Classification history
|
||||
const mockClassificationHistory: ClassificationResult[] = [];
|
||||
|
||||
// Mock data service
|
||||
export class MockDataService {
|
||||
private applications: ApplicationDetails[] = [...mockApplications];
|
||||
|
||||
async searchApplications(
|
||||
filters: SearchFilters,
|
||||
page: number = 1,
|
||||
pageSize: number = 25
|
||||
): Promise<SearchResult> {
|
||||
let filtered = [...this.applications];
|
||||
|
||||
// Apply search text filter
|
||||
if (filters.searchText) {
|
||||
const search = filters.searchText.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(app) =>
|
||||
app.name.toLowerCase().includes(search) ||
|
||||
(app.description?.toLowerCase().includes(search) ?? false) ||
|
||||
(app.supplierProduct?.toLowerCase().includes(search) ?? false) ||
|
||||
(app.searchReference?.toLowerCase().includes(search) ?? false)
|
||||
);
|
||||
}
|
||||
|
||||
// Apply status filter
|
||||
if (filters.statuses && filters.statuses.length > 0) {
|
||||
filtered = filtered.filter((app) =>
|
||||
app.status ? filters.statuses!.includes(app.status) : false
|
||||
);
|
||||
}
|
||||
|
||||
// Apply applicationFunction filter
|
||||
if (filters.applicationFunction === 'empty') {
|
||||
filtered = filtered.filter((app) => app.applicationFunctions.length === 0);
|
||||
} else if (filters.applicationFunction === 'filled') {
|
||||
filtered = filtered.filter((app) => app.applicationFunctions.length > 0);
|
||||
}
|
||||
|
||||
// Apply governanceModel filter
|
||||
if (filters.governanceModel === 'empty') {
|
||||
filtered = filtered.filter((app) => !app.governanceModel);
|
||||
} else if (filters.governanceModel === 'filled') {
|
||||
filtered = filtered.filter((app) => !!app.governanceModel);
|
||||
}
|
||||
|
||||
// Apply dynamicsFactor filter
|
||||
if (filters.dynamicsFactor === 'empty') {
|
||||
filtered = filtered.filter((app) => !app.dynamicsFactor);
|
||||
} else if (filters.dynamicsFactor === 'filled') {
|
||||
filtered = filtered.filter((app) => !!app.dynamicsFactor);
|
||||
}
|
||||
|
||||
// Apply complexityFactor filter
|
||||
if (filters.complexityFactor === 'empty') {
|
||||
filtered = filtered.filter((app) => !app.complexityFactor);
|
||||
} else if (filters.complexityFactor === 'filled') {
|
||||
filtered = filtered.filter((app) => !!app.complexityFactor);
|
||||
}
|
||||
|
||||
// Apply applicationCluster filter
|
||||
if (filters.applicationCluster === 'empty') {
|
||||
filtered = filtered.filter((app) => !app.applicationCluster);
|
||||
} else if (filters.applicationCluster === 'filled') {
|
||||
filtered = filtered.filter((app) => !!app.applicationCluster);
|
||||
}
|
||||
|
||||
// Apply applicationType filter
|
||||
if (filters.applicationType === 'empty') {
|
||||
filtered = filtered.filter((app) => !app.applicationType);
|
||||
} else if (filters.applicationType === 'filled') {
|
||||
filtered = filtered.filter((app) => !!app.applicationType);
|
||||
}
|
||||
|
||||
// Apply organisation filter
|
||||
if (filters.organisation) {
|
||||
filtered = filtered.filter((app) => app.organisation === filters.organisation);
|
||||
}
|
||||
|
||||
// Apply hostingType filter
|
||||
if (filters.hostingType) {
|
||||
filtered = filtered.filter((app) => {
|
||||
if (!app.hostingType) return false;
|
||||
return app.hostingType.name === filters.hostingType || app.hostingType.key === filters.hostingType;
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.businessImportance) {
|
||||
filtered = filtered.filter((app) => app.businessImportance === filters.businessImportance);
|
||||
}
|
||||
|
||||
const totalCount = filtered.length;
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginatedApps = filtered.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
return {
|
||||
applications: paginatedApps.map((app) => {
|
||||
const effort = calculateRequiredEffortApplicationManagement(app);
|
||||
return {
|
||||
id: app.id,
|
||||
key: app.key,
|
||||
name: app.name,
|
||||
status: app.status,
|
||||
applicationFunctions: app.applicationFunctions,
|
||||
governanceModel: app.governanceModel,
|
||||
dynamicsFactor: app.dynamicsFactor,
|
||||
complexityFactor: app.complexityFactor,
|
||||
applicationCluster: app.applicationCluster,
|
||||
applicationType: app.applicationType,
|
||||
platform: app.platform,
|
||||
requiredEffortApplicationManagement: effort,
|
||||
};
|
||||
}),
|
||||
totalCount,
|
||||
currentPage: page,
|
||||
pageSize,
|
||||
totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
async getApplicationById(id: string): Promise<ApplicationDetails | null> {
|
||||
const app = this.applications.find((app) => app.id === id);
|
||||
if (!app) return null;
|
||||
|
||||
// Calculate required effort
|
||||
const effort = calculateRequiredEffortApplicationManagement(app);
|
||||
return {
|
||||
...app,
|
||||
requiredEffortApplicationManagement: effort,
|
||||
};
|
||||
}
|
||||
|
||||
async updateApplication(
|
||||
id: string,
|
||||
updates: {
|
||||
applicationFunctions?: ReferenceValue[];
|
||||
dynamicsFactor?: ReferenceValue;
|
||||
complexityFactor?: ReferenceValue;
|
||||
numberOfUsers?: ReferenceValue;
|
||||
governanceModel?: ReferenceValue;
|
||||
applicationCluster?: ReferenceValue;
|
||||
applicationType?: ReferenceValue;
|
||||
hostingType?: ReferenceValue;
|
||||
businessImpactAnalyse?: ReferenceValue;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
const index = this.applications.findIndex((app) => app.id === id);
|
||||
if (index === -1) return false;
|
||||
|
||||
const app = this.applications[index];
|
||||
|
||||
if (updates.applicationFunctions !== undefined) {
|
||||
app.applicationFunctions = updates.applicationFunctions;
|
||||
}
|
||||
if (updates.dynamicsFactor !== undefined) {
|
||||
app.dynamicsFactor = updates.dynamicsFactor;
|
||||
}
|
||||
if (updates.complexityFactor !== undefined) {
|
||||
app.complexityFactor = updates.complexityFactor;
|
||||
}
|
||||
if (updates.numberOfUsers !== undefined) {
|
||||
app.numberOfUsers = updates.numberOfUsers;
|
||||
}
|
||||
if (updates.governanceModel !== undefined) {
|
||||
app.governanceModel = updates.governanceModel;
|
||||
}
|
||||
if (updates.applicationCluster !== undefined) {
|
||||
app.applicationCluster = updates.applicationCluster;
|
||||
}
|
||||
if (updates.applicationType !== undefined) {
|
||||
app.applicationType = updates.applicationType;
|
||||
}
|
||||
if (updates.hostingType !== undefined) {
|
||||
app.hostingType = updates.hostingType;
|
||||
}
|
||||
if (updates.businessImpactAnalyse !== undefined) {
|
||||
app.businessImpactAnalyse = updates.businessImpactAnalyse;
|
||||
}
|
||||
if (updates.applicationCluster !== undefined) {
|
||||
app.applicationCluster = updates.applicationCluster;
|
||||
}
|
||||
if (updates.applicationType !== undefined) {
|
||||
app.applicationType = updates.applicationType;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async getDynamicsFactors(): Promise<ReferenceValue[]> {
|
||||
return mockDynamicsFactors;
|
||||
}
|
||||
|
||||
async getComplexityFactors(): Promise<ReferenceValue[]> {
|
||||
return mockComplexityFactors;
|
||||
}
|
||||
|
||||
async getNumberOfUsers(): Promise<ReferenceValue[]> {
|
||||
return mockNumberOfUsers;
|
||||
}
|
||||
|
||||
async getGovernanceModels(): Promise<ReferenceValue[]> {
|
||||
return mockGovernanceModels;
|
||||
}
|
||||
|
||||
async getOrganisations(): Promise<ReferenceValue[]> {
|
||||
return mockOrganisations;
|
||||
}
|
||||
|
||||
async getHostingTypes(): Promise<ReferenceValue[]> {
|
||||
return mockHostingTypes;
|
||||
}
|
||||
|
||||
async getBusinessImpactAnalyses(): Promise<ReferenceValue[]> {
|
||||
return mockBusinessImpactAnalyses;
|
||||
}
|
||||
|
||||
async getApplicationManagementHosting(): Promise<ReferenceValue[]> {
|
||||
// Mock Application Management - Hosting values (v25)
|
||||
return [
|
||||
{ objectId: '1', key: 'AMH-1', name: 'On-Premises' },
|
||||
{ objectId: '2', key: 'AMH-2', name: 'Azure - Eigen beheer' },
|
||||
{ objectId: '3', key: 'AMH-3', name: 'Azure - Delegated Management' },
|
||||
{ objectId: '4', key: 'AMH-4', name: 'Extern (SaaS)' },
|
||||
];
|
||||
}
|
||||
|
||||
async getApplicationManagementTAM(): Promise<ReferenceValue[]> {
|
||||
// Mock Application Management - TAM values
|
||||
return [
|
||||
{ objectId: '1', key: 'TAM-1', name: 'ICMT' },
|
||||
{ objectId: '2', key: 'TAM-2', name: 'Business' },
|
||||
{ objectId: '3', key: 'TAM-3', name: 'Leverancier' },
|
||||
];
|
||||
}
|
||||
|
||||
async getApplicationFunctions(): Promise<ReferenceValue[]> {
|
||||
// Return empty for mock - in real implementation, this comes from Jira
|
||||
return [];
|
||||
}
|
||||
|
||||
async getApplicationClusters(): Promise<ReferenceValue[]> {
|
||||
// Return empty for mock - in real implementation, this comes from Jira
|
||||
return [];
|
||||
}
|
||||
|
||||
async getApplicationTypes(): Promise<ReferenceValue[]> {
|
||||
// Return empty for mock - in real implementation, this comes from Jira
|
||||
return [];
|
||||
}
|
||||
|
||||
async getBusinessImportance(): Promise<ReferenceValue[]> {
|
||||
// Return empty for mock - in real implementation, this comes from Jira
|
||||
return [];
|
||||
}
|
||||
|
||||
async getApplicationFunctionCategories(): Promise<ReferenceValue[]> {
|
||||
// Return empty for mock - in real implementation, this comes from Jira
|
||||
return [];
|
||||
}
|
||||
|
||||
async getStats() {
|
||||
// Filter out applications with status "Closed" for KPIs
|
||||
const activeApplications = this.applications.filter((a) => a.status !== 'Closed');
|
||||
const total = activeApplications.length;
|
||||
const classified = activeApplications.filter((a) => a.applicationFunctions.length > 0).length;
|
||||
const unclassified = total - classified;
|
||||
|
||||
const byStatus: Record<string, number> = {};
|
||||
const byGovernanceModel: Record<string, number> = {};
|
||||
|
||||
activeApplications.forEach((app) => {
|
||||
if (app.status) {
|
||||
byStatus[app.status] = (byStatus[app.status] || 0) + 1;
|
||||
}
|
||||
if (app.governanceModel) {
|
||||
byGovernanceModel[app.governanceModel.name] =
|
||||
(byGovernanceModel[app.governanceModel.name] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
totalApplications: total,
|
||||
classifiedCount: classified,
|
||||
unclassifiedCount: unclassified,
|
||||
byStatus,
|
||||
byDomain: {},
|
||||
byGovernanceModel,
|
||||
recentClassifications: mockClassificationHistory.slice(-10),
|
||||
};
|
||||
}
|
||||
|
||||
addClassificationResult(result: ClassificationResult): void {
|
||||
mockClassificationHistory.push(result);
|
||||
}
|
||||
|
||||
getClassificationHistory(): ClassificationResult[] {
|
||||
return [...mockClassificationHistory];
|
||||
}
|
||||
|
||||
async getTeamDashboardData(excludedStatuses: ApplicationStatus[] = []): Promise<TeamDashboardData> {
|
||||
// Convert ApplicationDetails to ApplicationListItem for dashboard
|
||||
let listItems: ApplicationListItem[] = this.applications.map(app => ({
|
||||
id: app.id,
|
||||
key: app.key,
|
||||
name: app.name,
|
||||
status: app.status,
|
||||
applicationFunctions: app.applicationFunctions,
|
||||
governanceModel: app.governanceModel,
|
||||
dynamicsFactor: app.dynamicsFactor,
|
||||
complexityFactor: app.complexityFactor,
|
||||
applicationCluster: app.applicationCluster,
|
||||
applicationType: app.applicationType,
|
||||
platform: app.platform,
|
||||
requiredEffortApplicationManagement: app.requiredEffortApplicationManagement,
|
||||
}));
|
||||
|
||||
// Filter out excluded statuses
|
||||
if (excludedStatuses.length > 0) {
|
||||
listItems = listItems.filter(app => !app.status || !excludedStatuses.includes(app.status));
|
||||
}
|
||||
|
||||
// Separate applications into Platforms, Workloads, and regular applications
|
||||
const platforms: ApplicationListItem[] = [];
|
||||
const workloads: ApplicationListItem[] = [];
|
||||
const regularApplications: ApplicationListItem[] = [];
|
||||
|
||||
for (const app of listItems) {
|
||||
const isPlatform = app.applicationType?.name === 'Platform';
|
||||
const isWorkload = app.platform !== null;
|
||||
|
||||
if (isPlatform) {
|
||||
platforms.push(app);
|
||||
} else if (isWorkload) {
|
||||
workloads.push(app);
|
||||
} else {
|
||||
regularApplications.push(app);
|
||||
}
|
||||
}
|
||||
|
||||
// Group workloads by their platform
|
||||
const workloadsByPlatform = new Map<string, ApplicationListItem[]>();
|
||||
for (const workload of workloads) {
|
||||
const platformId = workload.platform!.objectId;
|
||||
if (!workloadsByPlatform.has(platformId)) {
|
||||
workloadsByPlatform.set(platformId, []);
|
||||
}
|
||||
workloadsByPlatform.get(platformId)!.push(workload);
|
||||
}
|
||||
|
||||
// Build PlatformWithWorkloads structures
|
||||
const platformsWithWorkloads: import('../types/index.js').PlatformWithWorkloads[] = [];
|
||||
for (const platform of platforms) {
|
||||
const platformWorkloads = workloadsByPlatform.get(platform.id) || [];
|
||||
const platformEffort = platform.requiredEffortApplicationManagement || 0;
|
||||
const workloadsEffort = platformWorkloads.reduce((sum, w) => sum + (w.requiredEffortApplicationManagement || 0), 0);
|
||||
|
||||
platformsWithWorkloads.push({
|
||||
platform,
|
||||
workloads: platformWorkloads,
|
||||
platformEffort,
|
||||
workloadsEffort,
|
||||
totalEffort: platformEffort + workloadsEffort,
|
||||
});
|
||||
}
|
||||
|
||||
// Group all applications (regular + platforms + workloads) by cluster
|
||||
const clusterMap = new Map<string, {
|
||||
regular: ApplicationListItem[];
|
||||
platforms: import('../types/index.js').PlatformWithWorkloads[];
|
||||
}>();
|
||||
const unassigned: {
|
||||
regular: ApplicationListItem[];
|
||||
platforms: import('../types/index.js').PlatformWithWorkloads[];
|
||||
} = {
|
||||
regular: [],
|
||||
platforms: [],
|
||||
};
|
||||
|
||||
// Group regular applications by cluster
|
||||
for (const app of regularApplications) {
|
||||
if (app.applicationCluster) {
|
||||
const clusterId = app.applicationCluster.objectId;
|
||||
if (!clusterMap.has(clusterId)) {
|
||||
clusterMap.set(clusterId, { regular: [], platforms: [] });
|
||||
}
|
||||
clusterMap.get(clusterId)!.regular.push(app);
|
||||
} else {
|
||||
unassigned.regular.push(app);
|
||||
}
|
||||
}
|
||||
|
||||
// Group platforms by cluster
|
||||
for (const platformWithWorkloads of platformsWithWorkloads) {
|
||||
const platform = platformWithWorkloads.platform;
|
||||
if (platform.applicationCluster) {
|
||||
const clusterId = platform.applicationCluster.objectId;
|
||||
if (!clusterMap.has(clusterId)) {
|
||||
clusterMap.set(clusterId, { regular: [], platforms: [] });
|
||||
}
|
||||
clusterMap.get(clusterId)!.platforms.push(platformWithWorkloads);
|
||||
} else {
|
||||
unassigned.platforms.push(platformWithWorkloads);
|
||||
}
|
||||
}
|
||||
|
||||
// Get all clusters
|
||||
const allClusters = mockApplicationClusters;
|
||||
const clusters = allClusters.map(cluster => {
|
||||
const clusterData = clusterMap.get(cluster.objectId) || { regular: [], platforms: [] };
|
||||
const regularApps = clusterData.regular;
|
||||
const platforms = clusterData.platforms;
|
||||
|
||||
// Calculate total effort: regular apps + platforms (including their workloads)
|
||||
const regularEffort = regularApps.reduce((sum, app) =>
|
||||
sum + (app.requiredEffortApplicationManagement || 0), 0
|
||||
);
|
||||
const platformsEffort = platforms.reduce((sum, p) => sum + p.totalEffort, 0);
|
||||
const totalEffort = regularEffort + platformsEffort;
|
||||
|
||||
// Calculate total application count: regular apps + platforms + workloads
|
||||
const platformsCount = platforms.length;
|
||||
const workloadsCount = platforms.reduce((sum, p) => sum + p.workloads.length, 0);
|
||||
const applicationCount = regularApps.length + platformsCount + workloadsCount;
|
||||
|
||||
// Calculate governance model distribution (including platforms and workloads)
|
||||
const byGovernanceModel: Record<string, number> = {};
|
||||
for (const app of regularApps) {
|
||||
const govModel = app.governanceModel?.name || 'Niet ingesteld';
|
||||
byGovernanceModel[govModel] = (byGovernanceModel[govModel] || 0) + 1;
|
||||
}
|
||||
for (const platformWithWorkloads of platforms) {
|
||||
const platform = platformWithWorkloads.platform;
|
||||
const govModel = platform.governanceModel?.name || 'Niet ingesteld';
|
||||
byGovernanceModel[govModel] = (byGovernanceModel[govModel] || 0) + 1;
|
||||
// Also count workloads
|
||||
for (const workload of platformWithWorkloads.workloads) {
|
||||
const workloadGovModel = workload.governanceModel?.name || 'Niet ingesteld';
|
||||
byGovernanceModel[workloadGovModel] = (byGovernanceModel[workloadGovModel] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cluster,
|
||||
applications: regularApps,
|
||||
platforms,
|
||||
totalEffort,
|
||||
minEffort: totalEffort * 0.8, // Mock: min is 80% of total
|
||||
maxEffort: totalEffort * 1.2, // Mock: max is 120% of total
|
||||
applicationCount,
|
||||
byGovernanceModel,
|
||||
};
|
||||
});
|
||||
|
||||
// Calculate unassigned totals
|
||||
const unassignedRegularEffort = unassigned.regular.reduce((sum, app) =>
|
||||
sum + (app.requiredEffortApplicationManagement || 0), 0
|
||||
);
|
||||
const unassignedPlatformsEffort = unassigned.platforms.reduce((sum, p) => sum + p.totalEffort, 0);
|
||||
const unassignedTotalEffort = unassignedRegularEffort + unassignedPlatformsEffort;
|
||||
|
||||
const unassignedPlatformsCount = unassigned.platforms.length;
|
||||
const unassignedWorkloadsCount = unassigned.platforms.reduce((sum, p) => sum + p.workloads.length, 0);
|
||||
const unassignedApplicationCount = unassigned.regular.length + unassignedPlatformsCount + unassignedWorkloadsCount;
|
||||
|
||||
// Calculate governance model distribution for unassigned
|
||||
const unassignedByGovernanceModel: Record<string, number> = {};
|
||||
for (const app of unassigned.regular) {
|
||||
const govModel = app.governanceModel?.name || 'Niet ingesteld';
|
||||
unassignedByGovernanceModel[govModel] = (unassignedByGovernanceModel[govModel] || 0) + 1;
|
||||
}
|
||||
for (const platformWithWorkloads of unassigned.platforms) {
|
||||
const platform = platformWithWorkloads.platform;
|
||||
const govModel = platform.governanceModel?.name || 'Niet ingesteld';
|
||||
unassignedByGovernanceModel[govModel] = (unassignedByGovernanceModel[govModel] || 0) + 1;
|
||||
for (const workload of platformWithWorkloads.workloads) {
|
||||
const workloadGovModel = workload.governanceModel?.name || 'Niet ingesteld';
|
||||
unassignedByGovernanceModel[workloadGovModel] = (unassignedByGovernanceModel[workloadGovModel] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
clusters,
|
||||
unassigned: {
|
||||
applications: unassigned.regular,
|
||||
platforms: unassigned.platforms,
|
||||
totalEffort: unassignedTotalEffort,
|
||||
minEffort: unassignedTotalEffort * 0.8, // Mock: min is 80% of total
|
||||
maxEffort: unassignedTotalEffort * 1.2, // Mock: max is 120% of total
|
||||
applicationCount: unassignedApplicationCount,
|
||||
byGovernanceModel: unassignedByGovernanceModel,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const mockDataService = new MockDataService();
|
||||
409
backend/src/types/index.ts
Normal file
409
backend/src/types/index.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
// Application status types
|
||||
export type ApplicationStatus =
|
||||
| 'Status'
|
||||
| 'Closed'
|
||||
| 'Deprecated'
|
||||
| 'End of life'
|
||||
| 'End of support'
|
||||
| 'Implementation'
|
||||
| 'In Production'
|
||||
| 'Proof of Concept'
|
||||
| 'Shadow IT'
|
||||
| 'Undefined';
|
||||
|
||||
// Reference value from Jira Assets
|
||||
export interface ReferenceValue {
|
||||
objectId: string;
|
||||
key: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
summary?: string; // Summary attribute for Dynamics Factor, Complexity Factor, and Governance Model
|
||||
category?: string; // Deprecated: kept for backward compatibility, use applicationFunctionCategory instead
|
||||
applicationFunctionCategory?: ReferenceValue; // Reference to ApplicationFunctionCategory object
|
||||
keywords?: string; // Keywords for ApplicationFunction
|
||||
order?: number;
|
||||
factor?: number; // Factor attribute for Dynamics Factor, Complexity Factor, and Number of Users
|
||||
remarks?: string; // Remarks attribute for Governance Model
|
||||
application?: string; // Application attribute for Governance Model
|
||||
indicators?: string; // Indicators attribute for Business Impact Analyse
|
||||
}
|
||||
|
||||
// Application list item (summary view)
|
||||
export interface ApplicationListItem {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
status: ApplicationStatus | null;
|
||||
applicationFunctions: ReferenceValue[]; // Multiple functions supported
|
||||
governanceModel: ReferenceValue | null;
|
||||
dynamicsFactor: ReferenceValue | null;
|
||||
complexityFactor: ReferenceValue | null;
|
||||
applicationCluster: ReferenceValue | null;
|
||||
applicationType: ReferenceValue | null;
|
||||
platform: ReferenceValue | null; // Reference to parent Platform Application Component
|
||||
requiredEffortApplicationManagement: number | null; // Calculated field
|
||||
minFTE?: number | null; // Minimum FTE from configuration range
|
||||
maxFTE?: number | null; // Maximum FTE from configuration range
|
||||
overrideFTE?: number | null; // Override FTE value (if set, overrides calculated value)
|
||||
applicationManagementHosting?: ReferenceValue | null; // Application Management - Hosting
|
||||
applicationManagementTAM?: ReferenceValue | null; // Application Management - TAM
|
||||
}
|
||||
|
||||
// Full application details
|
||||
export interface ApplicationDetails {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
searchReference: string | null;
|
||||
description: string | null;
|
||||
supplierProduct: string | null;
|
||||
organisation: string | null;
|
||||
hostingType: ReferenceValue | null;
|
||||
status: ApplicationStatus | null;
|
||||
businessImportance: string | null;
|
||||
businessImpactAnalyse: ReferenceValue | null;
|
||||
systemOwner: string | null;
|
||||
businessOwner: string | null;
|
||||
functionalApplicationManagement: string | null;
|
||||
technicalApplicationManagement: string | null;
|
||||
technicalApplicationManagementPrimary?: string | null; // Technical Application Management Primary
|
||||
technicalApplicationManagementSecondary?: string | null; // Technical Application Management Secondary
|
||||
medischeTechniek: boolean;
|
||||
applicationFunctions: ReferenceValue[]; // Multiple functions supported
|
||||
dynamicsFactor: ReferenceValue | null;
|
||||
complexityFactor: ReferenceValue | null;
|
||||
numberOfUsers: ReferenceValue | null;
|
||||
governanceModel: ReferenceValue | null;
|
||||
applicationCluster: ReferenceValue | null;
|
||||
applicationType: ReferenceValue | null;
|
||||
platform: ReferenceValue | null; // Reference to parent Platform Application Component
|
||||
requiredEffortApplicationManagement: number | null; // Calculated field
|
||||
overrideFTE?: number | null; // Override FTE value (if set, overrides calculated value)
|
||||
applicationManagementHosting?: ReferenceValue | null; // Application Management - Hosting
|
||||
applicationManagementTAM?: ReferenceValue | null; // Application Management - TAM
|
||||
technischeArchitectuur?: string | null; // URL to Technical Architecture document (Attribute ID 572)
|
||||
}
|
||||
|
||||
// Search filters
|
||||
export interface SearchFilters {
|
||||
searchText?: string;
|
||||
statuses?: ApplicationStatus[];
|
||||
applicationFunction?: 'all' | 'filled' | 'empty';
|
||||
governanceModel?: 'all' | 'filled' | 'empty';
|
||||
dynamicsFactor?: 'all' | 'filled' | 'empty';
|
||||
complexityFactor?: 'all' | 'filled' | 'empty';
|
||||
applicationCluster?: 'all' | 'filled' | 'empty';
|
||||
applicationType?: 'all' | 'filled' | 'empty';
|
||||
organisation?: string;
|
||||
hostingType?: string;
|
||||
businessImportance?: string;
|
||||
}
|
||||
|
||||
// Paginated search result
|
||||
export interface SearchResult {
|
||||
applications: ApplicationListItem[];
|
||||
totalCount: number;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// AI classification suggestion
|
||||
export interface AISuggestion {
|
||||
primaryFunction: {
|
||||
code: string;
|
||||
name: string;
|
||||
reasoning: string;
|
||||
};
|
||||
secondaryFunctions: Array<{
|
||||
code: string;
|
||||
name: string;
|
||||
reasoning: string;
|
||||
}>;
|
||||
managementClassification?: {
|
||||
applicationType?: {
|
||||
value: string;
|
||||
reasoning: string;
|
||||
};
|
||||
dynamicsFactor?: {
|
||||
value: string;
|
||||
label: string;
|
||||
reasoning: string;
|
||||
};
|
||||
complexityFactor?: {
|
||||
value: string;
|
||||
label: string;
|
||||
reasoning: string;
|
||||
};
|
||||
hostingType?: {
|
||||
value: string;
|
||||
reasoning: string;
|
||||
};
|
||||
applicationManagementHosting?: {
|
||||
value: string;
|
||||
reasoning: string;
|
||||
};
|
||||
applicationManagementTAM?: {
|
||||
value: string;
|
||||
reasoning: string;
|
||||
};
|
||||
biaClassification?: {
|
||||
value: string;
|
||||
reasoning: string;
|
||||
};
|
||||
governanceModel?: {
|
||||
value: string;
|
||||
reasoning: string;
|
||||
};
|
||||
};
|
||||
validationWarnings?: string[];
|
||||
confidence: 'HOOG' | 'MIDDEN' | 'LAAG';
|
||||
notes: string;
|
||||
}
|
||||
|
||||
// Pending changes for an application
|
||||
export interface PendingChanges {
|
||||
applicationFunctions?: { from: ReferenceValue[]; to: ReferenceValue[] };
|
||||
dynamicsFactor?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||
complexityFactor?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||
numberOfUsers?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||
governanceModel?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||
applicationCluster?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||
applicationType?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||
}
|
||||
|
||||
// Classification result for audit log
|
||||
export interface ClassificationResult {
|
||||
applicationId: string;
|
||||
applicationName: string;
|
||||
changes: PendingChanges;
|
||||
source: 'AI_ACCEPTED' | 'AI_MODIFIED' | 'MANUAL';
|
||||
timestamp: Date;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
// Reference options for dropdowns
|
||||
export interface ReferenceOptions {
|
||||
dynamicsFactors: ReferenceValue[];
|
||||
complexityFactors: ReferenceValue[];
|
||||
numberOfUsers: ReferenceValue[];
|
||||
governanceModels: ReferenceValue[];
|
||||
applicationFunctions: ReferenceValue[];
|
||||
applicationClusters: ReferenceValue[];
|
||||
applicationTypes: ReferenceValue[];
|
||||
organisations: ReferenceValue[];
|
||||
hostingTypes: ReferenceValue[];
|
||||
businessImportance: ReferenceValue[];
|
||||
applicationManagementHosting: ReferenceValue[];
|
||||
applicationManagementTAM: ReferenceValue[];
|
||||
}
|
||||
|
||||
// ZiRA domain structure
|
||||
export interface ZiraDomain {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
functions: ZiraFunction[];
|
||||
}
|
||||
|
||||
export interface ZiraFunction {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
keywords: string[];
|
||||
}
|
||||
|
||||
export interface ZiraTaxonomy {
|
||||
version: string;
|
||||
source: string;
|
||||
lastUpdated: string;
|
||||
domains: ZiraDomain[];
|
||||
}
|
||||
|
||||
// Dashboard statistics
|
||||
export interface DashboardStats {
|
||||
totalApplications: number;
|
||||
classifiedCount: number;
|
||||
unclassifiedCount: number;
|
||||
byStatus: Record<string, number>;
|
||||
byDomain: Record<string, number>;
|
||||
byGovernanceModel: Record<string, number>;
|
||||
recentClassifications: ClassificationResult[];
|
||||
}
|
||||
|
||||
// Navigation state for detail screen
|
||||
export interface NavigationState {
|
||||
currentIndex: number;
|
||||
totalInResults: number;
|
||||
applicationIds: string[];
|
||||
filters: SearchFilters;
|
||||
}
|
||||
|
||||
// Effort calculation breakdown (v25)
|
||||
export interface EffortCalculationBreakdown {
|
||||
// Base FTE values
|
||||
baseEffort: number; // Average of min/max
|
||||
baseEffortMin: number;
|
||||
baseEffortMax: number;
|
||||
|
||||
// Lookup path used
|
||||
governanceModel: string | null;
|
||||
governanceModelName: string | null;
|
||||
applicationType: string | null;
|
||||
businessImpactAnalyse: string | null;
|
||||
applicationManagementHosting: string | null;
|
||||
|
||||
// Factors applied
|
||||
numberOfUsersFactor: { value: number; name: string | null };
|
||||
dynamicsFactor: { value: number; name: string | null };
|
||||
complexityFactor: { value: number; name: string | null };
|
||||
|
||||
// Fallback information
|
||||
usedDefaults: string[]; // Which levels used default values
|
||||
|
||||
// Validation warnings/errors
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
|
||||
// Special flags
|
||||
requiresManualAssessment: boolean;
|
||||
isFixedFte: boolean;
|
||||
notRecommended: boolean;
|
||||
|
||||
// Hours calculation (based on final FTE)
|
||||
hoursPerYear: number;
|
||||
hoursPerMonth: number;
|
||||
hoursPerWeek: number;
|
||||
}
|
||||
|
||||
// Legacy type for backward compatibility
|
||||
export interface EffortCalculationBreakdownLegacy {
|
||||
baseEffort: number;
|
||||
governanceModel: string | null;
|
||||
applicationType: string | null;
|
||||
businessImpactAnalyse: string | null;
|
||||
hostingType: string | null;
|
||||
numberOfUsersFactor: { value: number; name: string | null };
|
||||
dynamicsFactor: { value: number; name: string | null };
|
||||
complexityFactor: { value: number; name: string | null };
|
||||
}
|
||||
|
||||
// Team dashboard types
|
||||
export interface PlatformWithWorkloads {
|
||||
platform: ApplicationListItem;
|
||||
workloads: ApplicationListItem[];
|
||||
platformEffort: number;
|
||||
workloadsEffort: number;
|
||||
totalEffort: number; // platformEffort + workloadsEffort
|
||||
}
|
||||
|
||||
export interface TeamDashboardCluster {
|
||||
cluster: ReferenceValue | null;
|
||||
applications: ApplicationListItem[]; // Regular applications (non-Platform, non-Workload)
|
||||
platforms: PlatformWithWorkloads[]; // Platforms with their workloads
|
||||
totalEffort: number; // Sum of all applications + platforms + workloads
|
||||
minEffort: number; // Sum of all minimum FTE values
|
||||
maxEffort: number; // Sum of all maximum FTE values
|
||||
applicationCount: number; // Count of all applications (including platforms and workloads)
|
||||
byGovernanceModel: Record<string, number>; // Distribution per governance model
|
||||
}
|
||||
|
||||
export interface TeamDashboardData {
|
||||
clusters: TeamDashboardCluster[];
|
||||
unassigned: {
|
||||
applications: ApplicationListItem[]; // Regular applications (non-Platform, non-Workload)
|
||||
platforms: PlatformWithWorkloads[]; // Platforms with their workloads
|
||||
totalEffort: number; // Sum of all applications + platforms + workloads
|
||||
minEffort: number; // Sum of all minimum FTE values
|
||||
maxEffort: number; // Sum of all maximum FTE values
|
||||
applicationCount: number; // Count of all applications (including platforms and workloads)
|
||||
byGovernanceModel: Record<string, number>; // Distribution per governance model
|
||||
};
|
||||
}
|
||||
|
||||
// Jira Assets API types
|
||||
export interface JiraAssetsObject {
|
||||
id: number;
|
||||
objectKey: string;
|
||||
label: string;
|
||||
objectType: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
attributes: JiraAssetsAttribute[];
|
||||
}
|
||||
|
||||
export interface JiraAssetsAttribute {
|
||||
objectTypeAttributeId: number;
|
||||
objectTypeAttribute?: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
objectAttributeValues: Array<{
|
||||
value?: string;
|
||||
displayValue?: string;
|
||||
referencedObject?: {
|
||||
id: number;
|
||||
objectKey: string;
|
||||
label: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface JiraAssetsSearchResponse {
|
||||
objectEntries: JiraAssetsObject[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalCount: number;
|
||||
totalFilterCount?: number; // Optional, may not be present in all API versions
|
||||
}
|
||||
|
||||
export interface ApplicationUpdateRequest {
|
||||
applicationFunctions?: string[];
|
||||
dynamicsFactor?: string;
|
||||
complexityFactor?: string;
|
||||
numberOfUsers?: string;
|
||||
governanceModel?: string;
|
||||
applicationCluster?: string;
|
||||
applicationType?: string;
|
||||
hostingType?: string;
|
||||
businessImpactAnalyse?: string;
|
||||
overrideFTE?: number | null; // Override FTE value (null to clear)
|
||||
applicationManagementHosting?: string; // Application Management - Hosting object key
|
||||
applicationManagementTAM?: string; // Application Management - TAM object key
|
||||
}
|
||||
|
||||
// Chat message for AI conversation
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
// For assistant messages, include the structured suggestion if available
|
||||
suggestion?: AISuggestion;
|
||||
}
|
||||
|
||||
// Chat conversation state
|
||||
export interface ChatConversation {
|
||||
id: string;
|
||||
applicationId: string;
|
||||
applicationName: string;
|
||||
messages: ChatMessage[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// Chat request for follow-up
|
||||
export interface ChatRequest {
|
||||
conversationId?: string; // If continuing existing conversation
|
||||
applicationId: string;
|
||||
message: string;
|
||||
provider?: 'claude' | 'openai';
|
||||
}
|
||||
|
||||
// Chat response
|
||||
export interface ChatResponse {
|
||||
conversationId: string;
|
||||
message: ChatMessage;
|
||||
suggestion?: AISuggestion; // Updated suggestion if AI provided one
|
||||
}
|
||||
20
backend/tsconfig.json
Normal file
20
backend/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
35
docker-compose.yml
Normal file
35
docker-compose.yml
Normal file
@@ -0,0 +1,35 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "3001:3001"
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- PORT=3001
|
||||
- JIRA_HOST=${JIRA_HOST}
|
||||
- JIRA_PAT=${JIRA_PAT}
|
||||
- JIRA_SCHEMA_ID=${JIRA_SCHEMA_ID}
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||
volumes:
|
||||
- ./backend/src:/app/src
|
||||
- backend_data:/app/data
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "5173:5173"
|
||||
depends_on:
|
||||
- backend
|
||||
volumes:
|
||||
- ./frontend/src:/app/src
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
backend_data:
|
||||
16
frontend/Dockerfile
Normal file
16
frontend/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5173
|
||||
|
||||
# Start development server
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="nl">
|
||||
<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>ZiRA Classificatie Tool - Zuyderland</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
27
frontend/package.json
Normal file
27
frontend/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "zira-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"zustand": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^7.3.0"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
84
frontend/src/App.tsx
Normal file
84
frontend/src/App.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Routes, Route, Link, useLocation } from 'react-router-dom';
|
||||
import { clsx } from 'clsx';
|
||||
import Dashboard from './components/Dashboard';
|
||||
import ApplicationList from './components/ApplicationList';
|
||||
import ApplicationDetail from './components/ApplicationDetail';
|
||||
import TeamDashboard from './components/TeamDashboard';
|
||||
import Configuration from './components/Configuration';
|
||||
import ConfigurationV25 from './components/ConfigurationV25';
|
||||
|
||||
function App() {
|
||||
const location = useLocation();
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: 'Dashboard', exact: true },
|
||||
{ path: '/applications', label: 'Applicaties', exact: false },
|
||||
{ path: '/teams', label: 'Team-indeling', exact: true },
|
||||
{ path: '/configuration', label: 'FTE Config v25', exact: true },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow-sm border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<div className="flex items-center space-x-8">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-sm">ZiRA</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-gray-900">
|
||||
Classificatie Tool
|
||||
</h1>
|
||||
<p className="text-xs text-gray-500">Zuyderland CMDB</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="hidden md:flex space-x-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive = item.exact
|
||||
? location.pathname === item.path
|
||||
: location.pathname.startsWith(item.path);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={clsx(
|
||||
'px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-gray-500">ICMT</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/applications" element={<ApplicationList />} />
|
||||
<Route path="/applications/:id" element={<ApplicationDetail />} />
|
||||
<Route path="/teams" element={<TeamDashboard />} />
|
||||
<Route path="/configuration" element={<ConfigurationV25 />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
2555
frontend/src/components/ApplicationDetail.tsx
Normal file
2555
frontend/src/components/ApplicationDetail.tsx
Normal file
File diff suppressed because it is too large
Load Diff
682
frontend/src/components/ApplicationList.tsx
Normal file
682
frontend/src/components/ApplicationList.tsx
Normal file
@@ -0,0 +1,682 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
|
||||
import { clsx } from 'clsx';
|
||||
import { searchApplications, getReferenceData } from '../services/api';
|
||||
import { useSearchStore } from '../stores/searchStore';
|
||||
import { useNavigationStore } from '../stores/navigationStore';
|
||||
import type { ApplicationListItem, SearchResult, ReferenceValue, ApplicationStatus } from '../types';
|
||||
|
||||
const ALL_STATUSES: ApplicationStatus[] = [
|
||||
'In Production',
|
||||
'Implementation',
|
||||
'Proof of Concept',
|
||||
'End of support',
|
||||
'End of life',
|
||||
'Deprecated',
|
||||
'Shadow IT',
|
||||
'Closed',
|
||||
'Undefined',
|
||||
];
|
||||
|
||||
export default function ApplicationList() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const {
|
||||
filters,
|
||||
currentPage,
|
||||
pageSize,
|
||||
setSearchText,
|
||||
setStatuses,
|
||||
setApplicationFunction,
|
||||
setGovernanceModel,
|
||||
setApplicationCluster,
|
||||
setApplicationType,
|
||||
setOrganisation,
|
||||
setHostingType,
|
||||
setBusinessImportance,
|
||||
setCurrentPage,
|
||||
resetFilters,
|
||||
} = useSearchStore();
|
||||
const { setNavigationContext } = useNavigationStore();
|
||||
|
||||
const [result, setResult] = useState<SearchResult | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [organisations, setOrganisations] = useState<ReferenceValue[]>([]);
|
||||
const [hostingTypes, setHostingTypes] = useState<ReferenceValue[]>([]);
|
||||
const [businessImportanceOptions, setBusinessImportanceOptions] = useState<ReferenceValue[]>([]);
|
||||
const [showFilters, setShowFilters] = useState(true);
|
||||
|
||||
// Sync URL params with store on mount
|
||||
useEffect(() => {
|
||||
const pageParam = searchParams.get('page');
|
||||
if (pageParam) {
|
||||
const page = parseInt(pageParam, 10);
|
||||
if (!isNaN(page) && page > 0 && page !== currentPage) {
|
||||
setCurrentPage(page);
|
||||
}
|
||||
}
|
||||
}, []); // Only run on mount
|
||||
|
||||
// Update URL when page changes
|
||||
useEffect(() => {
|
||||
const currentUrlPage = searchParams.get('page');
|
||||
const currentUrlPageNum = currentUrlPage ? parseInt(currentUrlPage, 10) : 1;
|
||||
|
||||
if (currentPage !== currentUrlPageNum) {
|
||||
if (currentPage === 1) {
|
||||
// Remove page param when on page 1
|
||||
searchParams.delete('page');
|
||||
} else {
|
||||
searchParams.set('page', currentPage.toString());
|
||||
}
|
||||
setSearchParams(searchParams, { replace: true });
|
||||
}
|
||||
}, [currentPage, searchParams, setSearchParams]);
|
||||
|
||||
const fetchApplications = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await searchApplications(filters, currentPage, pageSize);
|
||||
setResult(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load applications');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters, currentPage, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchApplications();
|
||||
}, [fetchApplications]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadReferenceData() {
|
||||
try {
|
||||
const data = await getReferenceData();
|
||||
setOrganisations(data.organisations);
|
||||
setHostingTypes(data.hostingTypes);
|
||||
setBusinessImportanceOptions(data.businessImportance || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to load reference data', err);
|
||||
}
|
||||
}
|
||||
loadReferenceData();
|
||||
}, []);
|
||||
|
||||
// Update navigation context whenever results change, so "Opslaan & Volgende" works
|
||||
// even when user opens an application in a new tab
|
||||
useEffect(() => {
|
||||
if (result && result.applications.length > 0) {
|
||||
const allIds = result.applications.map((a) => a.id);
|
||||
// Preserve current index if it's still valid, otherwise reset to 0
|
||||
setNavigationContext(allIds, filters, 0);
|
||||
}
|
||||
}, [result, filters, setNavigationContext]);
|
||||
|
||||
const handleRowClick = (app: ApplicationListItem, index: number, event: React.MouseEvent) => {
|
||||
// Update current index in navigation context
|
||||
if (result) {
|
||||
const allIds = result.applications.map((a) => a.id);
|
||||
setNavigationContext(allIds, filters, index);
|
||||
}
|
||||
|
||||
// Let the browser handle CTRL+click / CMD+click / middle-click natively for new tab
|
||||
// Only navigate programmatically for regular clicks
|
||||
if (!event.ctrlKey && !event.metaKey && !event.shiftKey && event.button === 0) {
|
||||
event.preventDefault();
|
||||
navigate(`/applications/${app.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleStatus = (status: ApplicationStatus) => {
|
||||
const current = filters.statuses || [];
|
||||
if (current.includes(status)) {
|
||||
setStatuses(current.filter((s) => s !== status));
|
||||
} else {
|
||||
setStatuses([...current, status]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page header */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">Applicaties</h2>
|
||||
<p className="text-gray-600">Zoek en classificeer applicatiecomponenten</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{showFilters ? 'Verberg filters' : 'Toon filters'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search and filters */}
|
||||
<div className="card">
|
||||
{/* Search bar */}
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Zoeken op naam, beschrijving, leverancier..."
|
||||
value={filters.searchText || ''}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<svg
|
||||
className="absolute left-3 top-2.5 h-5 w-5 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{showFilters && (
|
||||
<div className="p-4 bg-gray-50 border-b border-gray-200">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="font-medium text-gray-900">Filters</h3>
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
className="text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Wis alle filters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Status filter */}
|
||||
<div>
|
||||
<label className="label mb-2">Status</label>
|
||||
<div className="space-y-1 max-h-48 overflow-y-auto">
|
||||
{ALL_STATUSES.map((status) => (
|
||||
<label key={status} className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(filters.statuses || []).includes(status)}
|
||||
onChange={() => toggleStatus(status)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">{status}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Classification filters */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="label mb-2">ApplicationFunction</label>
|
||||
<div className="space-y-1">
|
||||
{(['all', 'filled', 'empty'] as const).map((value) => (
|
||||
<label key={value} className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="applicationFunction"
|
||||
checked={filters.applicationFunction === value}
|
||||
onChange={() => setApplicationFunction(value)}
|
||||
className="border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
{value === 'all' ? 'Alle' : value === 'filled' ? 'Ingevuld' : 'Leeg'}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label mb-2">Governance Model</label>
|
||||
<div className="space-y-1">
|
||||
{(['all', 'filled', 'empty'] as const).map((value) => (
|
||||
<label key={value} className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="governanceModel"
|
||||
checked={filters.governanceModel === value}
|
||||
onChange={() => setGovernanceModel(value)}
|
||||
className="border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
{value === 'all' ? 'Alle' : value === 'filled' ? 'Ingevuld' : 'Leeg'}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label mb-2">Application Cluster</label>
|
||||
<div className="space-y-1">
|
||||
{(['all', 'filled', 'empty'] as const).map((value) => (
|
||||
<label key={value} className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="applicationCluster"
|
||||
checked={filters.applicationCluster === value}
|
||||
onChange={() => setApplicationCluster(value)}
|
||||
className="border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
{value === 'all' ? 'Alle' : value === 'filled' ? 'Ingevuld' : 'Leeg'}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label mb-2">Application Type</label>
|
||||
<div className="space-y-1">
|
||||
{(['all', 'filled', 'empty'] as const).map((value) => (
|
||||
<label key={value} className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="applicationType"
|
||||
checked={filters.applicationType === value}
|
||||
onChange={() => setApplicationType(value)}
|
||||
className="border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
{value === 'all' ? 'Alle' : value === 'filled' ? 'Ingevuld' : 'Leeg'}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dropdown filters */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="label mb-2">Organisatie</label>
|
||||
<select
|
||||
value={filters.organisation || ''}
|
||||
onChange={(e) => setOrganisation(e.target.value || undefined)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">Alle organisaties</option>
|
||||
{organisations.map((org) => (
|
||||
<option key={org.objectId} value={org.name}>
|
||||
{org.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label mb-2">Hosting Type</label>
|
||||
<select
|
||||
value={filters.hostingType || ''}
|
||||
onChange={(e) => setHostingType(e.target.value || undefined)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">Alle types</option>
|
||||
{hostingTypes.map((type) => (
|
||||
<option key={type.objectId} value={type.name}>
|
||||
{type.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label mb-2">Business Importance</label>
|
||||
<select
|
||||
value={filters.businessImportance || ''}
|
||||
onChange={(e) => setBusinessImportance(e.target.value || undefined)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">Alle</option>
|
||||
{businessImportanceOptions.map((importance) => (
|
||||
<option key={importance.objectId} value={importance.name}>
|
||||
{importance.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results count */}
|
||||
<div className="px-4 py-3 bg-white border-b border-gray-200 flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600">
|
||||
{result ? (
|
||||
<>
|
||||
Resultaten: <strong>{result.totalCount}</strong> applicaties
|
||||
</>
|
||||
) : (
|
||||
'Laden...'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Results table */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-4 text-red-600">{error}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
#
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Naam
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
AppFunctie
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Governance
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Benodigde inspanning
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{result?.applications.map((app, index) => (
|
||||
<tr
|
||||
key={app.id}
|
||||
className="hover:bg-blue-50 transition-colors group"
|
||||
>
|
||||
<td className="py-0">
|
||||
<Link
|
||||
to={`/applications/${app.id}`}
|
||||
onClick={(e) => handleRowClick(app, index, e)}
|
||||
className="block px-4 py-3 text-sm text-gray-500"
|
||||
>
|
||||
{(currentPage - 1) * pageSize + index + 1}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-0">
|
||||
<Link
|
||||
to={`/applications/${app.id}`}
|
||||
onClick={(e) => handleRowClick(app, index, e)}
|
||||
className="block px-4 py-3"
|
||||
>
|
||||
<div className="text-sm font-medium text-blue-600 group-hover:text-blue-800 group-hover:underline">
|
||||
{app.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{app.key}</div>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-0">
|
||||
<Link
|
||||
to={`/applications/${app.id}`}
|
||||
onClick={(e) => handleRowClick(app, index, e)}
|
||||
className="block px-4 py-3"
|
||||
>
|
||||
<StatusBadge status={app.status} />
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-0">
|
||||
<Link
|
||||
to={`/applications/${app.id}`}
|
||||
onClick={(e) => handleRowClick(app, index, e)}
|
||||
className="block px-4 py-3"
|
||||
>
|
||||
{app.applicationFunctions && app.applicationFunctions.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{app.applicationFunctions.map((func) => (
|
||||
<span
|
||||
key={func.objectId}
|
||||
className="inline-block px-2 py-0.5 text-xs bg-blue-100 text-blue-800 rounded"
|
||||
title={func.description || func.name}
|
||||
>
|
||||
{func.name}{func.applicationFunctionCategory ? ` (${func.applicationFunctionCategory.name})` : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-orange-600 font-medium">
|
||||
Leeg
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-0">
|
||||
<Link
|
||||
to={`/applications/${app.id}`}
|
||||
onClick={(e) => handleRowClick(app, index, e)}
|
||||
className="block px-4 py-3"
|
||||
>
|
||||
{app.governanceModel ? (
|
||||
<span className="text-sm text-gray-900">
|
||||
{app.governanceModel.name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-orange-600 font-medium">
|
||||
Leeg
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-0">
|
||||
<Link
|
||||
to={`/applications/${app.id}`}
|
||||
onClick={(e) => handleRowClick(app, index, e)}
|
||||
className="block px-4 py-3 text-sm text-gray-900"
|
||||
>
|
||||
{app.requiredEffortApplicationManagement !== null ? (
|
||||
<span className="font-medium">
|
||||
{app.requiredEffortApplicationManagement.toFixed(2)} FTE
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400">-</span>
|
||||
)}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{result && result.totalPages > 1 && (
|
||||
<div className="px-4 py-3 bg-gray-50 border-t border-gray-200 flex items-center justify-between">
|
||||
{currentPage > 1 ? (
|
||||
<Link
|
||||
to={currentPage === 2 ? '/applications' : `/applications?page=${currentPage - 1}`}
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Vorige
|
||||
</Link>
|
||||
) : (
|
||||
<button disabled className="btn btn-secondary opacity-50 cursor-not-allowed">
|
||||
Vorige
|
||||
</button>
|
||||
)}
|
||||
<span className="text-sm text-gray-600">
|
||||
Pagina {currentPage} van {result.totalPages}
|
||||
</span>
|
||||
{currentPage < result.totalPages ? (
|
||||
<Link
|
||||
to={`/applications?page=${currentPage + 1}`}
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Volgende
|
||||
</Link>
|
||||
) : (
|
||||
<button disabled className="btn btn-secondary opacity-50 cursor-not-allowed">
|
||||
Volgende
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status: string | null }) {
|
||||
const statusColors: Record<string, string> = {
|
||||
'Closed': 'badge-dark-red',
|
||||
'Deprecated': 'badge-yellow',
|
||||
'End of life': 'badge-light-red',
|
||||
'End of support': 'badge-light-red',
|
||||
'Implementation': 'badge-blue',
|
||||
'In Production': 'badge-dark-green',
|
||||
'Proof of Concept': 'badge-light-green',
|
||||
'Shadow IT': 'badge-black',
|
||||
'Undefined': 'badge-gray',
|
||||
};
|
||||
|
||||
if (!status) return <span className="text-sm text-gray-400">-</span>;
|
||||
|
||||
return (
|
||||
<span className={clsx('badge', statusColors[status] || 'badge-gray')}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function BusinessImportanceBadge({ importance }: { importance: string | null }) {
|
||||
// Helper function to get the number prefix from the importance string
|
||||
const getImportanceNumber = (value: string | null): string | null => {
|
||||
if (!value) return null;
|
||||
// Match patterns like "0 - Critical Infrastructure" or just "0"
|
||||
const match = value.match(/^(\d+)/);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
const importanceNumber = getImportanceNumber(importance);
|
||||
|
||||
// Map importance number to icon type and color
|
||||
const getImportanceConfig = (num: string | null) => {
|
||||
switch (num) {
|
||||
case '0':
|
||||
return {
|
||||
icon: 'warning',
|
||||
color: 'badge-darker-red',
|
||||
label: importance || '0 - Critical Infrastructure',
|
||||
};
|
||||
case '1':
|
||||
return {
|
||||
icon: 'exclamation',
|
||||
color: 'badge-dark-red',
|
||||
label: importance || '1 - Critical',
|
||||
};
|
||||
case '2':
|
||||
return {
|
||||
icon: 'exclamation',
|
||||
color: 'badge-red',
|
||||
label: importance || '2 - Highest',
|
||||
};
|
||||
case '3':
|
||||
return {
|
||||
icon: 'circle',
|
||||
color: 'badge-yellow-orange',
|
||||
label: importance || '3 - High',
|
||||
};
|
||||
case '4':
|
||||
return {
|
||||
icon: 'circle',
|
||||
color: 'badge-dark-blue',
|
||||
label: importance || '4 - Medium',
|
||||
};
|
||||
case '5':
|
||||
return {
|
||||
icon: 'circle',
|
||||
color: 'badge-light-blue',
|
||||
label: importance || '5 - Low',
|
||||
};
|
||||
case '6':
|
||||
return {
|
||||
icon: 'circle',
|
||||
color: 'badge-lighter-blue',
|
||||
label: importance || '6 - Lowest',
|
||||
};
|
||||
case '9':
|
||||
return {
|
||||
icon: 'question',
|
||||
color: 'badge-black',
|
||||
label: importance || '9 - Unknown',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: null,
|
||||
color: 'badge-gray',
|
||||
label: importance || '-',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if (!importance) return <span className="text-sm text-gray-400">-</span>;
|
||||
|
||||
const config = getImportanceConfig(importanceNumber);
|
||||
|
||||
// Icon components
|
||||
const WarningIcon = () => (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ExclamationIcon = () => (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CircleIcon = () => (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<circle cx="10" cy="10" r="8" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const QuestionIcon = () => (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const renderIcon = () => {
|
||||
switch (config.icon) {
|
||||
case 'warning':
|
||||
return <WarningIcon />;
|
||||
case 'exclamation':
|
||||
return <ExclamationIcon />;
|
||||
case 'circle':
|
||||
return <CircleIcon />;
|
||||
case 'question':
|
||||
return <QuestionIcon />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={clsx('badge inline-flex items-center gap-1.5', config.color)}>
|
||||
{renderIcon()}
|
||||
<span>{config.label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
809
frontend/src/components/Configuration.tsx
Normal file
809
frontend/src/components/Configuration.tsx
Normal file
@@ -0,0 +1,809 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getEffortCalculationConfig, updateEffortCalculationConfig, getApplicationManagementHosting, getApplicationTypes, type EffortCalculationConfig } from '../services/api';
|
||||
import type { ReferenceValue } from '../types';
|
||||
|
||||
export default function Configuration() {
|
||||
const [config, setConfig] = useState<EffortCalculationConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [applicationManagementHosting, setApplicationManagementHosting] = useState<ReferenceValue[]>([]);
|
||||
const [applicationTypes, setApplicationTypes] = useState<ReferenceValue[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
loadReferenceData();
|
||||
}, []);
|
||||
|
||||
const loadReferenceData = async () => {
|
||||
try {
|
||||
const [hostingData, applicationTypesData] = await Promise.all([
|
||||
getApplicationManagementHosting(),
|
||||
getApplicationTypes(),
|
||||
]);
|
||||
setApplicationManagementHosting(hostingData);
|
||||
setApplicationTypes(applicationTypesData);
|
||||
} catch (err) {
|
||||
console.error('Failed to load reference data:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await getEffortCalculationConfig();
|
||||
setConfig(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load configuration');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!config) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
await updateEffortCalculationConfig(config);
|
||||
setSuccess('Configuration saved successfully!');
|
||||
setTimeout(() => setSuccess(null), 3000);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save configuration');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateDefaultResult = (value: number) => {
|
||||
if (!config) return;
|
||||
setConfig({
|
||||
...config,
|
||||
default: { result: value },
|
||||
});
|
||||
};
|
||||
|
||||
const updateGovernanceModelRule = (index: number, updates: Partial<EffortCalculationConfig['governanceModelRules'][0]>) => {
|
||||
if (!config) return;
|
||||
const newRules = [...config.governanceModelRules];
|
||||
newRules[index] = { ...newRules[index], ...updates };
|
||||
setConfig({ ...config, governanceModelRules: newRules });
|
||||
};
|
||||
|
||||
const addGovernanceModelRule = () => {
|
||||
if (!config) return;
|
||||
setConfig({
|
||||
...config,
|
||||
governanceModelRules: [
|
||||
...config.governanceModelRules,
|
||||
{
|
||||
governanceModel: 'New Governance Model',
|
||||
applicationTypeRules: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const removeGovernanceModelRule = (index: number) => {
|
||||
if (!config) return;
|
||||
const newRules = config.governanceModelRules.filter((_, i) => i !== index);
|
||||
setConfig({ ...config, governanceModelRules: newRules });
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading configuration...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="text-red-500">Failed to load configuration</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Basis FTE Configuration</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Configure the Required Effort Application Management calculation rules
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={loadConfig}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Configuration'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-md">
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="p-4 bg-green-50 border border-green-200 rounded-md">
|
||||
<p className="text-sm text-green-800">{success}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Default Result */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Default Result</h2>
|
||||
<div className="flex items-center space-x-4">
|
||||
<label className="text-sm font-medium text-gray-700">Result (FTE):</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={config.default.result}
|
||||
onChange={(e) => updateDefaultResult(parseFloat(e.target.value) || 0)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Governance Model Rules */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Governance Model Rules</h2>
|
||||
<button
|
||||
onClick={addGovernanceModelRule}
|
||||
className="px-3 py-1 text-sm font-medium text-blue-600 bg-blue-50 rounded-md hover:bg-blue-100"
|
||||
>
|
||||
+ Add Governance Model
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{[...config.governanceModelRules]
|
||||
.sort((a, b) => a.governanceModel.localeCompare(b.governanceModel))
|
||||
.map((rule, originalIndex) => {
|
||||
// Find the original index in the unsorted array
|
||||
const index = config.governanceModelRules.findIndex(r => r === rule);
|
||||
return (
|
||||
<GovernanceModelRuleEditor
|
||||
key={index}
|
||||
rule={rule}
|
||||
index={index}
|
||||
applicationManagementHosting={applicationManagementHosting}
|
||||
applicationTypes={applicationTypes}
|
||||
onUpdate={(updates) => updateGovernanceModelRule(index, updates)}
|
||||
onRemove={() => removeGovernanceModelRule(index)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface GovernanceModelRuleEditorProps {
|
||||
rule: EffortCalculationConfig['governanceModelRules'][0];
|
||||
index: number;
|
||||
applicationManagementHosting: ReferenceValue[];
|
||||
applicationTypes: ReferenceValue[];
|
||||
onUpdate: (updates: Partial<EffortCalculationConfig['governanceModelRules'][0]>) => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function GovernanceModelRuleEditor({ rule, applicationManagementHosting, applicationTypes, onUpdate, onRemove }: GovernanceModelRuleEditorProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const updateGovernanceModel = (value: string) => {
|
||||
onUpdate({ governanceModel: value });
|
||||
};
|
||||
|
||||
const updateDefaultResult = (value: number) => {
|
||||
onUpdate({ default: { result: value } });
|
||||
};
|
||||
|
||||
const updateApplicationTypeRule = (key: string, updates: any) => {
|
||||
const newRules = { ...rule.applicationTypeRules };
|
||||
if (updates === null) {
|
||||
delete newRules[key];
|
||||
} else {
|
||||
newRules[key] = { ...newRules[key], ...updates };
|
||||
}
|
||||
onUpdate({ applicationTypeRules: newRules });
|
||||
};
|
||||
|
||||
const addApplicationTypeRule = () => {
|
||||
const newKey = `New Application Type ${Object.keys(rule.applicationTypeRules).length + 1}`;
|
||||
const newRules = {
|
||||
...rule.applicationTypeRules,
|
||||
[newKey]: {
|
||||
applicationTypes: [],
|
||||
businessImpactRules: {},
|
||||
},
|
||||
};
|
||||
onUpdate({ applicationTypeRules: newRules });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg">
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{expanded ? '▼' : '▶'}
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
value={rule.governanceModel}
|
||||
onChange={(e) => updateGovernanceModel(e.target.value)}
|
||||
className="px-3 py-1 text-sm font-medium border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Governance Model Name"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="px-3 py-1 text-sm font-medium text-red-600 bg-red-50 rounded-md hover:bg-red-100"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Default Result for this Governance Model */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Default Result (FTE):
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={rule.default?.result ?? 0}
|
||||
onChange={(e) => updateDefaultResult(parseFloat(e.target.value) || 0)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Application Type Rules */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Application Type Rules
|
||||
</label>
|
||||
<button
|
||||
onClick={addApplicationTypeRule}
|
||||
className="px-2 py-1 text-xs font-medium text-blue-600 bg-blue-50 rounded-md hover:bg-blue-100"
|
||||
>
|
||||
+ Add Application Type
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(rule.applicationTypeRules).map(([key, appTypeRule]) => (
|
||||
<ApplicationTypeRuleEditor
|
||||
key={key}
|
||||
ruleKey={key}
|
||||
rule={appTypeRule}
|
||||
applicationManagementHosting={applicationManagementHosting}
|
||||
applicationTypes={applicationTypes}
|
||||
onUpdate={(updates) => updateApplicationTypeRule(key, updates)}
|
||||
onRemove={() => updateApplicationTypeRule(key, null)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ApplicationTypeRuleEditorProps {
|
||||
ruleKey: string;
|
||||
rule: EffortCalculationConfig['governanceModelRules'][0]['applicationTypeRules'][string];
|
||||
applicationManagementHosting: ReferenceValue[];
|
||||
applicationTypes: ReferenceValue[];
|
||||
onUpdate: (updates: any) => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function ApplicationTypeRuleEditor({ ruleKey, rule, applicationManagementHosting, applicationTypes, onUpdate, onRemove }: ApplicationTypeRuleEditorProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Check if rule is a simple EffortRule or ApplicationTypeRule
|
||||
const isSimpleRule = 'result' in rule && !('applicationTypes' in rule);
|
||||
|
||||
if (isSimpleRule) {
|
||||
// Simple EffortRule
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-md p-3 bg-gray-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-sm font-medium text-gray-700">{ruleKey}:</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={rule.result}
|
||||
onChange={(e) => onUpdate({ result: parseFloat(e.target.value) || 0 })}
|
||||
className="px-2 py-1 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">FTE</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="px-2 py-1 text-xs font-medium text-red-600 bg-red-50 rounded-md hover:bg-red-100"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Full ApplicationTypeRule
|
||||
const updateApplicationTypes = (selectedTypes: string[]) => {
|
||||
if (selectedTypes.length === 0) {
|
||||
// If no types selected, remove the entire rule
|
||||
onRemove();
|
||||
} else {
|
||||
onUpdate({ applicationTypes: selectedTypes.length === 1 ? selectedTypes[0] : selectedTypes });
|
||||
}
|
||||
};
|
||||
|
||||
const updateBusinessImpactRule = (key: string, updates: any) => {
|
||||
const newRules = { ...rule.businessImpactRules };
|
||||
if (updates === null) {
|
||||
delete newRules[key];
|
||||
} else {
|
||||
newRules[key] = updates;
|
||||
}
|
||||
onUpdate({ businessImpactRules: newRules });
|
||||
};
|
||||
|
||||
const addBusinessImpactRule = () => {
|
||||
// Find the next available Business Impact level (F, E, D, C, B, A)
|
||||
const availableLevels = ['F', 'E', 'D', 'C', 'B', 'A'];
|
||||
const existingKeys = Object.keys(rule.businessImpactRules);
|
||||
const nextLevel = availableLevels.find(level => !existingKeys.includes(level));
|
||||
|
||||
if (nextLevel) {
|
||||
const newRules = {
|
||||
...rule.businessImpactRules,
|
||||
[nextLevel]: { result: 0.1 },
|
||||
};
|
||||
onUpdate({ businessImpactRules: newRules });
|
||||
}
|
||||
};
|
||||
|
||||
const updateDefaultRule = (updates: any) => {
|
||||
onUpdate({ default: updates });
|
||||
};
|
||||
|
||||
const selectedApplicationTypeNames = Array.isArray(rule.applicationTypes)
|
||||
? rule.applicationTypes
|
||||
: rule.applicationTypes ? [rule.applicationTypes] : [];
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-md">
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{expanded ? '▼' : '▶'}
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<MultiSelect
|
||||
options={applicationTypes.map(at => at.name)}
|
||||
selected={selectedApplicationTypeNames}
|
||||
onChange={updateApplicationTypes}
|
||||
placeholder="Select Application Types"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="px-2 py-1 text-xs font-medium text-red-600 bg-red-50 rounded-md hover:bg-red-100 ml-2"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Business Impact Rules */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Business Impact Rules
|
||||
</label>
|
||||
<button
|
||||
onClick={addBusinessImpactRule}
|
||||
disabled={Object.keys(rule.businessImpactRules).length >= 6}
|
||||
className="px-2 py-1 text-xs font-medium text-blue-600 bg-blue-50 rounded-md hover:bg-blue-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={Object.keys(rule.businessImpactRules).length >= 6 ? 'All Business Impact levels (F, E, D, C, B, A) are already added' : 'Add next available Business Impact level'}
|
||||
>
|
||||
+ Add Business Impact
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(rule.businessImpactRules).map(([key, impactRule]) => (
|
||||
<BusinessImpactRuleEditor
|
||||
key={key}
|
||||
ruleKey={key}
|
||||
rule={impactRule}
|
||||
applicationManagementHosting={applicationManagementHosting}
|
||||
onUpdate={(updates) => updateBusinessImpactRule(key, updates)}
|
||||
onRemove={() => updateBusinessImpactRule(key, null)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Default Rule */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Default Rule
|
||||
</label>
|
||||
{!rule.default && (
|
||||
<button
|
||||
onClick={() => updateDefaultRule({ result: 0.1 })}
|
||||
className="px-2 py-1 text-xs font-medium text-blue-600 bg-blue-50 rounded-md hover:bg-blue-100"
|
||||
>
|
||||
+ Add Default Rule
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{rule.default ? (
|
||||
Array.isArray(rule.default) ? (
|
||||
<div className="space-y-2">
|
||||
{rule.default.map((r, index) => (
|
||||
<EffortRuleEditor
|
||||
key={index}
|
||||
rule={r}
|
||||
applicationManagementHosting={applicationManagementHosting}
|
||||
onUpdate={(updates) => {
|
||||
const newRules = [...rule.default as any[]];
|
||||
newRules[index] = { ...newRules[index], ...updates };
|
||||
updateDefaultRule(newRules);
|
||||
}}
|
||||
onRemove={rule.default.length > 1 ? () => {
|
||||
const newRules = (rule.default as any[]).filter((_, i) => i !== index);
|
||||
updateDefaultRule(newRules.length === 1 ? newRules[0] : newRules);
|
||||
} : undefined}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
onClick={() => {
|
||||
const newRules = [...(rule.default as any[]), { result: 0.1 }];
|
||||
updateDefaultRule(newRules);
|
||||
}}
|
||||
className="px-2 py-1 text-xs font-medium text-blue-600 bg-blue-50 rounded-md hover:bg-blue-100"
|
||||
>
|
||||
+ Add Rule
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<EffortRuleEditor
|
||||
rule={rule.default}
|
||||
applicationManagementHosting={applicationManagementHosting}
|
||||
onUpdate={updateDefaultRule}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 italic">No default rule set</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BusinessImpactRuleEditorProps {
|
||||
ruleKey: string;
|
||||
rule: EffortCalculationConfig['governanceModelRules'][0]['applicationTypeRules'][string]['businessImpactRules'][string];
|
||||
applicationManagementHosting: ReferenceValue[];
|
||||
onUpdate: (updates: any) => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function BusinessImpactRuleEditor({ ruleKey, rule, applicationManagementHosting, onUpdate, onRemove }: BusinessImpactRuleEditorProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const isArray = Array.isArray(rule);
|
||||
|
||||
if (!isArray) {
|
||||
// Simple EffortRule - convert to array format to support hosting type differentiation
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-md">
|
||||
<div className="flex items-center justify-between p-2 bg-gray-50">
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{expanded ? '▼' : '▶'}
|
||||
</button>
|
||||
<span className="text-sm font-medium text-gray-700">{ruleKey}:</span>
|
||||
{!expanded && (
|
||||
<>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={rule.result}
|
||||
onChange={(e) => onUpdate({ result: parseFloat(e.target.value) || 0 })}
|
||||
className="px-2 py-1 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">FTE</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="px-2 py-1 text-xs font-medium text-red-600 bg-red-50 rounded-md hover:bg-red-100"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="p-2 space-y-2">
|
||||
<EffortRuleEditor
|
||||
rule={rule}
|
||||
applicationManagementHosting={applicationManagementHosting}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Convert to array format to support multiple Application Management - Hosting rules
|
||||
const newRules = [{ ...rule }, { result: 0.1 }];
|
||||
onUpdate(newRules);
|
||||
}}
|
||||
className="px-2 py-1 text-xs font-medium text-blue-600 bg-blue-50 rounded-md hover:bg-blue-100"
|
||||
>
|
||||
+ Add Application Management - Hosting Rule
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Array of EffortRules
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-md">
|
||||
<div className="flex items-center justify-between p-2 bg-gray-50">
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{expanded ? '▼' : '▶'}
|
||||
</button>
|
||||
<span className="text-sm font-medium text-gray-700">{ruleKey} ({rule.length} rules)</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="px-2 py-1 text-xs font-medium text-red-600 bg-red-50 rounded-md hover:bg-red-100"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="p-2 space-y-2">
|
||||
{rule.map((r, index) => {
|
||||
const isDefault = !r.conditions?.applicationManagementHosting ||
|
||||
(Array.isArray(r.conditions.applicationManagementHosting) && r.conditions.applicationManagementHosting.length === 0);
|
||||
const isLastDefault = isDefault && index === rule.length - 1;
|
||||
return (
|
||||
<div key={index} className={isLastDefault ? 'border-l-4 border-blue-500 pl-2' : ''}>
|
||||
{isLastDefault && (
|
||||
<div className="text-xs font-medium text-blue-600 mb-1">Default Rule (no Application Management - Hosting match)</div>
|
||||
)}
|
||||
<EffortRuleEditor
|
||||
rule={r}
|
||||
applicationManagementHosting={applicationManagementHosting}
|
||||
onUpdate={(updates) => {
|
||||
const newRules = [...rule];
|
||||
newRules[index] = { ...newRules[index], ...updates };
|
||||
onUpdate(newRules);
|
||||
}}
|
||||
onRemove={rule.length > 1 ? () => {
|
||||
const newRules = rule.filter((_, i) => i !== index);
|
||||
onUpdate(newRules.length === 1 ? newRules[0] : newRules);
|
||||
} : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => {
|
||||
const newRules = [...rule, { result: 0.1 }];
|
||||
onUpdate(newRules);
|
||||
}}
|
||||
className="px-2 py-1 text-xs font-medium text-blue-600 bg-blue-50 rounded-md hover:bg-blue-100"
|
||||
>
|
||||
+ Add Application Management - Hosting Rule
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EffortRuleEditorProps {
|
||||
rule: {
|
||||
result: number;
|
||||
conditions?: {
|
||||
applicationManagementHosting?: string | string[];
|
||||
};
|
||||
};
|
||||
applicationManagementHosting: ReferenceValue[];
|
||||
onUpdate: (updates: any) => void;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
function EffortRuleEditor({ rule, applicationManagementHosting, onUpdate, onRemove }: EffortRuleEditorProps) {
|
||||
const selectedHostingTypeNames = rule.conditions?.applicationManagementHosting
|
||||
? Array.isArray(rule.conditions.applicationManagementHosting)
|
||||
? rule.conditions.applicationManagementHosting
|
||||
: [rule.conditions.applicationManagementHosting]
|
||||
: [];
|
||||
|
||||
const updateHostingTypes = (selectedTypes: string[]) => {
|
||||
onUpdate({
|
||||
conditions: {
|
||||
...rule.conditions,
|
||||
applicationManagementHosting: selectedTypes.length === 1 ? selectedTypes[0] : selectedTypes.length > 0 ? selectedTypes : undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-md p-2 bg-white">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<label className="text-sm font-medium text-gray-700">Result (FTE):</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={rule.result}
|
||||
onChange={(e) => onUpdate({ result: parseFloat(e.target.value) || 0 })}
|
||||
className="px-2 py-1 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
{onRemove && (
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="px-2 py-1 text-xs font-medium text-red-600 bg-red-50 rounded-md hover:bg-red-100 ml-2"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Application Management - Hosting (optional):</label>
|
||||
<MultiSelect
|
||||
options={applicationManagementHosting.map(ht => ht.name)}
|
||||
selected={selectedHostingTypeNames}
|
||||
onChange={updateHostingTypes}
|
||||
placeholder="Select Application Management - Hosting"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// MultiSelect Component
|
||||
interface MultiSelectProps {
|
||||
options: string[];
|
||||
selected: string[];
|
||||
onChange: (selected: string[]) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
function MultiSelect({ options, selected, onChange, placeholder = 'Select options' }: MultiSelectProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const filteredOptions = options.filter(opt =>
|
||||
opt.toLowerCase().includes(filter.toLowerCase())
|
||||
);
|
||||
|
||||
const toggleOption = (option: string) => {
|
||||
if (selected.includes(option)) {
|
||||
onChange(selected.filter(s => s !== option));
|
||||
} else {
|
||||
onChange([...selected, option]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full px-3 py-2 text-left text-sm border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 min-h-[2.5rem] flex items-center justify-between"
|
||||
>
|
||||
{selected.length === 0 ? (
|
||||
<span className="text-gray-500">{placeholder}</span>
|
||||
) : (
|
||||
<span className="text-gray-900 flex-1 text-left">
|
||||
{selected.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-2 flex-shrink-0">{isOpen ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-64 overflow-auto">
|
||||
<div className="p-2 border-b border-gray-200">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="w-full px-2 py-1 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
<div className="py-1">
|
||||
{filteredOptions.length === 0 ? (
|
||||
<p className="px-3 py-2 text-sm text-gray-500">No options found</p>
|
||||
) : (
|
||||
filteredOptions.map((option) => {
|
||||
const isSelected = selected.includes(option);
|
||||
return (
|
||||
<label
|
||||
key={option}
|
||||
className={`flex items-center px-3 py-2 cursor-pointer hover:bg-gray-50 ${
|
||||
isSelected ? 'bg-blue-50' : ''
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleOption(option)}
|
||||
className="mr-3 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span className="text-sm text-gray-900">{option}</span>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
529
frontend/src/components/ConfigurationV25.tsx
Normal file
529
frontend/src/components/ConfigurationV25.tsx
Normal file
@@ -0,0 +1,529 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
getEffortCalculationConfigV25,
|
||||
updateEffortCalculationConfigV25,
|
||||
getApplicationManagementHosting,
|
||||
getApplicationTypes,
|
||||
getBusinessImpactAnalyses,
|
||||
getGovernanceModels,
|
||||
type EffortCalculationConfigV25,
|
||||
type GovernanceModelConfigV25,
|
||||
type ApplicationTypeConfigV25,
|
||||
type BIALevelConfig,
|
||||
type FTERange,
|
||||
} from '../services/api';
|
||||
import type { ReferenceValue } from '../types';
|
||||
|
||||
export default function ConfigurationV25() {
|
||||
const [config, setConfig] = useState<EffortCalculationConfigV25 | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
// Reference data from Jira Assets
|
||||
const [hostingOptions, setHostingOptions] = useState<ReferenceValue[]>([]);
|
||||
const [applicationTypeOptions, setApplicationTypeOptions] = useState<ReferenceValue[]>([]);
|
||||
const [biaOptions, setBiaOptions] = useState<ReferenceValue[]>([]);
|
||||
const [governanceOptions, setGovernanceOptions] = useState<ReferenceValue[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
loadReferenceData();
|
||||
}, []);
|
||||
|
||||
const loadReferenceData = async () => {
|
||||
try {
|
||||
const [hosting, appTypes, bia, governance] = await Promise.all([
|
||||
getApplicationManagementHosting(),
|
||||
getApplicationTypes(),
|
||||
getBusinessImpactAnalyses(),
|
||||
getGovernanceModels(),
|
||||
]);
|
||||
setHostingOptions(hosting);
|
||||
setApplicationTypeOptions(appTypes);
|
||||
setBiaOptions(bia);
|
||||
setGovernanceOptions(governance);
|
||||
} catch (err) {
|
||||
console.error('Failed to load reference data:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await getEffortCalculationConfigV25();
|
||||
setConfig(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load configuration');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!config) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
await updateEffortCalculationConfigV25(config);
|
||||
setSuccess('Configuration v25 saved successfully!');
|
||||
setTimeout(() => setSuccess(null), 3000);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save configuration');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateRegieModel = (code: string, updates: Partial<GovernanceModelConfigV25>) => {
|
||||
if (!config) return;
|
||||
setConfig({
|
||||
...config,
|
||||
regiemodellen: {
|
||||
...config.regiemodellen,
|
||||
[code]: {
|
||||
...config.regiemodellen[code],
|
||||
...updates,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const updateApplicationType = (regieModelCode: string, appType: string, updates: Partial<ApplicationTypeConfigV25>) => {
|
||||
if (!config) return;
|
||||
const regieModel = config.regiemodellen[regieModelCode];
|
||||
if (!regieModel) return;
|
||||
|
||||
setConfig({
|
||||
...config,
|
||||
regiemodellen: {
|
||||
...config.regiemodellen,
|
||||
[regieModelCode]: {
|
||||
...regieModel,
|
||||
applicationTypes: {
|
||||
...regieModel.applicationTypes,
|
||||
[appType]: {
|
||||
...regieModel.applicationTypes[appType],
|
||||
...updates,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading configuration v25...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="text-red-500">Failed to load configuration</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">FTE Configuration v25</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Configure the Required Effort Application Management calculation (Dienstencatalogus Applicatiebeheer v25)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={loadConfig}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Configuration'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-md">
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="p-4 bg-green-50 border border-green-200 rounded-md">
|
||||
<p className="text-sm text-green-800">{success}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Configuration Info</h2>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div><span className="font-medium">Version:</span> {config.metadata.version}</div>
|
||||
<div><span className="font-medium">Date:</span> {config.metadata.date}</div>
|
||||
<div className="col-span-2"><span className="font-medium">Formula:</span> {config.metadata.formula}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation Rules Summary */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Validation Rules</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">BIA vs Regiemodel Constraints</h3>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
{Object.entries(config.validationRules.biaRegieModelConstraints).map(([model, biaLevels]) => (
|
||||
<div key={model} className="bg-gray-50 p-2 rounded">
|
||||
<span className="font-medium">{model}:</span> {biaLevels.join(', ')}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">Platform Restrictions</h3>
|
||||
<div className="space-y-1 text-xs">
|
||||
{config.validationRules.platformRestrictions.map((r, i) => (
|
||||
<div key={i} className="bg-yellow-50 p-2 rounded">
|
||||
<span className="font-medium">{r.regiemodel} + {r.applicationType}:</span> {r.warning}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Regiemodellen */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Regiemodellen</h2>
|
||||
{Object.entries(config.regiemodellen)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([code, model]) => (
|
||||
<RegieModelEditor
|
||||
key={code}
|
||||
code={code}
|
||||
model={model}
|
||||
hostingOptions={hostingOptions}
|
||||
applicationTypeOptions={applicationTypeOptions}
|
||||
biaOptions={biaOptions}
|
||||
onUpdate={(updates) => updateRegieModel(code, updates)}
|
||||
onUpdateAppType={(appType, updates) => updateApplicationType(code, appType, updates)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RegieModelEditorProps {
|
||||
code: string;
|
||||
model: GovernanceModelConfigV25;
|
||||
hostingOptions: ReferenceValue[];
|
||||
applicationTypeOptions: ReferenceValue[];
|
||||
biaOptions: ReferenceValue[];
|
||||
onUpdate: (updates: Partial<GovernanceModelConfigV25>) => void;
|
||||
onUpdateAppType: (appType: string, updates: Partial<ApplicationTypeConfigV25>) => void;
|
||||
}
|
||||
|
||||
function RegieModelEditor({
|
||||
code,
|
||||
model,
|
||||
hostingOptions,
|
||||
applicationTypeOptions,
|
||||
biaOptions,
|
||||
onUpdate,
|
||||
onUpdateAppType
|
||||
}: RegieModelEditorProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
<div
|
||||
className="flex items-center justify-between p-4 bg-blue-50 cursor-pointer hover:bg-blue-100"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-lg">{expanded ? '▼' : '▶'}</span>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Regiemodel {code}: {model.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">{model.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-sm">
|
||||
<div className="font-medium">Default FTE: {model.defaultFte.min} - {model.defaultFte.max}</div>
|
||||
<div className="text-gray-500">Allowed BIA: {model.allowedBia.join(', ')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="p-4 space-y-4 border-t">
|
||||
{/* Default FTE Range */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<label className="text-sm font-medium text-gray-700">Default FTE Range:</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={model.defaultFte.min}
|
||||
onChange={(e) => onUpdate({
|
||||
defaultFte: { ...model.defaultFte, min: parseFloat(e.target.value) || 0 }
|
||||
})}
|
||||
className="w-20 px-2 py-1 text-sm border border-gray-300 rounded"
|
||||
/>
|
||||
<span>-</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={model.defaultFte.max}
|
||||
onChange={(e) => onUpdate({
|
||||
defaultFte: { ...model.defaultFte, max: parseFloat(e.target.value) || 0 }
|
||||
})}
|
||||
className="w-20 px-2 py-1 text-sm border border-gray-300 rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Application Types */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-gray-700 border-b pb-2">Application Types</h4>
|
||||
{Object.entries(model.applicationTypes).map(([appType, appConfig]) => (
|
||||
<ApplicationTypeEditor
|
||||
key={appType}
|
||||
appType={appType}
|
||||
config={appConfig}
|
||||
hostingOptions={hostingOptions}
|
||||
biaOptions={biaOptions}
|
||||
onUpdate={(updates) => onUpdateAppType(appType, updates)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ApplicationTypeEditorProps {
|
||||
appType: string;
|
||||
config: ApplicationTypeConfigV25;
|
||||
hostingOptions: ReferenceValue[];
|
||||
biaOptions: ReferenceValue[];
|
||||
onUpdate: (updates: Partial<ApplicationTypeConfigV25>) => void;
|
||||
}
|
||||
|
||||
function ApplicationTypeEditor({ appType, config, hostingOptions, biaOptions, onUpdate }: ApplicationTypeEditorProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg">
|
||||
<div
|
||||
className="flex items-center justify-between p-3 bg-gray-50 cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-sm">{expanded ? '▼' : '▶'}</span>
|
||||
<span className="font-medium text-gray-800">{appType}</span>
|
||||
{config.fixedFte && <span className="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded">Vast</span>}
|
||||
{config.requiresManualAssessment && <span className="text-xs bg-orange-100 text-orange-700 px-2 py-0.5 rounded">Handmatig</span>}
|
||||
{config.notRecommended && <span className="text-xs bg-red-100 text-red-700 px-2 py-0.5 rounded">Niet aanbevolen</span>}
|
||||
</div>
|
||||
{config.defaultFte && (
|
||||
<span className="text-sm text-gray-600">
|
||||
Default: {config.defaultFte.min} - {config.defaultFte.max} FTE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="p-3 space-y-3 border-t">
|
||||
{/* Flags */}
|
||||
<div className="flex items-center space-x-4 text-sm">
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.fixedFte || false}
|
||||
onChange={(e) => onUpdate({ fixedFte: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>Fixed FTE</span>
|
||||
</label>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.requiresManualAssessment || false}
|
||||
onChange={(e) => onUpdate({ requiresManualAssessment: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>Requires Manual Assessment</span>
|
||||
</label>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.notRecommended || false}
|
||||
onChange={(e) => onUpdate({ notRecommended: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>Not Recommended</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Default FTE */}
|
||||
{config.defaultFte && (
|
||||
<div className="flex items-center space-x-4">
|
||||
<label className="text-sm font-medium text-gray-700">Default FTE:</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={config.defaultFte.min}
|
||||
onChange={(e) => onUpdate({
|
||||
defaultFte: { ...config.defaultFte!, min: parseFloat(e.target.value) || 0 }
|
||||
})}
|
||||
className="w-16 px-2 py-1 text-sm border border-gray-300 rounded"
|
||||
/>
|
||||
<span>-</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={config.defaultFte.max}
|
||||
onChange={(e) => onUpdate({
|
||||
defaultFte: { ...config.defaultFte!, max: parseFloat(e.target.value) || 0 }
|
||||
})}
|
||||
className="w-16 px-2 py-1 text-sm border border-gray-300 rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* BIA Levels */}
|
||||
<div className="space-y-2">
|
||||
<h5 className="text-sm font-medium text-gray-700">BIA Levels</h5>
|
||||
{Object.entries(config.biaLevels).map(([biaLevel, biaConfig]) => (
|
||||
<BIALevelEditor
|
||||
key={biaLevel}
|
||||
biaLevel={biaLevel}
|
||||
config={biaConfig}
|
||||
hostingOptions={hostingOptions}
|
||||
onUpdate={(updates) => {
|
||||
const newBiaLevels = { ...config.biaLevels };
|
||||
newBiaLevels[biaLevel] = { ...newBiaLevels[biaLevel], ...updates };
|
||||
onUpdate({ biaLevels: newBiaLevels });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{config.note && (
|
||||
<div className="text-xs text-gray-500 italic bg-gray-50 p-2 rounded">
|
||||
Note: {config.note}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BIALevelEditorProps {
|
||||
biaLevel: string;
|
||||
config: BIALevelConfig;
|
||||
hostingOptions: ReferenceValue[];
|
||||
onUpdate: (updates: Partial<BIALevelConfig>) => void;
|
||||
}
|
||||
|
||||
function BIALevelEditor({ biaLevel, config, hostingOptions, onUpdate }: BIALevelEditorProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="border border-gray-100 rounded bg-gray-50">
|
||||
<div
|
||||
className="flex items-center justify-between p-2 cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs">{expanded ? '▼' : '▶'}</span>
|
||||
<span className="text-sm font-medium">
|
||||
{biaLevel === '_all' ? 'All BIA Levels' : `BIA ${biaLevel}`}
|
||||
</span>
|
||||
{config.description && (
|
||||
<span className="text-xs text-gray-500">- {config.description}</span>
|
||||
)}
|
||||
</div>
|
||||
{config.defaultFte && (
|
||||
<span className="text-xs text-gray-600">
|
||||
Default: {config.defaultFte.min} - {config.defaultFte.max}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="p-2 space-y-2 border-t bg-white">
|
||||
{/* Hosting Rules */}
|
||||
<div className="space-y-1">
|
||||
<h6 className="text-xs font-medium text-gray-600">Hosting Rules</h6>
|
||||
{Object.entries(config.hosting).map(([hostingKey, hostingRule]) => (
|
||||
<div key={hostingKey} className="flex items-center space-x-2 text-xs bg-blue-50 p-2 rounded">
|
||||
<span className="font-medium min-w-24">{hostingKey === '_all' ? 'All Hosting' : hostingKey}:</span>
|
||||
<span className="text-gray-600">
|
||||
[{hostingRule.hostingValues.join(', ')}]
|
||||
</span>
|
||||
<span className="ml-auto">
|
||||
FTE: {hostingRule.fte.min} - {hostingRule.fte.max}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={hostingRule.fte.min}
|
||||
onChange={(e) => {
|
||||
const newHosting = { ...config.hosting };
|
||||
newHosting[hostingKey] = {
|
||||
...newHosting[hostingKey],
|
||||
fte: { ...newHosting[hostingKey].fte, min: parseFloat(e.target.value) || 0 }
|
||||
};
|
||||
onUpdate({ hosting: newHosting });
|
||||
}}
|
||||
className="w-14 px-1 py-0.5 text-xs border border-gray-300 rounded"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span>-</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={hostingRule.fte.max}
|
||||
onChange={(e) => {
|
||||
const newHosting = { ...config.hosting };
|
||||
newHosting[hostingKey] = {
|
||||
...newHosting[hostingKey],
|
||||
fte: { ...newHosting[hostingKey].fte, max: parseFloat(e.target.value) || 0 }
|
||||
};
|
||||
onUpdate({ hosting: newHosting });
|
||||
}}
|
||||
className="w-14 px-1 py-0.5 text-xs border border-gray-300 rounded"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
199
frontend/src/components/CustomSelect.tsx
Normal file
199
frontend/src/components/CustomSelect.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { ReferenceValue } from '../types';
|
||||
|
||||
interface CustomSelectProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: ReferenceValue[];
|
||||
placeholder?: string;
|
||||
showSummary?: boolean;
|
||||
showRemarks?: boolean; // Show description + remarks concatenated
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Helper function to get display text for an option
|
||||
function getDisplayText(option: ReferenceValue, showSummary: boolean, showRemarks: boolean): string | null {
|
||||
if (showRemarks) {
|
||||
// Concatenate description and remarks with ". "
|
||||
const parts: string[] = [];
|
||||
if (option.description) parts.push(option.description);
|
||||
if (option.remarks) parts.push(option.remarks);
|
||||
return parts.length > 0 ? parts.join('. ') : null;
|
||||
}
|
||||
if (showSummary && option.summary) {
|
||||
return option.summary;
|
||||
}
|
||||
if (showSummary && !option.summary && option.description) {
|
||||
return option.description;
|
||||
}
|
||||
if (!showSummary && option.description) {
|
||||
return option.description;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function CustomSelect({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = 'Selecteer...',
|
||||
showSummary = false,
|
||||
showRemarks = false,
|
||||
className = '',
|
||||
}: CustomSelectProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
const selectRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selectedOption = options.find((opt) => opt.objectId === value);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
selectRef.current &&
|
||||
!selectRef.current.contains(event.target as Node) &&
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && dropdownRef.current) {
|
||||
const selectedElement = dropdownRef.current.querySelector('[data-selected="true"]');
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSelect = (option: ReferenceValue) => {
|
||||
onChange(option.objectId);
|
||||
setIsOpen(false);
|
||||
setHighlightedIndex(-1);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (highlightedIndex >= 0 && highlightedIndex < options.length) {
|
||||
handleSelect(options[highlightedIndex]);
|
||||
} else {
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setIsOpen(true);
|
||||
setHighlightedIndex((prev) =>
|
||||
prev < options.length - 1 ? prev + 1 : prev
|
||||
);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : -1));
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
setHighlightedIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={selectRef}>
|
||||
<div
|
||||
role="combobox"
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`w-full border border-gray-300 rounded-lg px-3 py-2 pr-10 bg-white cursor-pointer focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${className}`}
|
||||
>
|
||||
{selectedOption ? (
|
||||
<div className="pr-2">
|
||||
<div className="font-medium text-gray-900">{selectedOption.name}</div>
|
||||
{(() => {
|
||||
const displayText = getDisplayText(selectedOption, showSummary, showRemarks);
|
||||
return displayText ? (
|
||||
<div className="text-xs text-gray-500 mt-0.5 whitespace-normal break-words">
|
||||
{displayText}
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-500">{placeholder}</span>
|
||||
)}
|
||||
<svg
|
||||
className={`absolute right-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400 transition-transform ${
|
||||
isOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-60 overflow-auto"
|
||||
role="listbox"
|
||||
>
|
||||
{options.length === 0 ? (
|
||||
<div className="px-3 py-2 text-gray-500 text-sm">Geen opties beschikbaar</div>
|
||||
) : (
|
||||
options.map((option, index) => {
|
||||
const isSelected = option.objectId === value;
|
||||
const isHighlighted = index === highlightedIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.objectId}
|
||||
data-selected={isSelected}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onClick={() => handleSelect(option)}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
className={`px-3 py-2 cursor-pointer transition-colors ${
|
||||
isSelected
|
||||
? 'bg-blue-50 text-blue-900'
|
||||
: isHighlighted
|
||||
? 'bg-gray-100'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-gray-900">{option.name}</div>
|
||||
{(() => {
|
||||
const displayText = getDisplayText(option, showSummary, showRemarks);
|
||||
return displayText ? (
|
||||
<div className="text-xs text-gray-600 mt-0.5 whitespace-normal break-words">
|
||||
{displayText}
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
299
frontend/src/components/Dashboard.tsx
Normal file
299
frontend/src/components/Dashboard.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getDashboardStats, getRecentClassifications } from '../services/api';
|
||||
import type { DashboardStats, ClassificationResult } from '../types';
|
||||
|
||||
// Extended type to include stale indicator from API
|
||||
interface DashboardStatsWithMeta extends DashboardStats {
|
||||
stale?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [stats, setStats] = useState<DashboardStatsWithMeta | null>(null);
|
||||
const [recentClassifications, setRecentClassifications] = useState<ClassificationResult[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchData = useCallback(async (forceRefresh: boolean = false) => {
|
||||
if (forceRefresh) {
|
||||
setRefreshing(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const [statsData, recentData] = await Promise.all([
|
||||
getDashboardStats(forceRefresh),
|
||||
getRecentClassifications(10),
|
||||
]);
|
||||
setStats(statsData as DashboardStatsWithMeta);
|
||||
setRecentClassifications(recentData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load dashboard');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(false);
|
||||
}, [fetchData]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchData(true);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const progressPercentage = stats
|
||||
? Math.round((stats.classifiedCount / stats.totalApplications) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page header */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">Dashboard</h2>
|
||||
<p className="text-gray-600">
|
||||
Overzicht van de ZiRA classificatie voortgang
|
||||
{stats?.stale && (
|
||||
<span className="ml-2 text-amber-600 text-sm">
|
||||
(gecachte data - API timeout)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="btn btn-secondary flex items-center space-x-2"
|
||||
title="Ververs data"
|
||||
>
|
||||
<svg
|
||||
className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
<span>{refreshing ? 'Laden...' : 'Ververs'}</span>
|
||||
</button>
|
||||
<Link to="/applications" className="btn btn-primary">
|
||||
Start classificeren
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="card p-6">
|
||||
<div className="text-sm text-gray-500 mb-1">Totaal applicaties</div>
|
||||
<div className="text-3xl font-bold text-gray-900">
|
||||
{stats?.totalApplications || 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-6">
|
||||
<div className="text-sm text-gray-500 mb-1">Geclassificeerd</div>
|
||||
<div className="text-3xl font-bold text-green-600">
|
||||
{stats?.classifiedCount || 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-6">
|
||||
<div className="text-sm text-gray-500 mb-1">Nog te classificeren</div>
|
||||
<div className="text-3xl font-bold text-orange-600">
|
||||
{Math.max(0, stats?.unclassifiedCount || 0)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-6">
|
||||
<div className="text-sm text-gray-500 mb-1">Voortgang</div>
|
||||
<div className="text-3xl font-bold text-blue-600">
|
||||
{progressPercentage}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="card p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||
Classificatie voortgang
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm text-gray-600">
|
||||
<span>ApplicationFunction ingevuld</span>
|
||||
<span>
|
||||
{stats?.classifiedCount || 0} / {stats?.totalApplications || 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-4">
|
||||
<div
|
||||
className="bg-blue-600 h-4 rounded-full transition-all duration-500"
|
||||
style={{ width: `${progressPercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two column layout */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Status distribution */}
|
||||
<div className="card p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||
Verdeling per status
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{stats?.byStatus &&
|
||||
Object.entries(stats.byStatus)
|
||||
.sort((a, b) => {
|
||||
// Sort alphabetically, but put "Undefined" at the end
|
||||
if (a[0] === 'Undefined') return 1;
|
||||
if (b[0] === 'Undefined') return -1;
|
||||
return a[0].localeCompare(b[0], 'nl', { sensitivity: 'base' });
|
||||
})
|
||||
.map(([status, count]) => (
|
||||
<div key={status} className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">{status}</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-32 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full"
|
||||
style={{
|
||||
width: `${(count / (stats?.totalApplications || 1)) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-900 w-8 text-right">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Governance model distribution */}
|
||||
<div className="card p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||
Verdeling per regiemodel
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{stats?.byGovernanceModel &&
|
||||
Object.entries(stats.byGovernanceModel)
|
||||
.sort((a, b) => {
|
||||
// Sort alphabetically, but put "Niet ingesteld" at the end
|
||||
if (a[0] === 'Niet ingesteld') return 1;
|
||||
if (b[0] === 'Niet ingesteld') return -1;
|
||||
return a[0].localeCompare(b[0], 'nl', { sensitivity: 'base' });
|
||||
})
|
||||
.map(([model, count]) => (
|
||||
<div key={model} className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">{model}</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-32 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-purple-600 h-2 rounded-full"
|
||||
style={{
|
||||
width: `${(count / (stats?.totalApplications || 1)) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-900 w-8 text-right">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(!stats?.byGovernanceModel ||
|
||||
Object.keys(stats.byGovernanceModel).length === 0) && (
|
||||
<p className="text-sm text-gray-500">Geen data beschikbaar</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent classifications */}
|
||||
<div className="card">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
Recente classificaties
|
||||
</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-200">
|
||||
{recentClassifications.length === 0 ? (
|
||||
<div className="px-6 py-8 text-center text-gray-500">
|
||||
Nog geen classificaties uitgevoerd
|
||||
</div>
|
||||
) : (
|
||||
recentClassifications.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="px-6 py-4 flex items-center justify-between hover:bg-gray-50"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">
|
||||
{item.applicationName}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{item.changes.applicationFunctions && item.changes.applicationFunctions.to.length > 0 && (
|
||||
<span>
|
||||
ApplicationFunctions: {item.changes.applicationFunctions.to.map((f) => f.name).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<span
|
||||
className={`badge ${
|
||||
item.source === 'AI_ACCEPTED'
|
||||
? 'badge-green'
|
||||
: item.source === 'AI_MODIFIED'
|
||||
? 'badge-yellow'
|
||||
: 'badge-blue'
|
||||
}`}
|
||||
>
|
||||
{item.source === 'AI_ACCEPTED'
|
||||
? 'AI Geaccepteerd'
|
||||
: item.source === 'AI_MODIFIED'
|
||||
? 'AI Aangepast'
|
||||
: 'Handmatig'}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{new Date(item.timestamp).toLocaleString('nl-NL')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
908
frontend/src/components/TeamDashboard.tsx
Normal file
908
frontend/src/components/TeamDashboard.tsx
Normal file
@@ -0,0 +1,908 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getTeamDashboardData, getReferenceData } from '../services/api';
|
||||
import type { TeamDashboardData, TeamDashboardCluster, ApplicationStatus, ReferenceValue } from '../types';
|
||||
|
||||
const ALL_STATUSES: ApplicationStatus[] = [
|
||||
'In Production',
|
||||
'Implementation',
|
||||
'Proof of Concept',
|
||||
'End of support',
|
||||
'End of life',
|
||||
'Deprecated',
|
||||
'Shadow IT',
|
||||
'Closed',
|
||||
'Undefined',
|
||||
];
|
||||
|
||||
type SortOption = 'alphabetical' | 'fte-descending';
|
||||
|
||||
// Color scheme for governance models - matches exact names from Jira Assets
|
||||
const GOVERNANCE_MODEL_COLORS: Record<string, { bg: string; text: string; letter: string }> = {
|
||||
'Regiemodel A': { bg: '#20556B', text: '#FFFFFF', letter: 'A' },
|
||||
'Regiemodel B': { bg: '#286B86', text: '#FFFFFF', letter: 'B' },
|
||||
'Regiemodel B+': { bg: '#286B86', text: '#FFFFFF', letter: 'B+' },
|
||||
'Regiemodel C': { bg: '#81CBF2', text: '#20556B', letter: 'C' },
|
||||
'Regiemodel D': { bg: '#F5A733', text: '#FFFFFF', letter: 'D' },
|
||||
'Regiemodel E': { bg: '#E95053', text: '#FFFFFF', letter: 'E' },
|
||||
'Niet ingesteld': { bg: '#EEEEEE', text: '#AAAAAA', letter: '?' },
|
||||
};
|
||||
|
||||
// Get governance model colors and letter - with fallback for unknown models
|
||||
const getGovernanceModelStyle = (governanceModelName: string | null | undefined) => {
|
||||
const name = governanceModelName || 'Niet ingesteld';
|
||||
|
||||
// First try exact match
|
||||
if (GOVERNANCE_MODEL_COLORS[name]) {
|
||||
return GOVERNANCE_MODEL_COLORS[name];
|
||||
}
|
||||
|
||||
// Try to match by pattern (e.g., "Regiemodel X" -> letter X)
|
||||
const match = name.match(/Regiemodel\s+(.+)/i);
|
||||
if (match) {
|
||||
const letter = match[1];
|
||||
// Return a color based on the letter
|
||||
if (letter === 'A') return { bg: '#20556B', text: '#FFFFFF', letter: 'A' };
|
||||
if (letter === 'B') return { bg: '#286B86', text: '#FFFFFF', letter: 'B' };
|
||||
if (letter === 'B+') return { bg: '#286B86', text: '#FFFFFF', letter: 'B+' };
|
||||
if (letter === 'C') return { bg: '#81CBF2', text: '#20556B', letter: 'C' };
|
||||
if (letter === 'D') return { bg: '#F5A733', text: '#FFFFFF', letter: 'D' };
|
||||
if (letter === 'E') return { bg: '#E95053', text: '#FFFFFF', letter: 'E' };
|
||||
return { bg: '#6B7280', text: '#FFFFFF', letter };
|
||||
}
|
||||
|
||||
return { bg: '#6B7280', text: '#FFFFFF', letter: '?' };
|
||||
};
|
||||
|
||||
export default function TeamDashboard() {
|
||||
const [data, setData] = useState<TeamDashboardData | null>(null);
|
||||
const [initialLoading, setInitialLoading] = useState(true); // Only for first load
|
||||
const [dataLoading, setDataLoading] = useState(false); // For filter changes
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedClusters, setExpandedClusters] = useState<Set<string>>(new Set()); // Start with all clusters collapsed
|
||||
const [expandedPlatforms, setExpandedPlatforms] = useState<Set<string>>(new Set()); // Track expanded platforms
|
||||
// Status filter: excludedStatuses contains statuses that are NOT shown
|
||||
const [excludedStatuses, setExcludedStatuses] = useState<ApplicationStatus[]>(['Closed', 'Deprecated']); // Default: exclude Closed and Deprecated
|
||||
const [sortOption, setSortOption] = useState<SortOption>('fte-descending');
|
||||
const [statusDropdownOpen, setStatusDropdownOpen] = useState(false);
|
||||
const [governanceModels, setGovernanceModels] = useState<ReferenceValue[]>([]);
|
||||
const [hoveredGovModel, setHoveredGovModel] = useState<string | null>(null);
|
||||
|
||||
// Fetch governance models on mount
|
||||
useEffect(() => {
|
||||
async function fetchGovernanceModels() {
|
||||
try {
|
||||
const refData = await getReferenceData();
|
||||
setGovernanceModels(refData.governanceModels);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch governance models:', err);
|
||||
}
|
||||
}
|
||||
fetchGovernanceModels();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
// Only show full page loading on initial load
|
||||
const isInitialLoad = data === null;
|
||||
if (isInitialLoad) {
|
||||
setInitialLoading(true);
|
||||
} else {
|
||||
setDataLoading(true);
|
||||
}
|
||||
const dashboardData = await getTeamDashboardData(excludedStatuses);
|
||||
setData(dashboardData);
|
||||
// Keep clusters collapsed by default (expandedClusters remains empty)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load team dashboard');
|
||||
} finally {
|
||||
setInitialLoading(false);
|
||||
setDataLoading(false);
|
||||
}
|
||||
}
|
||||
fetchData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [excludedStatuses]);
|
||||
|
||||
// Close status dropdown when pressing Escape
|
||||
useEffect(() => {
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && statusDropdownOpen) {
|
||||
setStatusDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (statusDropdownOpen) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [statusDropdownOpen]);
|
||||
|
||||
const toggleCluster = (clusterId: string, event?: React.MouseEvent) => {
|
||||
// Prevent scroll jump by storing and restoring scroll position
|
||||
const scrollY = window.scrollY;
|
||||
|
||||
setExpandedClusters(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(clusterId)) {
|
||||
newSet.delete(clusterId);
|
||||
} else {
|
||||
newSet.add(clusterId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
|
||||
// Use requestAnimationFrame to restore scroll position after state update
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollTo(0, scrollY);
|
||||
});
|
||||
};
|
||||
|
||||
const togglePlatform = (platformId: string, e?: React.MouseEvent) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
setExpandedPlatforms(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(platformId)) {
|
||||
newSet.delete(platformId);
|
||||
} else {
|
||||
newSet.add(platformId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleStatus = (status: ApplicationStatus) => {
|
||||
setExcludedStatuses(prev => {
|
||||
if (prev.includes(status)) {
|
||||
// Remove from excluded (show it)
|
||||
return prev.filter(s => s !== status);
|
||||
} else {
|
||||
// Add to excluded (hide it)
|
||||
return [...prev, status];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Only show full page loading on initial load
|
||||
if (initialLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasNoApplications = data ? (
|
||||
data.clusters.length === 0 &&
|
||||
data.unassigned.applications.length === 0 &&
|
||||
data.unassigned.platforms.length === 0
|
||||
) : true;
|
||||
|
||||
const ClusterBlock = ({ clusterData, isUnassigned = false }: { clusterData: TeamDashboardCluster; isUnassigned?: boolean }) => {
|
||||
const clusterId = clusterData.cluster?.objectId || 'unassigned';
|
||||
const isExpanded = expandedClusters.has(clusterId);
|
||||
const clusterName = isUnassigned ? 'Nog niet toegekend' : (clusterData.cluster?.name || 'Onbekend');
|
||||
|
||||
// Helper function to get effective FTE for an application
|
||||
const getEffectiveFTE = (app: { overrideFTE?: number | null; requiredEffortApplicationManagement?: number | null }) =>
|
||||
app.overrideFTE !== null && app.overrideFTE !== undefined ? app.overrideFTE : (app.requiredEffortApplicationManagement || 0);
|
||||
|
||||
// Use pre-calculated min/max from backend (sum of all min/max FTE values)
|
||||
const minFTE = clusterData.minEffort ?? 0;
|
||||
const maxFTE = clusterData.maxEffort ?? 0;
|
||||
|
||||
// Calculate application type distribution
|
||||
const byApplicationType: Record<string, number> = {};
|
||||
clusterData.applications.forEach(app => {
|
||||
const appType = app.applicationType?.name || 'Niet ingesteld';
|
||||
byApplicationType[appType] = (byApplicationType[appType] || 0) + 1;
|
||||
});
|
||||
clusterData.platforms.forEach(platformWithWorkloads => {
|
||||
const platformType = platformWithWorkloads.platform.applicationType?.name || 'Niet ingesteld';
|
||||
byApplicationType[platformType] = (byApplicationType[platformType] || 0) + 1;
|
||||
platformWithWorkloads.workloads.forEach(workload => {
|
||||
const workloadType = workload.applicationType?.name || 'Niet ingesteld';
|
||||
byApplicationType[workloadType] = (byApplicationType[workloadType] || 0) + 1;
|
||||
});
|
||||
});
|
||||
|
||||
// Sort applications based on selected sort option
|
||||
const sortedApplications = [...clusterData.applications].sort((a, b) => {
|
||||
if (sortOption === 'alphabetical') {
|
||||
return a.name.localeCompare(b.name, 'nl', { sensitivity: 'base' });
|
||||
} else {
|
||||
// Sort by FTE descending (use override if present, otherwise calculated)
|
||||
const aFTE = getEffectiveFTE(a);
|
||||
const bFTE = getEffectiveFTE(b);
|
||||
return bFTE - aFTE;
|
||||
}
|
||||
});
|
||||
|
||||
// Sort platforms based on selected sort option
|
||||
const sortedPlatforms = [...clusterData.platforms].sort((a, b) => {
|
||||
if (sortOption === 'alphabetical') {
|
||||
return a.platform.name.localeCompare(b.platform.name, 'nl', { sensitivity: 'base' });
|
||||
} else {
|
||||
// Sort by total FTE descending
|
||||
return b.totalEffort - a.totalEffort;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 rounded-lg shadow-sm border border-gray-200 mb-4" style={{ overflow: 'visible' }}>
|
||||
<button
|
||||
onClick={() => toggleCluster(clusterId)}
|
||||
className="w-full px-6 py-4 hover:bg-gray-50 transition-colors text-left"
|
||||
style={{ overflow: 'visible' }}
|
||||
>
|
||||
{/* First row: Cluster name and expand icon */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex-shrink-0">
|
||||
{isExpanded ? (
|
||||
<svg className="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex-1">{clusterName}</h3>
|
||||
</div>
|
||||
|
||||
{/* Second row: KPIs - all horizontally aligned */}
|
||||
<div className="mt-3 ml-9 flex flex-wrap items-stretch gap-4">
|
||||
{/* FTE: Total and Min-Max range - most important KPI first - with highlight */}
|
||||
<div className="bg-gradient-to-br from-emerald-50 to-teal-50 border border-emerald-200 rounded-xl px-4 py-3 shadow-sm flex flex-col justify-center w-[180px] flex-shrink-0">
|
||||
<div className="text-xs text-emerald-700 font-semibold flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
FTE Totaal
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-emerald-800">{clusterData.totalEffort.toFixed(2)}</div>
|
||||
<div className="text-[10px] text-emerald-600 font-medium mt-1">
|
||||
Bandbreedte:
|
||||
</div>
|
||||
<div className="text-xs text-emerald-600 font-medium">
|
||||
{minFTE.toFixed(2)} - {maxFTE.toFixed(2)} FTE
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Applicatie count with type distribution */}
|
||||
<div className="flex flex-col justify-center">
|
||||
<div className="text-xs text-gray-500 font-semibold flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
Applicaties
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{clusterData.applicationCount}</div>
|
||||
{Object.keys(byApplicationType).length > 0 && (
|
||||
<div className="text-xs text-gray-500 mt-1 grid grid-cols-2 gap-x-4 gap-y-0.5">
|
||||
{Object.entries(byApplicationType)
|
||||
.sort((a, b) => {
|
||||
// Sort "Niet ingesteld" to the end
|
||||
if (a[0] === 'Niet ingesteld') return 1;
|
||||
if (b[0] === 'Niet ingesteld') return -1;
|
||||
return a[0].localeCompare(b[0], 'nl', { sensitivity: 'base' });
|
||||
})
|
||||
.map(([type, count]) => {
|
||||
// Color coding for application types
|
||||
const typeColors: Record<string, string> = {
|
||||
'Applicatie': 'bg-blue-400',
|
||||
'Platform': 'bg-purple-400',
|
||||
'Workload': 'bg-orange-400',
|
||||
'Connected Device': 'bg-cyan-400',
|
||||
'Niet ingesteld': 'bg-gray-300',
|
||||
};
|
||||
const dotColor = typeColors[type] || 'bg-gray-400';
|
||||
|
||||
return (
|
||||
<div key={type} className="flex items-center gap-1.5">
|
||||
<span className={`w-2 h-2 rounded-full ${dotColor} flex-shrink-0`}></span>
|
||||
<span className="truncate">{type}: <span className="font-medium text-gray-700">{count}</span></span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Governance Model Distribution - right aligned */}
|
||||
<div className="flex-1 flex flex-col justify-center" style={{ position: 'relative', zIndex: 10, overflow: 'visible' }}>
|
||||
<div className="text-xs font-semibold text-gray-500 text-right mb-1.5 flex items-center justify-end gap-1">
|
||||
Verdeling per regiemodel
|
||||
<span className="text-gray-400 text-[10px]" title="Hover voor details">ⓘ</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 justify-end" style={{ overflow: 'visible' }}>
|
||||
{/* Show all governance models from Jira Assets + "Niet ingesteld" */}
|
||||
{(() => {
|
||||
// Get all governance models, sort alphabetically, add "Niet ingesteld" at the end
|
||||
const allModels = [
|
||||
...governanceModels
|
||||
.map(g => g.name)
|
||||
.sort((a, b) => a.localeCompare(b, 'nl', { sensitivity: 'base' })),
|
||||
'Niet ingesteld'
|
||||
];
|
||||
|
||||
// Color schemes based on the model name/key
|
||||
const getColorScheme = (name: string): { bg: string; text: string } => {
|
||||
if (name.includes('Regiemodel A')) return { bg: '#20556B', text: '#FFFFFF' };
|
||||
if (name.includes('Regiemodel B+') || name.includes('B+')) return { bg: '#286B86', text: '#FFFFFF' };
|
||||
if (name.includes('Regiemodel B')) return { bg: '#286B86', text: '#FFFFFF' };
|
||||
if (name.includes('Regiemodel C')) return { bg: '#81CBF2', text: '#20556B' };
|
||||
if (name.includes('Regiemodel D')) return { bg: '#F5A733', text: '#FFFFFF' };
|
||||
if (name.includes('Regiemodel E')) return { bg: '#E95053', text: '#FFFFFF' };
|
||||
if (name === 'Niet ingesteld') return { bg: '#E5E7EB', text: '#9CA3AF' };
|
||||
return { bg: '#6B7280', text: '#FFFFFF' }; // Default gray
|
||||
};
|
||||
|
||||
// Get short label from model name
|
||||
const getShortLabel = (name: string): string => {
|
||||
if (name === 'Niet ingesteld') return '?';
|
||||
// Extract letter(s) after "Regiemodel " or use first char
|
||||
const match = name.match(/Regiemodel\s+(.+)/i);
|
||||
return match ? match[1] : name.charAt(0);
|
||||
};
|
||||
|
||||
return allModels;
|
||||
})()
|
||||
.map((govModel) => {
|
||||
const count = clusterData.byGovernanceModel[govModel] || 0;
|
||||
const colors = (() => {
|
||||
if (govModel.includes('Regiemodel A')) return { bg: '#20556B', text: '#FFFFFF' };
|
||||
if (govModel.includes('Regiemodel B+') || govModel.includes('B+')) return { bg: '#286B86', text: '#FFFFFF' };
|
||||
if (govModel.includes('Regiemodel B')) return { bg: '#286B86', text: '#FFFFFF' };
|
||||
if (govModel.includes('Regiemodel C')) return { bg: '#81CBF2', text: '#20556B' };
|
||||
if (govModel.includes('Regiemodel D')) return { bg: '#F5A733', text: '#FFFFFF' };
|
||||
if (govModel.includes('Regiemodel E')) return { bg: '#E95053', text: '#FFFFFF' };
|
||||
if (govModel === 'Niet ingesteld') return { bg: '#E5E7EB', text: '#9CA3AF' };
|
||||
return { bg: '#6B7280', text: '#FFFFFF' }; // Default gray
|
||||
})();
|
||||
// Short label: extract letter(s) after "Regiemodel " or use "?"
|
||||
const shortLabel = govModel === 'Niet ingesteld'
|
||||
? '?'
|
||||
: (govModel.match(/Regiemodel\s+(.+)/i)?.[1] || govModel.charAt(0));
|
||||
// Find governance model details from fetched data
|
||||
const govModelData = governanceModels.find(g => g.name === govModel);
|
||||
const hoverKey = `${clusterId}-${govModel}`;
|
||||
const isHovered = hoveredGovModel === hoverKey;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={govModel}
|
||||
className={`rounded-xl py-2 shadow-sm hover:shadow-lg transition-all duration-200 w-[48px] text-center cursor-pointer ${count === 0 ? 'opacity-50 hover:opacity-70' : ''}`}
|
||||
style={{
|
||||
backgroundColor: colors.bg,
|
||||
color: colors.text,
|
||||
position: isHovered ? 'relative' : 'static',
|
||||
zIndex: isHovered ? 9999 : 'auto'
|
||||
}}
|
||||
onMouseEnter={() => setHoveredGovModel(hoverKey)}
|
||||
onMouseLeave={() => setHoveredGovModel(null)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="text-[10px] font-bold uppercase tracking-wider" style={{ opacity: 0.9 }}>
|
||||
{shortLabel}
|
||||
</div>
|
||||
<div className="text-xl font-bold leading-tight">
|
||||
{count}
|
||||
</div>
|
||||
|
||||
{/* Hover popup */}
|
||||
{isHovered && govModel !== 'Niet ingesteld' && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-3 w-80 rounded-xl shadow-2xl border border-gray-200 p-4 text-left"
|
||||
style={{
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 99999,
|
||||
backgroundColor: '#ffffff',
|
||||
opacity: 1
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Arrow pointer */}
|
||||
<div
|
||||
className="absolute -top-2 right-5 w-0 h-0 border-l-8 border-r-8 border-b-8 border-transparent"
|
||||
style={{ borderBottomColor: '#ffffff', filter: 'drop-shadow(0 -1px 1px rgba(0,0,0,0.1))' }}
|
||||
/>
|
||||
|
||||
{/* Header: Summary (Description) */}
|
||||
<div className="text-sm font-bold text-gray-900 mb-2">
|
||||
{govModelData?.summary || govModel}
|
||||
{govModelData?.description && (
|
||||
<span className="font-normal text-gray-500"> ({govModelData.description})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Remarks */}
|
||||
{govModelData?.remarks && (
|
||||
<div className="text-xs text-gray-600 mb-3 whitespace-pre-wrap leading-relaxed">
|
||||
{govModelData.remarks}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Application section */}
|
||||
{govModelData?.application && (
|
||||
<div className="border-t border-gray-100 pt-3 mt-3">
|
||||
<div className="text-xs font-bold text-gray-700 mb-1.5 uppercase tracking-wide">
|
||||
Toepassing
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 whitespace-pre-wrap leading-relaxed">
|
||||
{govModelData.application}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fallback message if no data */}
|
||||
{!govModelData && (
|
||||
<div className="text-xs text-gray-400 italic">
|
||||
Geen aanvullende informatie beschikbaar
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-200 px-6 py-4">
|
||||
{clusterData.applications.length === 0 && clusterData.platforms.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">Geen applicaties in dit cluster</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* Platforms with Workloads - shown first */}
|
||||
{sortedPlatforms.map((platformWithWorkloads) => {
|
||||
const platformId = platformWithWorkloads.platform.id;
|
||||
const isPlatformExpanded = expandedPlatforms.has(platformId);
|
||||
const hasWorkloads = platformWithWorkloads.workloads.length > 0;
|
||||
const platformGovStyle = getGovernanceModelStyle(platformWithWorkloads.platform.governanceModel?.name);
|
||||
const platform = platformWithWorkloads.platform;
|
||||
const platformMinFTE = platform.overrideFTE !== null && platform.overrideFTE !== undefined
|
||||
? platform.overrideFTE
|
||||
: (platform.minFTE ?? platform.requiredEffortApplicationManagement ?? 0);
|
||||
const platformMaxFTE = platform.overrideFTE !== null && platform.overrideFTE !== undefined
|
||||
? platform.overrideFTE
|
||||
: (platform.maxFTE ?? platform.requiredEffortApplicationManagement ?? 0);
|
||||
// Calculate total min/max including workloads
|
||||
const totalMinFTE = platformMinFTE + platformWithWorkloads.workloads.reduce((sum, w) => {
|
||||
return sum + (w.overrideFTE ?? w.minFTE ?? w.requiredEffortApplicationManagement ?? 0);
|
||||
}, 0);
|
||||
const totalMaxFTE = platformMaxFTE + platformWithWorkloads.workloads.reduce((sum, w) => {
|
||||
return sum + (w.overrideFTE ?? w.maxFTE ?? w.requiredEffortApplicationManagement ?? 0);
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<div key={platformId} className="border border-blue-200 rounded-lg bg-blue-50 overflow-hidden flex">
|
||||
{/* Governance Model indicator */}
|
||||
<div
|
||||
className="w-10 flex-shrink-0 flex items-center justify-center font-bold text-sm"
|
||||
style={{ backgroundColor: platformGovStyle.bg, color: platformGovStyle.text }}
|
||||
title={platform.governanceModel?.name || 'Niet ingesteld'}
|
||||
>
|
||||
{platformGovStyle.letter}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
{/* Platform header */}
|
||||
<div className="flex items-center">
|
||||
{hasWorkloads && (
|
||||
<button
|
||||
onClick={(e) => togglePlatform(platformId, e)}
|
||||
className="p-2 hover:bg-blue-100 transition-colors flex-shrink-0"
|
||||
title={isPlatformExpanded ? 'Inklappen' : 'Uitklappen'}
|
||||
>
|
||||
<svg
|
||||
className={`w-5 h-5 text-blue-700 transition-transform ${isPlatformExpanded ? 'transform rotate-90' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
to={`/applications/${platformId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`flex-1 p-3 hover:bg-blue-100 transition-colors ${!hasWorkloads ? 'rounded-r-lg' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 flex-wrap gap-y-1">
|
||||
<div className="font-medium text-gray-900">{platformWithWorkloads.platform.name}</div>
|
||||
<span className="text-xs font-semibold text-blue-700 bg-blue-200 px-2 py-0.5 rounded">
|
||||
Platform
|
||||
</span>
|
||||
{platform.applicationManagementHosting?.name && (
|
||||
<span className="text-xs px-2 py-0.5 bg-blue-100 text-blue-700 rounded-full">
|
||||
{platform.applicationManagementHosting.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">{platformWithWorkloads.platform.key}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{(() => {
|
||||
const platformHasOverride = platform.overrideFTE !== null && platform.overrideFTE !== undefined;
|
||||
const platformCalculated = platform.requiredEffortApplicationManagement || 0;
|
||||
const workloadsCalculated = platformWithWorkloads.workloads.reduce((sum, w) =>
|
||||
sum + (w.requiredEffortApplicationManagement || 0), 0
|
||||
);
|
||||
const totalCalculated = platformCalculated + workloadsCalculated;
|
||||
const hasAnyOverride = platformHasOverride || platformWithWorkloads.workloads.some(w =>
|
||||
w.overrideFTE !== null && w.overrideFTE !== undefined
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{platformWithWorkloads.totalEffort.toFixed(2)} FTE
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{totalMinFTE.toFixed(2)} - {totalMaxFTE.toFixed(2)}
|
||||
</div>
|
||||
{hasAnyOverride && (
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
(berekend: {totalCalculated.toFixed(2)})
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Platform: {platformWithWorkloads.platformEffort.toFixed(2)} FTE
|
||||
{platformHasOverride && platformCalculated !== null && (
|
||||
<span className="text-gray-400"> (berekend: {platformCalculated.toFixed(2)})</span>
|
||||
)}
|
||||
{hasWorkloads && (
|
||||
<> + Workloads: {platformWithWorkloads.workloadsEffort.toFixed(2)} FTE</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Workloads list */}
|
||||
{hasWorkloads && isPlatformExpanded && (
|
||||
<div className="border-t border-blue-200 bg-white rounded-br-lg">
|
||||
<div className="px-3 py-2 bg-blue-100 border-b border-blue-200">
|
||||
<div className="text-xs font-medium text-blue-700">
|
||||
Workloads ({platformWithWorkloads.workloads.length})
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-200">
|
||||
{[...platformWithWorkloads.workloads]
|
||||
.sort((a, b) => {
|
||||
if (sortOption === 'alphabetical') {
|
||||
return a.name.localeCompare(b.name, 'nl', { sensitivity: 'base' });
|
||||
} else {
|
||||
// Sort by FTE descending (use override if present, otherwise calculated)
|
||||
const wlEffectiveFTE = (wl: typeof a) => wl.overrideFTE !== null && wl.overrideFTE !== undefined ? wl.overrideFTE : (wl.requiredEffortApplicationManagement || 0);
|
||||
const aFTE = wlEffectiveFTE(a);
|
||||
const bFTE = wlEffectiveFTE(b);
|
||||
return bFTE - aFTE;
|
||||
}
|
||||
})
|
||||
.map((workload) => {
|
||||
const workloadGovStyle = getGovernanceModelStyle(workload.governanceModel?.name);
|
||||
const workloadType = workload.applicationType?.name || 'Workload';
|
||||
const workloadHosting = workload.applicationManagementHosting?.name;
|
||||
const workloadEffectiveFTE = workload.overrideFTE !== null && workload.overrideFTE !== undefined
|
||||
? workload.overrideFTE
|
||||
: workload.requiredEffortApplicationManagement;
|
||||
const workloadMinFTE = workload.overrideFTE ?? workload.minFTE ?? workload.requiredEffortApplicationManagement ?? 0;
|
||||
const workloadMaxFTE = workload.overrideFTE ?? workload.maxFTE ?? workload.requiredEffortApplicationManagement ?? 0;
|
||||
|
||||
return (
|
||||
<div key={workload.id} className="flex items-stretch">
|
||||
{/* Governance Model indicator for workload */}
|
||||
<div
|
||||
className="w-8 flex-shrink-0 flex items-center justify-center font-bold text-xs"
|
||||
style={{ backgroundColor: workloadGovStyle.bg, color: workloadGovStyle.text, opacity: 0.7 }}
|
||||
title={workload.governanceModel?.name || 'Niet ingesteld'}
|
||||
>
|
||||
{workloadGovStyle.letter}
|
||||
</div>
|
||||
<Link
|
||||
to={`/applications/${workload.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 px-4 py-2 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-gray-700">{workload.name}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-gray-100 text-gray-500 rounded">
|
||||
{workloadType}
|
||||
</span>
|
||||
{workloadHosting && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-blue-50 text-blue-600 rounded">
|
||||
{workloadHosting}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{workload.key}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{workloadEffectiveFTE !== null && workloadEffectiveFTE !== undefined ? (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-600">
|
||||
{workloadEffectiveFTE.toFixed(2)} FTE
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-400">
|
||||
{workloadMinFTE.toFixed(2)} - {workloadMaxFTE.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-400">Niet berekend</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Regular applications - shown after platforms */}
|
||||
{sortedApplications.map((app) => {
|
||||
const govStyle = getGovernanceModelStyle(app.governanceModel?.name);
|
||||
const appType = app.applicationType?.name || 'Niet ingesteld';
|
||||
const appHosting = app.applicationManagementHosting?.name;
|
||||
const effectiveFTE = app.overrideFTE !== null && app.overrideFTE !== undefined
|
||||
? app.overrideFTE
|
||||
: app.requiredEffortApplicationManagement;
|
||||
const appMinFTE = app.overrideFTE !== null && app.overrideFTE !== undefined
|
||||
? app.overrideFTE
|
||||
: (app.minFTE ?? app.requiredEffortApplicationManagement ?? 0);
|
||||
const appMaxFTE = app.overrideFTE !== null && app.overrideFTE !== undefined
|
||||
? app.overrideFTE
|
||||
: (app.maxFTE ?? app.requiredEffortApplicationManagement ?? 0);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={app.id}
|
||||
to={`/applications/${app.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-stretch bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors overflow-hidden"
|
||||
>
|
||||
{/* Governance Model indicator */}
|
||||
<div
|
||||
className="w-10 flex-shrink-0 flex items-center justify-center font-bold text-sm"
|
||||
style={{ backgroundColor: govStyle.bg, color: govStyle.text }}
|
||||
title={app.governanceModel?.name || 'Niet ingesteld'}
|
||||
>
|
||||
{govStyle.letter}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-3 flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-gray-900">{app.name}</span>
|
||||
<span className="text-xs px-2 py-0.5 bg-gray-200 text-gray-600 rounded-full">
|
||||
{appType}
|
||||
</span>
|
||||
{appHosting && (
|
||||
<span className="text-xs px-2 py-0.5 bg-blue-100 text-blue-700 rounded-full">
|
||||
{appHosting}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">{app.key}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{effectiveFTE !== null && effectiveFTE !== undefined ? (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{effectiveFTE.toFixed(2)} FTE
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{appMinFTE.toFixed(2)} - {appMaxFTE.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-400">Niet berekend</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Team-indeling</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Overzicht van applicaties gegroepeerd per Application Cluster
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Compact Filter Bar */}
|
||||
<div className="mb-6 bg-gray-50 rounded-lg shadow-sm border border-gray-200 p-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Sort Option */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs font-medium text-gray-600 whitespace-nowrap">
|
||||
Sorteer:
|
||||
</label>
|
||||
<select
|
||||
value={sortOption}
|
||||
onChange={(e) => setSortOption(e.target.value as SortOption)}
|
||||
className="px-2.5 py-1.5 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 text-sm bg-white min-w-[140px]"
|
||||
>
|
||||
<option value="alphabetical">Alfabetisch</option>
|
||||
<option value="fte-descending">FTE (aflopend)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Status Filter Dropdown */}
|
||||
<div className="flex items-center gap-2 relative">
|
||||
<label className="text-xs font-medium text-gray-600 whitespace-nowrap">
|
||||
Status:
|
||||
</label>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatusDropdownOpen(!statusDropdownOpen)}
|
||||
className="px-2.5 py-1.5 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 text-sm bg-white min-w-[180px] text-left flex items-center justify-between gap-2 hover:bg-gray-50"
|
||||
>
|
||||
<span className="text-gray-700">
|
||||
{excludedStatuses.length === 0
|
||||
? 'Alle statussen'
|
||||
: excludedStatuses.length === 1
|
||||
? `${ALL_STATUSES.length - 1} van ${ALL_STATUSES.length}`
|
||||
: `${ALL_STATUSES.length - excludedStatuses.length} van ${ALL_STATUSES.length}`}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-4 h-4 text-gray-400 transition-transform ${statusDropdownOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{statusDropdownOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setStatusDropdownOpen(false)}
|
||||
/>
|
||||
<div className="absolute z-20 mt-1 w-64 bg-white border border-gray-300 rounded-md shadow-lg max-h-80 overflow-auto">
|
||||
<div className="p-2 border-b border-gray-200 flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-gray-700">Selecteer statussen</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExcludedStatuses(['Closed', 'Deprecated']);
|
||||
setStatusDropdownOpen(false);
|
||||
}}
|
||||
className="text-xs text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-2 space-y-1">
|
||||
{ALL_STATUSES.map((status) => {
|
||||
const isExcluded = excludedStatuses.includes(status);
|
||||
return (
|
||||
<label
|
||||
key={status}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex items-center space-x-2 cursor-pointer p-2 rounded hover:bg-gray-50"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!isExcluded}
|
||||
onChange={() => toggleStatus(status)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">{status}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="p-2 border-t border-gray-200 bg-gray-50">
|
||||
<p className="text-xs text-gray-500">
|
||||
Uitgevinkte statussen worden verborgen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading indicator for data updates */}
|
||||
{dataLoading && (
|
||||
<div className="mb-6 flex items-center justify-center py-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600" />
|
||||
<span>Resultaten bijwerken...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Clusters */}
|
||||
{!dataLoading && data && data.clusters.length > 0 && (
|
||||
<div className="mb-8">
|
||||
{data.clusters.map((clusterData) => (
|
||||
<ClusterBlock key={clusterData.cluster?.objectId || 'unknown'} clusterData={clusterData} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unassigned applications */}
|
||||
{!dataLoading && data && (data.unassigned.applications.length > 0 || data.unassigned.platforms.length > 0) && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<ClusterBlock
|
||||
isUnassigned={true}
|
||||
clusterData={{
|
||||
cluster: null,
|
||||
applications: data.unassigned.applications,
|
||||
platforms: data.unassigned.platforms,
|
||||
totalEffort: data.unassigned.totalEffort,
|
||||
applicationCount: data.unassigned.applicationCount,
|
||||
byGovernanceModel: data.unassigned.byGovernanceModel,
|
||||
}}
|
||||
/>
|
||||
<div className="px-6 pb-4">
|
||||
<p className="text-sm text-yellow-700">
|
||||
Deze applicaties zijn nog niet toegekend aan een cluster.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!dataLoading && data && hasNoApplications && (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
|
||||
<p className="text-gray-500">Geen applicaties gevonden</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
115
frontend/src/index.css
Normal file
115
frontend/src/index.css
Normal file
@@ -0,0 +1,115 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900 antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center px-4 py-2 text-sm font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 focus:ring-blue-500;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
@apply bg-green-600 text-white hover:bg-green-700 focus:ring-green-500;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-red-600 text-white hover:bg-red-700 focus:ring-red-500;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
@apply bg-transparent text-blue-600 border border-blue-600 hover:bg-blue-50 focus:ring-blue-500;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply block text-sm font-medium text-gray-700;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.badge-green {
|
||||
@apply bg-green-100 text-green-800;
|
||||
}
|
||||
|
||||
.badge-yellow {
|
||||
@apply bg-yellow-100 text-yellow-800;
|
||||
}
|
||||
|
||||
.badge-red {
|
||||
@apply bg-red-100 text-red-800;
|
||||
}
|
||||
|
||||
.badge-blue {
|
||||
@apply bg-blue-100 text-blue-800;
|
||||
}
|
||||
|
||||
.badge-gray {
|
||||
@apply bg-gray-100 text-gray-800;
|
||||
}
|
||||
|
||||
.badge-dark-red {
|
||||
@apply bg-red-800 text-white;
|
||||
}
|
||||
|
||||
.badge-light-red {
|
||||
@apply bg-red-200 text-red-900;
|
||||
}
|
||||
|
||||
.badge-dark-green {
|
||||
@apply bg-green-800 text-white;
|
||||
}
|
||||
|
||||
.badge-light-green {
|
||||
@apply bg-green-200 text-green-900;
|
||||
}
|
||||
|
||||
.badge-black {
|
||||
@apply bg-black text-white;
|
||||
}
|
||||
|
||||
.badge-darker-red {
|
||||
@apply bg-red-900 text-white;
|
||||
}
|
||||
|
||||
.badge-red {
|
||||
@apply bg-red-500 text-white;
|
||||
}
|
||||
|
||||
.badge-yellow-orange {
|
||||
@apply bg-yellow-500 text-white;
|
||||
}
|
||||
|
||||
.badge-dark-blue {
|
||||
@apply bg-blue-800 text-white;
|
||||
}
|
||||
|
||||
.badge-light-blue {
|
||||
@apply bg-blue-400 text-white;
|
||||
}
|
||||
|
||||
.badge-lighter-blue {
|
||||
@apply bg-blue-300 text-white;
|
||||
}
|
||||
}
|
||||
13
frontend/src/main.tsx
Normal file
13
frontend/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
391
frontend/src/services/api.ts
Normal file
391
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
import type {
|
||||
ApplicationDetails,
|
||||
SearchFilters,
|
||||
SearchResult,
|
||||
AISuggestion,
|
||||
ReferenceValue,
|
||||
DashboardStats,
|
||||
ClassificationResult,
|
||||
ZiraTaxonomy,
|
||||
TeamDashboardData,
|
||||
ApplicationStatus,
|
||||
EffortCalculationBreakdown,
|
||||
} from '../types';
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
async function fetchApi<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(error.error || error.message || 'API request failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Applications
|
||||
export async function searchApplications(
|
||||
filters: SearchFilters,
|
||||
page: number = 1,
|
||||
pageSize: number = 25
|
||||
): Promise<SearchResult> {
|
||||
return fetchApi<SearchResult>('/applications/search', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ filters, page, pageSize }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getApplicationById(id: string): Promise<ApplicationDetails> {
|
||||
return fetchApi<ApplicationDetails>(`/applications/${id}`);
|
||||
}
|
||||
|
||||
export async function updateApplication(
|
||||
id: string,
|
||||
updates: {
|
||||
applicationFunctions?: ReferenceValue[];
|
||||
dynamicsFactor?: ReferenceValue;
|
||||
complexityFactor?: ReferenceValue;
|
||||
numberOfUsers?: ReferenceValue;
|
||||
governanceModel?: ReferenceValue;
|
||||
applicationCluster?: ReferenceValue;
|
||||
applicationType?: ReferenceValue;
|
||||
hostingType?: ReferenceValue;
|
||||
businessImpactAnalyse?: ReferenceValue;
|
||||
applicationManagementHosting?: string;
|
||||
applicationManagementTAM?: string;
|
||||
overrideFTE?: number | null;
|
||||
source?: 'AI_ACCEPTED' | 'AI_MODIFIED' | 'MANUAL';
|
||||
}
|
||||
): Promise<ApplicationDetails> {
|
||||
return fetchApi<ApplicationDetails>(`/applications/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate FTE effort for an application (real-time calculation without saving)
|
||||
export async function calculateEffort(
|
||||
applicationData: Partial<ApplicationDetails>
|
||||
): Promise<{
|
||||
requiredEffortApplicationManagement: number | null;
|
||||
breakdown: EffortCalculationBreakdown;
|
||||
}> {
|
||||
return fetchApi<{
|
||||
requiredEffortApplicationManagement: number | null;
|
||||
breakdown: EffortCalculationBreakdown;
|
||||
}>('/applications/calculate-effort', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(applicationData),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getApplicationHistory(id: string): Promise<ClassificationResult[]> {
|
||||
return fetchApi<ClassificationResult[]>(`/applications/${id}/history`);
|
||||
}
|
||||
|
||||
// AI Provider type
|
||||
export type AIProvider = 'claude' | 'openai';
|
||||
|
||||
// AI Status response type
|
||||
export interface AIStatusResponse {
|
||||
available: boolean;
|
||||
providers: AIProvider[];
|
||||
defaultProvider: AIProvider;
|
||||
claude: {
|
||||
available: boolean;
|
||||
model: string;
|
||||
};
|
||||
openai: {
|
||||
available: boolean;
|
||||
model: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Classifications
|
||||
export async function getAISuggestion(id: string, provider?: AIProvider): Promise<AISuggestion> {
|
||||
const url = provider
|
||||
? `/classifications/suggest/${id}?provider=${provider}`
|
||||
: `/classifications/suggest/${id}`;
|
||||
return fetchApi<AISuggestion>(url, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export async function getTaxonomy(): Promise<ZiraTaxonomy> {
|
||||
return fetchApi<ZiraTaxonomy>('/classifications/taxonomy');
|
||||
}
|
||||
|
||||
export async function getFunctionByCode(
|
||||
code: string
|
||||
): Promise<{ domain: string; function: { code: string; name: string; description: string } }> {
|
||||
return fetchApi(`/classifications/function/${code}`);
|
||||
}
|
||||
|
||||
export async function getClassificationHistory(limit: number = 50): Promise<ClassificationResult[]> {
|
||||
return fetchApi<ClassificationResult[]>(`/classifications/history?limit=${limit}`);
|
||||
}
|
||||
|
||||
export async function getAIStatus(): Promise<AIStatusResponse> {
|
||||
return fetchApi('/classifications/ai-status');
|
||||
}
|
||||
|
||||
export async function getAIPrompt(id: string): Promise<{ prompt: string }> {
|
||||
return fetchApi(`/classifications/prompt/${id}`);
|
||||
}
|
||||
|
||||
// Reference Data
|
||||
export async function getReferenceData(): Promise<{
|
||||
dynamicsFactors: ReferenceValue[];
|
||||
complexityFactors: ReferenceValue[];
|
||||
numberOfUsers: ReferenceValue[];
|
||||
governanceModels: ReferenceValue[];
|
||||
organisations: ReferenceValue[];
|
||||
hostingTypes: ReferenceValue[];
|
||||
applicationFunctions: ReferenceValue[];
|
||||
applicationClusters: ReferenceValue[];
|
||||
applicationTypes: ReferenceValue[];
|
||||
businessImportance: ReferenceValue[];
|
||||
businessImpactAnalyses: ReferenceValue[];
|
||||
applicationManagementHosting: ReferenceValue[];
|
||||
applicationManagementTAM: ReferenceValue[];
|
||||
}> {
|
||||
return fetchApi('/reference-data');
|
||||
}
|
||||
|
||||
export async function getApplicationFunctions(): Promise<ReferenceValue[]> {
|
||||
return fetchApi<ReferenceValue[]>('/reference-data/application-functions');
|
||||
}
|
||||
|
||||
export async function getDynamicsFactors(): Promise<ReferenceValue[]> {
|
||||
return fetchApi<ReferenceValue[]>('/reference-data/dynamics-factors');
|
||||
}
|
||||
|
||||
export async function getComplexityFactors(): Promise<ReferenceValue[]> {
|
||||
return fetchApi<ReferenceValue[]>('/reference-data/complexity-factors');
|
||||
}
|
||||
|
||||
export async function getNumberOfUsers(): Promise<ReferenceValue[]> {
|
||||
return fetchApi<ReferenceValue[]>('/reference-data/number-of-users');
|
||||
}
|
||||
|
||||
export async function getGovernanceModels(): Promise<ReferenceValue[]> {
|
||||
return fetchApi<ReferenceValue[]>('/reference-data/governance-models');
|
||||
}
|
||||
|
||||
export async function getOrganisations(): Promise<ReferenceValue[]> {
|
||||
return fetchApi<ReferenceValue[]>('/reference-data/organisations');
|
||||
}
|
||||
|
||||
export async function getHostingTypes(): Promise<ReferenceValue[]> {
|
||||
return fetchApi<ReferenceValue[]>('/reference-data/hosting-types');
|
||||
}
|
||||
|
||||
export async function getApplicationClusters(): Promise<ReferenceValue[]> {
|
||||
return fetchApi<ReferenceValue[]>('/reference-data/application-clusters');
|
||||
}
|
||||
|
||||
export async function getApplicationTypes(): Promise<ReferenceValue[]> {
|
||||
return fetchApi<ReferenceValue[]>('/reference-data/application-types');
|
||||
}
|
||||
|
||||
export async function getApplicationManagementHosting(): Promise<ReferenceValue[]> {
|
||||
return fetchApi<ReferenceValue[]>('/reference-data/application-management-hosting');
|
||||
}
|
||||
|
||||
export async function getBusinessImportance(): Promise<ReferenceValue[]> {
|
||||
return fetchApi<ReferenceValue[]>('/reference-data/business-importance');
|
||||
}
|
||||
|
||||
export async function getBusinessImpactAnalyses(): Promise<ReferenceValue[]> {
|
||||
return fetchApi<ReferenceValue[]>('/reference-data/business-impact-analyses');
|
||||
}
|
||||
|
||||
// Config
|
||||
export async function getConfig(): Promise<{ jiraHost: string }> {
|
||||
return fetchApi<{ jiraHost: string }>('/config');
|
||||
}
|
||||
|
||||
// Dashboard
|
||||
export async function getDashboardStats(forceRefresh: boolean = false): Promise<DashboardStats> {
|
||||
const params = forceRefresh ? '?refresh=true' : '';
|
||||
return fetchApi<DashboardStats>(`/dashboard/stats${params}`);
|
||||
}
|
||||
|
||||
export async function getRecentClassifications(limit: number = 10): Promise<ClassificationResult[]> {
|
||||
return fetchApi<ClassificationResult[]>(`/dashboard/recent?limit=${limit}`);
|
||||
}
|
||||
|
||||
// Team Dashboard
|
||||
export async function getTeamDashboardData(excludedStatuses: ApplicationStatus[] = []): Promise<TeamDashboardData> {
|
||||
const params = new URLSearchParams();
|
||||
// Always send excludedStatuses parameter, even if empty, so backend knows the user's intent
|
||||
params.append('excludedStatuses', excludedStatuses.join(','));
|
||||
const queryString = params.toString();
|
||||
return fetchApi<TeamDashboardData>(`/applications/team-dashboard?${queryString}`);
|
||||
}
|
||||
|
||||
// Configuration
|
||||
export interface EffortCalculationConfig {
|
||||
governanceModelRules: Array<{
|
||||
governanceModel: string;
|
||||
applicationTypeRules: {
|
||||
[key: string]: {
|
||||
applicationTypes: string | string[];
|
||||
businessImpactRules: {
|
||||
[key: string]: {
|
||||
result: number;
|
||||
conditions?: {
|
||||
hostingType?: string | string[];
|
||||
};
|
||||
} | Array<{
|
||||
result: number;
|
||||
conditions?: {
|
||||
hostingType?: string | string[];
|
||||
};
|
||||
}>;
|
||||
};
|
||||
default?: {
|
||||
result: number;
|
||||
conditions?: {
|
||||
hostingType?: string | string[];
|
||||
};
|
||||
} | Array<{
|
||||
result: number;
|
||||
conditions?: {
|
||||
hostingType?: string | string[];
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
default?: {
|
||||
result: number;
|
||||
conditions?: {
|
||||
hostingType?: string | string[];
|
||||
};
|
||||
};
|
||||
}>;
|
||||
default: {
|
||||
result: number;
|
||||
};
|
||||
}
|
||||
|
||||
export async function getEffortCalculationConfig(): Promise<EffortCalculationConfig> {
|
||||
return fetchApi<EffortCalculationConfig>('/configuration/effort-calculation');
|
||||
}
|
||||
|
||||
export async function updateEffortCalculationConfig(config: EffortCalculationConfig): Promise<{ success: boolean; message: string }> {
|
||||
return fetchApi<{ success: boolean; message: string }>('/configuration/effort-calculation', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
}
|
||||
|
||||
// V25 Configuration types
|
||||
export interface FTERange {
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
export interface HostingRule {
|
||||
hostingValues: string[];
|
||||
fte: FTERange;
|
||||
}
|
||||
|
||||
export interface BIALevelConfig {
|
||||
description?: string;
|
||||
defaultFte?: FTERange;
|
||||
hosting: {
|
||||
[key: string]: HostingRule;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApplicationTypeConfigV25 {
|
||||
defaultFte?: FTERange;
|
||||
note?: string;
|
||||
requiresManualAssessment?: boolean;
|
||||
fixedFte?: boolean;
|
||||
notRecommended?: boolean;
|
||||
biaLevels: {
|
||||
[key: string]: BIALevelConfig;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GovernanceModelConfigV25 {
|
||||
name: string;
|
||||
description?: string;
|
||||
allowedBia: string[];
|
||||
defaultFte: FTERange;
|
||||
note?: string;
|
||||
applicationTypes: {
|
||||
[key: string]: ApplicationTypeConfigV25;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EffortCalculationConfigV25 {
|
||||
metadata: {
|
||||
version: string;
|
||||
description: string;
|
||||
date: string;
|
||||
formula: string;
|
||||
};
|
||||
regiemodellen: {
|
||||
[key: string]: GovernanceModelConfigV25;
|
||||
};
|
||||
validationRules: {
|
||||
biaRegieModelConstraints: {
|
||||
[regiemodel: string]: string[];
|
||||
};
|
||||
platformRestrictions: Array<{
|
||||
regiemodel: string;
|
||||
applicationType: string;
|
||||
warning: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export async function getEffortCalculationConfigV25(): Promise<EffortCalculationConfigV25> {
|
||||
return fetchApi<EffortCalculationConfigV25>('/configuration/effort-calculation-v25');
|
||||
}
|
||||
|
||||
export async function updateEffortCalculationConfigV25(config: EffortCalculationConfigV25): Promise<{ success: boolean; message: string }> {
|
||||
return fetchApi<{ success: boolean; message: string }>('/configuration/effort-calculation-v25', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
}
|
||||
|
||||
// AI Chat
|
||||
import type { ChatMessage, ChatResponse } from '../types';
|
||||
|
||||
export async function sendChatMessage(
|
||||
applicationId: string,
|
||||
message: string,
|
||||
conversationId?: string,
|
||||
provider?: AIProvider
|
||||
): Promise<ChatResponse> {
|
||||
return fetchApi<ChatResponse>(`/classifications/chat/${applicationId}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ message, conversationId, provider }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getConversationHistory(conversationId: string): Promise<{ conversationId: string; messages: ChatMessage[] }> {
|
||||
return fetchApi<{ conversationId: string; messages: ChatMessage[] }>(`/classifications/chat/conversation/${conversationId}`);
|
||||
}
|
||||
|
||||
export async function clearConversation(conversationId: string): Promise<{ success: boolean }> {
|
||||
return fetchApi<{ success: boolean }>(`/classifications/chat/conversation/${conversationId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
91
frontend/src/stores/navigationStore.ts
Normal file
91
frontend/src/stores/navigationStore.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { SearchFilters } from '../types';
|
||||
|
||||
interface NavigationState {
|
||||
applicationIds: string[];
|
||||
currentIndex: number;
|
||||
filters: SearchFilters;
|
||||
setNavigationContext: (ids: string[], filters: SearchFilters, currentIndex?: number) => void;
|
||||
setCurrentIndexById: (id: string) => void;
|
||||
getCurrentId: () => string | null;
|
||||
getNextId: () => string | null;
|
||||
getPreviousId: () => string | null;
|
||||
goToNext: () => void;
|
||||
goToPrevious: () => void;
|
||||
goToIndex: (index: number) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export const useNavigationStore = create<NavigationState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
applicationIds: [],
|
||||
currentIndex: -1,
|
||||
filters: {},
|
||||
|
||||
setNavigationContext: (ids, filters, currentIndex = 0) =>
|
||||
set({
|
||||
applicationIds: ids,
|
||||
filters,
|
||||
currentIndex,
|
||||
}),
|
||||
|
||||
setCurrentIndexById: (id: string) => {
|
||||
const { applicationIds } = get();
|
||||
const index = applicationIds.indexOf(id);
|
||||
if (index !== -1) {
|
||||
set({ currentIndex: index });
|
||||
}
|
||||
},
|
||||
|
||||
getCurrentId: () => {
|
||||
const { applicationIds, currentIndex } = get();
|
||||
return currentIndex >= 0 && currentIndex < applicationIds.length
|
||||
? applicationIds[currentIndex]
|
||||
: null;
|
||||
},
|
||||
|
||||
getNextId: () => {
|
||||
const { applicationIds, currentIndex } = get();
|
||||
return currentIndex + 1 < applicationIds.length
|
||||
? applicationIds[currentIndex + 1]
|
||||
: null;
|
||||
},
|
||||
|
||||
getPreviousId: () => {
|
||||
const { applicationIds, currentIndex } = get();
|
||||
return currentIndex > 0 ? applicationIds[currentIndex - 1] : null;
|
||||
},
|
||||
|
||||
goToNext: () =>
|
||||
set((state) => ({
|
||||
currentIndex:
|
||||
state.currentIndex + 1 < state.applicationIds.length
|
||||
? state.currentIndex + 1
|
||||
: state.currentIndex,
|
||||
})),
|
||||
|
||||
goToPrevious: () =>
|
||||
set((state) => ({
|
||||
currentIndex: state.currentIndex > 0 ? state.currentIndex - 1 : 0,
|
||||
})),
|
||||
|
||||
goToIndex: (index) =>
|
||||
set((state) => ({
|
||||
currentIndex:
|
||||
index >= 0 && index < state.applicationIds.length ? index : state.currentIndex,
|
||||
})),
|
||||
|
||||
clear: () =>
|
||||
set({
|
||||
applicationIds: [],
|
||||
currentIndex: -1,
|
||||
filters: {},
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'zira-navigation-context',
|
||||
}
|
||||
)
|
||||
);
|
||||
130
frontend/src/stores/searchStore.ts
Normal file
130
frontend/src/stores/searchStore.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { create } from 'zustand';
|
||||
import type { SearchFilters, ApplicationStatus } from '../types';
|
||||
|
||||
interface SearchState {
|
||||
filters: SearchFilters;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
setSearchText: (text: string) => void;
|
||||
setStatuses: (statuses: ApplicationStatus[]) => void;
|
||||
setApplicationFunction: (value: 'all' | 'filled' | 'empty') => void;
|
||||
setGovernanceModel: (value: 'all' | 'filled' | 'empty') => void;
|
||||
setDynamicsFactor: (value: 'all' | 'filled' | 'empty') => void;
|
||||
setComplexityFactor: (value: 'all' | 'filled' | 'empty') => void;
|
||||
setApplicationCluster: (value: 'all' | 'filled' | 'empty') => void;
|
||||
setApplicationType: (value: 'all' | 'filled' | 'empty') => void;
|
||||
setOrganisation: (value: string | undefined) => void;
|
||||
setHostingType: (value: string | undefined) => void;
|
||||
setBusinessImportance: (value: string | undefined) => void;
|
||||
setCurrentPage: (page: number) => void;
|
||||
setPageSize: (size: number) => void;
|
||||
resetFilters: () => void;
|
||||
}
|
||||
|
||||
// Default statuses: all except "Closed"
|
||||
const defaultStatuses: ApplicationStatus[] = [
|
||||
'In Production',
|
||||
'Implementation',
|
||||
'Proof of Concept',
|
||||
'End of support',
|
||||
'End of life',
|
||||
'Deprecated',
|
||||
'Shadow IT',
|
||||
'Undefined',
|
||||
];
|
||||
|
||||
const defaultFilters: SearchFilters = {
|
||||
searchText: '',
|
||||
statuses: defaultStatuses,
|
||||
applicationFunction: 'all',
|
||||
governanceModel: 'all',
|
||||
dynamicsFactor: 'all',
|
||||
complexityFactor: 'all',
|
||||
applicationCluster: 'all',
|
||||
applicationType: 'all',
|
||||
organisation: undefined,
|
||||
hostingType: undefined,
|
||||
businessImportance: undefined,
|
||||
};
|
||||
|
||||
export const useSearchStore = create<SearchState>((set) => ({
|
||||
filters: { ...defaultFilters },
|
||||
currentPage: 1,
|
||||
pageSize: 25,
|
||||
|
||||
setSearchText: (text) =>
|
||||
set((state) => ({
|
||||
filters: { ...state.filters, searchText: text },
|
||||
currentPage: 1,
|
||||
})),
|
||||
|
||||
setStatuses: (statuses) =>
|
||||
set((state) => ({
|
||||
filters: { ...state.filters, statuses },
|
||||
currentPage: 1,
|
||||
})),
|
||||
|
||||
setApplicationFunction: (value) =>
|
||||
set((state) => ({
|
||||
filters: { ...state.filters, applicationFunction: value },
|
||||
currentPage: 1,
|
||||
})),
|
||||
|
||||
setGovernanceModel: (value) =>
|
||||
set((state) => ({
|
||||
filters: { ...state.filters, governanceModel: value },
|
||||
currentPage: 1,
|
||||
})),
|
||||
|
||||
setDynamicsFactor: (value) =>
|
||||
set((state) => ({
|
||||
filters: { ...state.filters, dynamicsFactor: value },
|
||||
currentPage: 1,
|
||||
})),
|
||||
|
||||
setComplexityFactor: (value) =>
|
||||
set((state) => ({
|
||||
filters: { ...state.filters, complexityFactor: value },
|
||||
currentPage: 1,
|
||||
})),
|
||||
|
||||
setApplicationCluster: (value) =>
|
||||
set((state) => ({
|
||||
filters: { ...state.filters, applicationCluster: value },
|
||||
currentPage: 1,
|
||||
})),
|
||||
|
||||
setApplicationType: (value) =>
|
||||
set((state) => ({
|
||||
filters: { ...state.filters, applicationType: value },
|
||||
currentPage: 1,
|
||||
})),
|
||||
|
||||
setOrganisation: (value) =>
|
||||
set((state) => ({
|
||||
filters: { ...state.filters, organisation: value },
|
||||
currentPage: 1,
|
||||
})),
|
||||
|
||||
setHostingType: (value) =>
|
||||
set((state) => ({
|
||||
filters: { ...state.filters, hostingType: value },
|
||||
currentPage: 1,
|
||||
})),
|
||||
|
||||
setBusinessImportance: (value) =>
|
||||
set((state) => ({
|
||||
filters: { ...state.filters, businessImportance: value },
|
||||
currentPage: 1,
|
||||
})),
|
||||
|
||||
setCurrentPage: (page) => set({ currentPage: page }),
|
||||
|
||||
setPageSize: (size) => set({ pageSize: size, currentPage: 1 }),
|
||||
|
||||
resetFilters: () =>
|
||||
set({
|
||||
filters: { ...defaultFilters },
|
||||
currentPage: 1,
|
||||
}),
|
||||
}));
|
||||
344
frontend/src/types/index.ts
Normal file
344
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
// Application status types
|
||||
export type ApplicationStatus =
|
||||
| 'Status'
|
||||
| 'Closed'
|
||||
| 'Deprecated'
|
||||
| 'End of life'
|
||||
| 'End of support'
|
||||
| 'Implementation'
|
||||
| 'In Production'
|
||||
| 'Proof of Concept'
|
||||
| 'Shadow IT'
|
||||
| 'Undefined';
|
||||
|
||||
// Reference value from Jira Assets
|
||||
export interface ReferenceValue {
|
||||
objectId: string;
|
||||
key: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
summary?: string; // Summary attribute for Dynamics Factor, Complexity Factor, and Governance Model
|
||||
category?: string; // Deprecated: kept for backward compatibility, use applicationFunctionCategory instead
|
||||
applicationFunctionCategory?: ReferenceValue; // Reference to ApplicationFunctionCategory object
|
||||
keywords?: string; // Keywords for ApplicationFunction
|
||||
order?: number;
|
||||
factor?: number; // Factor attribute for Dynamics Factor, Complexity Factor, and Number of Users
|
||||
remarks?: string; // Remarks attribute for Governance Model
|
||||
application?: string; // Application attribute for Governance Model
|
||||
indicators?: string; // Indicators attribute for Business Impact Analyse
|
||||
}
|
||||
|
||||
// Application list item (summary view)
|
||||
export interface ApplicationListItem {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
status: ApplicationStatus | null;
|
||||
applicationFunctions: ReferenceValue[]; // Multiple functions supported
|
||||
governanceModel: ReferenceValue | null;
|
||||
dynamicsFactor: ReferenceValue | null;
|
||||
complexityFactor: ReferenceValue | null;
|
||||
applicationCluster: ReferenceValue | null;
|
||||
applicationType: ReferenceValue | null;
|
||||
platform: ReferenceValue | null; // Reference to parent Platform Application Component
|
||||
requiredEffortApplicationManagement: number | null; // Calculated field
|
||||
minFTE?: number | null; // Minimum FTE from configuration range
|
||||
maxFTE?: number | null; // Maximum FTE from configuration range
|
||||
overrideFTE?: number | null; // Override FTE value (if set, overrides calculated value)
|
||||
applicationManagementHosting?: ReferenceValue | null; // Application Management - Hosting
|
||||
applicationManagementTAM?: ReferenceValue | null; // Application Management - TAM
|
||||
}
|
||||
|
||||
// Full application details
|
||||
export interface ApplicationDetails {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
searchReference: string | null;
|
||||
description: string | null;
|
||||
supplierProduct: string | null;
|
||||
organisation: string | null;
|
||||
hostingType: ReferenceValue | null;
|
||||
status: ApplicationStatus | null;
|
||||
businessImportance: string | null;
|
||||
businessImpactAnalyse: ReferenceValue | null;
|
||||
systemOwner: string | null;
|
||||
businessOwner: string | null;
|
||||
functionalApplicationManagement: string | null;
|
||||
technicalApplicationManagement: string | null;
|
||||
technicalApplicationManagementPrimary?: string | null; // Technical Application Management Primary
|
||||
technicalApplicationManagementSecondary?: string | null; // Technical Application Management Secondary
|
||||
medischeTechniek: boolean;
|
||||
applicationFunctions: ReferenceValue[]; // Multiple functions supported
|
||||
dynamicsFactor: ReferenceValue | null;
|
||||
complexityFactor: ReferenceValue | null;
|
||||
numberOfUsers: ReferenceValue | null;
|
||||
governanceModel: ReferenceValue | null;
|
||||
applicationCluster: ReferenceValue | null;
|
||||
applicationType: ReferenceValue | null;
|
||||
platform: ReferenceValue | null; // Reference to parent Platform Application Component
|
||||
requiredEffortApplicationManagement: number | null; // Calculated field
|
||||
overrideFTE?: number | null; // Override FTE value (if set, overrides calculated value)
|
||||
applicationManagementHosting?: ReferenceValue | null; // Application Management - Hosting
|
||||
applicationManagementTAM?: ReferenceValue | null; // Application Management - TAM
|
||||
technischeArchitectuur?: string | null; // URL to Technical Architecture document (Attribute ID 572)
|
||||
}
|
||||
|
||||
// Search filters
|
||||
export interface SearchFilters {
|
||||
searchText?: string;
|
||||
statuses?: ApplicationStatus[];
|
||||
applicationFunction?: 'all' | 'filled' | 'empty';
|
||||
governanceModel?: 'all' | 'filled' | 'empty';
|
||||
dynamicsFactor?: 'all' | 'filled' | 'empty';
|
||||
complexityFactor?: 'all' | 'filled' | 'empty';
|
||||
applicationCluster?: 'all' | 'filled' | 'empty';
|
||||
applicationType?: 'all' | 'filled' | 'empty';
|
||||
organisation?: string;
|
||||
hostingType?: string;
|
||||
businessImportance?: string;
|
||||
}
|
||||
|
||||
// Paginated search result
|
||||
export interface SearchResult {
|
||||
applications: ApplicationListItem[];
|
||||
totalCount: number;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// AI classification suggestion
|
||||
export interface AISuggestion {
|
||||
primaryFunction: {
|
||||
code: string;
|
||||
name: string;
|
||||
reasoning: string;
|
||||
};
|
||||
secondaryFunctions: Array<{
|
||||
code: string;
|
||||
name: string;
|
||||
reasoning: string;
|
||||
}>;
|
||||
managementClassification?: {
|
||||
applicationType?: {
|
||||
value: string;
|
||||
reasoning: string;
|
||||
};
|
||||
dynamicsFactor?: {
|
||||
value: string;
|
||||
label: string;
|
||||
reasoning: string;
|
||||
};
|
||||
complexityFactor?: {
|
||||
value: string;
|
||||
label: string;
|
||||
reasoning: string;
|
||||
};
|
||||
hostingType?: {
|
||||
value: string;
|
||||
reasoning: string;
|
||||
};
|
||||
applicationManagementHosting?: {
|
||||
value: string;
|
||||
reasoning: string;
|
||||
};
|
||||
applicationManagementTAM?: {
|
||||
value: string;
|
||||
reasoning: string;
|
||||
};
|
||||
biaClassification?: {
|
||||
value: string;
|
||||
reasoning: string;
|
||||
};
|
||||
governanceModel?: {
|
||||
value: string;
|
||||
reasoning: string;
|
||||
};
|
||||
};
|
||||
validationWarnings?: string[];
|
||||
confidence: 'HOOG' | 'MIDDEN' | 'LAAG';
|
||||
notes: string;
|
||||
}
|
||||
|
||||
// Pending changes for an application
|
||||
export interface PendingChanges {
|
||||
applicationFunctions?: { from: ReferenceValue[]; to: ReferenceValue[] };
|
||||
dynamicsFactor?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||
complexityFactor?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||
numberOfUsers?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||
governanceModel?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||
applicationCluster?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||
applicationType?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||
}
|
||||
|
||||
// Classification result for audit log
|
||||
export interface ClassificationResult {
|
||||
applicationId: string;
|
||||
applicationName: string;
|
||||
changes: PendingChanges;
|
||||
source: 'AI_ACCEPTED' | 'AI_MODIFIED' | 'MANUAL';
|
||||
timestamp: Date;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
// Reference options for dropdowns
|
||||
export interface ReferenceOptions {
|
||||
dynamicsFactors: ReferenceValue[];
|
||||
complexityFactors: ReferenceValue[];
|
||||
numberOfUsers: ReferenceValue[];
|
||||
governanceModels: ReferenceValue[];
|
||||
applicationFunctions: ReferenceValue[];
|
||||
applicationClusters: ReferenceValue[];
|
||||
applicationTypes: ReferenceValue[];
|
||||
organisations: ReferenceValue[];
|
||||
hostingTypes: ReferenceValue[];
|
||||
businessImportance: ReferenceValue[];
|
||||
}
|
||||
|
||||
// ZiRA domain structure
|
||||
export interface ZiraDomain {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
functions: ZiraFunction[];
|
||||
}
|
||||
|
||||
export interface ZiraFunction {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
keywords: string[];
|
||||
}
|
||||
|
||||
export interface ZiraTaxonomy {
|
||||
version: string;
|
||||
source: string;
|
||||
lastUpdated: string;
|
||||
domains: ZiraDomain[];
|
||||
}
|
||||
|
||||
// Dashboard statistics
|
||||
export interface DashboardStats {
|
||||
totalApplications: number;
|
||||
classifiedCount: number;
|
||||
unclassifiedCount: number;
|
||||
byStatus: Record<string, number>;
|
||||
byDomain: Record<string, number>;
|
||||
byGovernanceModel: Record<string, number>;
|
||||
recentClassifications: ClassificationResult[];
|
||||
}
|
||||
|
||||
// Navigation state for detail screen
|
||||
export interface NavigationState {
|
||||
currentIndex: number;
|
||||
totalInResults: number;
|
||||
applicationIds: string[];
|
||||
filters: SearchFilters;
|
||||
}
|
||||
|
||||
// Effort calculation breakdown
|
||||
// Effort calculation breakdown (v25)
|
||||
export interface EffortCalculationBreakdown {
|
||||
// Base FTE values
|
||||
baseEffort: number; // Average of min/max
|
||||
baseEffortMin: number;
|
||||
baseEffortMax: number;
|
||||
|
||||
// Lookup path used
|
||||
governanceModel: string | null;
|
||||
governanceModelName: string | null;
|
||||
applicationType: string | null;
|
||||
businessImpactAnalyse: string | null;
|
||||
applicationManagementHosting: string | null;
|
||||
|
||||
// Factors applied
|
||||
numberOfUsersFactor: { value: number; name: string | null };
|
||||
dynamicsFactor: { value: number; name: string | null };
|
||||
complexityFactor: { value: number; name: string | null };
|
||||
|
||||
// Fallback information
|
||||
usedDefaults: string[]; // Which levels used default values
|
||||
|
||||
// Validation warnings/errors
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
|
||||
// Special flags
|
||||
requiresManualAssessment: boolean;
|
||||
isFixedFte: boolean;
|
||||
notRecommended: boolean;
|
||||
|
||||
// Hours calculation (based on final FTE)
|
||||
hoursPerYear: number;
|
||||
hoursPerMonth: number;
|
||||
hoursPerWeek: number;
|
||||
}
|
||||
|
||||
// Team dashboard types
|
||||
export interface PlatformWithWorkloads {
|
||||
platform: ApplicationListItem;
|
||||
workloads: ApplicationListItem[];
|
||||
platformEffort: number;
|
||||
workloadsEffort: number;
|
||||
totalEffort: number; // platformEffort + workloadsEffort
|
||||
}
|
||||
|
||||
export interface TeamDashboardCluster {
|
||||
cluster: ReferenceValue | null;
|
||||
applications: ApplicationListItem[]; // Regular applications (non-Platform, non-Workload)
|
||||
platforms: PlatformWithWorkloads[]; // Platforms with their workloads
|
||||
totalEffort: number; // Sum of all applications + platforms + workloads
|
||||
minEffort: number; // Sum of all minimum FTE values
|
||||
maxEffort: number; // Sum of all maximum FTE values
|
||||
applicationCount: number; // Count of all applications (including platforms and workloads)
|
||||
byGovernanceModel: Record<string, number>; // Distribution per governance model
|
||||
}
|
||||
|
||||
export interface TeamDashboardData {
|
||||
clusters: TeamDashboardCluster[];
|
||||
unassigned: {
|
||||
applications: ApplicationListItem[]; // Regular applications (non-Platform, non-Workload)
|
||||
platforms: PlatformWithWorkloads[]; // Platforms with their workloads
|
||||
totalEffort: number; // Sum of all applications + platforms + workloads
|
||||
minEffort: number; // Sum of all minimum FTE values
|
||||
maxEffort: number; // Sum of all maximum FTE values
|
||||
applicationCount: number; // Count of all applications (including platforms and workloads)
|
||||
byGovernanceModel: Record<string, number>; // Distribution per governance model
|
||||
};
|
||||
}
|
||||
|
||||
// Chat message for AI conversation
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
// For assistant messages, include the structured suggestion if available
|
||||
suggestion?: AISuggestion;
|
||||
}
|
||||
|
||||
// Chat conversation state
|
||||
export interface ChatConversation {
|
||||
id: string;
|
||||
applicationId: string;
|
||||
applicationName: string;
|
||||
messages: ChatMessage[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// Chat request for follow-up
|
||||
export interface ChatRequest {
|
||||
conversationId?: string; // If continuing existing conversation
|
||||
applicationId: string;
|
||||
message: string;
|
||||
provider?: 'claude' | 'openai';
|
||||
}
|
||||
|
||||
// Chat response
|
||||
export interface ChatResponse {
|
||||
conversationId: string;
|
||||
message: ChatMessage;
|
||||
suggestion?: AISuggestion; // Updated suggestion if AI provided one
|
||||
}
|
||||
31
frontend/tailwind.config.js
Normal file
31
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,31 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
zuyderland: {
|
||||
primary: '#003366',
|
||||
secondary: '#006699',
|
||||
accent: '#00a3e0',
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
21
frontend/vite.config.ts
Normal file
21
frontend/vite.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
284
management-parameters.json
Normal file
284
management-parameters.json
Normal file
@@ -0,0 +1,284 @@
|
||||
{
|
||||
"version": "2024.1",
|
||||
"source": "Zuyderland ICMT - Application Management Framework",
|
||||
"lastUpdated": "2024-12-19",
|
||||
"referenceData": {
|
||||
"applicationStatuses": [
|
||||
{
|
||||
"key": "status",
|
||||
"name": "Status",
|
||||
"description": "Algemene status",
|
||||
"order": 0,
|
||||
"color": "#6b7280",
|
||||
"includeInFilter": true
|
||||
},
|
||||
{
|
||||
"key": "prod",
|
||||
"name": "In Production",
|
||||
"description": "Productie - actief in gebruik",
|
||||
"order": 1,
|
||||
"color": "#22c55e",
|
||||
"includeInFilter": true
|
||||
},
|
||||
{
|
||||
"key": "impl",
|
||||
"name": "Implementation",
|
||||
"description": "In implementatie",
|
||||
"order": 2,
|
||||
"color": "#3b82f6",
|
||||
"includeInFilter": true
|
||||
},
|
||||
{
|
||||
"key": "poc",
|
||||
"name": "Proof of Concept",
|
||||
"description": "Proefproject",
|
||||
"order": 3,
|
||||
"color": "#8b5cf6",
|
||||
"includeInFilter": true
|
||||
},
|
||||
{
|
||||
"key": "eos",
|
||||
"name": "End of support",
|
||||
"description": "Geen ondersteuning meer van leverancier",
|
||||
"order": 4,
|
||||
"color": "#f97316",
|
||||
"includeInFilter": true
|
||||
},
|
||||
{
|
||||
"key": "eol",
|
||||
"name": "End of life",
|
||||
"description": "Einde levensduur, wordt uitgefaseerd",
|
||||
"order": 5,
|
||||
"color": "#ef4444",
|
||||
"includeInFilter": true
|
||||
},
|
||||
{
|
||||
"key": "deprecated",
|
||||
"name": "Deprecated",
|
||||
"description": "Verouderd, wordt uitgefaseerd",
|
||||
"order": 6,
|
||||
"color": "#f97316",
|
||||
"includeInFilter": true
|
||||
},
|
||||
{
|
||||
"key": "shadow",
|
||||
"name": "Shadow IT",
|
||||
"description": "Niet-geautoriseerde IT",
|
||||
"order": 7,
|
||||
"color": "#eab308",
|
||||
"includeInFilter": true
|
||||
},
|
||||
{
|
||||
"key": "closed",
|
||||
"name": "Closed",
|
||||
"description": "Afgesloten",
|
||||
"order": 8,
|
||||
"color": "#6b7280",
|
||||
"includeInFilter": true
|
||||
},
|
||||
{
|
||||
"key": "undefined",
|
||||
"name": "Undefined",
|
||||
"description": "Niet gedefinieerd",
|
||||
"order": 9,
|
||||
"color": "#9ca3af",
|
||||
"includeInFilter": true
|
||||
}
|
||||
],
|
||||
"dynamicsFactors": [
|
||||
{
|
||||
"key": "1",
|
||||
"name": "Stabiel",
|
||||
"description": "Weinig wijzigingen, uitgekristalliseerd systeem, < 2 releases/jaar",
|
||||
"order": 1,
|
||||
"color": "#22c55e"
|
||||
},
|
||||
{
|
||||
"key": "2",
|
||||
"name": "Gemiddeld",
|
||||
"description": "Regelmatige wijzigingen, 2-4 releases/jaar, incidentele projecten",
|
||||
"order": 2,
|
||||
"color": "#eab308"
|
||||
},
|
||||
{
|
||||
"key": "3",
|
||||
"name": "Hoog",
|
||||
"description": "Veel wijzigingen, > 4 releases/jaar, continue doorontwikkeling",
|
||||
"order": 3,
|
||||
"color": "#f97316"
|
||||
},
|
||||
{
|
||||
"key": "4",
|
||||
"name": "Zeer hoog",
|
||||
"description": "Continu in beweging, grote transformatieprojecten, veel nieuwe functionaliteit",
|
||||
"order": 4,
|
||||
"color": "#ef4444"
|
||||
}
|
||||
],
|
||||
"complexityFactors": [
|
||||
{
|
||||
"key": "1",
|
||||
"name": "Laag",
|
||||
"description": "Standalone applicatie, geen/weinig integraties, standaard configuratie",
|
||||
"order": 1,
|
||||
"color": "#22c55e"
|
||||
},
|
||||
{
|
||||
"key": "2",
|
||||
"name": "Gemiddeld",
|
||||
"description": "Enkele integraties, beperkt maatwerk, standaard governance",
|
||||
"order": 2,
|
||||
"color": "#eab308"
|
||||
},
|
||||
{
|
||||
"key": "3",
|
||||
"name": "Hoog",
|
||||
"description": "Veel integraties, significant maatwerk, meerdere stakeholdergroepen",
|
||||
"order": 3,
|
||||
"color": "#f97316"
|
||||
},
|
||||
{
|
||||
"key": "4",
|
||||
"name": "Zeer hoog",
|
||||
"description": "Platform met meerdere workloads, uitgebreide governance, veel maatwerk",
|
||||
"order": 4,
|
||||
"color": "#ef4444"
|
||||
}
|
||||
],
|
||||
"numberOfUsers": [
|
||||
{
|
||||
"key": "1",
|
||||
"name": "< 100",
|
||||
"minUsers": 0,
|
||||
"maxUsers": 99,
|
||||
"order": 1
|
||||
},
|
||||
{
|
||||
"key": "2",
|
||||
"name": "100 - 500",
|
||||
"minUsers": 100,
|
||||
"maxUsers": 500,
|
||||
"order": 2
|
||||
},
|
||||
{
|
||||
"key": "3",
|
||||
"name": "500 - 2.000",
|
||||
"minUsers": 500,
|
||||
"maxUsers": 2000,
|
||||
"order": 3
|
||||
},
|
||||
{
|
||||
"key": "4",
|
||||
"name": "2.000 - 5.000",
|
||||
"minUsers": 2000,
|
||||
"maxUsers": 5000,
|
||||
"order": 4
|
||||
},
|
||||
{
|
||||
"key": "5",
|
||||
"name": "5.000 - 10.000",
|
||||
"minUsers": 5000,
|
||||
"maxUsers": 10000,
|
||||
"order": 5
|
||||
},
|
||||
{
|
||||
"key": "6",
|
||||
"name": "10.000 - 15.000",
|
||||
"minUsers": 10000,
|
||||
"maxUsers": 15000,
|
||||
"order": 6
|
||||
},
|
||||
{
|
||||
"key": "7",
|
||||
"name": "> 15.000",
|
||||
"minUsers": 15000,
|
||||
"maxUsers": null,
|
||||
"order": 7
|
||||
}
|
||||
],
|
||||
"governanceModels": [
|
||||
{
|
||||
"key": "A",
|
||||
"name": "Centraal Beheer",
|
||||
"shortDescription": "ICMT voert volledig beheer uit",
|
||||
"description": "Volledige dienstverlening door ICMT. Dit is het standaardmodel voor kernapplicaties.",
|
||||
"applicability": "Kernapplicaties met BIA-classificatie D, E of F (belangrijk tot zeer kritiek). Voorbeelden: EPD (HiX), ERP (AFAS), Microsoft 365, kritieke zorgapplicaties.",
|
||||
"icmtInvolvement": "Volledig",
|
||||
"businessInvolvement": "Minimaal",
|
||||
"supplierInvolvement": "Via ICMT",
|
||||
"order": 1,
|
||||
"color": "#3b82f6"
|
||||
},
|
||||
{
|
||||
"key": "B",
|
||||
"name": "Federatief Beheer",
|
||||
"shortDescription": "ICMT + business delen beheer",
|
||||
"description": "ICMT en business delen de verantwoordelijkheid. Geschikt voor applicaties met een sterke key user organisatie.",
|
||||
"applicability": "Kernapplicaties met BIA-classificatie D, E of F (belangrijk tot zeer kritiek). Voorbeelden: EPD (HiX), ERP (AFAS), Microsoft 365, kritieke zorgapplicaties.",
|
||||
"icmtInvolvement": "Gedeeld",
|
||||
"businessInvolvement": "Gedeeld",
|
||||
"supplierInvolvement": "Via ICMT/Business",
|
||||
"order": 2,
|
||||
"color": "#8b5cf6"
|
||||
},
|
||||
{
|
||||
"key": "C",
|
||||
"name": "Uitbesteed met ICMT-Regie",
|
||||
"shortDescription": "Leverancier beheert, ICMT regisseert",
|
||||
"description": "Leverancier voert beheer uit, ICMT houdt regie. Dit is het standaardmodel voor SaaS waar ICMT contractpartij is.",
|
||||
"applicability": "SaaS-applicaties waar ICMT het contract beheert. Voorbeelden: AFAS, diverse zorg-SaaS oplossingen. De mate van FAB-dienstverlening hangt af van de BIA-classificatie.",
|
||||
"icmtInvolvement": "Regie",
|
||||
"businessInvolvement": "Gebruiker",
|
||||
"supplierInvolvement": "Volledig beheer",
|
||||
"contractHolder": "ICMT",
|
||||
"order": 3,
|
||||
"color": "#06b6d4"
|
||||
},
|
||||
{
|
||||
"key": "D",
|
||||
"name": "Uitbesteed met Business-Regie",
|
||||
"shortDescription": "Leverancier beheert, business regisseert",
|
||||
"description": "Business onderhoudt de leveranciersrelatie. ICMT heeft beperkte betrokkenheid.",
|
||||
"applicability": "SaaS-applicaties waar de business zelf het contract en de leveranciersrelatie beheert. Voorbeelden: niche SaaS tools, afdelingsspecifieke oplossingen, tools waar de business expertise heeft die ICMT niet heeft.",
|
||||
"icmtInvolvement": "Beperkt",
|
||||
"businessInvolvement": "Regie",
|
||||
"supplierInvolvement": "Volledig beheer",
|
||||
"contractHolder": "Business",
|
||||
"order": 4,
|
||||
"color": "#14b8a6"
|
||||
},
|
||||
{
|
||||
"key": "E",
|
||||
"name": "Volledig Decentraal Beheer",
|
||||
"shortDescription": "Business voert volledig beheer uit",
|
||||
"description": "Business voert zelf beheer uit. ICMT heeft minimale betrokkenheid.",
|
||||
"applicability": "Afdelingsspecifieke tools met beperkte impact, Shadow IT die in kaart is gebracht. Voorbeelden: standalone afdelingstools, pilotapplicaties, persoonlijke productiviteitstools.",
|
||||
"icmtInvolvement": "Minimaal",
|
||||
"businessInvolvement": "Volledig",
|
||||
"supplierInvolvement": "Direct met business",
|
||||
"order": 5,
|
||||
"color": "#6b7280"
|
||||
}
|
||||
]
|
||||
},
|
||||
"visualizations": {
|
||||
"capacityMatrix": {
|
||||
"description": "Matrix voor capaciteitsplanning gebaseerd op Dynamiek x Complexiteit",
|
||||
"formula": "Beheerlast = Dynamiek * Complexiteit * log(Gebruikers)",
|
||||
"weightings": {
|
||||
"dynamics": 1.0,
|
||||
"complexity": 1.2,
|
||||
"users": 0.3
|
||||
}
|
||||
},
|
||||
"governanceDecisionTree": {
|
||||
"description": "Beslisboom voor keuze regiemodel",
|
||||
"factors": [
|
||||
"BIA-classificatie",
|
||||
"Hosting type (SaaS/On-prem)",
|
||||
"Contracthouder",
|
||||
"Key user maturity"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
5251
package-lock.json
generated
Normal file
5251
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
Normal file
20
package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "zira-classificatie-tool",
|
||||
"version": "1.0.0",
|
||||
"description": "ZiRA Classificatie Tool voor Zuyderland CMDB",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"backend",
|
||||
"frontend"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
|
||||
"dev:backend": "npm run dev --workspace=backend",
|
||||
"dev:frontend": "npm run dev --workspace=frontend",
|
||||
"build": "npm run build --workspaces",
|
||||
"start": "npm run start --workspace=backend"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
}
|
||||
}
|
||||
1036
zira-classificatie-tool-specificatie.md
Normal file
1036
zira-classificatie-tool-specificatie.md
Normal file
File diff suppressed because it is too large
Load Diff
649
zira-taxonomy.json
Normal file
649
zira-taxonomy.json
Normal file
@@ -0,0 +1,649 @@
|
||||
{
|
||||
"version": "2024.1",
|
||||
"source": "ZiRA - Ziekenhuis Referentie Architectuur (Nictiz)",
|
||||
"lastUpdated": "2024-12-19",
|
||||
"domains": [
|
||||
{
|
||||
"code": "STU",
|
||||
"name": "Sturing",
|
||||
"description": "Applicatiefuncties ter ondersteuning van besturing en management",
|
||||
"functions": [
|
||||
{
|
||||
"code": "STU-001",
|
||||
"name": "Beleid & Innovatie",
|
||||
"description": "Functionaliteit voor ondersteuning van het bepalen en beheren van beleid, ontwikkeling producten & diensten, planning & control cyclus en ondersteunende managementinformatie",
|
||||
"keywords": ["beleid", "innovatie", "strategie", "planning", "control", "managementinformatie", "BI", "business intelligence"]
|
||||
},
|
||||
{
|
||||
"code": "STU-002",
|
||||
"name": "Proces & Architectuur",
|
||||
"description": "Functionaliteit voor het ontwikkelen en beheren van de enterprise architectuur (organisatie, processen, informatie, applicatie, techniek)",
|
||||
"keywords": ["architectuur", "proces", "enterprise", "TOGAF", "ArchiMate", "modellering", "BPM"]
|
||||
},
|
||||
{
|
||||
"code": "STU-003",
|
||||
"name": "Project & Portfoliomanagement",
|
||||
"description": "Functionaliteit voor het beheren van projecten en programma's",
|
||||
"keywords": ["project", "portfolio", "programma", "PMO", "planning", "resource", "Jira", "MS Project"]
|
||||
},
|
||||
{
|
||||
"code": "STU-004",
|
||||
"name": "Kwaliteitsinformatiemanagement",
|
||||
"description": "Functionaliteit voor de ondersteuning van het maken, verwerken en beheren van kwaliteitsdocumenten (inclusief protocollen)",
|
||||
"keywords": ["kwaliteit", "protocol", "procedure", "document", "QMS", "ISO", "accreditatie", "Zenya"]
|
||||
},
|
||||
{
|
||||
"code": "STU-005",
|
||||
"name": "Performance & Verantwoording",
|
||||
"description": "Functionaliteit voor het beheren van productieafspraken, KPI's inclusief beheer van de verantwoording in het kader van wet & regelgeving alsmede prestaties en maatschappelijk verantwoordschap",
|
||||
"keywords": ["KPI", "dashboard", "verantwoording", "rapportage", "compliance", "prestatie", "IGJ"]
|
||||
},
|
||||
{
|
||||
"code": "STU-006",
|
||||
"name": "Marketing & Contractmanagement",
|
||||
"description": "Functionaliteit voor ondersteuning van marktanalyses en contractmanagement",
|
||||
"keywords": ["marketing", "contract", "leverancier", "SLA", "marktanalyse", "CRM"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "ONZ",
|
||||
"name": "Onderzoek",
|
||||
"description": "Applicatiefuncties ter ondersteuning van wetenschappelijk onderzoek",
|
||||
"functions": [
|
||||
{
|
||||
"code": "ONZ-001",
|
||||
"name": "Onderzoek ontwikkeling",
|
||||
"description": "Functionaliteit voor de administratieve ondersteuning voor het indienen van een onderzoeksaanvraag, het opstellen van een onderzoeksprotocol, het opstellen van een onderzoeksvoorstel en de medisch etische keuring",
|
||||
"keywords": ["onderzoek", "protocol", "METC", "ethiek", "aanvraag", "voorstel"]
|
||||
},
|
||||
{
|
||||
"code": "ONZ-002",
|
||||
"name": "Onderzoekvoorbereiding",
|
||||
"description": "Functionaliteit voor de administratieve voorbereiding van het onderzoek als aanvraag van vergunningen en financieringen",
|
||||
"keywords": ["vergunning", "financiering", "subsidie", "grant", "voorbereiding"]
|
||||
},
|
||||
{
|
||||
"code": "ONZ-003",
|
||||
"name": "Onderzoeksmanagement",
|
||||
"description": "Functionaliteit voor de administratieve uitvoering van het onderzoek als aanvraag patientenselectie, verkrijgen consent",
|
||||
"keywords": ["consent", "inclusie", "patientselectie", "trial", "studie", "CTMS"]
|
||||
},
|
||||
{
|
||||
"code": "ONZ-004",
|
||||
"name": "Researchdatamanagement",
|
||||
"description": "Functionaliteit voor het verzamelen, bewerken, analyseren en publiceren van onderzoeksdata",
|
||||
"keywords": ["research", "data", "analyse", "statistiek", "SPSS", "R", "Castor", "REDCap"]
|
||||
},
|
||||
{
|
||||
"code": "ONZ-005",
|
||||
"name": "Onderzoekpublicatie",
|
||||
"description": "Functionaliteit voor de opslag van publicaties van onderzoeksresultaten",
|
||||
"keywords": ["publicatie", "artikel", "repository", "Pure", "bibliografie"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-SAM",
|
||||
"name": "Zorg - Samenwerking",
|
||||
"description": "Applicatiefuncties ter ondersteuning van samenwerking met patiënt en ketenpartners",
|
||||
"functions": [
|
||||
{
|
||||
"code": "ZRG-SAM-001",
|
||||
"name": "Dossier inzage",
|
||||
"description": "Functionaliteit die het mogelijk maakt voor patiënten om digitale inzage te krijgen in medische dossiers die de zorgverleners over hen bijhouden",
|
||||
"keywords": ["portaal", "inzage", "dossier", "patient", "MijnZuyderland", "toegang"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-SAM-002",
|
||||
"name": "Behandelondersteuning",
|
||||
"description": "Functionaliteit voor het voorlichten en coachen van en communiceren met de patiënt over zijn zorg met als doel de patiënt te helpen bij het bereiken van de behandeldoelen en (mede)verantwoordelijkheid te geven voor behandelkeuzes en behandeling (patientempowerment)",
|
||||
"keywords": ["voorlichting", "coaching", "empowerment", "educatie", "patient", "zelfmanagement"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-SAM-003",
|
||||
"name": "Interactie PGO",
|
||||
"description": "Functionaliteit voor ondersteuning en integraties met een persoonlijke gezondheidsomgeving",
|
||||
"keywords": ["PGO", "PHR", "persoonlijk", "gezondheidsomgeving", "MedMij"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-SAM-004",
|
||||
"name": "Patientenforum",
|
||||
"description": "Functionaliteit voor het aanbieden van een online omgeving voor patienten (bv discussieforum voor patienten onderling)",
|
||||
"keywords": ["forum", "community", "patient", "discussie", "lotgenoten"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-SAM-005",
|
||||
"name": "Preventie",
|
||||
"description": "Functionaliteit ter bevordering van de gezondheid en ter voorkoming van klachten en problemen",
|
||||
"keywords": ["preventie", "screening", "gezondheid", "vroegdetectie", "risico"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-SAM-006",
|
||||
"name": "Gezondheidsvragen",
|
||||
"description": "Functionaliteit voor het on-line invullen van vragenlijsten bijvoorbeeld anamnestische vragenlijsten of gezondheidsvragenlijsten",
|
||||
"keywords": ["vragenlijst", "anamnese", "intake", "PROM", "ePRO", "formulier"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-SAM-007",
|
||||
"name": "Kwaliteit en tevredenheidsmeting",
|
||||
"description": "Functionaliteit om de effecten van behandelingen en de patiënttevredenheid te kunnen meten en vaststellen",
|
||||
"keywords": ["tevredenheid", "kwaliteit", "PREM", "CQI", "NPS", "enquete", "feedback"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-SAM-008",
|
||||
"name": "Tele-consultatie",
|
||||
"description": "Functionaliteit om een zorgprofessional remote (niet in elkaars fysieke aanwezigheid) te raadplegen in het kader van een gezondheidsvraag",
|
||||
"keywords": ["teleconsultatie", "videoconsult", "beeldbellen", "remote", "consult"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-SAM-009",
|
||||
"name": "Zelfmonitoring",
|
||||
"description": "Functionaliteit om de eigen gezondheidstoestand te bewaken",
|
||||
"keywords": ["zelfmonitoring", "thuismeten", "wearable", "app", "meten"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-SAM-010",
|
||||
"name": "Tele-monitoring",
|
||||
"description": "Functionaliteit waarmee de patient op afstand (tele) gevolgd en begeleid (monitoring) wordt door de zorgverlener met behulp van bij de patient aanwezige meetapparatuur",
|
||||
"keywords": ["telemonitoring", "remote", "monitoring", "thuiszorg", "hartfalen", "COPD"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-SAM-011",
|
||||
"name": "On-line afspraken",
|
||||
"description": "Functionaliteit voor het on-line maken van afspraken",
|
||||
"keywords": ["afspraak", "online", "boeken", "reserveren", "planning"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-SAM-012",
|
||||
"name": "Dossieruitwisseling",
|
||||
"description": "Functionaliteit voor het versturen en ontvangen en verwerken van dossierinformatie door bijvoorbeeld verwijzer, overdragende of consulterend arts",
|
||||
"keywords": ["uitwisseling", "overdracht", "verwijzing", "XDS", "LSP", "Zorgplatform"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-SAM-013",
|
||||
"name": "Interactie externe bronnen",
|
||||
"description": "Functionaliteit voor informatieuitwisseling met derden voor het verzamelen van additionele gegevens",
|
||||
"keywords": ["extern", "koppeling", "integratie", "bron", "register"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-SAM-014",
|
||||
"name": "Samenwerking betrokken zorgverleners",
|
||||
"description": "Functionaliteit voor het coördineren van zorg met andere zorgverleners en het documenteren daarvan",
|
||||
"keywords": ["samenwerking", "keten", "MDO", "multidisciplinair", "consult"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-CON",
|
||||
"name": "Zorg - Consultatie & Behandeling",
|
||||
"description": "Applicatiefuncties ter ondersteuning van het primaire zorgproces",
|
||||
"functions": [
|
||||
{
|
||||
"code": "ZRG-CON-001",
|
||||
"name": "Dossierraadpleging",
|
||||
"description": "Functionaliteit voor het raadplegen van het dossier via verschillende views als patiëntgeschiedenis, decursus, samenvatting, problemen, diagnoses en allergieën",
|
||||
"keywords": ["dossier", "raadplegen", "EPD", "decursus", "samenvatting", "overzicht"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-CON-002",
|
||||
"name": "Dossiervoering",
|
||||
"description": "Functionaliteit voor het bijwerken van het dossier aan de hand van gegevens uit consult, behandeling en input vanuit andere bronnen",
|
||||
"keywords": ["dossier", "registratie", "EPD", "notitie", "verslag", "brief"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-CON-003",
|
||||
"name": "Medicatie",
|
||||
"description": "Functionaliteit van de ondersteuning van de medicamenteuze behandeling",
|
||||
"keywords": ["medicatie", "voorschrijven", "EVS", "apotheek", "recept", "CPOE"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-CON-004",
|
||||
"name": "Operatie",
|
||||
"description": "Functionaliteit voor de ondersteuning van het operatieve proces",
|
||||
"keywords": ["OK", "operatie", "chirurgie", "planning", "anesthesie", "perioperatief"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-CON-005",
|
||||
"name": "Patientbewaking",
|
||||
"description": "Functionaliteit voor bewaking van de patienten (bv medische alarmering, monitoring, dwaaldetectie, valdetectie)",
|
||||
"keywords": ["monitoring", "bewaking", "alarm", "IC", "telemetrie", "vitale functies"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-CON-006",
|
||||
"name": "Beslissingsondersteuning",
|
||||
"description": "Functionaliteit voor de ondersteuning van besluiten van de zorgverlener",
|
||||
"keywords": ["CDSS", "beslissing", "advies", "alert", "waarschuwing", "protocol"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-CON-007",
|
||||
"name": "Verzorgingondersteuning",
|
||||
"description": "Functionaliteit voor de ondersteuning van het verzorgingsproces als aanvragen van verzorgingsdiensten",
|
||||
"keywords": ["verzorging", "verpleging", "zorgplan", "ADL", "voeding"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-CON-008",
|
||||
"name": "Ordermanagement",
|
||||
"description": "Functionaliteit voor de uitvoering van de closed order loop van onderzoeken (aanvraag, planning, oplevering, acceptatie)",
|
||||
"keywords": ["order", "aanvraag", "lab", "onderzoek", "workflow", "ORM"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-CON-009",
|
||||
"name": "Resultaat afhandeling",
|
||||
"description": "Functionaliteit voor de analyse en rapportage van resultaten en notificatie naar zorgverleners en/of patient",
|
||||
"keywords": ["resultaat", "uitslag", "notificatie", "rapport", "bevinding"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-CON-010",
|
||||
"name": "Kwaliteitsbewaking",
|
||||
"description": "Functionaliteit voor de bewaking en signalering van (mogelijke) fouten (verkeerde patient, verkeerde dosis, verkeerde tijd, verkeerde vervolgstap)",
|
||||
"keywords": ["kwaliteit", "veiligheid", "controle", "check", "alert", "CDSS"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-AOZ",
|
||||
"name": "Zorg - Aanvullend onderzoek",
|
||||
"description": "Applicatiefuncties ter ondersteuning van diagnostisch onderzoek",
|
||||
"functions": [
|
||||
{
|
||||
"code": "ZRG-AOZ-001",
|
||||
"name": "Laboratoriumonderzoek",
|
||||
"description": "Functionaliteit voor de ondersteuning van processen op laboratoria (kcl, microbiologie, pathologie, klinische genetica, apotheeklab, etc)",
|
||||
"keywords": ["lab", "LIMS", "laboratorium", "KCL", "microbiologie", "pathologie", "genetica"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-AOZ-002",
|
||||
"name": "Beeldvormend onderzoek",
|
||||
"description": "Functionaliteit voor de ondersteuning van Beeldvormend onderzoek voor bijvoorbeeld Radiologie, Nucleair, Cardologie inclusief beeldmanagement (zoals VNA)",
|
||||
"keywords": ["PACS", "RIS", "radiologie", "CT", "MRI", "echo", "VNA", "DICOM"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-AOZ-003",
|
||||
"name": "Functieonderzoek",
|
||||
"description": "Functionaliteit voor de ondersteuning van Functieonderzoek (voorbeelden ECG, Longfunctie, Audiologie)",
|
||||
"keywords": ["ECG", "longfunctie", "audiologie", "functie", "EEG", "EMG"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-ZON",
|
||||
"name": "Zorg - Zorgondersteuning",
|
||||
"description": "Applicatiefuncties ter ondersteuning van de zorglogistiek",
|
||||
"functions": [
|
||||
{
|
||||
"code": "ZRG-ZON-001",
|
||||
"name": "Zorgrelatiebeheer",
|
||||
"description": "Functionaliteit voor beheren van alle gegevens van zorgrelaties (zorgaanbieders, zorgverleners, zorgverzekeraars e.d.)",
|
||||
"keywords": ["AGB", "zorgverlener", "verwijzer", "huisarts", "verzekeraar", "register"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-ZON-002",
|
||||
"name": "Zorgplanning",
|
||||
"description": "Functionaliteit voor het maken en beheren van afspraken, opnames, overplaatsingen, ontslag en verwijzing",
|
||||
"keywords": ["planning", "afspraak", "agenda", "opname", "ontslag", "bed"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-ZON-003",
|
||||
"name": "Resource planning",
|
||||
"description": "Functionaliteit voor het plannen van resources (personen, zorgverleners) en middelen",
|
||||
"keywords": ["resource", "capaciteit", "rooster", "personeel", "middelen"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-ZON-004",
|
||||
"name": "Patiëntadministratie",
|
||||
"description": "Functionaliteit voor beheer van demografie, contactpersonen en alle andere (niet medische) informatie nodig voor het ondersteunen van het consult en de behandeling",
|
||||
"keywords": ["ZIS", "administratie", "demografie", "patient", "registratie", "NAW"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-ZON-005",
|
||||
"name": "Patiëntenlogistiek",
|
||||
"description": "Functionaliteit voor de ondersteuning van het verplaatsen van mensen en middelen (bv transportlogistiek, route ondersteuning, track & tracing, aanmeldregistratie, wachtrijmanagement, oproep)",
|
||||
"keywords": ["logistiek", "transport", "wachtrij", "aanmeldzuil", "tracking", "routing"]
|
||||
},
|
||||
{
|
||||
"code": "ZRG-ZON-006",
|
||||
"name": "Zorgfacturering",
|
||||
"description": "Functionaliteit voor de vastlegging van de verrichting en factureren van het zorgproduct",
|
||||
"keywords": ["facturatie", "DBC", "DOT", "declaratie", "verrichting", "tarief"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "OND",
|
||||
"name": "Onderwijs",
|
||||
"description": "Applicatiefuncties ter ondersteuning van medisch onderwijs",
|
||||
"functions": [
|
||||
{
|
||||
"code": "OND-001",
|
||||
"name": "Onderwijsportfolio",
|
||||
"description": "Functionaliteit voor creatie en beheer van het onderwijsportfolio",
|
||||
"keywords": ["portfolio", "EPA", "competentie", "voortgang", "student"]
|
||||
},
|
||||
{
|
||||
"code": "OND-002",
|
||||
"name": "Learning Content Management",
|
||||
"description": "Functionaliteit creatie en beheer van onderwijscontent",
|
||||
"keywords": ["LMS", "content", "cursus", "module", "e-learning"]
|
||||
},
|
||||
{
|
||||
"code": "OND-003",
|
||||
"name": "Educatie",
|
||||
"description": "Functionaliteit voor het geven van educatie dmv digitale middelen",
|
||||
"keywords": ["educatie", "training", "scholing", "e-learning", "webinar"]
|
||||
},
|
||||
{
|
||||
"code": "OND-004",
|
||||
"name": "Toetsing",
|
||||
"description": "Functionaliteit voor het geven en beoordelen van toetsen",
|
||||
"keywords": ["toets", "examen", "beoordeling", "assessment", "evaluatie"]
|
||||
},
|
||||
{
|
||||
"code": "OND-005",
|
||||
"name": "Student Informatie",
|
||||
"description": "Functionaliteit voor het beheren van alle informatie van en over de student",
|
||||
"keywords": ["SIS", "student", "opleiding", "registratie", "inschrijving"]
|
||||
},
|
||||
{
|
||||
"code": "OND-006",
|
||||
"name": "Onderwijs rooster & planning",
|
||||
"description": "Functionaliteit voor het roosteren en plannen van het onderwijsprogramma",
|
||||
"keywords": ["rooster", "planning", "stage", "coschap", "onderwijs"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "BED",
|
||||
"name": "Bedrijfsondersteuning",
|
||||
"description": "Applicatiefuncties ter ondersteuning van bedrijfsvoering",
|
||||
"functions": [
|
||||
{
|
||||
"code": "BED-001",
|
||||
"name": "Vastgoed",
|
||||
"description": "Functionaliteit die beheer, bouw en exploitatie van gebouwen en de daaraan verbonden faciliteiten en goederenstromen ondersteunt",
|
||||
"keywords": ["vastgoed", "gebouw", "facilitair", "onderhoud", "FMIS"]
|
||||
},
|
||||
{
|
||||
"code": "BED-002",
|
||||
"name": "Inkoop",
|
||||
"description": "Functionaliteit die inkopen van producten en diensten alsook het beheren van leveranciers en contracten ondersteunt",
|
||||
"keywords": ["inkoop", "procurement", "leverancier", "bestelling", "contract"]
|
||||
},
|
||||
{
|
||||
"code": "BED-003",
|
||||
"name": "Voorraadbeheer",
|
||||
"description": "Beheren/beheersen van de in- en uitgaande goederenstroom (door middel van planningtools) inclusief supply chain",
|
||||
"keywords": ["voorraad", "magazijn", "supply chain", "logistiek", "inventaris"]
|
||||
},
|
||||
{
|
||||
"code": "BED-004",
|
||||
"name": "Kennismanagement",
|
||||
"description": "Functionaliteit die het creëeren en delen van gezamenlijke kennis ondersteunt",
|
||||
"keywords": ["kennis", "wiki", "intranet", "SharePoint", "documentatie"]
|
||||
},
|
||||
{
|
||||
"code": "BED-005",
|
||||
"name": "Datamanagement",
|
||||
"description": "Functionaliteit voor ondersteunen van datamanagement, inclusief reference & master datamangement, metadatamanagement, dataanalytics",
|
||||
"keywords": ["data", "master data", "metadata", "analytics", "datawarehouse", "BI"]
|
||||
},
|
||||
{
|
||||
"code": "BED-006",
|
||||
"name": "Voorlichting",
|
||||
"description": "Functionaliteit die het geven van voorlichting via verschillende kanalen ondersteunt",
|
||||
"keywords": ["website", "CMS", "communicatie", "voorlichting", "publicatie"]
|
||||
},
|
||||
{
|
||||
"code": "BED-007",
|
||||
"name": "Hotelservice",
|
||||
"description": "Functionaliteit die de hotelfunctie ondersteunt, hierbij inbegrepen zijn parkeren, catering, kassa",
|
||||
"keywords": ["catering", "restaurant", "parkeren", "kassa", "hotel"]
|
||||
},
|
||||
{
|
||||
"code": "BED-008",
|
||||
"name": "Klachtenafhandeling",
|
||||
"description": "Functionaliteit die de afhandeling van klachten ondersteunt",
|
||||
"keywords": ["klacht", "melding", "incident", "feedback", "MIC", "MIM"]
|
||||
},
|
||||
{
|
||||
"code": "BED-009",
|
||||
"name": "Personeelbeheer",
|
||||
"description": "Functionaliteit die het administreren en managen van medewerkers ondersteunt",
|
||||
"keywords": ["HR", "HRM", "personeel", "medewerker", "werving", "talent"]
|
||||
},
|
||||
{
|
||||
"code": "BED-010",
|
||||
"name": "Tijdsregistratie",
|
||||
"description": "Functionaliteit waarmee het registreren van de bestede tijd van individuen wordt ondersteund",
|
||||
"keywords": ["tijd", "uren", "registratie", "klokken", "rooster"]
|
||||
},
|
||||
{
|
||||
"code": "BED-011",
|
||||
"name": "Financieel beheer",
|
||||
"description": "Functionaliteit waarmee de financiële administratie en verwerking van financiële stromen wordt ondersteund",
|
||||
"keywords": ["financieel", "boekhouding", "factuur", "budget", "ERP", "SAP"]
|
||||
},
|
||||
{
|
||||
"code": "BED-012",
|
||||
"name": "Salarisverwerking",
|
||||
"description": "Functionaliteit waarmee het uitbetalen van salarissen aan medewerkers wordt ondersteund",
|
||||
"keywords": ["salaris", "loon", "payroll", "verloning"]
|
||||
},
|
||||
{
|
||||
"code": "BED-013",
|
||||
"name": "Beheren medische technologie",
|
||||
"description": "Functionaliteit die beheer, onderhoud en gebruik van diverse medische apparatuur ondersteunt",
|
||||
"keywords": ["MT", "medische techniek", "apparatuur", "onderhoud", "kalibratie"]
|
||||
},
|
||||
{
|
||||
"code": "BED-014",
|
||||
"name": "Beveiliging",
|
||||
"description": "Functionaliteit die ondersteunt bij het uitvoeren van de veiligheid, kwaliteit en milieu taken en verplichtingen",
|
||||
"keywords": ["beveiliging", "VGM", "ARBO", "milieu", "veiligheid"]
|
||||
},
|
||||
{
|
||||
"code": "BED-015",
|
||||
"name": "Relatiebeheer",
|
||||
"description": "Functionaliteit ter ondersteuning van relatiebeheer in brede zin",
|
||||
"keywords": ["CRM", "relatie", "stakeholder", "contact", "netwerk"]
|
||||
},
|
||||
{
|
||||
"code": "BED-016",
|
||||
"name": "ICT-change en servicemanagement",
|
||||
"description": "Functies voor het faciliteren van hulpvragen en oplossingen",
|
||||
"keywords": ["ITSM", "servicedesk", "incident", "change", "TOPdesk", "ServiceNow"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "GEN-WRK",
|
||||
"name": "Generieke ICT - Werkplek en samenwerken",
|
||||
"description": "Generieke ICT-functies voor werkplek en samenwerking",
|
||||
"functions": [
|
||||
{
|
||||
"code": "GEN-WRK-001",
|
||||
"name": "Beheren werkplek",
|
||||
"description": "Functionaliteit voor beheren hardware (PC, monitor, mobile device, printers, scanners, bedside, tv e.d.) en software op de werkplek of bed-site (LCM, CMDB, deployment, virtual desktop)",
|
||||
"keywords": ["werkplek", "PC", "laptop", "VDI", "Citrix", "deployment", "SCCM", "Intune"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-WRK-002",
|
||||
"name": "Printing & scanning",
|
||||
"description": "Functionaliteit voor het afdrukken en scannen",
|
||||
"keywords": ["print", "scan", "printer", "MFP", "document"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-WRK-003",
|
||||
"name": "Kantoorautomatisering",
|
||||
"description": "Functionaliteit voor standaard kantoorondersteuning (tekstverwerking, spreadsheet, e-mail en agenda)",
|
||||
"keywords": ["Office", "Microsoft 365", "Word", "Excel", "Outlook", "email", "agenda"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-WRK-004",
|
||||
"name": "Unified communications",
|
||||
"description": "Functionaliteit voor de (geïntegreerde) communicatie tussen mensen via verschillende kanalen (spraak, instant messaging, video)",
|
||||
"keywords": ["Teams", "telefonie", "video", "chat", "communicatie", "VoIP"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-WRK-005",
|
||||
"name": "Document & Beeld beheer",
|
||||
"description": "Functionaliteit voor het beheren van documenten en beelden",
|
||||
"keywords": ["DMS", "document", "archief", "SharePoint", "OneDrive"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-WRK-006",
|
||||
"name": "Content management",
|
||||
"description": "Functionaliteit voor het verzamelen, managen en publiceren van (niet-patientgebonden) informatie in elke vorm of medium",
|
||||
"keywords": ["CMS", "website", "intranet", "publicatie", "content"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-WRK-007",
|
||||
"name": "Publieke ICT services",
|
||||
"description": "Functionaliteit voor het aanbieden van bv radio en tv, internet, e-books, netflix",
|
||||
"keywords": ["gastnetwerk", "wifi", "entertainment", "internet", "publiek"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "GEN-IAM",
|
||||
"name": "Generieke ICT - Identiteit, toegang en beveiliging",
|
||||
"description": "Generieke ICT-functies voor identity en access management",
|
||||
"functions": [
|
||||
{
|
||||
"code": "GEN-IAM-001",
|
||||
"name": "Identiteit & Authenticatie",
|
||||
"description": "Functionaliteit voor het identificeren en authenticeren van individuen in systemen",
|
||||
"keywords": ["IAM", "identiteit", "authenticatie", "SSO", "MFA", "Active Directory", "Entra"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-IAM-002",
|
||||
"name": "Autorisatie management",
|
||||
"description": "Functionaliteit voor beheren van rechten en toegang",
|
||||
"keywords": ["autorisatie", "RBAC", "rechten", "toegang", "rollen"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-IAM-003",
|
||||
"name": "Auditing & monitoring",
|
||||
"description": "Functionaliteit voor audits en monitoring in het kader van rechtmatig gebruik en toegang",
|
||||
"keywords": ["audit", "logging", "SIEM", "compliance", "NEN7513"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-IAM-004",
|
||||
"name": "Certificate service",
|
||||
"description": "Functionaliteit voor uitgifte en beheer van certificaten",
|
||||
"keywords": ["certificaat", "PKI", "SSL", "TLS", "signing"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-IAM-005",
|
||||
"name": "ICT Preventie en protectie",
|
||||
"description": "Functionaliteit voor beheersen van kwetsbaarheden en penetraties",
|
||||
"keywords": ["security", "antivirus", "EDR", "firewall", "vulnerability", "pentest"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "GEN-DC",
|
||||
"name": "Generieke ICT - Datacenter",
|
||||
"description": "Generieke ICT-functies voor datacenter en hosting",
|
||||
"functions": [
|
||||
{
|
||||
"code": "GEN-DC-001",
|
||||
"name": "Hosting servercapaciteit",
|
||||
"description": "Functionaliteit voor het leveren van serverinfrastructuur (CPU power)",
|
||||
"keywords": ["server", "hosting", "VM", "compute", "cloud", "Azure"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-DC-002",
|
||||
"name": "Datacenter housing",
|
||||
"description": "Functionaliteit voor beheren van het datacenter, bijvoorbeeld fysieke toegang, cooling",
|
||||
"keywords": ["datacenter", "housing", "colocation", "rack", "cooling"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-DC-003",
|
||||
"name": "Hosting data storage",
|
||||
"description": "Functionaliteit voor data opslag",
|
||||
"keywords": ["storage", "SAN", "NAS", "opslag", "disk"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-DC-004",
|
||||
"name": "Data archiving",
|
||||
"description": "Functionaliteit voor het archiveren van gegevens",
|
||||
"keywords": ["archief", "archivering", "retentie", "backup", "cold storage"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-DC-005",
|
||||
"name": "Backup & recovery",
|
||||
"description": "Functionaliteit voor back-up en herstel",
|
||||
"keywords": ["backup", "restore", "recovery", "DR", "disaster recovery"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-DC-006",
|
||||
"name": "Database management",
|
||||
"description": "Functionaliteit voor het beheren van databases",
|
||||
"keywords": ["database", "SQL", "Oracle", "DBA", "DBMS"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-DC-007",
|
||||
"name": "Provisioning & automation service",
|
||||
"description": "Functionaliteit voor het distribueren en automatiseren van diensten/applicaties",
|
||||
"keywords": ["automation", "provisioning", "deployment", "DevOps", "CI/CD"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-DC-008",
|
||||
"name": "Monitoring & alerting",
|
||||
"description": "Functionaliteit voor het monitoren en analyseren van het datacentrum",
|
||||
"keywords": ["monitoring", "APM", "alerting", "Zabbix", "Splunk", "observability"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-DC-009",
|
||||
"name": "Servermanagement",
|
||||
"description": "Functionaliteit voor het beheren van servers",
|
||||
"keywords": ["server", "beheer", "patching", "configuratie", "lifecycle"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "GEN-CON",
|
||||
"name": "Generieke ICT - Connectiviteit",
|
||||
"description": "Generieke ICT-functies voor netwerk en connectiviteit",
|
||||
"functions": [
|
||||
{
|
||||
"code": "GEN-CON-001",
|
||||
"name": "Netwerkmanagement",
|
||||
"description": "Functionaliteit voor het beheren van het netwerk zoals bijv. acceptatie van hardware op netwerk/DC-LAN, Campus-LAN, WAN",
|
||||
"keywords": ["netwerk", "LAN", "WAN", "switch", "router", "wifi"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-CON-002",
|
||||
"name": "Locatiebepaling",
|
||||
"description": "Functies voor het traceren en volgen van items of eigendom, nu of in het verleden. Bijvoorbeeld RFID-toepassingen",
|
||||
"keywords": ["RFID", "RTLS", "tracking", "locatie", "asset tracking"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-CON-003",
|
||||
"name": "DNS & IP Adress management",
|
||||
"description": "Functionaliteit voor het beheren van DNS en IP adressen",
|
||||
"keywords": ["DNS", "DHCP", "IP", "IPAM", "domain"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-CON-004",
|
||||
"name": "Remote Access",
|
||||
"description": "Functionaliteit voor toegang op afstand zoals inbelfaciliteiten",
|
||||
"keywords": ["VPN", "remote", "thuiswerken", "toegang", "DirectAccess"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-CON-005",
|
||||
"name": "Load Balancing",
|
||||
"description": "Functionaliteit voor beheren van server en netwerkbelasting",
|
||||
"keywords": ["load balancer", "F5", "HAProxy", "traffic", "availability"]
|
||||
},
|
||||
{
|
||||
"code": "GEN-CON-006",
|
||||
"name": "Gegevensuitwisseling",
|
||||
"description": "Functionaliteit voor de ondersteuning van het gegevensuitwisseling (ESB, Message broker)",
|
||||
"keywords": ["integratie", "ESB", "API", "HL7", "FHIR", "message broker", "MuleSoft"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user