Initial commit: ZiRA Classification Tool for Zuyderland CMDB

This commit is contained in:
2026-01-06 15:32:28 +01:00
commit 0b27adc2fb
55 changed files with 24310 additions and 0 deletions

View 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
View 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
View 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
View 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
View 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

Binary file not shown.

View 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
View 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"
}
}

View 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
View 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.');
}
}

View 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"
]
}
}
}

View 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
View 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);
});

View 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;

View 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;

View 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;

View 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;

View 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;

File diff suppressed because it is too large Load Diff

View 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);
},
};

View 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();

View 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] || [];
}

File diff suppressed because it is too large Load Diff

View 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',
})
);
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

84
frontend/src/App.tsx Normal file
View 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;

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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>
);

View 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',
});
}

View 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',
}
)
);

View 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
View 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
}

View 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
View 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" }]
}

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

20
package.json Normal file
View 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"
}
}

File diff suppressed because it is too large Load Diff

649
zira-taxonomy.json Normal file
View 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"]
}
]
}
]
}