From 0b27adc2fb932d8f724c1f1359f64c187aa2dec9 Mon Sep 17 00:00:00 2001 From: Bert Hausmans Date: Tue, 6 Jan 2026 15:32:28 +0100 Subject: [PATCH] Initial commit: ZiRA Classification Tool for Zuyderland CMDB --- .claude/settings.local.json | 10 + .env.example | 49 + .gitignore | 37 + CLAUDE.md | 197 + backend/Dockerfile | 19 + backend/data/BIA.xlsx | Bin 0 -> 46999 bytes backend/data/effort-calculation-config.json | 902 +++ backend/package.json | 32 + backend/src/config/effortCalculation.ts | 720 +++ backend/src/config/env.ts | 144 + backend/src/data/management-parameters.json | 284 + backend/src/data/zira-taxonomy.json | 649 ++ backend/src/index.ts | 102 + backend/src/routes/applications.ts | 217 + backend/src/routes/classifications.ts | 203 + backend/src/routes/configuration.ts | 121 + backend/src/routes/dashboard.ts | 79 + backend/src/routes/referenceData.ts | 203 + backend/src/services/claude.ts | 1410 +++++ backend/src/services/dataService.ts | 207 + backend/src/services/database.ts | 154 + backend/src/services/effortCalculation.ts | 577 ++ backend/src/services/jiraAssets.ts | 2092 +++++++ backend/src/services/logger.ts | 40 + backend/src/services/mockData.ts | 859 +++ backend/src/types/index.ts | 409 ++ backend/tsconfig.json | 20 + docker-compose.yml | 35 + frontend/Dockerfile | 16 + frontend/index.html | 13 + frontend/package.json | 27 + frontend/postcss.config.js | 6 + frontend/src/App.tsx | 84 + frontend/src/components/ApplicationDetail.tsx | 2555 ++++++++ frontend/src/components/ApplicationList.tsx | 682 +++ frontend/src/components/Configuration.tsx | 809 +++ frontend/src/components/ConfigurationV25.tsx | 529 ++ frontend/src/components/CustomSelect.tsx | 199 + frontend/src/components/Dashboard.tsx | 299 + frontend/src/components/TeamDashboard.tsx | 908 +++ frontend/src/index.css | 115 + frontend/src/main.tsx | 13 + frontend/src/services/api.ts | 391 ++ frontend/src/stores/navigationStore.ts | 91 + frontend/src/stores/searchStore.ts | 130 + frontend/src/types/index.ts | 344 ++ frontend/tailwind.config.js | 31 + frontend/tsconfig.json | 25 + frontend/tsconfig.node.json | 11 + frontend/vite.config.ts | 21 + management-parameters.json | 284 + package-lock.json | 5251 +++++++++++++++++ package.json | 20 + zira-classificatie-tool-specificatie.md | 1036 ++++ zira-taxonomy.json | 649 ++ 55 files changed, 24310 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 backend/Dockerfile create mode 100644 backend/data/BIA.xlsx create mode 100644 backend/data/effort-calculation-config.json create mode 100644 backend/package.json create mode 100644 backend/src/config/effortCalculation.ts create mode 100644 backend/src/config/env.ts create mode 100644 backend/src/data/management-parameters.json create mode 100644 backend/src/data/zira-taxonomy.json create mode 100644 backend/src/index.ts create mode 100644 backend/src/routes/applications.ts create mode 100644 backend/src/routes/classifications.ts create mode 100644 backend/src/routes/configuration.ts create mode 100644 backend/src/routes/dashboard.ts create mode 100644 backend/src/routes/referenceData.ts create mode 100644 backend/src/services/claude.ts create mode 100644 backend/src/services/dataService.ts create mode 100644 backend/src/services/database.ts create mode 100644 backend/src/services/effortCalculation.ts create mode 100644 backend/src/services/jiraAssets.ts create mode 100644 backend/src/services/logger.ts create mode 100644 backend/src/services/mockData.ts create mode 100644 backend/src/types/index.ts create mode 100644 backend/tsconfig.json create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/ApplicationDetail.tsx create mode 100644 frontend/src/components/ApplicationList.tsx create mode 100644 frontend/src/components/Configuration.tsx create mode 100644 frontend/src/components/ConfigurationV25.tsx create mode 100644 frontend/src/components/CustomSelect.tsx create mode 100644 frontend/src/components/Dashboard.tsx create mode 100644 frontend/src/components/TeamDashboard.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/services/api.ts create mode 100644 frontend/src/stores/navigationStore.ts create mode 100644 frontend/src/stores/searchStore.ts create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 management-parameters.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 zira-classificatie-tool-specificatie.md create mode 100644 zira-taxonomy.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..48aab54 --- /dev/null +++ b/.claude/settings.local.json @@ -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:*)" + ] + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..967e9c1 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a78a67 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..aa8bef2 --- /dev/null +++ b/CLAUDE.md @@ -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= +JIRA_SCHEMA_ID= + +# Jira Object Type IDs +JIRA_APPLICATION_COMPONENT_TYPE_ID= +JIRA_APPLICATION_FUNCTION_TYPE_ID= +JIRA_DYNAMICS_FACTOR_TYPE_ID= +JIRA_COMPLEXITY_FACTOR_TYPE_ID= +JIRA_NUMBER_OF_USERS_TYPE_ID= +JIRA_GOVERNANCE_MODEL_TYPE_ID= +JIRA_APPLICATION_CLUSTER_TYPE_ID= +JIRA_APPLICATION_TYPE_TYPE_ID= + +# Jira Attribute IDs +JIRA_ATTR_APPLICATION_FUNCTION= +JIRA_ATTR_DYNAMICS_FACTOR= +JIRA_ATTR_COMPLEXITY_FACTOR= +JIRA_ATTR_NUMBER_OF_USERS= +JIRA_ATTR_GOVERNANCE_MODEL= +JIRA_ATTR_APPLICATION_CLUSTER= +JIRA_ATTR_APPLICATION_TYPE= + +# Claude AI +ANTHROPIC_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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..8fabdac --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/data/BIA.xlsx b/backend/data/BIA.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..7afa57ebbbd7e07f245c7cfb8d1099e2ce0a7430 GIT binary patch literal 46999 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 D@EUPJ literal 0 HcmV?d00001 diff --git a/backend/data/effort-calculation-config.json b/backend/data/effort-calculation-config.json new file mode 100644 index 0000000..859c0d4 --- /dev/null +++ b/backend/data/effort-calculation-config.json @@ -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 + } +} \ No newline at end of file diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..8b39967 --- /dev/null +++ b/backend/package.json @@ -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" + } +} diff --git a/backend/src/config/effortCalculation.ts b/backend/src/config/effortCalculation.ts new file mode 100644 index 0000000..bc45c3d --- /dev/null +++ b/backend/src/config/effortCalculation.ts @@ -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 }, +}; diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts new file mode 100644 index 0000000..62e0dbc --- /dev/null +++ b/backend/src/config/env.ts @@ -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.'); + } +} diff --git a/backend/src/data/management-parameters.json b/backend/src/data/management-parameters.json new file mode 100644 index 0000000..10a6f3c --- /dev/null +++ b/backend/src/data/management-parameters.json @@ -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" + ] + } + } +} diff --git a/backend/src/data/zira-taxonomy.json b/backend/src/data/zira-taxonomy.json new file mode 100644 index 0000000..82da0ac --- /dev/null +++ b/backend/src/data/zira-taxonomy.json @@ -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"] + } + ] + } + ] +} diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..3c327b2 --- /dev/null +++ b/backend/src/index.ts @@ -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); +}); diff --git a/backend/src/routes/applications.ts b/backend/src/routes/applications.ts new file mode 100644 index 0000000..4150a33 --- /dev/null +++ b/backend/src/routes/applications.ts @@ -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; + + // 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; diff --git a/backend/src/routes/classifications.ts b/backend/src/routes/classifications.ts new file mode 100644 index 0000000..b964cae --- /dev/null +++ b/backend/src/routes/classifications.ts @@ -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; diff --git a/backend/src/routes/configuration.ts b/backend/src/routes/configuration.ts new file mode 100644 index 0000000..a3d43f5 --- /dev/null +++ b/backend/src/routes/configuration.ts @@ -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; + diff --git a/backend/src/routes/dashboard.ts b/backend/src/routes/dashboard.ts new file mode 100644 index 0000000..3764670 --- /dev/null +++ b/backend/src/routes/dashboard.ts @@ -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; diff --git a/backend/src/routes/referenceData.ts b/backend/src/routes/referenceData.ts new file mode 100644 index 0000000..ac8d335 --- /dev/null +++ b/backend/src/routes/referenceData.ts @@ -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; diff --git a/backend/src/services/claude.ts b/backend/src/services/claude.ts new file mode 100644 index 0000000..e280444 --- /dev/null +++ b/backend/src/services/claude.ts @@ -0,0 +1,1410 @@ +import Anthropic from '@anthropic-ai/sdk'; +import OpenAI from 'openai'; +import { config } from '../config/env.js'; +import { logger } from './logger.js'; +import type { ApplicationDetails, AISuggestion, ZiraTaxonomy, ReferenceValue, ChatMessage, ChatConversation, ChatResponse } from '../types/index.js'; +import { dataService } from './dataService.js'; +import { readFileSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import * as XLSX from 'xlsx'; +import { randomUUID } from 'crypto'; + +// AI Provider type +export type AIProvider = 'claude' | 'openai'; + +// In-memory store for conversations +const conversationStore = new Map(); + +// Clean up old conversations periodically (keep for 1 hour) +const CONVERSATION_TTL = 60 * 60 * 1000; // 1 hour +setInterval(() => { + const now = Date.now(); + for (const [id, conv] of conversationStore.entries()) { + if (now - conv.updatedAt.getTime() > CONVERSATION_TTL) { + conversationStore.delete(id); + logger.debug(`Cleaned up old conversation: ${id}`); + } + } +}, 5 * 60 * 1000); // Check every 5 minutes + +// Interface for Application Function Category with name and description +interface ApplicationFunctionCategoryInfo { + objectId: string; + name: string; + description?: string; +} + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Load ZiRA taxonomy (kept for backward compatibility) +let ziraTaxonomy: ZiraTaxonomy; +try { + const taxonomyPath = join(__dirname, '../data/zira-taxonomy.json'); + ziraTaxonomy = JSON.parse(readFileSync(taxonomyPath, 'utf-8')); +} catch (error) { + logger.error('Failed to load ZiRA taxonomy', error); + ziraTaxonomy = { version: '', source: '', lastUpdated: '', domains: [] }; +} + +// BIA Excel data cache +interface BIARecord { + applicationName: string; + biaValue: string; +} + +let biaDataCache: BIARecord[] | null = null; +let biaDataCacheTimestamp: number = 0; +const BIA_CACHE_TTL = 5 * 60 * 1000; // 5 minutes + +// Load BIA data from Excel file +function loadBIAData(): BIARecord[] { + const now = Date.now(); + // Return cached data if still valid + if (biaDataCache && (now - biaDataCacheTimestamp) < BIA_CACHE_TTL) { + return biaDataCache; + } + + // Path to BIA.xlsx: from compiled location (dist/services/) go up 2 levels to backend/, then into data/ + const biaFilePath = join(__dirname, '../../data/BIA.xlsx'); + + if (!existsSync(biaFilePath)) { + logger.warn(`BIA.xlsx file not found at ${biaFilePath}, skipping BIA lookup`); + biaDataCache = []; + biaDataCacheTimestamp = now; + return []; + } + + logger.debug(`Loading BIA data from: ${biaFilePath}`); + + try { + const workbook = XLSX.readFile(biaFilePath); + const sheetName = workbook.SheetNames[0]; + const worksheet = workbook.Sheets[sheetName]; + const data = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as any[][]; + + // Find the header row and determine column indices dynamically + let headerRowIndex = -1; + let applicationNameColumnIndex = -1; + let biaValueColumnIndex = -1; + + // First, find the header row by looking for "BIA - Informatiemiddel" and "BIA - Bruto risicoscore" + for (let i = 0; i < Math.min(10, data.length); i++) { + const row = data[i]; + if (!row || row.length < 3) continue; + + // Search for "BIA - Informatiemiddel" (application name column) + for (let col = 0; col < row.length; col++) { + const cellValue = String(row[col] || '').trim().toLowerCase(); + if (cellValue.includes('bia') && cellValue.includes('informatiemiddel')) { + applicationNameColumnIndex = col; + headerRowIndex = i; + break; + } + } + + // If we found the application name column, now find "BIA - Bruto risicoscore" + if (headerRowIndex !== -1 && applicationNameColumnIndex !== -1) { + for (let col = 0; col < row.length; col++) { + const cellValue = String(row[col] || '').trim().toLowerCase(); + if (cellValue.includes('bia') && cellValue.includes('bruto') && cellValue.includes('risicoscore')) { + biaValueColumnIndex = col; + break; + } + } + break; + } + } + + if (headerRowIndex === -1 || applicationNameColumnIndex === -1) { + logger.warn('Could not find "BIA - Informatiemiddel" column in BIA.xlsx'); + biaDataCache = []; + biaDataCacheTimestamp = now; + return []; + } + + if (biaValueColumnIndex === -1) { + logger.warn('Could not find "BIA - Bruto risicoscore" column in BIA.xlsx'); + biaDataCache = []; + biaDataCacheTimestamp = now; + return []; + } + + logger.info(`Found BIA columns: Application name at column ${applicationNameColumnIndex + 1} (${String.fromCharCode(65 + applicationNameColumnIndex)}), BIA value at column ${biaValueColumnIndex + 1} (${String.fromCharCode(65 + biaValueColumnIndex)})`); + + // Extract data starting from the row after the header + const records: BIARecord[] = []; + for (let i = headerRowIndex + 1; i < data.length; i++) { + const row = data[i]; + if (row && row.length > applicationNameColumnIndex) { + const applicationName = String(row[applicationNameColumnIndex] || '').trim(); + + if (!applicationName || applicationName.length === 0) { + continue; // Skip empty rows + } + + // Get BIA value from the dynamically found column + let biaValue = ''; + if (row.length > biaValueColumnIndex) { + biaValue = String(row[biaValueColumnIndex] || '').trim().toUpperCase(); + } else { + logger.debug(`Row ${i} does not have enough columns for BIA value (need column ${biaValueColumnIndex + 1})`); + } + + // Extract just the letter if the value contains more than just A-F (e.g., "A - Test/Archief") + if (biaValue && !/^[A-F]$/.test(biaValue)) { + const match = biaValue.match(/^([A-F])/); + if (match) { + biaValue = match[1]; + } + } + + // Only add record if we have both application name and BIA value + if (applicationName && /^[A-F]$/.test(biaValue)) { + records.push({ + applicationName: applicationName, + biaValue: biaValue, + }); + } + } + } + + logger.info(`Loaded ${records.length} BIA records from Excel file`); + biaDataCache = records; + biaDataCacheTimestamp = now; + return records; + } catch (error) { + logger.error('Failed to load BIA data from Excel', error); + biaDataCache = []; + biaDataCacheTimestamp = now; + return []; + } +} + +// Calculate Levenshtein distance for fuzzy matching +function levenshteinDistance(str1: string, str2: string): number { + const matrix: number[][] = []; + const len1 = str1.length; + const len2 = str2.length; + + if (len1 === 0) return len2; + if (len2 === 0) return len1; + + for (let i = 0; i <= len1; i++) { + matrix[i] = [i]; + } + + for (let j = 0; j <= len2; j++) { + matrix[0][j] = j; + } + + for (let i = 1; i <= len1; i++) { + for (let j = 1; j <= len2; j++) { + const cost = str1[i - 1] === str2[j - 1] ? 0 : 1; + matrix[i][j] = Math.min( + matrix[i - 1][j] + 1, // deletion + matrix[i][j - 1] + 1, // insertion + matrix[i - 1][j - 1] + cost // substitution + ); + } + } + + return matrix[len1][len2]; +} + +// Calculate similarity score (0-1, where 1 is identical) +function calculateSimilarity(str1: string, str2: string): number { + const maxLen = Math.max(str1.length, str2.length); + if (maxLen === 0) return 1; + const distance = levenshteinDistance(str1.toLowerCase(), str2.toLowerCase()); + return 1 - (distance / maxLen); +} + +// Find BIA value for application using exact match first, then fuzzy matching +function findBIAValue(applicationName: string): string | null { + const biaData = loadBIAData(); + if (biaData.length === 0) { + logger.debug(`No BIA data available for lookup of "${applicationName}"`); + return null; + } + + const normalizedAppName = applicationName.toLowerCase().trim(); + + // Step 1: Try exact match (case-insensitive) + for (const record of biaData) { + const normalizedRecordName = record.applicationName.toLowerCase().trim(); + if (normalizedAppName === normalizedRecordName) { + logger.info(`Found exact BIA match for "${applicationName}" -> "${record.applicationName}": ${record.biaValue}`); + return record.biaValue; + } + } + + // Step 2: Try partial match (one name contains the other) + for (const record of biaData) { + const normalizedRecordName = record.applicationName.toLowerCase().trim(); + if (normalizedAppName.includes(normalizedRecordName) || normalizedRecordName.includes(normalizedAppName)) { + logger.info(`Found partial BIA match for "${applicationName}" -> "${record.applicationName}": ${record.biaValue}`); + return record.biaValue; + } + } + + // Step 3: Try fuzzy matching with lower threshold + let bestMatch: { value: string; similarity: number; recordName: string } | null = null; + const threshold = 0.6; // Lowered threshold to 60% for better matching + + for (const record of biaData) { + const normalizedRecordName = record.applicationName.toLowerCase().trim(); + const similarity = calculateSimilarity(normalizedAppName, normalizedRecordName); + + if (similarity >= threshold) { + if (!bestMatch || similarity > bestMatch.similarity) { + bestMatch = { + value: record.biaValue, + similarity: similarity, + recordName: record.applicationName, + }; + } + } + } + + if (bestMatch) { + logger.info(`Found fuzzy BIA match for "${applicationName}" -> "${bestMatch.recordName}": ${bestMatch.value} (similarity: ${(bestMatch.similarity * 100).toFixed(1)}%)`); + return bestMatch.value; + } + + logger.debug(`No BIA match found for "${applicationName}" (checked ${biaData.length} records)`); + return null; +} + +// Get Governance Models with additional attributes (Remarks, Application) +async function getGovernanceModelsWithDetails(): Promise { + const models = await dataService.getGovernanceModels(); + // getReferenceObjects now extracts Remarks and Application attributes + return models; +} + +// Format Application Functions from Jira Assets for the prompt +async function formatApplicationFunctionsForPrompt( + functions: ReferenceValue[], + categories: ReferenceValue[] +): Promise { + if (functions.length === 0) { + return 'Geen applicatiefuncties beschikbaar in Jira Assets.'; + } + + // Create a map of category ID to category info for quick lookup + const categoryMap = new Map(); + categories.forEach((cat) => { + categoryMap.set(cat.objectId, { + objectId: cat.objectId, + name: cat.name, + description: cat.description, + }); + }); + + // Group by Application Function Category + const byCategory = new Map(); + const withoutCategory: ReferenceValue[] = []; + + functions.forEach((func) => { + if (func.applicationFunctionCategory?.objectId) { + const categoryId = func.applicationFunctionCategory.objectId; + const categoryInfo = categoryMap.get(categoryId); + + if (categoryInfo) { + if (!byCategory.has(categoryId)) { + byCategory.set(categoryId, { + categoryInfo, + functions: [], + }); + } + byCategory.get(categoryId)!.functions.push(func); + } else { + // Category reference exists but category not found in categories list + withoutCategory.push(func); + } + } else { + withoutCategory.push(func); + } + }); + + const sections: string[] = []; + + // Add categorized functions with category name and description + if (byCategory.size > 0) { + // Sort categories by name + const sortedCategories = Array.from(byCategory.entries()).sort((a, b) => + a[1].categoryInfo.name.localeCompare(b[1].categoryInfo.name) + ); + + sortedCategories.forEach(([categoryId, { categoryInfo, functions: funcs }]) => { + const categoryDescription = categoryInfo.description + ? `\n${categoryInfo.description}` + : ''; + + const functionList = funcs + .sort((a, b) => a.name.localeCompare(b.name)) + .map((f) => { + const desc = f.description ? ` - ${f.description}` : ''; + const keywords = f.keywords ? ` [Keywords: ${f.keywords}]` : ''; + return ` - ${f.key}: ${f.name}${desc}${keywords}`; + }) + .join('\n'); + + sections.push(`### ${categoryInfo.name}${categoryDescription}\n${functionList}`); + }); + } + + // Add uncategorized functions + if (withoutCategory.length > 0) { + const functionList = withoutCategory + .sort((a, b) => a.name.localeCompare(b.name)) + .map((f) => { + const desc = f.description ? ` - ${f.description}` : ''; + const keywords = f.keywords ? ` [Keywords: ${f.keywords}]` : ''; + return ` - ${f.key}: ${f.name}${desc}${keywords}`; + }) + .join('\n'); + sections.push(`### Overige functies\n${functionList}`); + } + + return sections.join('\n\n'); +} + +// Format reference objects for prompt (Application Type, Dynamics Factor, etc.) +function formatReferenceObjectsForPrompt( + objects: ReferenceValue[], + useSummary: boolean = false +): string { + if (objects.length === 0) { + return 'Geen objecten beschikbaar.'; + } + + return objects + .map((obj) => { + const displayText = useSummary && obj.summary + ? obj.summary + : obj.description || ''; + return ` - ${obj.key}: ${obj.name}${displayText ? ` - ${displayText}` : ''}`; + }) + .join('\n'); +} + +// Format reference objects with emphasis on exact name (for fields where AI must use exact name) +function formatReferenceObjectsWithExactNames( + objects: ReferenceValue[], + useSummary: boolean = false +): string { + if (objects.length === 0) { + return 'Geen objecten beschikbaar.'; + } + + return objects + .map((obj) => { + const displayText = useSummary && obj.summary + ? obj.summary + : obj.description || ''; + // Emphasize the exact name that should be used + return ` - **"${obj.name}"**${displayText ? ` - ${displayText}` : ''}`; + }) + .join('\n'); +} + +// Format Governance Models with Remarks and Application attributes +async function formatGovernanceModelsForPrompt(): Promise { + const models = await getGovernanceModelsWithDetails(); + if (models.length === 0) { + return 'Geen regiemodellen beschikbaar.'; + } + + return models + .map((model) => { + const parts: string[] = [` - ${model.key}: ${model.name}`]; + if (model.summary) { + parts.push(` Summary: ${model.summary}`); + } + if (model.description) { + parts.push(` Description: ${model.description}`); + } + if (model.remarks) { + parts.push(` Remarks: ${model.remarks}`); + } + if (model.application) { + parts.push(` Application: ${model.application}`); + } + return parts.join('\n'); + }) + .join('\n'); +} + +// Format Business Impact Analyses for the main list +function formatBusinessImpactAnalysesForPrompt(analyses: ReferenceValue[]): string { + if (analyses.length === 0) { + return 'Geen BIA-classificaties beschikbaar.'; + } + + return analyses + .map((analysis) => { + const parts: string[] = [` - ${analysis.key}: ${analysis.name}`]; + if (analysis.description) { + parts.push(` ${analysis.description}`); + } + return parts.join('\n'); + }) + .join('\n'); +} + +// Format Business Impact Analyses Indicators (for the indicators section) +function formatBusinessImpactIndicatorsForPrompt(analyses: ReferenceValue[]): string { + if (analyses.length === 0) { + return 'Geen BIA-indicatoren beschikbaar.'; + } + + return analyses + .map((analysis) => { + const parts: string[] = [` - ${analysis.key}: ${analysis.name}`]; + if (analysis.indicators) { + parts.push(` ${analysis.indicators}`); + } else if (analysis.description) { + // Fallback to description if indicators not available + parts.push(` ${analysis.description}`); + } + return parts.join('\n'); + }) + .join('\n'); +} + +// Format application data for the prompt +function formatApplicationDataForPrompt(application: ApplicationDetails, webSearchResults?: string): string { + // Get Application Component Hosting Type (set by IT Architect - informational only) + const applicationHostingTypeName = typeof application.hostingType === 'string' + ? application.hostingType + : application.hostingType?.name || 'Niet ingesteld'; + const applicationHostingTypeDescription = typeof application.hostingType === 'object' && application.hostingType?.description + ? ` (${application.hostingType.description})` + : ''; + + let result = `Naam: ${application.name} +Beschrijving: ${application.description || 'Geen beschrijving beschikbaar'} +Leverancier/Product: ${application.supplierProduct || 'Onbekend'} +Organisatie: ${application.organisation || 'Onbekend'} +Application Component Hosting Type (door IT Architect ingesteld): ${applicationHostingTypeName}${applicationHostingTypeDescription} +Status: ${application.status || 'Onbekend'} +Business Importance: ${application.businessImportance || 'Onbekend'} +Business Owner: ${application.businessOwner || 'Onbekend'} +Medische Techniek: ${application.medischeTechniek ? 'Ja' : 'Nee'}`; + + if (webSearchResults) { + result += `\n\n## Aanvullende informatie van web search:\n${webSearchResults}`; + } + + return result; +} + +// Check if application has insufficient information +function hasInsufficientInformation(application: ApplicationDetails): boolean { + const hasDescription = application.description && application.description.trim().length > 20; + const hasSupplier = application.supplierProduct && application.supplierProduct.trim().length > 0; + + // Consider insufficient if description is missing or very short, or supplier is unknown + if (!hasDescription) { + return true; + } + + if (!hasSupplier) { + return true; + } + + // At this point, we know supplierProduct is not null due to hasSupplier check + const supplierLower = application.supplierProduct!.toLowerCase(); + return supplierLower.includes('onbekend') || supplierLower.includes('unknown'); +} + +// Interface for Tavily API response +interface TavilySearchResult { + title: string; + url: string; + content?: string; +} + +interface TavilySearchResponse { + answer?: string; + results?: TavilySearchResult[]; +} + +// Perform web search using Tavily API +async function performWebSearch(query: string): Promise { + if (!config.enableWebSearch || !config.tavilyApiKey) { + return null; + } + + try { + const response = await fetch('https://api.tavily.com/search', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + api_key: config.tavilyApiKey, + query: query, + search_depth: 'basic', + include_answer: true, + include_raw_content: false, + max_results: 5, + }), + }); + + if (!response.ok) { + logger.warn(`Tavily API request failed: ${response.status} ${response.statusText}`); + return null; + } + + const data = await response.json() as TavilySearchResponse; + + if (!data.results || data.results.length === 0) { + return null; + } + + // Format search results + const formattedResults: string[] = []; + + if (data.answer) { + formattedResults.push(`Samenvatting: ${data.answer}`); + } + + formattedResults.push('Relevante bronnen:'); + data.results.slice(0, 5).forEach((result: TavilySearchResult, index: number) => { + formattedResults.push(`${index + 1}. ${result.title} (${result.url})`); + if (result.content) { + formattedResults.push(` ${result.content.substring(0, 200)}...`); + } + }); + + return formattedResults.join('\n'); + } catch (error) { + logger.error('Web search failed', error); + return null; + } +} + +const CLASSIFICATION_PROMPT = `Je bent een ervaren informatiemanager in de Nederlandse zorg met expertise in applicatieclassificatie volgens ZiRA (Ziekenhuis Referentie Architectuur), IT-governance, Business Impact Analyse conform NEN 7510, en CMDB-inrichting. + +## Doel + +Classificeer de aangeleverde applicatiecomponent voor de CMDB in Jira Assets. Je levert: +1. Functionele classificatie - Welke ZiRA-applicatiefunctie(s) ondersteunt de applicatie? +2. Beheerclassificatie - Hoe moet de applicatie worden beheerd? + +## Organisatiecontext + +Zuyderland Medisch Centrum: groot ziekenhuis, circa 14.000 medewerkers, business units Cure/Care-Zorgcentra/Care-Thuiszorg/Care-Thuishulp/GGZ, NEN 7510 compliance, "SaaS-unless, Azure-unless" strategie. + +## Classificatieprincipes + +Applicatiefuncties: +- Productonafhankelijk: focus op FUNCTIONALITEIT, niet op merknaam +- Bepaal primaire functie (hoofddoel) en secundaire functies (substantiële nevenfuncties) +- Een applicatiefunctie is: "samenhangende functionaliteit die ondersteuning biedt aan bedrijfsactiviteiten" + +Beheerclassificaties: +- Gebaseerd op objectieve criteria en beslislogica +- Consistent met bestaand beleid (Dienstencatalogus Applicatiebeheer v25) +- Rekening houdend met patiëntveiligheid en zorgcontext + +## Beschikbare classificatiewaarden + +### Applicatiefuncties (ZiRA) +{{APPLICATIEFUNCTIES}} + +### Application Type +Beslislogica: +1. Fysieke hardware met IT-backend? → Connected Device +2. Module/onderdeel binnen groter platform? → Workload +3. Multi-service omgeving met meerdere workloads? → Platform +4. Zelfstandige applicatie? → Applicatie + +Let op: Platforms zijn NIET geschikt voor Regiemodel E en NIET aanbevolen voor Regiemodel D vanwege governance-risico's (tenant-niveau configuratie, security-beslissingen, integraties vereisen centraal overzicht). + +{{APPLICATION_TYPES}} + +### Dynamiek Factor +Vuistregels: SaaS grote vendors (Microsoft, Salesforce) = 3-4; On-premises legacy = 1-2; Actief ontwikkelde zorgapplicaties = 2-3; Connected Devices = 1-2 + +{{DYNAMIEK_FACTORS}} + +### Complexiteit Factor +Indicatoren: Aantal integraties (0-2=laag, 3-5=gemiddeld, 6-10=hoog, >10=zeer hoog), aantal stakeholdergroepen, mate van maatwerk + +{{COMPLEXITEIT_FACTORS}} + +### Application Management - Hosting +Dit veld bepaalt waar de applicatie wordt gehost en wie verantwoordelijk is voor het technisch applicatiebeheer. + +**Beslislogica:** +1. Status "Proof of Concept"? → Kies een PoC-variant +2. Alles bij leverancier zonder eigen infrastructuur (SaaS)? → Kies "Extern" variant +3. Azure met leverancier Delegated Management? → Kies "Azure - Delegated Management" of vergelijkbaar +4. Azure met eigen beheer (ICMT of Decentrale IT)? → Kies "Azure - Eigen beheer" of vergelijkbaar +5. Zuyderland datacenter? → Kies "On-premises" of vergelijkbaar + +**Beschikbare waarden (gebruik EXACT een van deze namen):** +{{APPLICATION_MANAGEMENT_HOSTING}} + +**BELANGRIJK:** Gebruik in je JSON output EXACT de naam zoals deze in de bovenstaande lijst staat (inclusief hoofdletters en spaties). + +### Business Impact Analyse (BIA) +Indicatoren: +- A = Test/ontwikkelomgevingen, archiefsystemen zonder operationele functie +- B = Ondersteunende tooling, niet-kritieke managementinformatie +- C = Kantoorautomatisering, standaard bedrijfsvoering +- D = Ondersteunende zorgapplicaties, belangrijke bedrijfsprocessen +- E = Lab/PACS/communicatie, kritieke zorgondersteunende systemen +- F = EPD-kern/medicatie/IC/OK/spoed - levensbedreigende impact bij uitval + +{{BIA_CLASSIFICATIES}} + +### ICT Governance Model (Regiemodel) + +#### Toegestane BIA per Regiemodel (HARD CONSTRAINT) +- Model A (Centraal Beheer): BIA D, E, F +- Model B (Federatief Beheer): BIA C, D, E +- Model B+ (Gescheiden Beheer): BIA C, D, E +- Model C (Uitbesteed met ICMT-Regie): BIA C, D, E, F +- Model D (Decentraal met Business-Regie): BIA A, B, C +- Model E (Volledig Decentraal): BIA A, B + +#### Beslisboom Regiemodel + +**Stap 1: Check Application Type restricties** +- Platform + Model D → Niet aanbevolen (waarschuwing) +- Platform + Model E → Niet toegestaan +- Connected Device → Aparte beoordeling (standaard Model A of D afhankelijk van BIA) + +**Stap 2: Bepaal regiemodel op basis van Hosting en BIA** + +Voor SaaS/Extern: +- BIA D-F + ICMT-contract → Model C +- BIA C + ICMT-contract → Model C +- BIA A-C + Business-contract → Model D +- BIA A-B + Business volledig zelfstandig → Model E + +Voor Azure-DM (Leverancier Delegated Management): +- BIA C-F → Model C (ICMT houdt regie) +- BIA A-B → Model D (Business houdt regie) + +Voor Azure (eigen beheer) of On-premises: +- BIA D-F → Model A (ICMT doet TAB + FAB) +- BIA C-E + Competente key users aanwezig → Model B (ICMT doet TAB, business doet FAB met coaching) +- BIA C-E + Bewezen zelfstandige FAB-competentie → Model B+ (ICMT doet TAB, business doet FAB zonder coaching) +- BIA A-B + Decentrale IT aanwezig → Model D +- BIA A-B + Business volledig zelfstandig → Model E + +**Stap 3: Valideer combinatie** +Controleer of de BIA binnen de toegestane range van het gekozen regiemodel valt. + +#### Verschil Model B vs B+ +- Model B: ICMT coacht de business key users, monitort FAB-kwaliteit, escaleert proactief +- Model B+: Business heeft bewezen FAB-competentie, ICMT doet alleen TAB, geen coaching + +Criteria voor B+ in plaats van B: +- Gedocumenteerde FAB-processen bij business +- Bewezen track record (minimaal 1 jaar succesvol FAB) +- Kennisborging (geen single-point-of-failure) +- Escalatiepad naar ICMT voor complexe issues + +{{REGIEMODELLEN}} + +## Te classificeren applicatie + +{{APPLICATIE_DATA}} + +## Validatieregels + +Controleer logische consistentie: + +### Harde constraints (niet toegestaan) +- BIA F + Regiemodel D = NIET TOEGESTAAN +- BIA F + Regiemodel E = NIET TOEGESTAAN +- BIA E + Regiemodel E = NIET TOEGESTAAN +- BIA D + Regiemodel E = NIET TOEGESTAAN +- BIA C + Regiemodel E = NIET TOEGESTAAN (alleen A en B toegestaan) +- BIA D + Regiemodel D = NIET TOEGESTAAN (alleen A, B, C toegestaan) +- BIA E + Regiemodel D = NIET TOEGESTAAN +- BIA F + Regiemodel D = NIET TOEGESTAAN +- Platform + Regiemodel E = NIET TOEGESTAAN + +### Waarschuwingen (onwaarschijnlijk/niet aanbevolen) +- SaaS/Extern + Regiemodel A = onwaarschijnlijk (A is voor eigen beheer) +- SaaS/Extern + Regiemodel B/B+ = onwaarschijnlijk (B/B+ is voor eigen TAB) +- Connected Device + SaaS/Extern = onwaarschijnlijk +- Platform + Regiemodel D = niet aanbevolen (governance-risico) +- Platform + Complexiteit 1 (Laag) = onwaarschijnlijk +- PoC + BIA E/F = onwaarschijnlijk +- On-premises + Regiemodel C = onwaarschijnlijk (C is voor uitbestede TAB) +- Azure (eigen beheer) + Regiemodel C = onwaarschijnlijk +- BIA A/B + Regiemodel A = onwaarschijnlijk (overkill voor lage BIA) +- Workload zonder Platform-referentie = vraag om verduidelijking + +## Confidence niveau + +- HOOG: Duidelijke beschrijving, bekende applicatie, eenduidige functie, alle velden consistent +- MIDDEN: Beperkte beschrijving maar herkenbare functie, enkele aannames nodig +- LAAG: Onduidelijke beschrijving, generieke naam, meerdere mogelijke functies, validatiewaarschuwingen + +## Output + +Geef ALLEEN een JSON object terug, zonder markdown code blocks: + +{ + "primaire_functie": { + "code": "[exacte code, bijv. ICMT-269758]", + "naam": "[exacte naam uit lijst]", + "onderbouwing": "[korte uitleg]" + }, + "secundaire_functies": [ + { + "code": "[code]", + "naam": "[naam]", + "onderbouwing": "[uitleg]" + } + ], + "beheerclassificatie": { + "application_type": { + "waarde": "[Applicatie|Connected Device|Platform|Workload]", + "onderbouwing": "[uitleg]" + }, + "dynamiek_factor": { + "waarde": "[1 - Stabiel|2 - Gemiddeld|3 - Hoog|4 - Zeer hoog]", + "label": "[Stabiel|Gemiddeld|Hoog|Zeer hoog]", + "onderbouwing": "[uitleg]" + }, + "complexiteit_factor": { + "waarde": "[1 - Laag|2 - Gemiddeld|3 - Hoog|4 - Zeer hoog]", + "label": "[Laag|Gemiddeld|Hoog|Zeer hoog]", + "onderbouwing": "[uitleg]" + }, + "hosting_type": { + "waarde": "[EXACT een naam uit de lijst Application Management - Hosting]", + "onderbouwing": "[uitleg]" + }, + "bia_classificatie": { + "waarde": "[A|B|C|D|E|F]", + "onderbouwing": "[uitleg]" + }, + "regiemodel": { + "waarde": "[Regiemodel A|Regiemodel B|Regiemodel B+|Regiemodel C|Regiemodel D|Regiemodel E]", + "onderbouwing": "[uitleg, inclusief verwijzing naar BIA-constraint en beslisboom-stap]" + } + }, + "confidence": "[HOOG|MIDDEN|LAAG]", + "validatie_waarschuwingen": ["[lijst van getriggerde waarschuwingen, leeg als geen]"], + "aandachtspunten": "[onzekerheden, verificatiesuggesties, aanvullende vragen]" +}`; + +class AIService { + private anthropicClient: Anthropic | null = null; + private openaiClient: OpenAI | null = null; + + constructor() { + if (config.anthropicApiKey) { + this.anthropicClient = new Anthropic({ + apiKey: config.anthropicApiKey, + }); + logger.info('Anthropic (Claude) API configured'); + } else { + logger.warn('Anthropic API key not configured. Claude classification will not work.'); + } + + if (config.openaiApiKey) { + this.openaiClient = new OpenAI({ + apiKey: config.openaiApiKey, + }); + logger.info('OpenAI API configured'); + } else { + logger.warn('OpenAI API key not configured. OpenAI classification will not work.'); + } + } + + // Check if a specific provider is configured + isProviderConfigured(provider: AIProvider): boolean { + if (provider === 'claude') { + return this.anthropicClient !== null; + } else { + return this.openaiClient !== null; + } + } + + // Get available providers + getAvailableProviders(): AIProvider[] { + const providers: AIProvider[] = []; + if (this.anthropicClient) providers.push('claude'); + if (this.openaiClient) providers.push('openai'); + return providers; + } + + async classifyApplication(application: ApplicationDetails, provider: AIProvider = config.defaultAIProvider): Promise { + // Validate provider + if (provider === 'claude' && !this.anthropicClient) { + throw new Error('Claude API not configured. Please set ANTHROPIC_API_KEY.'); + } + if (provider === 'openai' && !this.openaiClient) { + throw new Error('OpenAI API not configured. Please set OPENAI_API_KEY.'); + } + + // Check if web search is needed + let webSearchResults: string | null = null; + if (hasInsufficientInformation(application)) { + logger.info(`Insufficient information detected for ${application.name}, performing web search...`); + const supplierPart = application.supplierProduct ? `${application.supplierProduct} ` : ''; + const searchQuery = `${application.name} ${supplierPart}healthcare software`.trim(); + webSearchResults = await performWebSearch(searchQuery); + if (webSearchResults) { + logger.info(`Web search completed for ${application.name}`); + } else { + logger.warn(`Web search returned no results for ${application.name}`); + } + } + + // Fetch all reference data needed for the prompt + const [ + applicationFunctions, + applicationFunctionCategories, + applicationTypes, + dynamicsFactors, + complexityFactors, + businessImpactAnalyses, + governanceModels, + applicationManagementHostingOptions, + ] = await Promise.all([ + dataService.getApplicationFunctions(), + dataService.getApplicationFunctionCategories(), + dataService.getApplicationTypes(), + dataService.getDynamicsFactors(), + dataService.getComplexityFactors(), + dataService.getBusinessImpactAnalyses(), + dataService.getGovernanceModels(), + dataService.getApplicationManagementHosting(), + ]); + + const functionsFormatted = await formatApplicationFunctionsForPrompt( + applicationFunctions, + applicationFunctionCategories + ); + const applicationTypesFormatted = formatReferenceObjectsForPrompt(applicationTypes, false); + const dynamicsFactorsFormatted = formatReferenceObjectsForPrompt(dynamicsFactors, true); + const complexityFactorsFormatted = formatReferenceObjectsForPrompt(complexityFactors, true); + const applicationManagementHostingFormatted = formatReferenceObjectsWithExactNames(applicationManagementHostingOptions, true); + const businessImpactAnalysesFormatted = formatBusinessImpactAnalysesForPrompt(businessImpactAnalyses); + const businessImpactIndicatorsFormatted = formatBusinessImpactIndicatorsForPrompt(businessImpactAnalyses); + const governanceModelsFormatted = await formatGovernanceModelsForPrompt(); + const applicationDataFormatted = formatApplicationDataForPrompt(application, webSearchResults || undefined); + + const prompt = CLASSIFICATION_PROMPT + .replace('{{APPLICATIEFUNCTIES}}', functionsFormatted) + .replace('{{APPLICATION_TYPES}}', applicationTypesFormatted) + .replace('{{DYNAMIEK_FACTORS}}', dynamicsFactorsFormatted) + .replace('{{COMPLEXITEIT_FACTORS}}', complexityFactorsFormatted) + .replace('{{APPLICATION_MANAGEMENT_HOSTING}}', applicationManagementHostingFormatted) + .replace('{{BIA_CLASSIFICATIES}}', businessImpactIndicatorsFormatted) + .replace('{{REGIEMODELLEN}}', governanceModelsFormatted) + .replace('{{APPLICATIE_DATA}}', applicationDataFormatted); + + logger.debug(`Classifying application: ${application.name} using ${provider}`); + + try { + let responseText: string; + + if (provider === 'claude') { + // Use Claude (Anthropic) + const message = await this.anthropicClient!.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 4096, + messages: [ + { + role: 'user', + content: prompt, + }, + ], + }); + + const textBlock = message.content.find((block) => block.type === 'text'); + if (!textBlock || textBlock.type !== 'text') { + throw new Error('No text response from Claude'); + } + responseText = textBlock.text.trim(); + } else { + // Use OpenAI + const completion = await this.openaiClient!.chat.completions.create({ + model: 'gpt-4o', + max_tokens: 4096, + messages: [ + { + role: 'system', + content: 'Je bent een ervaren informatiemanager. Geef je antwoord ALLEEN als een JSON object, zonder markdown code blocks.', + }, + { + role: 'user', + content: prompt, + }, + ], + }); + + const content = completion.choices[0]?.message?.content; + if (!content) { + throw new Error('No response from OpenAI'); + } + responseText = content.trim(); + } + + // Parse JSON response (same for both providers) + // Remove any potential markdown code blocks + const jsonText = responseText + .replace(/```json\n?/g, '') + .replace(/```\n?/g, '') + .trim(); + + const parsed = JSON.parse(jsonText); + + // Check for BIA value in Excel file using fuzzy matching + const excelBIAValue = findBIAValue(application.name); + + // Parse BIA classification from AI response + let biaClassification = parsed.beheerclassificatie?.bia_classificatie ? { + value: parsed.beheerclassificatie.bia_classificatie.waarde, + reasoning: parsed.beheerclassificatie.bia_classificatie.onderbouwing, + } : undefined; + + // Override BIA classification if found in Excel file - Excel value ALWAYS takes precedence + if (excelBIAValue) { + const originalAIValue = biaClassification?.value || 'geen'; + biaClassification = { + value: excelBIAValue, + reasoning: `Gevonden in BIA.xlsx export (match met "${application.name}"). Originele AI suggestie: ${originalAIValue}. Excel waarde heeft voorrang.`, + }; + logger.info(`✓ OVERRIDING BIA classification for "${application.name}": Excel value "${excelBIAValue}" (AI suggested: "${originalAIValue}")`); + } else { + logger.debug(`No Excel BIA value found for "${application.name}", using AI suggestion: ${biaClassification?.value || 'geen'}`); + } + + // Parse management classification if present + const managementClassification = parsed.beheerclassificatie ? { + applicationType: parsed.beheerclassificatie.application_type ? { + value: parsed.beheerclassificatie.application_type.waarde, + reasoning: parsed.beheerclassificatie.application_type.onderbouwing, + } : undefined, + dynamicsFactor: parsed.beheerclassificatie.dynamiek_factor ? { + value: parsed.beheerclassificatie.dynamiek_factor.waarde, + label: parsed.beheerclassificatie.dynamiek_factor.label || parsed.beheerclassificatie.dynamiek_factor.waarde, + reasoning: parsed.beheerclassificatie.dynamiek_factor.onderbouwing, + } : undefined, + complexityFactor: parsed.beheerclassificatie.complexiteit_factor ? { + value: parsed.beheerclassificatie.complexiteit_factor.waarde, + label: parsed.beheerclassificatie.complexiteit_factor.label || parsed.beheerclassificatie.complexiteit_factor.waarde, + reasoning: parsed.beheerclassificatie.complexiteit_factor.onderbouwing, + } : undefined, + hostingType: parsed.beheerclassificatie.hosting_type ? { + value: parsed.beheerclassificatie.hosting_type.waarde, + reasoning: parsed.beheerclassificatie.hosting_type.onderbouwing, + } : undefined, + // Also provide applicationManagementHosting for frontend compatibility + applicationManagementHosting: parsed.beheerclassificatie.hosting_type ? { + value: parsed.beheerclassificatie.hosting_type.waarde, + reasoning: parsed.beheerclassificatie.hosting_type.onderbouwing, + } : undefined, + // applicationManagementTAM is not yet part of the AI prompt + applicationManagementTAM: undefined, + biaClassification: biaClassification, + governanceModel: parsed.beheerclassificatie.regiemodel ? { + value: parsed.beheerclassificatie.regiemodel.waarde, + reasoning: parsed.beheerclassificatie.regiemodel.onderbouwing, + } : undefined, + } : undefined; + + // Parse validation warnings if present + const validationWarnings = parsed.validatie_waarschuwingen || []; + + return { + primaryFunction: { + code: parsed.primaire_functie.code, + name: parsed.primaire_functie.naam, + reasoning: parsed.primaire_functie.onderbouwing, + }, + secondaryFunctions: (parsed.secundaire_functies || []).map((f: any) => ({ + code: f.code, + name: f.naam, + reasoning: f.onderbouwing, + })), + managementClassification, + confidence: parsed.confidence as 'HOOG' | 'MIDDEN' | 'LAAG', + validationWarnings, + notes: parsed.aandachtspunten || '', + }; + } catch (error) { + logger.error(`Failed to classify application with ${provider}`, error); + throw error; + } + } + + async classifyBatch( + applications: ApplicationDetails[], + onProgress?: (completed: number, total: number) => void, + provider: AIProvider = config.defaultAIProvider + ): Promise> { + const results = new Map(); + const total = applications.length; + + for (let i = 0; i < applications.length; i++) { + const app = applications[i]; + try { + const suggestion = await this.classifyApplication(app, provider); + results.set(app.id, suggestion); + } catch (error) { + logger.error(`Failed to classify ${app.name}`, error); + } + + if (onProgress) { + onProgress(i + 1, total); + } + + // Rate limiting - wait 500ms between requests + if (i < applications.length - 1) { + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + + return results; + } + + getTaxonomy(): ZiraTaxonomy { + return ziraTaxonomy; + } + + getFunctionByCode(code: string): { domain: string; function: { code: string; name: string; description: string } } | null { + for (const domain of ziraTaxonomy.domains) { + const func = domain.functions.find((f) => f.code === code); + if (func) { + return { + domain: domain.name, + function: { + code: func.code, + name: func.name, + description: func.description, + }, + }; + } + } + return null; + } + + isConfigured(provider?: AIProvider): boolean { + if (provider) { + return this.isProviderConfigured(provider); + } + // Return true if at least one provider is configured + return this.anthropicClient !== null || this.openaiClient !== null; + } + + // Get the prompt that would be sent to the AI for a given application + async getPromptForApplication(application: ApplicationDetails): Promise { + // Fetch all reference data needed for the prompt + const [ + applicationFunctions, + applicationFunctionCategories, + applicationTypes, + dynamicsFactors, + complexityFactors, + businessImpactAnalyses, + governanceModels, + applicationManagementHostingOptions, + ] = await Promise.all([ + dataService.getApplicationFunctions(), + dataService.getApplicationFunctionCategories(), + dataService.getApplicationTypes(), + dataService.getDynamicsFactors(), + dataService.getComplexityFactors(), + dataService.getBusinessImpactAnalyses(), + dataService.getGovernanceModels(), + dataService.getApplicationManagementHosting(), + ]); + + const functionsFormatted = await formatApplicationFunctionsForPrompt( + applicationFunctions, + applicationFunctionCategories + ); + const applicationTypesFormatted = formatReferenceObjectsForPrompt(applicationTypes, false); + const dynamicsFactorsFormatted = formatReferenceObjectsForPrompt(dynamicsFactors, true); + const complexityFactorsFormatted = formatReferenceObjectsForPrompt(complexityFactors, true); + const applicationManagementHostingFormatted = formatReferenceObjectsWithExactNames(applicationManagementHostingOptions, true); + const businessImpactAnalysesFormatted = formatBusinessImpactAnalysesForPrompt(businessImpactAnalyses); + const businessImpactIndicatorsFormatted = formatBusinessImpactIndicatorsForPrompt(businessImpactAnalyses); + const governanceModelsFormatted = await formatGovernanceModelsForPrompt(); + const applicationDataFormatted = formatApplicationDataForPrompt(application); + + return CLASSIFICATION_PROMPT + .replace('{{APPLICATIEFUNCTIES}}', functionsFormatted) + .replace('{{APPLICATION_TYPES}}', applicationTypesFormatted) + .replace('{{DYNAMIEK_FACTORS}}', dynamicsFactorsFormatted) + .replace('{{COMPLEXITEIT_FACTORS}}', complexityFactorsFormatted) + .replace('{{APPLICATION_MANAGEMENT_HOSTING}}', applicationManagementHostingFormatted) + .replace('{{BIA_CLASSIFICATIES}}', businessImpactIndicatorsFormatted) + .replace('{{REGIEMODELLEN}}', governanceModelsFormatted) + .replace('{{APPLICATIE_DATA}}', applicationDataFormatted); + } + + // Get or create a conversation + getConversation(conversationId: string): ChatConversation | undefined { + return conversationStore.get(conversationId); + } + + // Create a new conversation + createConversation(applicationId: string, applicationName: string): ChatConversation { + const conversation: ChatConversation = { + id: randomUUID(), + applicationId, + applicationName, + messages: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + conversationStore.set(conversation.id, conversation); + return conversation; + } + + // Chat with AI - continue a conversation or start a new one + async chat( + application: ApplicationDetails, + userMessage: string, + conversationId?: string, + provider: AIProvider = config.defaultAIProvider + ): Promise { + // Validate provider + if (provider === 'claude' && !this.anthropicClient) { + throw new Error('Claude API not configured. Please set ANTHROPIC_API_KEY.'); + } + if (provider === 'openai' && !this.openaiClient) { + throw new Error('OpenAI API not configured. Please set OPENAI_API_KEY.'); + } + + // Get or create conversation + let conversation: ChatConversation; + if (conversationId && conversationStore.has(conversationId)) { + conversation = conversationStore.get(conversationId)!; + } else { + conversation = this.createConversation(application.id, application.name); + + // For new conversations, add the initial system context + const systemPrompt = await this.buildChatSystemPrompt(application); + conversation.messages.push({ + id: randomUUID(), + role: 'system', + content: systemPrompt, + timestamp: new Date(), + }); + } + + // Add user message to conversation + const userMsg: ChatMessage = { + id: randomUUID(), + role: 'user', + content: userMessage, + timestamp: new Date(), + }; + conversation.messages.push(userMsg); + conversation.updatedAt = new Date(); + + try { + // Build messages array for AI + const aiMessages = conversation.messages.map(m => ({ + role: m.role as 'user' | 'assistant' | 'system', + content: m.content, + })); + + let assistantContent: string; + + if (provider === 'claude') { + // Extract system message and other messages + const systemMessage = aiMessages.find(m => m.role === 'system'); + const otherMessages = aiMessages.filter(m => m.role !== 'system'); + + const response = await this.anthropicClient!.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 4096, + system: systemMessage?.content || '', + messages: otherMessages.map(m => ({ + role: m.role as 'user' | 'assistant', + content: m.content, + })), + }); + + assistantContent = response.content[0].type === 'text' ? response.content[0].text : ''; + } else { + // OpenAI + const response = await this.openaiClient!.chat.completions.create({ + model: 'gpt-4o', + max_tokens: 4096, + messages: aiMessages.map(m => ({ + role: m.role, + content: m.content, + })), + }); + + assistantContent = response.choices[0]?.message?.content || ''; + } + + // Try to extract a structured suggestion if the AI provided one + let suggestion: AISuggestion | undefined; + try { + // Look for JSON in the response + const jsonMatch = assistantContent.match(/\{[\s\S]*"primaire_functie"[\s\S]*\}/); + if (jsonMatch) { + const parsed = JSON.parse(jsonMatch[0]); + suggestion = this.parseAISuggestionFromJson(parsed); + } + } catch (e) { + // No valid JSON found, that's okay for conversational responses + } + + // Create assistant message + const assistantMsg: ChatMessage = { + id: randomUUID(), + role: 'assistant', + content: assistantContent, + timestamp: new Date(), + suggestion, + }; + conversation.messages.push(assistantMsg); + conversation.updatedAt = new Date(); + + // Update store + conversationStore.set(conversation.id, conversation); + + return { + conversationId: conversation.id, + message: assistantMsg, + suggestion, + }; + } catch (error) { + logger.error(`Failed to chat with ${provider}`, error); + throw error; + } + } + + // Build the system prompt for chat conversations + private async buildChatSystemPrompt(application: ApplicationDetails): Promise { + // Get the full classification prompt and modify it for chat + const classificationPrompt = await this.getPromptForApplication(application); + + return `${classificationPrompt} + +## Chat Mode Instructies + +Je bent nu in een conversatie met een informatiemanager die je kan helpen met aanvullende informatie over de applicatie. + +**KRITIEKE REGEL - JSON OUTPUT:** +Wanneer je classificatiewaarden aanpast, herziet of bevestigt, MOET je ALTIJD het volledige JSON-blok meegeven in je antwoord. Dit is essentieel omdat het systeem automatisch JSON detecteert om de GUI bij te werken. Zonder JSON-blok worden wijzigingen niet doorgevoerd. + +**Regels voor de conversatie:** +1. Beantwoord vragen en geef uitleg over je classificatiekeuzes +2. Als de gebruiker aanvullende informatie geeft die relevant is voor de classificatie: + - Pas je classificatie aan op basis van de nieuwe informatie + - Geef een korte toelichting waarom je de classificatie aanpast + - Voeg ALTIJD het volledige herziene JSON-blok toe aan je antwoord +3. Stel verduidelijkende vragen als je meer informatie nodig hebt +4. Wees behulpzaam en professioneel + +**Wanneer WEL JSON meegeven:** +- Bij elke aanpassing van een of meer classificatiewaarden +- Wanneer je zegt "Laat me de classificatie aanpassen" of vergelijkbare uitspraken +- Wanneer de gebruiker informatie geeft die de classificatie beïnvloedt +- Bij bevestiging van een herziene classificatie + +**Wanneer GEEN JSON nodig:** +- Bij het stellen van verduidelijkende vragen (zonder wijzigingen) +- Bij algemene uitleg over de classificatielogica (zonder wijzigingen) + +**Formaat van je antwoord bij wijzigingen:** +1. Korte tekstuele toelichting van de wijzigingen +2. Het volledige JSON-blok met alle classificaties (niet alleen de gewijzigde velden) + +**Huidige context:** Je hebt zojuist de applicatie "${application.name}" geanalyseerd.`; + } + + // Parse AI suggestion from JSON (extracted to reuse) + private parseAISuggestionFromJson(parsed: any): AISuggestion { + // Handle BIA classification - check for value from BIA.xlsx first + let biaClassification: { value: string; reasoning: string } | undefined; + if (parsed.beheerclassificatie?.bia_classificatie) { + biaClassification = { + value: parsed.beheerclassificatie.bia_classificatie.waarde, + reasoning: parsed.beheerclassificatie.bia_classificatie.onderbouwing, + }; + } + + const managementClassification = parsed.beheerclassificatie ? { + applicationType: parsed.beheerclassificatie.application_type ? { + value: parsed.beheerclassificatie.application_type.waarde, + reasoning: parsed.beheerclassificatie.application_type.onderbouwing, + } : undefined, + dynamicsFactor: parsed.beheerclassificatie.dynamiek_factor ? { + value: parsed.beheerclassificatie.dynamiek_factor.waarde, + label: parsed.beheerclassificatie.dynamiek_factor.label || parsed.beheerclassificatie.dynamiek_factor.waarde, + reasoning: parsed.beheerclassificatie.dynamiek_factor.onderbouwing, + } : undefined, + complexityFactor: parsed.beheerclassificatie.complexiteit_factor ? { + value: parsed.beheerclassificatie.complexiteit_factor.waarde, + label: parsed.beheerclassificatie.complexiteit_factor.label || parsed.beheerclassificatie.complexiteit_factor.waarde, + reasoning: parsed.beheerclassificatie.complexiteit_factor.onderbouwing, + } : undefined, + hostingType: parsed.beheerclassificatie.hosting_type ? { + value: parsed.beheerclassificatie.hosting_type.waarde, + reasoning: parsed.beheerclassificatie.hosting_type.onderbouwing, + } : undefined, + applicationManagementHosting: parsed.beheerclassificatie.hosting_type ? { + value: parsed.beheerclassificatie.hosting_type.waarde, + reasoning: parsed.beheerclassificatie.hosting_type.onderbouwing, + } : undefined, + applicationManagementTAM: undefined, + biaClassification: biaClassification, + governanceModel: parsed.beheerclassificatie.regiemodel ? { + value: parsed.beheerclassificatie.regiemodel.waarde, + reasoning: parsed.beheerclassificatie.regiemodel.onderbouwing, + } : undefined, + } : undefined; + + const validationWarnings = parsed.validatie_waarschuwingen || []; + + return { + primaryFunction: { + code: parsed.primaire_functie?.code || '', + name: parsed.primaire_functie?.naam || '', + reasoning: parsed.primaire_functie?.onderbouwing || '', + }, + secondaryFunctions: (parsed.secundaire_functies || []).map((f: any) => ({ + code: f.code, + name: f.naam, + reasoning: f.onderbouwing, + })), + managementClassification, + confidence: parsed.confidence as 'HOOG' | 'MIDDEN' | 'LAAG', + validationWarnings, + notes: parsed.aandachtspunten || '', + }; + } + + // Get conversation history + getConversationHistory(conversationId: string): ChatMessage[] { + const conversation = conversationStore.get(conversationId); + return conversation ? conversation.messages : []; + } + + // Clear a conversation + clearConversation(conversationId: string): boolean { + return conversationStore.delete(conversationId); + } +} + +// Export as aiService (new name) and claudeService (for backward compatibility) +export const aiService = new AIService(); +export const claudeService = aiService; // Backward compatibility alias + diff --git a/backend/src/services/dataService.ts b/backend/src/services/dataService.ts new file mode 100644 index 0000000..6d4e6c2 --- /dev/null +++ b/backend/src/services/dataService.ts @@ -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 { + if (useJiraAssets) { + return jiraAssetsService.searchApplications(filters, page, pageSize); + } + return mockDataService.searchApplications(filters, page, pageSize); + }, + + async getApplicationById(id: string): Promise { + 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 { + 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 { + if (useJiraAssets) { + return jiraAssetsService.getDynamicsFactors(); + } + return mockDataService.getDynamicsFactors(); + }, + + async getComplexityFactors(): Promise { + if (useJiraAssets) { + return jiraAssetsService.getComplexityFactors(); + } + return mockDataService.getComplexityFactors(); + }, + + async getNumberOfUsers(): Promise { + if (useJiraAssets) { + return jiraAssetsService.getNumberOfUsers(); + } + return mockDataService.getNumberOfUsers(); + }, + + async getGovernanceModels(): Promise { + if (useJiraAssets) { + return jiraAssetsService.getGovernanceModels(); + } + return mockDataService.getGovernanceModels(); + }, + + async getOrganisations(): Promise { + if (useJiraAssets) { + return jiraAssetsService.getOrganisations(); + } + return mockDataService.getOrganisations(); + }, + + async getHostingTypes(): Promise { + if (useJiraAssets) { + return jiraAssetsService.getHostingTypes(); + } + return mockDataService.getHostingTypes(); + }, + + async getBusinessImpactAnalyses(): Promise { + if (useJiraAssets) { + return jiraAssetsService.getBusinessImpactAnalyses(); + } + return mockDataService.getBusinessImpactAnalyses(); + }, + + async getApplicationManagementHosting(): Promise { + if (useJiraAssets) { + return jiraAssetsService.getApplicationManagementHosting(); + } + return mockDataService.getApplicationManagementHosting(); + }, + + async getApplicationManagementTAM(): Promise { + 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 { + if (useJiraAssets) { + return jiraAssetsService.getApplicationFunctions(); + } + return mockDataService.getApplicationFunctions(); + }, + + async getApplicationFunctionCategories(): Promise { + if (useJiraAssets) { + return jiraAssetsService.getApplicationFunctionCategories(); + } + return mockDataService.getApplicationFunctionCategories(); + }, + + async getApplicationClusters(): Promise { + if (useJiraAssets) { + return jiraAssetsService.getApplicationClusters(); + } + return mockDataService.getApplicationClusters(); + }, + + async getApplicationTypes(): Promise { + if (useJiraAssets) { + return jiraAssetsService.getApplicationTypes(); + } + return mockDataService.getApplicationTypes(); + }, + + async getBusinessImportance(): Promise { + if (useJiraAssets) { + return jiraAssetsService.getBusinessImportance(); + } + return mockDataService.getBusinessImportance(); + }, + + isUsingJiraAssets(): boolean { + return useJiraAssets; + }, + + async testConnection(): Promise { + if (useJiraAssets) { + return jiraAssetsService.testConnection(); + } + return true; + }, + + async getTeamDashboardData(excludedStatuses: ApplicationStatus[] = []): Promise { + if (useJiraAssets) { + return jiraAssetsService.getTeamDashboardData(excludedStatuses); + } + return mockDataService.getTeamDashboardData(excludedStatuses); + }, +}; diff --git a/backend/src/services/database.ts b/backend/src/services/database.ts new file mode 100644 index 0000000..28b94b2 --- /dev/null +++ b/backend/src/services/database.ts @@ -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(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 } { + 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 = {}; + bySourceRows.forEach((row) => { + bySource[row.source] = row.count; + }); + + return { totalClassifications: total, bySource }; + } + + close(): void { + this.db.close(); + } +} + +export const databaseService = new DatabaseService(); diff --git a/backend/src/services/effortCalculation.ts b/backend/src/services/effortCalculation.ts new file mode 100644 index 0000000..5640240 --- /dev/null +++ b/backend/src/services/effortCalculation.ts @@ -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 = { + '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] || []; +} diff --git a/backend/src/services/jiraAssets.ts b/backend/src/services/jiraAssets.ts new file mode 100644 index 0000000..425e694 --- /dev/null +++ b/backend/src/services/jiraAssets.ts @@ -0,0 +1,2092 @@ +import { config } from '../config/env.js'; +import { logger } from './logger.js'; +import { calculateRequiredEffortApplicationManagement, calculateRequiredEffortWithMinMax } from './effortCalculation.js'; +import type { + ApplicationDetails, + ApplicationListItem, + ApplicationStatus, + ReferenceValue, + SearchFilters, + SearchResult, + JiraAssetsObject, + JiraAssetsAttribute, + JiraAssetsSearchResponse, + ApplicationUpdateRequest, + TeamDashboardData, +} from '../types/index.js'; + +// Attribute name mappings (these should match your Jira Assets schema) +const ATTRIBUTE_NAMES = { + NAME: 'Name', + SEARCH_REFERENCE: 'SearchReference', + DESCRIPTION: 'Description', + ORGANISATION: 'Organisation', + APPLICATION_FUNCTION: 'ApplicationFunction', + STATUS: 'Status', + BUSINESS_IMPORTANCE: 'Business Importance', + BUSINESS_IMPACT_ANALYSE: 'Business Impact Analyse', + HOSTING_TYPE: 'Application Component Hosting Type', + SUPPLIER_PRODUCT: 'Supplier Product', + BUSINESS_OWNER: 'Business Owner', + SYSTEM_OWNER: 'System Owner', + FAM: 'Functional Application Management', + TAM: 'Technical Application Management', + TAM_PRIMARY: 'Technical Application Management Primary', + TAM_SECONDARY: 'Technical Application Management Secondary', + MEDISCHE_TECHNIEK: 'Medische Techniek', + DYNAMICS_FACTOR: 'Application Management - Dynamics Factor', + COMPLEXITY_FACTOR: 'Application Management - Complexity Factor', + NUMBER_OF_USERS: 'Application Management - Number of Users', + GOVERNANCE_MODEL: 'ICT Governance Model', + APPLICATION_CLUSTER: 'Application Management - Application Cluster', + APPLICATION_TYPE: 'Application Management - Application Type', + PLATFORM: 'Platform', + OVERRIDE_FTE: 'Application Management - Override FTE', + APPLICATION_MANAGEMENT_HOSTING: 'Application Management - Hosting', + APPLICATION_MANAGEMENT_TAM: 'Application Management - TAM', +}; + +// Jira Data Center (Insight) uses different API endpoints than Jira Cloud (Assets) +// Data Center: /rest/insight/1.0/ +// Cloud: /rest/assets/1.0/ + +// Helper function to strip HTML tags from a string +function stripHtmlTags(html: string): string { + // Remove HTML tags and decode common HTML entities + return html + .replace(/<[^>]*>/g, '') // Remove HTML tags + .replace(/ /g, ' ') // Non-breaking space + .replace(/&/g, '&') // Ampersand + .replace(/</g, '<') // Less than + .replace(/>/g, '>') // Greater than + .replace(/"/g, '"') // Quote + .replace(/'/g, "'") // Apostrophe + .replace(/'/g, "'") // Apostrophe (alternative) + .replace(/\s+/g, ' ') // Collapse multiple whitespace + .trim(); +} + +// Cache for object type attribute definitions +interface ObjectTypeAttributeDefinition { + id: number; + name: string; +} + +class JiraAssetsService { + private insightBaseUrl: string; + private assetsBaseUrl: string; + private headers: Record; + private isDataCenter: boolean | null = null; + // Cache: objectTypeName -> Map + private attributeSchemaCache: Map> = new Map(); + // Cache: Application functions with full details (applicationFunctionCategory, description, keywords) + private applicationFunctionsCache: Map | null = null; + // Cache: Application Function Categories + private applicationFunctionCategoriesCache: Map | null = null; + // Cache: Dynamics Factors with factors + private dynamicsFactorsCache: Map | null = null; + // Cache: Complexity Factors with factors + private complexityFactorsCache: Map | null = null; + // Cache: Number of Users with factors + private numberOfUsersCache: Map | null = null; + // Cache: Team dashboard data + private teamDashboardCache: { data: TeamDashboardData; timestamp: number } | null = null; + private readonly TEAM_DASHBOARD_CACHE_TTL = 5 * 60 * 1000; // 5 minutes + // Cache: Dashboard stats + private dashboardStatsCache: { + data: { + totalApplications: number; + classifiedCount: number; + unclassifiedCount: number; + byStatus: Record; + byGovernanceModel: Record; + }; + timestamp: number + } | null = null; + private readonly DASHBOARD_STATS_CACHE_TTL = 3 * 60 * 1000; // 3 minutes + + constructor() { + // Try both API paths - Insight (Data Center) and Assets (Cloud) + this.insightBaseUrl = `${config.jiraHost}/rest/insight/1.0`; + this.assetsBaseUrl = `${config.jiraHost}/rest/assets/1.0`; + this.headers = { + Authorization: `Bearer ${config.jiraPat}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }; + } + + private getBaseUrl(): string { + // Use detected API type or default to Insight (Data Center) + return this.isDataCenter === false ? this.assetsBaseUrl : this.insightBaseUrl; + } + + private async request( + endpoint: string, + options: RequestInit = {} + ): Promise { + const url = `${this.getBaseUrl()}${endpoint}`; + + try { + logger.debug(`Jira API request: ${options.method || 'GET'} ${url}`); + const response = await fetch(url, { + ...options, + headers: { + ...this.headers, + ...options.headers, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Jira API error: ${response.status} - ${errorText}`); + } + + return response.json() as Promise; + } catch (error) { + logger.error(`Jira API request failed: ${endpoint}`, error); + throw error; + } + } + + // Detect whether we're using Jira Data Center (Insight) or Jira Cloud (Assets) + async detectApiType(): Promise { + if (this.isDataCenter !== null) return; + + // Try Insight API first (Data Center) + try { + const response = await fetch(`${this.insightBaseUrl}/objectschema/list`, { + headers: this.headers, + }); + if (response.ok) { + this.isDataCenter = true; + logger.info('Detected Jira Data Center (Insight API)'); + return; + } + } catch { + // Ignore and try next + } + + // Try Assets API (Cloud) + try { + const response = await fetch(`${this.assetsBaseUrl}/objectschema/list`, { + headers: this.headers, + }); + if (response.ok) { + this.isDataCenter = false; + logger.info('Detected Jira Cloud (Assets API)'); + return; + } + } catch { + // Ignore + } + + // Default to Data Center + this.isDataCenter = true; + logger.warn('Could not detect Jira API type, defaulting to Insight (Data Center)'); + } + + // Fetch and cache attribute schema for an object type + private async fetchAttributeSchema(objectTypeId: number): Promise> { + try { + const response = await this.request( + `/objecttype/${objectTypeId}/attributes` + ); + const attrMap = new Map(); + response.forEach((attr) => { + attrMap.set(attr.id, attr.name); + }); + return attrMap; + } catch (error) { + logger.error(`Failed to fetch attribute schema for object type ${objectTypeId}`, error); + return new Map(); + } + } + + // Get attribute name from cache or schema, looking up by attribute ID + private getAttributeNameById( + objectTypeName: string, + attrId: number, + cachedSchema?: Map + ): string | undefined { + // First check the provided cache + if (cachedSchema) { + return cachedSchema.get(attrId); + } + // Check global cache + const typeCache = this.attributeSchemaCache.get(objectTypeName); + return typeCache?.get(attrId); + } + + // Get attribute value by name (searches through objectTypeAttribute names) + // Also tries case-insensitive matching and attribute schema lookup as fallbacks + private getAttributeByName( + obj: JiraAssetsObject, + attributeName: string, + attrSchema?: Map + ): JiraAssetsAttribute | undefined { + // First try exact match on objectTypeAttribute.name + let attr = obj.attributes.find( + (a) => a.objectTypeAttribute?.name === attributeName + ); + if (attr) return attr; + + // Try case-insensitive match on objectTypeAttribute.name + const lowerName = attributeName.toLowerCase(); + attr = obj.attributes.find( + (a) => a.objectTypeAttribute?.name?.toLowerCase() === lowerName + ); + if (attr) return attr; + + // If we have an attribute schema, try matching by looking up the attribute ID + if (attrSchema) { + attr = obj.attributes.find((a) => { + const schemaName = attrSchema.get(a.objectTypeAttributeId); + return schemaName === attributeName || schemaName?.toLowerCase() === lowerName; + }); + } + return attr; + } + + // Get simple text/select value from attribute (with optional schema for ID lookup) + private getAttributeValueWithSchema( + obj: JiraAssetsObject, + attributeName: string, + attrSchema?: Map + ): string | null { + const attr = this.getAttributeByName(obj, attributeName, attrSchema); + if (!attr || attr.objectAttributeValues.length === 0) { + return null; + } + + const value = attr.objectAttributeValues[0]; + // For select/status fields, use displayValue; for text fields, use value + if (value.displayValue !== undefined && value.displayValue !== null) { + return value.displayValue; + } + if (value.value !== undefined && value.value !== null) { + return value.value; + } + return null; + } + + // Get attribute value by attribute ID (useful when we know the ID but not the name) + private getAttributeValueById( + obj: JiraAssetsObject, + attributeId: number, + attrSchema?: Map + ): string | null { + if (!obj.attributes || obj.attributes.length === 0) { + return null; + } + + // Find attribute by ID + const attr = obj.attributes.find((a) => a.objectTypeAttributeId === attributeId); + if (!attr || !attr.objectAttributeValues || attr.objectAttributeValues.length === 0) { + return null; + } + + const value = attr.objectAttributeValues[0]; + // For URL/text fields, use value; for select fields, use displayValue + if (value.value !== undefined && value.value !== null) { + return value.value; + } + if (value.displayValue !== undefined && value.displayValue !== null) { + return value.displayValue; + } + return null; + } + + // Get simple text/select value from attribute + private getAttributeValue( + obj: JiraAssetsObject, + attributeName: string + ): string | null { + const attr = this.getAttributeByName(obj, attributeName); + if (!attr || attr.objectAttributeValues.length === 0) { + return null; + } + + const value = attr.objectAttributeValues[0]; + // For select/status fields, use displayValue; for text fields, use value + if (value.displayValue !== undefined && value.displayValue !== null) { + return value.displayValue; + } + if (value.value !== undefined && value.value !== null) { + return value.value; + } + return null; + } + + // Get reference object value from attribute + private getReferenceValue( + obj: JiraAssetsObject, + attributeName: string + ): ReferenceValue | null { + const attr = this.getAttributeByName(obj, attributeName); + if (!attr || attr.objectAttributeValues.length === 0) { + return null; + } + + const value = attr.objectAttributeValues[0]; + if (value.referencedObject) { + return { + objectId: value.referencedObject.id.toString(), + key: value.referencedObject.objectKey, + name: value.referencedObject.label, + }; + } + return null; + } + + // Get multiple reference values (for multi-select reference fields) + private getReferenceValues( + obj: JiraAssetsObject, + attributeName: string + ): ReferenceValue[] { + const attr = this.getAttributeByName(obj, attributeName); + if (!attr || attr.objectAttributeValues.length === 0) { + return []; + } + + return attr.objectAttributeValues + .filter((v) => v.referencedObject) + .map((v) => ({ + objectId: v.referencedObject!.id.toString(), + key: v.referencedObject!.objectKey, + name: v.referencedObject!.label, + })); + } + + // Get reference value by attribute ID + private getReferenceValueById( + obj: JiraAssetsObject, + attributeId: number, + attrSchema?: Map + ): ReferenceValue | null { + if (!obj.attributes || obj.attributes.length === 0) { + return null; + } + + // Find attribute by ID + const attr = obj.attributes.find((a) => a.objectTypeAttributeId === attributeId); + if (!attr || !attr.objectAttributeValues || attr.objectAttributeValues.length === 0) { + return null; + } + + const value = attr.objectAttributeValues[0]; + if (value.referencedObject) { + return { + objectId: value.referencedObject.id.toString(), + key: value.referencedObject.objectKey, + name: value.referencedObject.label, + }; + } + return null; + } + + // Get reference value with schema fallback for attribute lookup + private getReferenceValueWithSchema( + obj: JiraAssetsObject, + attributeName: string, + attrSchema?: Map + ): ReferenceValue | null { + const attr = this.getAttributeByName(obj, attributeName, attrSchema); + if (!attr || attr.objectAttributeValues.length === 0) { + return null; + } + + const value = attr.objectAttributeValues[0]; + if (value.referencedObject) { + return { + objectId: value.referencedObject.id.toString(), + key: value.referencedObject.objectKey, + name: value.referencedObject.label, + }; + } + return null; + } + + // Get multiple reference values with schema fallback + private getReferenceValuesWithSchema( + obj: JiraAssetsObject, + attributeName: string, + attrSchema?: Map + ): ReferenceValue[] { + const attr = this.getAttributeByName(obj, attributeName, attrSchema); + if (!attr || attr.objectAttributeValues.length === 0) { + return []; + } + + return attr.objectAttributeValues + .filter((v) => v.referencedObject) + .map((v) => ({ + objectId: v.referencedObject!.id.toString(), + key: v.referencedObject!.objectKey, + name: v.referencedObject!.label, + })); + } + + // Ensure application functions cache is populated + private async ensureApplicationFunctionsCache(): Promise { + // getApplicationFunctions now handles caching, so just call it + if (this.applicationFunctionsCache === null) { + await this.getApplicationFunctions(); + } + } + + // Ensure factor caches are populated + private async ensureFactorCaches(): Promise { + await Promise.all([ + this.getDynamicsFactors(), + this.getComplexityFactors(), + this.getNumberOfUsers(), + ]); + } + + // Enrich reference value with factor from cache + private enrichWithFactor(refValue: ReferenceValue | null, cache: Map | null): ReferenceValue | null { + if (!refValue || !cache) return refValue; + const cached = cache.get(refValue.objectId); + if (cached && cached.factor !== undefined) { + return { ...refValue, factor: cached.factor }; + } + return refValue; + } + + // Enrich reference values with applicationFunctionCategory, description, and keywords from cache + private enrichApplicationFunctions(refs: ReferenceValue[]): ReferenceValue[] { + if (!this.applicationFunctionsCache || refs.length === 0) { + return refs; + } + + return refs.map((ref) => { + const cached = this.applicationFunctionsCache!.get(ref.objectId); + if (cached) { + return { + ...ref, + applicationFunctionCategory: cached.applicationFunctionCategory, + description: cached.description, + keywords: cached.keywords, + }; + } + return ref; + }); + } + + // Parse Jira object for list view (summary) with optional schema for attribute lookup + private async parseJiraObject(obj: JiraAssetsObject, attrSchema?: Map): Promise { + const appFunctions = this.getReferenceValuesWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_FUNCTION, attrSchema); + + // Ensure factor caches are populated + await this.ensureFactorCaches(); + + // Get reference values and enrich with factors + const dynamicsFactor = this.enrichWithFactor( + this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.DYNAMICS_FACTOR, attrSchema), + this.dynamicsFactorsCache + ); + const complexityFactor = this.enrichWithFactor( + this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.COMPLEXITY_FACTOR, attrSchema), + this.complexityFactorsCache + ); + const numberOfUsers = this.enrichWithFactor( + this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.NUMBER_OF_USERS, attrSchema), + this.numberOfUsersCache + ); + + const applicationDetails = await this.parseJiraObjectDetails(obj, attrSchema); + + // Calculate min/max FTE + const minMaxFTE = calculateRequiredEffortWithMinMax(applicationDetails); + + return { + id: obj.id.toString(), + key: obj.objectKey, + name: obj.label, + status: this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.STATUS, attrSchema) as ApplicationStatus | null, + applicationFunctions: this.enrichApplicationFunctions(appFunctions), + governanceModel: this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.GOVERNANCE_MODEL, attrSchema), + dynamicsFactor, + complexityFactor, + applicationCluster: this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_CLUSTER, attrSchema), + applicationType: this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_TYPE, attrSchema), + platform: this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.PLATFORM, attrSchema), + requiredEffortApplicationManagement: applicationDetails.requiredEffortApplicationManagement, + minFTE: minMaxFTE.minFTE, + maxFTE: minMaxFTE.maxFTE, + overrideFTE: applicationDetails.overrideFTE, + applicationManagementHosting: applicationDetails.applicationManagementHosting, + applicationManagementTAM: applicationDetails.applicationManagementTAM, + }; + } + + // Parse Jira object for detail view (full details) with optional schema for attribute lookup + private async parseJiraObjectDetails(obj: JiraAssetsObject, attrSchema?: Map): Promise { + const appFunctions = this.getReferenceValuesWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_FUNCTION, attrSchema); + const rawDescription = this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.DESCRIPTION, attrSchema); + + // Ensure factor caches are populated + await this.ensureFactorCaches(); + + // Get reference values and enrich with factors + const dynamicsFactor = this.enrichWithFactor( + this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.DYNAMICS_FACTOR, attrSchema), + this.dynamicsFactorsCache + ); + const complexityFactor = this.enrichWithFactor( + this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.COMPLEXITY_FACTOR, attrSchema), + this.complexityFactorsCache + ); + const numberOfUsers = this.enrichWithFactor( + this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.NUMBER_OF_USERS, attrSchema), + this.numberOfUsersCache + ); + + const applicationDetails: ApplicationDetails = { + id: obj.id.toString(), + key: obj.objectKey, + name: obj.label, + searchReference: this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.SEARCH_REFERENCE, attrSchema), + description: rawDescription ? stripHtmlTags(rawDescription) : null, + supplierProduct: this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.SUPPLIER_PRODUCT, attrSchema), + organisation: this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.ORGANISATION, attrSchema), + hostingType: this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.HOSTING_TYPE, attrSchema), + status: this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.STATUS, attrSchema) as ApplicationStatus | null, + businessImportance: this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.BUSINESS_IMPORTANCE, attrSchema), + businessImpactAnalyse: this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.BUSINESS_IMPACT_ANALYSE, attrSchema), + systemOwner: this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.SYSTEM_OWNER, attrSchema), + businessOwner: this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.BUSINESS_OWNER, attrSchema), + functionalApplicationManagement: this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.FAM, attrSchema), + technicalApplicationManagement: this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.TAM, attrSchema), + technicalApplicationManagementPrimary: config.jiraAttrTechnicalApplicationManagementPrimary && config.jiraAttrTechnicalApplicationManagementPrimary.trim() !== '' + ? (() => { + const value = this.getAttributeValueById(obj, parseInt(config.jiraAttrTechnicalApplicationManagementPrimary, 10), attrSchema); + if (value) { + logger.debug(`Found Technical Application Management Primary (attr ${config.jiraAttrTechnicalApplicationManagementPrimary}): ${value}`); + } else { + logger.debug(`Technical Application Management Primary (attr ${config.jiraAttrTechnicalApplicationManagementPrimary}) not found or empty`); + } + return value; + })() + : null, + technicalApplicationManagementSecondary: config.jiraAttrTechnicalApplicationManagementSecondary && config.jiraAttrTechnicalApplicationManagementSecondary.trim() !== '' + ? (() => { + const value = this.getAttributeValueById(obj, parseInt(config.jiraAttrTechnicalApplicationManagementSecondary, 10), attrSchema); + if (value) { + logger.debug(`Found Technical Application Management Secondary (attr ${config.jiraAttrTechnicalApplicationManagementSecondary}): ${value}`); + } else { + logger.debug(`Technical Application Management Secondary (attr ${config.jiraAttrTechnicalApplicationManagementSecondary}) not found or empty`); + } + return value; + })() + : null, + medischeTechniek: this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.MEDISCHE_TECHNIEK, attrSchema) === 'true' || + this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.MEDISCHE_TECHNIEK, attrSchema) === 'Ja', + applicationFunctions: this.enrichApplicationFunctions(appFunctions), + dynamicsFactor, + complexityFactor, + numberOfUsers, + governanceModel: this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.GOVERNANCE_MODEL, attrSchema), + applicationCluster: this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_CLUSTER, attrSchema), + applicationType: this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_TYPE, attrSchema), + platform: this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.PLATFORM, attrSchema), + requiredEffortApplicationManagement: null, + technischeArchitectuur: this.getAttributeValueById(obj, parseInt(config.jiraAttrTechnischeArchitectuur, 10), attrSchema), + overrideFTE: config.jiraAttrOverrideFTE + ? (() => { + const value = this.getAttributeValueById(obj, parseInt(config.jiraAttrOverrideFTE, 10), attrSchema); + // Parse float value, return null if empty or invalid + if (!value || value === '') return null; + const parsed = parseFloat(value); + return isNaN(parsed) ? null : parsed; + })() + : null, + applicationManagementHosting: config.jiraAttrApplicationManagementHosting + ? this.getReferenceValueById(obj, parseInt(config.jiraAttrApplicationManagementHosting, 10), attrSchema) + : null, + applicationManagementTAM: config.jiraAttrApplicationManagementTAM + ? this.getReferenceValueById(obj, parseInt(config.jiraAttrApplicationManagementTAM, 10), attrSchema) + : null, + }; + + // Calculate required effort application management + applicationDetails.requiredEffortApplicationManagement = calculateRequiredEffortApplicationManagement(applicationDetails); + + return applicationDetails; + } + + private buildAqlQuery(filters: SearchFilters): string { + const conditions: string[] = ['objectType = "Application Component"']; + + if (filters.searchText && filters.searchText.trim()) { + const search = filters.searchText.trim().replace(/"/g, '\\"'); + conditions.push( + `(Name LIKE "${search}" OR Description LIKE "${search}")` + ); + } + + if (filters.statuses && filters.statuses.length > 0) { + // Escape quotes in status values and build IN clause + const statusList = filters.statuses + .map((s) => `"${s.replace(/"/g, '\\"')}"`) + .join(', '); + conditions.push(`Status IN (${statusList})`); + } + + if (filters.applicationFunction === 'empty') { + conditions.push('ApplicationFunction IS EMPTY'); + } else if (filters.applicationFunction === 'filled') { + conditions.push('ApplicationFunction IS NOT EMPTY'); + } + + if (filters.governanceModel === 'empty') { + conditions.push('"ICT Governance Model" IS EMPTY'); + } else if (filters.governanceModel === 'filled') { + conditions.push('"ICT Governance Model" IS NOT EMPTY'); + } + + if (filters.dynamicsFactor === 'empty') { + conditions.push('"Application Management - Dynamics Factor" IS EMPTY'); + } else if (filters.dynamicsFactor === 'filled') { + conditions.push('"Application Management - Dynamics Factor" IS NOT EMPTY'); + } + + if (filters.complexityFactor === 'empty') { + conditions.push('"Application Management - Complexity Factor" IS EMPTY'); + } else if (filters.complexityFactor === 'filled') { + conditions.push('"Application Management - Complexity Factor" IS NOT EMPTY'); + } + + if (filters.applicationCluster === 'empty') { + conditions.push('"Application Management - Application Cluster" IS EMPTY'); + } else if (filters.applicationCluster === 'filled') { + conditions.push('"Application Management - Application Cluster" IS NOT EMPTY'); + } + + if (filters.applicationType === 'empty') { + conditions.push('"Application Management - Application Type" IS EMPTY'); + } else if (filters.applicationType === 'filled') { + conditions.push('"Application Management - Application Type" IS NOT EMPTY'); + } + + if (filters.organisation) { + conditions.push(`Organisation = "${filters.organisation}"`); + } + + if (filters.hostingType) { + conditions.push(`"Application Component Hosting Type" = "${filters.hostingType}"`); + } + + if (filters.businessImportance) { + conditions.push(`"Business Importance" = "${filters.businessImportance}"`); + } + + return conditions.join(' AND '); + } + + async searchApplications( + filters: SearchFilters, + page: number = 1, + pageSize: number = 25 + ): Promise { + try { + await this.detectApiType(); + + const qlQuery = this.buildAqlQuery(filters); + logger.info(`Searching applications with query: ${qlQuery}`); + logger.debug(`Filters: ${JSON.stringify(filters)}`); + + let response: JiraAssetsSearchResponse; + + if (this.isDataCenter) { + // Jira Data Center (Insight) uses GET with query parameters + // Endpoint: /rest/insight/1.0/iql/objects + const params = new URLSearchParams({ + iql: qlQuery, + page: page.toString(), + resultPerPage: pageSize.toString(), + includeAttributes: 'true', + includeAttributesDeep: '1', + objectSchemaId: config.jiraSchemaId, + }); + + logger.debug(`IQL request: /iql/objects?${params.toString()}`); + response = await this.request( + `/iql/objects?${params.toString()}` + ); + } else { + // Jira Cloud (Assets) uses POST + const requestBody = { + qlQuery, + page, + resultPerPage: pageSize, + includeAttributes: true, + }; + logger.debug(`AQL request body: ${JSON.stringify(requestBody)}`); + response = await this.request( + '/aql/objects', + { + method: 'POST', + body: JSON.stringify(requestBody), + } + ); + } + + logger.info(`Search returned ${response.objectEntries?.length || 0} results (total: ${response.totalFilterCount || 0})`); + + // Fetch attribute schema for Application Component to enable attribute lookup by ID + let attrSchema: Map | undefined; + if (response.objectEntries && response.objectEntries.length > 0) { + const firstObj = response.objectEntries[0]; + const objectTypeId = firstObj.objectType?.id; + const objectTypeName = 'Application Component'; + if (objectTypeId) { + // Check cache first + if (this.attributeSchemaCache.has(objectTypeName)) { + attrSchema = this.attributeSchemaCache.get(objectTypeName); + } else { + // Fetch and cache the attribute schema + attrSchema = await this.fetchAttributeSchema(objectTypeId); + this.attributeSchemaCache.set(objectTypeName, attrSchema); + logger.info(`Cached attribute schema for ${objectTypeName}: ${attrSchema.size} attributes`); + } + } + } + + // Ensure application functions cache is populated for enrichment + await this.ensureApplicationFunctionsCache(); + // Ensure factor caches are populated + await this.ensureFactorCaches(); + + const applications = await Promise.all( + (response.objectEntries || []).map((obj) => + this.parseJiraObject(obj, attrSchema) + ) + ); + + const totalCount = response.totalFilterCount || 0; + const actualPageSize = pageSize > 0 ? pageSize : 25; // Ensure pageSize is never 0 + // Calculate totalPages: if there are results, ensure at least 1 page + const totalPages = totalCount > 0 ? Math.max(1, Math.ceil(totalCount / actualPageSize)) : 0; + // Use the requested page, not the response pageNumber, as the response might be incorrect + const actualPage = page > 0 ? page : 1; + + return { + applications, + totalCount, + currentPage: actualPage, + pageSize: actualPageSize, + totalPages, + }; + } catch (error) { + logger.error('Failed to search applications', error); + logger.error(`Query that failed: ${this.buildAqlQuery(filters)}`); + throw error; + } + } + + async getApplicationById(id: string): Promise { + try { + await this.detectApiType(); + const obj = await this.request(`/object/${id}`); + + // Fetch attribute schema for Application Component to enable attribute lookup by ID + let attrSchema: Map | undefined; + const objectTypeId = obj.objectType?.id; + const objectTypeName = 'Application Component'; + if (objectTypeId) { + // Check cache first + if (this.attributeSchemaCache.has(objectTypeName)) { + attrSchema = this.attributeSchemaCache.get(objectTypeName); + } else { + // Fetch and cache the attribute schema + attrSchema = await this.fetchAttributeSchema(objectTypeId); + this.attributeSchemaCache.set(objectTypeName, attrSchema); + logger.info(`Cached attribute schema for ${objectTypeName}: ${attrSchema.size} attributes`); + } + } + + // Ensure application functions cache is populated for enrichment + await this.ensureApplicationFunctionsCache(); + + return this.parseJiraObjectDetails(obj, attrSchema); + } catch (error) { + logger.error(`Failed to get application by ID: ${id}`, error); + return null; + } + } + + async updateApplication( + id: string, + updates: ApplicationUpdateRequest + ): Promise { + await this.detectApiType(); + + // For Jira Data Center (Insight), reference attributes need object keys + // For clearing a field, send an empty array + const attributes: Array<{ + objectTypeAttributeId: number; + objectAttributeValues: Array<{ value?: string }>; + }> = []; + + // ApplicationFunctions is now multi-select + if (updates.applicationFunctions !== undefined) { + if (updates.applicationFunctions.length > 0) { + attributes.push({ + objectTypeAttributeId: parseInt(config.jiraAttrApplicationFunction, 10), + objectAttributeValues: updates.applicationFunctions.map((key) => ({ + value: key, // Use the object key as value for reference attributes + })), + }); + } else { + // Clear the field by sending empty array + attributes.push({ + objectTypeAttributeId: parseInt(config.jiraAttrApplicationFunction, 10), + objectAttributeValues: [], + }); + } + } + + if (updates.dynamicsFactor) { + attributes.push({ + objectTypeAttributeId: parseInt(config.jiraAttrDynamicsFactor, 10), + objectAttributeValues: [ + { value: updates.dynamicsFactor }, + ], + }); + } + + if (updates.complexityFactor) { + attributes.push({ + objectTypeAttributeId: parseInt(config.jiraAttrComplexityFactor, 10), + objectAttributeValues: [ + { value: updates.complexityFactor }, + ], + }); + } + + if (updates.numberOfUsers) { + attributes.push({ + objectTypeAttributeId: parseInt(config.jiraAttrNumberOfUsers, 10), + objectAttributeValues: [ + { value: updates.numberOfUsers }, + ], + }); + } + + if (updates.governanceModel) { + attributes.push({ + objectTypeAttributeId: parseInt(config.jiraAttrGovernanceModel, 10), + objectAttributeValues: [ + { value: updates.governanceModel }, + ], + }); + } + + if (updates.applicationCluster) { + attributes.push({ + objectTypeAttributeId: parseInt(config.jiraAttrApplicationCluster, 10), + objectAttributeValues: [ + { value: updates.applicationCluster }, + ], + }); + } + + if (updates.applicationType) { + attributes.push({ + objectTypeAttributeId: parseInt(config.jiraAttrApplicationType, 10), + objectAttributeValues: [ + { value: updates.applicationType }, + ], + }); + } + + if (updates.hostingType) { + attributes.push({ + objectTypeAttributeId: parseInt(config.jiraAttrHostingType, 10), + objectAttributeValues: [ + { value: updates.hostingType }, + ], + }); + } + + if (updates.businessImpactAnalyse) { + attributes.push({ + objectTypeAttributeId: parseInt(config.jiraAttrBusinessImpactAnalyse, 10), + objectAttributeValues: [ + { value: updates.businessImpactAnalyse }, + ], + }); + } + + if (updates.overrideFTE !== undefined) { + // For float attributes, send the value as a string, or empty array to clear + if (updates.overrideFTE === null || updates.overrideFTE === undefined) { + // Clear the field + attributes.push({ + objectTypeAttributeId: parseInt(config.jiraAttrOverrideFTE, 10), + objectAttributeValues: [], + }); + } else { + // Set the float value + attributes.push({ + objectTypeAttributeId: parseInt(config.jiraAttrOverrideFTE, 10), + objectAttributeValues: [ + { value: updates.overrideFTE.toString() }, + ], + }); + } + } + + // Application Management - Hosting + if (updates.applicationManagementHosting !== undefined) { + if (updates.applicationManagementHosting) { + attributes.push({ + objectTypeAttributeId: parseInt(config.jiraAttrApplicationManagementHosting, 10), + objectAttributeValues: [{ value: updates.applicationManagementHosting }], + }); + } else { + // Clear the field + attributes.push({ + objectTypeAttributeId: parseInt(config.jiraAttrApplicationManagementHosting, 10), + objectAttributeValues: [], + }); + } + } + + // Application Management - TAM + if (updates.applicationManagementTAM !== undefined) { + if (updates.applicationManagementTAM) { + attributes.push({ + objectTypeAttributeId: parseInt(config.jiraAttrApplicationManagementTAM, 10), + objectAttributeValues: [{ value: updates.applicationManagementTAM }], + }); + } else { + // Clear the field + attributes.push({ + objectTypeAttributeId: parseInt(config.jiraAttrApplicationManagementTAM, 10), + objectAttributeValues: [], + }); + } + } + + // Log the update request for debugging + logger.info(`=== Updating application ${id} ===`); + logger.info(`Config attribute IDs: appFunc=${config.jiraAttrApplicationFunction}, dynamics=${config.jiraAttrDynamicsFactor}, complexity=${config.jiraAttrComplexityFactor}, users=${config.jiraAttrNumberOfUsers}, governance=${config.jiraAttrGovernanceModel}`); + logger.info(`Updates received: ${JSON.stringify(updates)}`); + logger.info(`Attributes to update: ${JSON.stringify(attributes, null, 2)}`); + + if (attributes.length === 0) { + logger.warn('No attributes to update - all update values were empty'); + return true; + } + + try { + const requestBody = { attributes }; + logger.info(`Request body: ${JSON.stringify(requestBody)}`); + logger.info(`Request URL: ${this.getBaseUrl()}/object/${id}`); + + const response = await this.request<{ id: number }>(`/object/${id}`, { + method: 'PUT', + body: JSON.stringify(requestBody), + }); + + logger.info(`Update response: ${JSON.stringify(response)}`); + logger.info(`Successfully updated application ${id} with ${attributes.length} attributes`); + return true; + } catch (error) { + logger.error(`Failed to update application ${id}`, error); + // Log more details about the error + if (error instanceof Error) { + logger.error(`Error message: ${error.message}`); + logger.error(`Error stack: ${error.stack}`); + } + return false; + } + } + + async getReferenceObjects(objectType: string): Promise { + try { + await this.detectApiType(); + + const qlQuery = `objectType = "${objectType}"`; + let response: JiraAssetsSearchResponse; + + if (this.isDataCenter) { + // Jira Data Center (Insight) uses GET with query parameters + const params = new URLSearchParams({ + iql: qlQuery, + resultPerPage: '200', + includeAttributes: 'true', + includeAttributesDeep: '1', + objectSchemaId: config.jiraSchemaId, + }); + + response = await this.request( + `/iql/objects?${params.toString()}` + ); + } else { + // Jira Cloud (Assets) uses POST + response = await this.request( + '/aql/objects', + { + method: 'POST', + body: JSON.stringify({ + qlQuery, + resultPerPage: 200, + includeAttributes: true, + }), + } + ); + } + + // Fetch attribute schema for this object type to enable attribute lookup by ID + let attrSchema: Map | undefined; + if (response.objectEntries.length > 0) { + const firstObj = response.objectEntries[0]; + const objectTypeId = firstObj.objectType?.id; + if (objectTypeId) { + // Check cache first + if (this.attributeSchemaCache.has(objectType)) { + attrSchema = this.attributeSchemaCache.get(objectType); + } else { + // Fetch and cache the attribute schema + attrSchema = await this.fetchAttributeSchema(objectTypeId); + this.attributeSchemaCache.set(objectType, attrSchema); + logger.info(`Cached attribute schema for ${objectType}: ${attrSchema.size} attributes`); + } + } + + // Log raw API response for first object to debug attribute structure + logger.info(`=== Debug: Reference data for ${objectType} ===`); + logger.info(`Object: id=${firstObj.id}, key=${firstObj.objectKey}, label=${firstObj.label}`); + logger.info(`ObjectType: id=${firstObj.objectType?.id}, name=${firstObj.objectType?.name}`); + logger.info(`Attributes count: ${firstObj.attributes?.length || 0}`); + if (firstObj.attributes && firstObj.attributes.length > 0) { + firstObj.attributes.forEach((attr, idx) => { + let attrInfo: string; + if (attr.objectTypeAttribute) { + attrInfo = `name="${attr.objectTypeAttribute.name}", typeAttrId=${attr.objectTypeAttribute.id}`; + } else { + // Try to get name from schema + const schemaName = attrSchema?.get(attr.objectTypeAttributeId); + attrInfo = `(objectTypeAttribute MISSING, attrId=${attr.objectTypeAttributeId}, schemaName="${schemaName || 'unknown'}")`; + } + const values = attr.objectAttributeValues.map(v => { + if (v.displayValue) return `displayValue="${v.displayValue}"`; + if (v.value) return `value="${v.value}"`; + if (v.referencedObject) return `ref:${v.referencedObject.label}`; + return 'empty'; + }).join(', '); + logger.info(` Attr[${idx}]: ${attrInfo} = [${values}]`); + }); + } else { + logger.info(` No attributes array or empty!`); + } + logger.info(`=== End Debug ===`); + } + + const results = response.objectEntries.map((obj) => { + // Extract Description attribute (try multiple possible attribute names) + // Use attrSchema for fallback lookup by attribute ID + const rawDescription = this.getAttributeValueWithSchema(obj, 'Description', attrSchema) + || this.getAttributeValueWithSchema(obj, 'Omschrijving', attrSchema) + || this.getAttributeValueWithSchema(obj, 'Beschrijving', attrSchema); + // Strip HTML tags from description + const description = rawDescription ? stripHtmlTags(rawDescription) : null; + + // Extract Summary attribute (for Dynamics Factor, Complexity Factor, and Governance Model) + const rawSummary = this.getAttributeValueWithSchema(obj, 'Summary', attrSchema) + || this.getAttributeValueWithSchema(obj, 'Samenvatting', attrSchema); + // Strip HTML tags from summary + const summary = rawSummary ? stripHtmlTags(rawSummary) : null; + + // Extract Application Function Category (reference) for ApplicationFunction objects + const applicationFunctionCategory = this.getReferenceValueWithSchema( + obj, + 'Application Function Category', + attrSchema + ) || undefined; + + // Extract Keywords attribute for ApplicationFunction objects + const keywords = this.getAttributeValueWithSchema(obj, 'Keywords', attrSchema) + || this.getAttributeValueWithSchema(obj, 'Trefwoorden', attrSchema) + || undefined; + + // Extract Order attribute (for sorting by Order field where applicable) + const orderStr = this.getAttributeValueWithSchema(obj, 'Order', attrSchema) + || this.getAttributeValueWithSchema(obj, 'Volgorde', attrSchema); + const order = orderStr ? parseFloat(orderStr) : undefined; + + // Extract Factor attribute for Dynamics Factor, Complexity Factor, and Number of Users + const factorStr = this.getAttributeValueWithSchema(obj, 'Factor', attrSchema) + || this.getAttributeValueWithSchema(obj, 'factor', attrSchema); + const factor = factorStr ? parseFloat(factorStr) : undefined; + + // Extract Remarks and Application attributes for Governance Models + const remarks = this.getAttributeValueWithSchema(obj, 'Remarks', attrSchema) + || this.getAttributeValueWithSchema(obj, 'Opmerkingen', attrSchema) + || undefined; + const application = this.getAttributeValueWithSchema(obj, 'Application', attrSchema) + || this.getAttributeValueWithSchema(obj, 'Applicatie', attrSchema) + || undefined; + + // Extract Indicators attribute for Business Impact Analyse + const indicators = this.getAttributeValueWithSchema(obj, 'Indicators', attrSchema) + || this.getAttributeValueWithSchema(obj, 'Indicatoren', attrSchema) + || undefined; + + const result: any = { + objectId: obj.id.toString(), + key: obj.objectKey, + name: obj.label, + description: description || undefined, + summary: summary || undefined, + applicationFunctionCategory: applicationFunctionCategory || undefined, + keywords: keywords || undefined, + order: order !== undefined && !isNaN(order) ? order : undefined, + factor: factor !== undefined && !isNaN(factor) ? factor : undefined, + }; + + // Add Remarks and Application for Governance Models (or other objects that might have these) + if (remarks) { + result.remarks = stripHtmlTags(remarks); + } + if (application) { + result.application = stripHtmlTags(application); + } + // Add Indicators for Business Impact Analyse + if (indicators) { + result.indicators = stripHtmlTags(indicators); + } + + return result; + }); + + // Log first result for debugging + if (results.length > 0) { + logger.debug(`Reference data for ${objectType}: first item = ${JSON.stringify(results[0])}`); + } + + return results; + } catch (error) { + logger.error(`Failed to get reference objects for type: ${objectType}`, error); + return []; + } + } + + async getApplicationFunctions(): Promise { + const functions = await this.getReferenceObjects('ApplicationFunction'); + // Ensure cache is populated for enrichment + if (this.applicationFunctionsCache === null) { + this.applicationFunctionsCache = new Map(); + for (const func of functions) { + this.applicationFunctionsCache.set(func.objectId, func); + } + logger.info(`Cached ${functions.length} application functions`); + } + return functions; + } + + async getApplicationFunctionCategories(): Promise { + if (this.applicationFunctionCategoriesCache !== null) { + return Array.from(this.applicationFunctionCategoriesCache.values()); + } + + const categories = await this.getReferenceObjects('ApplicationFunctionCategory'); + this.applicationFunctionCategoriesCache = new Map(); + for (const cat of categories) { + this.applicationFunctionCategoriesCache.set(cat.objectId, cat); + } + logger.info(`Cached ${categories.length} application function categories`); + return categories; + } + + async getDynamicsFactors(): Promise { + if (this.dynamicsFactorsCache === null) { + const factors = await this.getReferenceObjects('Application Management - Dynamics Factor'); + // Cache factors by objectId + this.dynamicsFactorsCache = new Map(); + for (const factor of factors) { + this.dynamicsFactorsCache.set(factor.objectId, factor); + } + logger.info(`Cached ${factors.length} dynamics factors`); + } + // Return sorted by Name + return Array.from(this.dynamicsFactorsCache.values()).sort((a, b) => a.name.localeCompare(b.name)); + } + + async getComplexityFactors(): Promise { + if (this.complexityFactorsCache === null) { + const factors = await this.getReferenceObjects('Application Management - Complexity Factor'); + // Cache factors by objectId + this.complexityFactorsCache = new Map(); + for (const factor of factors) { + this.complexityFactorsCache.set(factor.objectId, factor); + } + logger.info(`Cached ${factors.length} complexity factors`); + } + // Return sorted by Name + return Array.from(this.complexityFactorsCache.values()).sort((a, b) => a.name.localeCompare(b.name)); + } + + async getNumberOfUsers(): Promise { + if (this.numberOfUsersCache === null) { + const users = await this.getReferenceObjects('Application Management - Number of Users'); + // Cache users by objectId + this.numberOfUsersCache = new Map(); + for (const user of users) { + this.numberOfUsersCache.set(user.objectId, user); + } + logger.info(`Cached ${users.length} number of users`); + } + // Return sorted by Order attribute + return Array.from(this.numberOfUsersCache.values()).sort((a, b) => { + const orderA = a.order ?? Number.MAX_SAFE_INTEGER; + const orderB = b.order ?? Number.MAX_SAFE_INTEGER; + return orderA - orderB; + }); + } + + async getGovernanceModels(): Promise { + const models = await this.getReferenceObjects('ICT Governance Model'); + // Sort by Name + return models.sort((a, b) => a.name.localeCompare(b.name)); + } + + async getApplicationClusters(): Promise { + const clusters = await this.getReferenceObjects('Application Management - Application Cluster'); + // Sort by Name + return clusters.sort((a, b) => a.name.localeCompare(b.name)); + } + + async getApplicationTypes(): Promise { + const types = await this.getReferenceObjects('Application Management - Application Type'); + // Sort by Name + return types.sort((a, b) => a.name.localeCompare(b.name)); + } + + async getBusinessImportance(): Promise { + const importance = await this.getReferenceObjects('Business Importance'); + // Sort by Name + return importance.sort((a, b) => a.name.localeCompare(b.name)); + } + + async getOrganisations(): Promise { + return this.getReferenceObjects('Organisation'); + } + + async getHostingTypes(): Promise { + // Use objectType name "Hosting Type" (ID 39 in Zuyderland Jira) + return this.getReferenceObjects('Hosting Type'); + } + + async getBusinessImpactAnalyses(): Promise { + // Use objectType name "Business Impact Analyse" (ID 41 in Zuyderland Jira) + return this.getReferenceObjects('Business Impact Analyse'); + } + + async getApplicationManagementHosting(): Promise { + // Use objectType name "Application Management - Hosting" (ID 438 in Zuyderland Jira) + return this.getReferenceObjects('Application Management - Hosting'); + } + + async getApplicationManagementTAM(): Promise { + // Use objectType name "Application Management - TAM" (ID 439 in Zuyderland Jira) + return this.getReferenceObjects('Application Management - TAM'); + } + + async testConnection(): Promise { + try { + await this.detectApiType(); + await this.request('/objectschema/list'); + return true; + } catch { + return false; + } + } + + // Get statistics about applications from Jira + async getStats(includeDistributions: boolean = true): Promise<{ + totalApplications: number; + classifiedCount: number; + unclassifiedCount: number; + byStatus: Record; + byGovernanceModel: Record; + }> { + await this.detectApiType(); + + // Check cache first + if (this.dashboardStatsCache && + Date.now() - this.dashboardStatsCache.timestamp < this.DASHBOARD_STATS_CACHE_TTL) { + logger.info('Dashboard stats: Using cached data'); + return this.dashboardStatsCache.data; + } + + logger.info('Dashboard stats: Cache miss or expired, fetching fresh data'); + + try { + const allAppsQuery = 'objectType = "Application Component" AND Status != "Closed"'; + + // First, get total count with a single query + let totalCountResponse: JiraAssetsSearchResponse; + if (this.isDataCenter) { + const params = new URLSearchParams({ + iql: allAppsQuery, + resultPerPage: '1', + includeAttributes: 'true', + objectSchemaId: config.jiraSchemaId, + }); + totalCountResponse = await this.request( + `/iql/objects?${params.toString()}` + ); + } else { + totalCountResponse = await this.request( + '/aql/objects', + { + method: 'POST', + body: JSON.stringify({ + qlQuery: allAppsQuery, + resultPerPage: 1, + includeAttributes: true, + }), + } + ); + } + + const totalApplications = totalCountResponse.totalFilterCount || 0; + + // Initialize distributions + const byStatus: Record = {}; + const byGovernanceModel: Record = {}; + let classifiedCount = 0; + + if (includeDistributions && totalApplications > 0) { + // Fetch attribute schema once (use cached if available) + let attrSchema: Map | undefined; + const objectTypeName = 'Application Component'; + + if (this.attributeSchemaCache.has(objectTypeName)) { + attrSchema = this.attributeSchemaCache.get(objectTypeName); + } else { + // Get a sample object to fetch schema + const sampleQuery = 'objectType = "Application Component" AND Status != "Closed"'; + let sampleResponse: JiraAssetsSearchResponse; + + if (this.isDataCenter) { + const sampleParams = new URLSearchParams({ + iql: sampleQuery, + resultPerPage: '1', + includeAttributes: 'true', + objectSchemaId: config.jiraSchemaId, + }); + sampleResponse = await this.request( + `/iql/objects?${sampleParams.toString()}` + ); + } else { + sampleResponse = await this.request( + '/aql/objects', + { + method: 'POST', + body: JSON.stringify({ + qlQuery: sampleQuery, + resultPerPage: 1, + includeAttributes: true, + }), + } + ); + } + + if (sampleResponse.objectEntries && sampleResponse.objectEntries.length > 0) { + const firstObj = sampleResponse.objectEntries[0]; + const objectTypeId = firstObj.objectType?.id; + if (objectTypeId) { + attrSchema = await this.fetchAttributeSchema(objectTypeId); + this.attributeSchemaCache.set(objectTypeName, attrSchema); + } + } + } + + // Fetch all applications in batches and calculate all statistics in memory + // Using smaller batch size to avoid API timeouts + const maxApplicationsToFetch = 2000; // Limit for better performance + const pageSize = parseInt(config.jiraApiBatchSize || '15', 10); // Use configured batch size (small to avoid timeouts) + let currentPage = 1; + let hasMore = true; + let totalFetched = 0; + let consecutiveErrors = 0; + const maxConsecutiveErrors = 3; + + logger.info(`Dashboard stats: Fetching up to ${Math.min(maxApplicationsToFetch, totalApplications)} applications in batches of ${pageSize}`); + + while (hasMore && totalFetched < maxApplicationsToFetch && totalFetched < totalApplications && consecutiveErrors < maxConsecutiveErrors) { + let batchResponse: JiraAssetsSearchResponse; + + try { + if (this.isDataCenter) { + const params = new URLSearchParams({ + iql: allAppsQuery, + resultPerPage: pageSize.toString(), + pageNumber: currentPage.toString(), + includeAttributes: 'true', + objectSchemaId: config.jiraSchemaId, + }); + batchResponse = await this.request( + `/iql/objects?${params.toString()}` + ); + } else { + batchResponse = await this.request( + '/aql/objects', + { + method: 'POST', + body: JSON.stringify({ + qlQuery: allAppsQuery, + resultPerPage: pageSize, + pageNumber: currentPage, + includeAttributes: true, + }), + } + ); + } + consecutiveErrors = 0; // Reset error counter on success + } catch (error) { + consecutiveErrors++; + logger.warn(`Dashboard stats batch ${currentPage} failed (attempt ${consecutiveErrors}/${maxConsecutiveErrors}): ${error}`); + if (consecutiveErrors >= maxConsecutiveErrors) { + logger.warn(`Dashboard stats: Too many consecutive errors, returning partial data`); + break; + } + // Wait a bit before retrying + await new Promise(resolve => setTimeout(resolve, 1000)); + continue; + } + + const entries = batchResponse.objectEntries || []; + totalFetched += entries.length; + + // Process each application in the batch + for (const obj of entries) { + // Count by status (STATUS is a string/select value, not a reference) + const status = this.getAttributeValueWithSchema( + obj, + ATTRIBUTE_NAMES.STATUS, + attrSchema + ) as ApplicationStatus | null; + if (status) { + byStatus[status] = (byStatus[status] || 0) + 1; + } else { + byStatus['Undefined'] = (byStatus['Undefined'] || 0) + 1; + } + + // Count classified (has ApplicationFunction) + const applicationFunctions = this.getReferenceValuesWithSchema( + obj, + ATTRIBUTE_NAMES.APPLICATION_FUNCTION, + attrSchema + ); + if (applicationFunctions && applicationFunctions.length > 0) { + classifiedCount++; + } + + // Count by governance model + const governanceModel = this.getReferenceValueWithSchema( + obj, + ATTRIBUTE_NAMES.GOVERNANCE_MODEL, + attrSchema + ); + if (governanceModel) { + byGovernanceModel[governanceModel.name] = + (byGovernanceModel[governanceModel.name] || 0) + 1; + } else { + byGovernanceModel['Geen regiemodel'] = + (byGovernanceModel['Geen regiemodel'] || 0) + 1; + } + } + + // Check if there are more pages + hasMore = + entries.length === pageSize && + currentPage * pageSize < totalApplications && + totalFetched < maxApplicationsToFetch; + currentPage++; + } + + // If we didn't fetch all applications, scale the counts proportionally + // This gives a more accurate estimate for large datasets + if (totalFetched < totalApplications && totalFetched > 0) { + const scaleFactor = totalApplications / totalFetched; + classifiedCount = Math.round(classifiedCount * scaleFactor); + + // Ensure classifiedCount doesn't exceed totalApplications due to rounding + classifiedCount = Math.min(classifiedCount, totalApplications); + + // Scale status counts + for (const status in byStatus) { + byStatus[status] = Math.round(byStatus[status] * scaleFactor); + } + + // Scale governance model counts + for (const model in byGovernanceModel) { + byGovernanceModel[model] = Math.round(byGovernanceModel[model] * scaleFactor); + } + + logger.info(`Scaled statistics: fetched ${totalFetched} of ${totalApplications} applications (scale factor: ${scaleFactor.toFixed(2)})`); + } + } else { + // If distributions are not needed, still get classified count with a single query + const classifiedQuery = 'objectType = "Application Component" AND Status != "Closed" AND ApplicationFunction IS NOT EMPTY'; + let classifiedResponse: JiraAssetsSearchResponse; + + if (this.isDataCenter) { + const params = new URLSearchParams({ + iql: classifiedQuery, + resultPerPage: '1', + includeAttributes: 'true', + objectSchemaId: config.jiraSchemaId, + }); + classifiedResponse = await this.request( + `/iql/objects?${params.toString()}` + ); + } else { + classifiedResponse = await this.request( + '/aql/objects', + { + method: 'POST', + body: JSON.stringify({ + qlQuery: classifiedQuery, + resultPerPage: 1, + includeAttributes: true, + }), + } + ); + } + classifiedCount = classifiedResponse.totalFilterCount || 0; + } + + const result = { + totalApplications, + classifiedCount, + unclassifiedCount: Math.max(0, totalApplications - classifiedCount), + byStatus, + byGovernanceModel, + }; + + // Store in cache + this.dashboardStatsCache = { + data: result, + timestamp: Date.now(), + }; + + logger.info(`Dashboard stats: Cached data (total: ${totalApplications}, classified: ${classifiedCount})`); + return result; + } catch (error) { + logger.error('Failed to get stats from Jira', error); + + // Return cached data if available (even if expired), otherwise return zeros + if (this.dashboardStatsCache) { + logger.info('Dashboard stats: Returning stale cached data due to error'); + return this.dashboardStatsCache.data; + } + + return { + totalApplications: 0, + classifiedCount: 0, + unclassifiedCount: 0, + byStatus: {}, + byGovernanceModel: {}, + }; + } + } + + async getTeamDashboardData(excludedStatuses: ApplicationStatus[] = []): Promise { + try { + // Check cache first (only if no status filter is applied, as cache doesn't account for filters) + // For now, we'll always fetch fresh data when filters are applied + // TODO: Could implement cache key based on excludedStatuses if needed for performance + const hasFilters = excludedStatuses.length > 0; + if (!hasFilters && this.teamDashboardCache) { + const age = Date.now() - this.teamDashboardCache.timestamp; + if (age < this.TEAM_DASHBOARD_CACHE_TTL) { + logger.debug('Returning team dashboard data from cache'); + return this.teamDashboardCache.data; + } + } + + await this.detectApiType(); + + // Use a more efficient approach: fetch in batches but only parse minimal data + const emptyFilters: SearchFilters = {}; + const qlQuery = this.buildAqlQuery(emptyFilters); + + logger.info(`Fetching team dashboard data with query: ${qlQuery}`); + + // Fetch all applications in batches to avoid timeout, but parse them efficiently + const batchSize = config.jiraApiBatchSize; // Configurable batch size from .env (default: 15) + let page = 1; + let hasMore = true; + const allApplications: ApplicationListItem[] = []; + + // Fetch attribute schema once + let attrSchema: Map | undefined; + const objectTypeName = 'Application Component'; + if (this.attributeSchemaCache.has(objectTypeName)) { + attrSchema = this.attributeSchemaCache.get(objectTypeName); + } else { + // We need to fetch one object first to get the object type ID + const testParams = new URLSearchParams({ + iql: qlQuery, + page: '1', + resultPerPage: '1', + includeAttributes: 'true', + includeAttributesDeep: '1', + objectSchemaId: config.jiraSchemaId, + }); + const testResponse = await this.request( + `/iql/objects?${testParams.toString()}` + ); + if (testResponse.objectEntries && testResponse.objectEntries.length > 0) { + const objectTypeId = testResponse.objectEntries[0].objectType?.id; + if (objectTypeId) { + attrSchema = await this.fetchAttributeSchema(objectTypeId); + this.attributeSchemaCache.set(objectTypeName, attrSchema); + } + } + } + + // Ensure caches are populated + await Promise.all([ + this.ensureApplicationFunctionsCache(), + this.ensureFactorCaches(), + ]); + + // First, get total count to determine how many batches we need + let firstResponse: JiraAssetsSearchResponse; + if (this.isDataCenter) { + const params = new URLSearchParams({ + iql: qlQuery, + page: '1', + resultPerPage: '1', + includeAttributes: 'true', + includeAttributesDeep: '1', + objectSchemaId: config.jiraSchemaId, + }); + firstResponse = await this.request( + `/iql/objects?${params.toString()}` + ); + } else { + firstResponse = await this.request( + '/aql/objects', + { + method: 'POST', + body: JSON.stringify({ + qlQuery, + page: 1, + resultPerPage: 1, + includeAttributes: true, + }), + } + ); + } + + const totalCount = (firstResponse as any).totalFilterCount || firstResponse.totalCount || 0; + const totalPages = Math.ceil(totalCount / batchSize); + logger.info(`Total applications: ${totalCount}, will fetch in ${totalPages} batches of ${batchSize}`); + + // Fetch all pages in parallel (but limit concurrency to avoid overwhelming the API) + const maxConcurrentBatches = 5; // Fetch 5 batches at a time + const allPages: number[] = []; + for (let i = 1; i <= totalPages; i++) { + allPages.push(i); + } + + // Process pages in chunks to limit concurrency + for (let i = 0; i < allPages.length; i += maxConcurrentBatches) { + const pageChunk = allPages.slice(i, i + maxConcurrentBatches); + + const batchPromises = pageChunk.map(async (pageNum) => { + let response: JiraAssetsSearchResponse; + + if (this.isDataCenter) { + const params = new URLSearchParams({ + iql: qlQuery, + page: pageNum.toString(), + resultPerPage: batchSize.toString(), + includeAttributes: 'true', + includeAttributesDeep: '1', + objectSchemaId: config.jiraSchemaId, + }); + response = await this.request( + `/iql/objects?${params.toString()}` + ); + } else { + response = await this.request( + '/aql/objects', + { + method: 'POST', + body: JSON.stringify({ + qlQuery, + page: pageNum, + resultPerPage: batchSize, + includeAttributes: true, + }), + } + ); + } + + if (!response.objectEntries || response.objectEntries.length === 0) { + return []; + } + + // Parse applications in parallel + const batchApplications = await Promise.all( + response.objectEntries.map((obj) => this.parseJiraObject(obj, attrSchema)) + ); + + logger.debug(`Fetched batch ${pageNum}/${totalPages}: ${batchApplications.length} applications`); + return batchApplications; + }); + + const batchResults = await Promise.all(batchPromises); + for (const batch of batchResults) { + allApplications.push(...batch); + } + + logger.info(`Processed ${Math.min(i + maxConcurrentBatches, allPages.length)}/${totalPages} batches (${allApplications.length} applications so far)`); + } + + logger.info(`Fetched ${allApplications.length} applications for team dashboard`); + + // Filter out excluded statuses + const filteredApplications = excludedStatuses.length > 0 + ? allApplications.filter(app => !app.status || !excludedStatuses.includes(app.status)) + : allApplications; + + logger.info(`After status filter: ${filteredApplications.length} applications (excluded: ${excludedStatuses.join(', ')})`); + + // Separate applications into Platforms, Workloads, and regular applications + const platforms: ApplicationListItem[] = []; + const workloads: ApplicationListItem[] = []; + const regularApplications: ApplicationListItem[] = []; + + for (const app of filteredApplications) { + 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); + } + } + + logger.info(`Identified ${platforms.length} platforms, ${workloads.length} workloads, ${regularApplications.length} regular applications`); + + // Group workloads by their platform + const workloadsByPlatform = new Map(); + for (const workload of workloads) { + const platformId = workload.platform!.objectId; + if (!workloadsByPlatform.has(platformId)) { + workloadsByPlatform.set(platformId, []); + } + workloadsByPlatform.get(platformId)!.push(workload); + } + + // Helper function to get effective FTE (override if present, otherwise calculated) + const getEffectiveFTE = (app: ApplicationListItem): number => { + return app.overrideFTE !== null && app.overrideFTE !== undefined + ? app.overrideFTE + : (app.requiredEffortApplicationManagement || 0); + }; + + // Helper function to get min FTE (override if present, otherwise minFTE, fallback to calculated) + const getMinFTE = (app: ApplicationListItem): number => { + if (app.overrideFTE !== null && app.overrideFTE !== undefined) { + return app.overrideFTE; // If override is set, min = override + } + return app.minFTE ?? app.requiredEffortApplicationManagement ?? 0; + }; + + // Helper function to get max FTE (override if present, otherwise maxFTE, fallback to calculated) + const getMaxFTE = (app: ApplicationListItem): number => { + if (app.overrideFTE !== null && app.overrideFTE !== undefined) { + return app.overrideFTE; // If override is set, max = override + } + return app.maxFTE ?? app.requiredEffortApplicationManagement ?? 0; + }; + + // Build PlatformWithWorkloads structures + const platformsWithWorkloads: import('../types/index.js').PlatformWithWorkloads[] = []; + for (const platform of platforms) { + const platformWorkloads = workloadsByPlatform.get(platform.id) || []; + const platformEffort = getEffectiveFTE(platform); + const workloadsEffort = platformWorkloads.reduce((sum, w) => sum + getEffectiveFTE(w), 0); + + platformsWithWorkloads.push({ + platform, + workloads: platformWorkloads, + platformEffort, + workloadsEffort, + totalEffort: platformEffort + workloadsEffort, + }); + } + + // Group all applications (regular + platforms + workloads) by cluster + const clusterMap = new Map(); + 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 to maintain order + const allClusters = await this.getApplicationClusters(); + const clusterMapById = new Map(allClusters.map(c => [c.objectId, c])); + + // Build cluster data + const clusters: import('../types/index.js').TeamDashboardCluster[] = []; + + // Add clusters in the order they appear in allClusters + for (const cluster of allClusters) { + 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 + getEffectiveFTE(app), 0 + ); + const platformsEffort = platforms.reduce((sum, p) => sum + p.totalEffort, 0); + const totalEffort = regularEffort + platformsEffort; + + // Calculate min/max effort: sum of all min/max FTE values + const regularMinEffort = regularApps.reduce((sum, app) => sum + getMinFTE(app), 0); + const regularMaxEffort = regularApps.reduce((sum, app) => sum + getMaxFTE(app), 0); + const platformsMinEffort = platforms.reduce((sum, p) => { + const platformMin = getMinFTE(p.platform); + const workloadsMin = p.workloads.reduce((s, w) => s + getMinFTE(w), 0); + return sum + platformMin + workloadsMin; + }, 0); + const platformsMaxEffort = platforms.reduce((sum, p) => { + const platformMax = getMaxFTE(p.platform); + const workloadsMax = p.workloads.reduce((s, w) => s + getMaxFTE(w), 0); + return sum + platformMax + workloadsMax; + }, 0); + const minEffort = regularMinEffort + platformsMinEffort; + const maxEffort = regularMaxEffort + platformsMaxEffort; + + // 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 = {}; + 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; + } + } + + clusters.push({ + cluster, + applications: regularApps, + platforms, + totalEffort, + minEffort, + maxEffort, + applicationCount, + byGovernanceModel, + }); + } + + // Add any clusters that have applications but aren't in allClusters (shouldn't happen, but be safe) + for (const [clusterId, clusterData] of clusterMap.entries()) { + if (!clusterMapById.has(clusterId)) { + // Find cluster from first application or platform + const cluster = clusterData.regular[0]?.applicationCluster || + clusterData.platforms[0]?.platform.applicationCluster; + if (cluster) { + const regularApps = clusterData.regular; + const platforms = clusterData.platforms; + + const regularEffort = regularApps.reduce((sum, app) => + sum + getEffectiveFTE(app), 0 + ); + const platformsEffort = platforms.reduce((sum, p) => sum + p.totalEffort, 0); + const totalEffort = regularEffort + platformsEffort; + + // Calculate min/max effort + const regularMinEffort = regularApps.reduce((sum, app) => sum + getMinFTE(app), 0); + const regularMaxEffort = regularApps.reduce((sum, app) => sum + getMaxFTE(app), 0); + const platformsMinEffort = platforms.reduce((sum, p) => { + const platformMin = getMinFTE(p.platform); + const workloadsMin = p.workloads.reduce((s, w) => s + getMinFTE(w), 0); + return sum + platformMin + workloadsMin; + }, 0); + const platformsMaxEffort = platforms.reduce((sum, p) => { + const platformMax = getMaxFTE(p.platform); + const workloadsMax = p.workloads.reduce((s, w) => s + getMaxFTE(w), 0); + return sum + platformMax + workloadsMax; + }, 0); + const minEffort = regularMinEffort + platformsMinEffort; + const maxEffort = regularMaxEffort + platformsMaxEffort; + + const platformsCount = platforms.length; + const workloadsCount = platforms.reduce((sum, p) => sum + p.workloads.length, 0); + const applicationCount = regularApps.length + platformsCount + workloadsCount; + + const byGovernanceModel: Record = {}; + 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; + for (const workload of platformWithWorkloads.workloads) { + const workloadGovModel = workload.governanceModel?.name || 'Niet ingesteld'; + byGovernanceModel[workloadGovModel] = (byGovernanceModel[workloadGovModel] || 0) + 1; + } + } + + clusters.push({ + cluster, + applications: regularApps, + platforms, + totalEffort, + minEffort, + maxEffort, + applicationCount, + byGovernanceModel, + }); + } + } + } + + // Calculate unassigned totals + const unassignedRegularEffort = unassigned.regular.reduce((sum, app) => + sum + getEffectiveFTE(app), 0 + ); + const unassignedPlatformsEffort = unassigned.platforms.reduce((sum, p) => sum + p.totalEffort, 0); + const unassignedTotalEffort = unassignedRegularEffort + unassignedPlatformsEffort; + + // Calculate unassigned min/max effort + const unassignedRegularMinEffort = unassigned.regular.reduce((sum, app) => sum + getMinFTE(app), 0); + const unassignedRegularMaxEffort = unassigned.regular.reduce((sum, app) => sum + getMaxFTE(app), 0); + const unassignedPlatformsMinEffort = unassigned.platforms.reduce((sum, p) => { + const platformMin = getMinFTE(p.platform); + const workloadsMin = p.workloads.reduce((s, w) => s + getMinFTE(w), 0); + return sum + platformMin + workloadsMin; + }, 0); + const unassignedPlatformsMaxEffort = unassigned.platforms.reduce((sum, p) => { + const platformMax = getMaxFTE(p.platform); + const workloadsMax = p.workloads.reduce((s, w) => s + getMaxFTE(w), 0); + return sum + platformMax + workloadsMax; + }, 0); + const unassignedMinEffort = unassignedRegularMinEffort + unassignedPlatformsMinEffort; + const unassignedMaxEffort = unassignedRegularMaxEffort + unassignedPlatformsMaxEffort; + + 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 = {}; + 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; + } + } + + const result: TeamDashboardData = { + clusters, + unassigned: { + applications: unassigned.regular, + platforms: unassigned.platforms, + totalEffort: unassignedTotalEffort, + minEffort: unassignedMinEffort, + maxEffort: unassignedMaxEffort, + applicationCount: unassignedApplicationCount, + byGovernanceModel: unassignedByGovernanceModel, + }, + }; + + // Cache the result + this.teamDashboardCache = { + data: result, + timestamp: Date.now(), + }; + + return result; + } catch (error) { + logger.error('Failed to get team dashboard data', error); + return { + clusters: [], + unassigned: { + applications: [], + platforms: [], + totalEffort: 0, + minEffort: 0, + maxEffort: 0, + applicationCount: 0, + byGovernanceModel: {}, + }, + }; + } + } +} + +export const jiraAssetsService = new JiraAssetsService(); diff --git a/backend/src/services/logger.ts b/backend/src/services/logger.ts new file mode 100644 index 0000000..b8e4c79 --- /dev/null +++ b/backend/src/services/logger.ts @@ -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', + }) + ); +} diff --git a/backend/src/services/mockData.ts b/backend/src/services/mockData.ts new file mode 100644 index 0000000..1439bb7 --- /dev/null +++ b/backend/src/services/mockData.ts @@ -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 { + 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 { + 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 { + 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 { + return mockDynamicsFactors; + } + + async getComplexityFactors(): Promise { + return mockComplexityFactors; + } + + async getNumberOfUsers(): Promise { + return mockNumberOfUsers; + } + + async getGovernanceModels(): Promise { + return mockGovernanceModels; + } + + async getOrganisations(): Promise { + return mockOrganisations; + } + + async getHostingTypes(): Promise { + return mockHostingTypes; + } + + async getBusinessImpactAnalyses(): Promise { + return mockBusinessImpactAnalyses; + } + + async getApplicationManagementHosting(): Promise { + // 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 { + // 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 { + // Return empty for mock - in real implementation, this comes from Jira + return []; + } + + async getApplicationClusters(): Promise { + // Return empty for mock - in real implementation, this comes from Jira + return []; + } + + async getApplicationTypes(): Promise { + // Return empty for mock - in real implementation, this comes from Jira + return []; + } + + async getBusinessImportance(): Promise { + // Return empty for mock - in real implementation, this comes from Jira + return []; + } + + async getApplicationFunctionCategories(): Promise { + // 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 = {}; + const byGovernanceModel: Record = {}; + + 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 { + // 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(); + 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(); + 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 = {}; + 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 = {}; + 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(); diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts new file mode 100644 index 0000000..9a6803f --- /dev/null +++ b/backend/src/types/index.ts @@ -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; + byDomain: Record; + byGovernanceModel: Record; + 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; // 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; // 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 +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..c83c533 --- /dev/null +++ b/backend/tsconfig.json @@ -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"] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ee692a6 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..06f93b0 --- /dev/null +++ b/frontend/Dockerfile @@ -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"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0cea64a --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + ZiRA Classificatie Tool - Zuyderland + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..885c43f --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..8ed6947 --- /dev/null +++ b/frontend/src/App.tsx @@ -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 ( +
+ {/* Header */} +
+
+
+
+
+
+ ZiRA +
+
+

+ Classificatie Tool +

+

Zuyderland CMDB

+
+
+ + +
+ +
+ ICMT +
+
+
+
+ + {/* Main content */} +
+ + } /> + } /> + } /> + } /> + } /> + +
+
+ ); +} + +export default App; diff --git a/frontend/src/components/ApplicationDetail.tsx b/frontend/src/components/ApplicationDetail.tsx new file mode 100644 index 0000000..ffc8090 --- /dev/null +++ b/frontend/src/components/ApplicationDetail.tsx @@ -0,0 +1,2555 @@ +import { useEffect, useState, useRef } from 'react'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import { clsx } from 'clsx'; +import { + getApplicationById, + updateApplication, + getAISuggestion, + getAIPrompt, + getAIStatus, + getReferenceData, + getTaxonomy, + getConfig, + calculateEffort, + sendChatMessage, + clearConversation, + AIProvider, + AIStatusResponse, +} from '../services/api'; +import { useNavigationStore } from '../stores/navigationStore'; +import { StatusBadge, BusinessImportanceBadge } from './ApplicationList'; +import CustomSelect from './CustomSelect'; +import type { + ApplicationDetails, + AISuggestion, + ReferenceValue, + ZiraTaxonomy, + EffortCalculationBreakdown, + ChatMessage, +} from '../types'; + +export default function ApplicationDetail() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { + applicationIds, + currentIndex, + setCurrentIndexById, + getNextId, + getPreviousId, + goToNext, + goToPrevious, + } = useNavigationStore(); + + // Sync current index when page loads (e.g., in a new tab) + useEffect(() => { + if (id && applicationIds.length > 0) { + setCurrentIndexById(id); + } + }, [id, applicationIds, setCurrentIndexById]); + + const [application, setApplication] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + // Reference data + const [dynamicsFactors, setDynamicsFactors] = useState([]); + const [complexityFactors, setComplexityFactors] = useState([]); + const [numberOfUsers, setNumberOfUsers] = useState([]); + const [governanceModels, setGovernanceModels] = useState([]); + const [applicationFunctions, setApplicationFunctions] = useState([]); + const [applicationClusters, setApplicationClusters] = useState([]); + const [applicationTypes, setApplicationTypes] = useState([]); + const [hostingTypes, setHostingTypes] = useState([]); + const [businessImpactAnalyses, setBusinessImpactAnalyses] = useState([]); + const [applicationManagementHosting, setApplicationManagementHosting] = useState([]); + const [applicationManagementTAM, setApplicationManagementTAM] = useState([]); + const [taxonomy, setTaxonomy] = useState(null); + + // Edit state + const [selectedFunctions, setSelectedFunctions] = useState([]); + const [selectedDynamics, setSelectedDynamics] = useState(null); + const [selectedComplexity, setSelectedComplexity] = useState(null); + const [selectedUsers, setSelectedUsers] = useState(null); + const [selectedGovernance, setSelectedGovernance] = useState(null); + const [selectedCluster, setSelectedCluster] = useState(null); + const [selectedType, setSelectedType] = useState(null); + const [selectedHostingType, setSelectedHostingType] = useState(null); + const [selectedBusinessImpactAnalyse, setSelectedBusinessImpactAnalyse] = useState(null); + const [selectedApplicationManagementHosting, setSelectedApplicationManagementHosting] = useState(null); + const [selectedApplicationManagementTAM, setSelectedApplicationManagementTAM] = useState(null); + const [overrideFTE, setOverrideFTE] = useState(null); + + // AI state + const [aiSuggestion, setAiSuggestion] = useState(null); + const [aiLoading, setAiLoading] = useState(false); + const [aiError, setAiError] = useState(null); + const [aiPrompt, setAiPrompt] = useState(null); + const [showPrompt, setShowPrompt] = useState(false); + const [promptCopied, setPromptCopied] = useState(false); + + // AI Provider state + const [aiStatus, setAiStatus] = useState(null); + const [selectedAIProvider, setSelectedAIProvider] = useState('claude'); + + // Chat state + const [chatMessages, setChatMessages] = useState([]); + const [chatConversationId, setChatConversationId] = useState(null); + const [chatInput, setChatInput] = useState(''); + const [chatLoading, setChatLoading] = useState(false); + const [chatExpanded, setChatExpanded] = useState(false); + const chatContainerRef = useRef(null); + + // Filter for application functions dropdown + const [functionFilter, setFunctionFilter] = useState(''); + + // Track changes + const [hasChanges, setHasChanges] = useState(false); + + // Collapsible state for Application Functions block + const [applicationFunctionsExpanded, setApplicationFunctionsExpanded] = useState(false); + + // Collapsible state for Additional info block + const [additionalInfoExpanded, setAdditionalInfoExpanded] = useState(false); + + // Jira host URL + const [jiraHost, setJiraHost] = useState(''); + + // Real-time calculated FTE (updated when fields change, before saving) + const [calculatedEffort, setCalculatedEffort] = useState(null); + const [calculatedBreakdown, setCalculatedBreakdown] = useState(null); + const [calculatingEffort, setCalculatingEffort] = useState(false); + + useEffect(() => { + async function fetchData() { + if (!id) return; + + setLoading(true); + setError(null); + // Clear AI state when switching applications + setAiSuggestion(null); + setAiLoading(false); + setAiPrompt(null); + setShowPrompt(false); + setPromptCopied(false); + // Clear chat state when switching applications + setChatMessages([]); + setChatConversationId(null); + setChatInput(''); + setChatLoading(false); + setChatExpanded(false); + try { + const [app, refData, taxData, config] = await Promise.all([ + getApplicationById(id), + getReferenceData(), + getTaxonomy(), + getConfig(), + ]); + + setApplication(app); + setDynamicsFactors(refData.dynamicsFactors); + setComplexityFactors(refData.complexityFactors); + setNumberOfUsers(refData.numberOfUsers); + setGovernanceModels(refData.governanceModels); + setApplicationFunctions(refData.applicationFunctions || []); + setApplicationClusters(refData.applicationClusters || []); + setApplicationTypes(refData.applicationTypes || []); + setHostingTypes(refData.hostingTypes || []); + setBusinessImpactAnalyses(refData.businessImpactAnalyses || []); + setApplicationManagementHosting(refData.applicationManagementHosting || []); + setApplicationManagementTAM(refData.applicationManagementTAM || []); + setTaxonomy(taxData); + setJiraHost(config.jiraHost); + + // Initialize edit state - enrich selected functions with data from applicationFunctions + const enrichedSelectedFunctions = (app.applicationFunctions || []).map((appFunc) => { + // Find the enriched version from applicationFunctions to ensure category and keywords are included + const enriched = refData.applicationFunctions?.find( + (f) => f.objectId === appFunc.objectId + ); + return enriched || appFunc; + }); + setSelectedFunctions(enrichedSelectedFunctions); + setSelectedDynamics(app.dynamicsFactor); + setSelectedComplexity(app.complexityFactor); + setSelectedUsers(app.numberOfUsers); + setSelectedGovernance(app.governanceModel); + setSelectedCluster(app.applicationCluster); + setSelectedType(app.applicationType); + setSelectedHostingType(app.hostingType); + setSelectedBusinessImpactAnalyse(app.businessImpactAnalyse); + setSelectedApplicationManagementHosting(app.applicationManagementHosting ?? null); + setSelectedApplicationManagementTAM(app.applicationManagementTAM ?? null); + setOverrideFTE(app.overrideFTE ?? null); + + // Reset calculated effort when loading new application + setCalculatedEffort(null); + setCalculatedBreakdown(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load application'); + } finally { + setLoading(false); + } + } + fetchData(); + }, [id]); + + useEffect(() => { + if (!application) return; + + // Check if functions changed (compare arrays by objectId) + const currentFunctionIds = (application.applicationFunctions || []) + .map((f) => f.objectId) + .sort() + .join(','); + const selectedFunctionIds = selectedFunctions + .map((f) => f.objectId) + .sort() + .join(','); + const functionsChanged = currentFunctionIds !== selectedFunctionIds; + + const changed = + functionsChanged || + selectedDynamics?.objectId !== application.dynamicsFactor?.objectId || + selectedComplexity?.objectId !== application.complexityFactor?.objectId || + selectedUsers?.objectId !== application.numberOfUsers?.objectId || + selectedGovernance?.objectId !== application.governanceModel?.objectId || + selectedCluster?.objectId !== application.applicationCluster?.objectId || + selectedType?.objectId !== application.applicationType?.objectId || + selectedHostingType?.objectId !== application.hostingType?.objectId || + selectedBusinessImpactAnalyse?.objectId !== application.businessImpactAnalyse?.objectId || + selectedApplicationManagementHosting?.objectId !== application.applicationManagementHosting?.objectId || + selectedApplicationManagementTAM?.objectId !== application.applicationManagementTAM?.objectId; + + setHasChanges(changed); + }, [ + application, + selectedFunctions, + selectedDynamics, + selectedComplexity, + selectedUsers, + selectedGovernance, + selectedCluster, + selectedType, + selectedHostingType, + selectedBusinessImpactAnalyse, + ]); + + // Real-time FTE calculation when relevant fields change + useEffect(() => { + if (!application) { + setCalculatedEffort(null); + setCalculatedBreakdown(null); + return; + } + + // Build application data with current selected values + const currentApplicationData: Partial = { + ...application, + applicationFunctions: selectedFunctions.length > 0 ? selectedFunctions : application.applicationFunctions, + governanceModel: selectedGovernance, + applicationType: selectedType, + numberOfUsers: selectedUsers, + dynamicsFactor: selectedDynamics, + complexityFactor: selectedComplexity, + hostingType: selectedHostingType, + businessImpactAnalyse: selectedBusinessImpactAnalyse, + applicationManagementHosting: selectedApplicationManagementHosting, + }; + + // Debounce the calculation (wait 300ms after last change) + let isMounted = true; + const timeoutId = setTimeout(async () => { + try { + setCalculatingEffort(true); + const result = await calculateEffort(currentApplicationData); + // Only update state if component is still mounted + if (isMounted) { + setCalculatedEffort(result.requiredEffortApplicationManagement); + setCalculatedBreakdown(result.breakdown); + } + } catch (err) { + // Silently fail - don't show error for real-time calculation + // Only log if component is still mounted + if (isMounted) { + console.error('Failed to calculate effort:', err); + setCalculatedEffort(null); + setCalculatedBreakdown(null); + } + } finally { + if (isMounted) { + setCalculatingEffort(false); + } + } + }, 300); + + return () => { + isMounted = false; + clearTimeout(timeoutId); + }; + }, [ + application, + selectedFunctions, + selectedGovernance, + selectedType, + selectedUsers, + selectedDynamics, + selectedComplexity, + selectedHostingType, + selectedBusinessImpactAnalyse, + selectedApplicationManagementHosting, + ]); + + // Load AI status on mount + useEffect(() => { + const loadAIStatus = async () => { + try { + const status = await getAIStatus(); + setAiStatus(status); + // Set default provider from backend config + if (status.defaultProvider) { + setSelectedAIProvider(status.defaultProvider); + } + } catch (err) { + console.error('Failed to load AI status:', err); + } + }; + loadAIStatus(); + }, []); + + const handleRequestAI = async () => { + if (!id) return; + + setAiLoading(true); + setAiError(null); + try { + const suggestion = await getAISuggestion(id, selectedAIProvider); + setAiSuggestion(suggestion); + } catch (err) { + setAiError(err instanceof Error ? err.message : 'AI classification failed'); + } finally { + setAiLoading(false); + } + }; + + const handleShowPrompt = async () => { + if (!id) return; + + if (aiPrompt) { + // Already loaded, just toggle visibility + setShowPrompt(!showPrompt); + return; + } + + try { + const { prompt } = await getAIPrompt(id); + setAiPrompt(prompt); + setShowPrompt(true); + } catch (err) { + setAiError(err instanceof Error ? err.message : 'Failed to load prompt'); + } + }; + + const handleCopyPrompt = async () => { + if (!aiPrompt) return; + + try { + await navigator.clipboard.writeText(aiPrompt); + setPromptCopied(true); + setTimeout(() => setPromptCopied(false), 2000); + } catch (err) { + console.error('Failed to copy prompt:', err); + } + }; + + // Chat functions + const handleSendChat = async () => { + if (!id || !chatInput.trim() || chatLoading) return; + + const userMessage = chatInput.trim(); + setChatInput(''); + setChatLoading(true); + + // Optimistically add user message to the chat + const tempUserMessage: ChatMessage = { + id: `temp-${Date.now()}`, + role: 'user', + content: userMessage, + timestamp: new Date(), + }; + setChatMessages(prev => [...prev, tempUserMessage]); + + try { + const response = await sendChatMessage(id, userMessage, chatConversationId || undefined, selectedAIProvider); + + // Update conversation ID if this is a new conversation + if (!chatConversationId) { + setChatConversationId(response.conversationId); + } + + // Replace temp message and add assistant response + setChatMessages(prev => { + const filtered = prev.filter(m => m.id !== tempUserMessage.id); + return [...filtered, + { ...tempUserMessage, id: `user-${Date.now()}` }, + response.message + ]; + }); + + // If AI provided an updated suggestion, update it + if (response.suggestion) { + setAiSuggestion(response.suggestion); + } + + // Scroll to bottom + setTimeout(() => { + chatContainerRef.current?.scrollTo({ + top: chatContainerRef.current.scrollHeight, + behavior: 'smooth', + }); + }, 100); + } catch (err) { + // Remove temp message on error + setChatMessages(prev => prev.filter(m => m.id !== tempUserMessage.id)); + setAiError(err instanceof Error ? err.message : 'Chat failed'); + } finally { + setChatLoading(false); + } + }; + + const handleClearChat = async () => { + if (chatConversationId) { + try { + await clearConversation(chatConversationId); + } catch (err) { + // Ignore errors when clearing + } + } + setChatMessages([]); + setChatConversationId(null); + setChatExpanded(false); + }; + + const handleStartChat = () => { + setChatExpanded(true); + // Focus on input after expansion + setTimeout(() => { + const input = document.getElementById('chat-input'); + input?.focus(); + }, 100); + }; + + // Trigger FTE recalculation manually + const triggerFTECalculation = async () => { + if (!application) return; + + // Build application data with current selected values + const currentApplicationData: Partial = { + ...application, + applicationFunctions: selectedFunctions.length > 0 ? selectedFunctions : application.applicationFunctions, + governanceModel: selectedGovernance, + applicationType: selectedType, + numberOfUsers: selectedUsers, + dynamicsFactor: selectedDynamics, + complexityFactor: selectedComplexity, + hostingType: selectedHostingType, + businessImpactAnalyse: selectedBusinessImpactAnalyse, + applicationManagementHosting: selectedApplicationManagementHosting, + }; + + try { + setCalculatingEffort(true); + const result = await calculateEffort(currentApplicationData); + setCalculatedEffort(result.requiredEffortApplicationManagement); + setCalculatedBreakdown(result.breakdown); + } catch (err) { + console.error('Failed to calculate effort:', err); + setCalculatedEffort(null); + setCalculatedBreakdown(null); + } finally { + setCalculatingEffort(false); + } + }; + + // Accept AI suggestion for Application Functions with merge or overwrite mode + const handleAcceptAIFunctions = async (mergeMode: boolean) => { + if (!aiSuggestion) return; + + const functionsToSelect: ReferenceValue[] = []; + + // Find primary function + const primaryFunc = applicationFunctions.find( + (f) => f.key === aiSuggestion.primaryFunction.code || + f.name === aiSuggestion.primaryFunction.name + ); + if (primaryFunc) { + functionsToSelect.push(primaryFunc); + } + + // Find secondary functions + for (const secondary of aiSuggestion.secondaryFunctions) { + const secondaryFunc = applicationFunctions.find( + (f) => f.key === secondary.code || f.name === secondary.name + ); + if (secondaryFunc && !functionsToSelect.some((f) => f.objectId === secondaryFunc.objectId)) { + functionsToSelect.push(secondaryFunc); + } + } + + // Fallback to taxonomy if Jira functions not found + if (functionsToSelect.length === 0 && taxonomy) { + for (const domain of taxonomy.domains) { + const func = domain.functions.find( + (f) => f.code === aiSuggestion.primaryFunction.code + ); + if (func) { + functionsToSelect.push({ + objectId: func.code, + key: func.code, + name: func.name, + description: func.description, + }); + break; + } + } + } + + // Apply merge or overwrite logic + if (mergeMode) { + // Merge mode: combine existing functions with AI suggestions (avoid duplicates) + const existingFunctionIds = new Set(selectedFunctions.map(f => f.objectId)); + const mergedFunctions = [...selectedFunctions]; + + for (const newFunc of functionsToSelect) { + if (!existingFunctionIds.has(newFunc.objectId)) { + mergedFunctions.push(newFunc); + existingFunctionIds.add(newFunc.objectId); + } + } + + setSelectedFunctions(mergedFunctions); + } else { + // Overwrite mode: replace existing functions with AI suggestions + setSelectedFunctions(functionsToSelect); + } + + // Trigger FTE calculation after state update + setTimeout(() => triggerFTECalculation(), 100); + }; + + // Accept AI suggestion for a specific field + const handleAcceptAIField = async (field: 'functions' | 'applicationType' | 'dynamicsFactor' | 'complexityFactor' | 'hostingType' | 'applicationManagementHosting' | 'applicationManagementTAM' | 'biaClassification' | 'governanceModel') => { + if (!aiSuggestion) return; + + switch (field) { + case 'functions': { + // Default to overwrite mode for backward compatibility + await handleAcceptAIFunctions(false); + break; + } + case 'applicationType': { + if (aiSuggestion.managementClassification?.applicationType) { + const suggested = applicationTypes.find( + (t) => t.name === aiSuggestion.managementClassification!.applicationType!.value + ); + if (suggested) { + setSelectedType(suggested); + setTimeout(() => triggerFTECalculation(), 100); + } + } + break; + } + case 'dynamicsFactor': { + if (aiSuggestion.managementClassification?.dynamicsFactor) { + const suggested = dynamicsFactors.find( + (f) => f.name === aiSuggestion.managementClassification!.dynamicsFactor!.value + ); + if (suggested) { + setSelectedDynamics(suggested); + setTimeout(() => triggerFTECalculation(), 100); + } + } + break; + } + case 'complexityFactor': { + if (aiSuggestion.managementClassification?.complexityFactor) { + const suggested = complexityFactors.find( + (f) => f.name === aiSuggestion.managementClassification!.complexityFactor!.value + ); + if (suggested) { + setSelectedComplexity(suggested); + setTimeout(() => triggerFTECalculation(), 100); + } + } + break; + } + case 'hostingType': + case 'applicationManagementHosting': { + // Both hostingType and applicationManagementHosting map to the same field + const hostingData = aiSuggestion.managementClassification?.hostingType || aiSuggestion.managementClassification?.applicationManagementHosting; + if (hostingData) { + const aiValue = hostingData.value; + console.log('AI suggested hosting type:', aiValue); + + // Try exact match first against Application Management - Hosting + let suggested = applicationManagementHosting.find((h) => h.name === aiValue); + // If no exact match, try case-insensitive match + if (!suggested) { + suggested = applicationManagementHosting.find( + (h) => h.name.toLowerCase() === aiValue.toLowerCase() + ); + } + // If still no match, try partial match + if (!suggested) { + suggested = applicationManagementHosting.find( + (h) => h.name.toLowerCase().includes(aiValue.toLowerCase()) || + aiValue.toLowerCase().includes(h.name.toLowerCase()) + ); + } + if (suggested) { + console.log('Matched hosting type:', suggested.name); + setSelectedApplicationManagementHosting(suggested); + requestAnimationFrame(() => { + setTimeout(() => triggerFTECalculation(), 100); + }); + } else { + console.warn('Could not find matching hosting type for AI suggestion:', aiValue); + } + } + break; + } + case 'applicationManagementTAM': { + if (aiSuggestion.managementClassification?.applicationManagementTAM) { + const aiValue = aiSuggestion.managementClassification.applicationManagementTAM.value; + console.log('AI suggested TAM:', aiValue); + + // Try exact match first + let suggested = applicationManagementTAM.find((t) => t.name === aiValue); + // If no exact match, try case-insensitive match + if (!suggested) { + suggested = applicationManagementTAM.find( + (t) => t.name.toLowerCase() === aiValue.toLowerCase() + ); + } + // If still no match, try partial match + if (!suggested) { + suggested = applicationManagementTAM.find( + (t) => t.name.toLowerCase().includes(aiValue.toLowerCase()) || + aiValue.toLowerCase().includes(t.name.toLowerCase()) + ); + } + if (suggested) { + console.log('Matched TAM:', suggested.name); + setSelectedApplicationManagementTAM(suggested); + requestAnimationFrame(() => { + setTimeout(() => triggerFTECalculation(), 100); + }); + } else { + console.warn('Could not find matching TAM for AI suggestion:', aiValue); + } + } + break; + } + case 'biaClassification': { + if (aiSuggestion.managementClassification?.biaClassification) { + const suggested = businessImpactAnalyses.find( + (b) => b.name === aiSuggestion.managementClassification!.biaClassification!.value || + b.name.includes(aiSuggestion.managementClassification!.biaClassification!.value) + ); + if (suggested) { + setSelectedBusinessImpactAnalyse(suggested); + setTimeout(() => triggerFTECalculation(), 100); + } + } + break; + } + case 'governanceModel': { + if (aiSuggestion.managementClassification?.governanceModel) { + const suggested = governanceModels.find( + (m) => m.name === aiSuggestion.managementClassification!.governanceModel!.value + ); + if (suggested) { + setSelectedGovernance(suggested); + setTimeout(() => triggerFTECalculation(), 100); + } + } + break; + } + } + }; + + // Accept all AI suggestions + const handleAcceptAllAI = async () => { + if (!aiSuggestion) return; + + // Accept all fields that have suggestions + if (aiSuggestion.primaryFunction || aiSuggestion.secondaryFunctions.length > 0) { + await handleAcceptAIField('functions'); + } + if (aiSuggestion.managementClassification?.applicationType) { + await handleAcceptAIField('applicationType'); + } + if (aiSuggestion.managementClassification?.dynamicsFactor) { + await handleAcceptAIField('dynamicsFactor'); + } + if (aiSuggestion.managementClassification?.complexityFactor) { + await handleAcceptAIField('complexityFactor'); + } + // Application Management - Hosting (check both possible field names) + if (aiSuggestion.managementClassification?.hostingType || aiSuggestion.managementClassification?.applicationManagementHosting) { + await handleAcceptAIField('applicationManagementHosting'); + } + // Application Management - TAM + if (aiSuggestion.managementClassification?.applicationManagementTAM) { + await handleAcceptAIField('applicationManagementTAM'); + } + if (aiSuggestion.managementClassification?.biaClassification) { + await handleAcceptAIField('biaClassification'); + } + if (aiSuggestion.managementClassification?.governanceModel) { + await handleAcceptAIField('governanceModel'); + } + + // Final trigger after all state updates + setTimeout(() => triggerFTECalculation(), 200); + }; + + const handleNavigateNext = () => { + const nextId = getNextId(); + if (nextId) { + goToNext(); + navigate(`/applications/${nextId}`); + } else { + navigate('/applications'); + } + }; + + const handleSave = async (andNavigate: 'next' | 'close' | 'stay' = 'stay') => { + if (!id || !application) return; + + setSaving(true); + try { + // Determine source based on AI suggestion acceptance + const aiPrimaryCode = aiSuggestion?.primaryFunction.code; + const selectedHasPrimary = aiPrimaryCode && selectedFunctions.some( + (f) => f.key === aiPrimaryCode + ); + const source = aiSuggestion + ? selectedHasPrimary + ? 'AI_ACCEPTED' + : 'AI_MODIFIED' + : 'MANUAL'; + + await updateApplication(id, { + applicationFunctions: selectedFunctions.length > 0 ? selectedFunctions : undefined, + dynamicsFactor: selectedDynamics || undefined, + complexityFactor: selectedComplexity || undefined, + numberOfUsers: selectedUsers || undefined, + governanceModel: selectedGovernance || undefined, + applicationCluster: selectedCluster || undefined, + applicationType: selectedType || undefined, + hostingType: selectedHostingType || undefined, + businessImpactAnalyse: selectedBusinessImpactAnalyse || undefined, + applicationManagementHosting: selectedApplicationManagementHosting?.key || undefined, + applicationManagementTAM: selectedApplicationManagementTAM?.key || undefined, + overrideFTE: overrideFTE !== null ? overrideFTE : (overrideFTE === null && application.overrideFTE !== null ? null : undefined), + source, + }); + + if (andNavigate === 'next') { + const nextId = getNextId(); + if (nextId) { + goToNext(); + navigate(`/applications/${nextId}`); + } else { + navigate('/applications'); + } + } else if (andNavigate === 'close') { + navigate('/applications'); + } else { + // Refresh data + const app = await getApplicationById(id); + setApplication(app); + setHasChanges(false); + // Reset calculated effort to show saved value + setCalculatedEffort(null); + setCalculatedBreakdown(null); + + // Update selected state values to match saved values (this will hide reset buttons) + const enrichedSelectedFunctions = (app.applicationFunctions || []).map((appFunc) => { + const enriched = applicationFunctions.find( + (f) => f.objectId === appFunc.objectId + ); + return enriched || appFunc; + }); + setSelectedFunctions(enrichedSelectedFunctions); + setSelectedDynamics(app.dynamicsFactor); + setSelectedComplexity(app.complexityFactor); + setSelectedUsers(app.numberOfUsers); + setSelectedGovernance(app.governanceModel); + setSelectedCluster(app.applicationCluster); + setSelectedType(app.applicationType); + setSelectedHostingType(app.hostingType); + setSelectedBusinessImpactAnalyse(app.businessImpactAnalyse); + setOverrideFTE(app.overrideFTE ?? null); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save'); + } finally { + setSaving(false); + } + }; + + const handlePrevious = () => { + const prevId = getPreviousId(); + if (prevId) { + goToPrevious(); + navigate(`/applications/${prevId}`); + } + }; + + // Filter application functions by search text + const filteredFunctions = applicationFunctions.filter( + (f) => + f.name.toLowerCase().includes(functionFilter.toLowerCase()) || + f.key.toLowerCase().includes(functionFilter.toLowerCase()) + ); + + if (loading) { + return ( +
+
+
+ ); + } + + if (error || !application) { + return ( +
+ {error || 'Application not found'} +
+ ); + } + + return ( +
+ {/* Navigation header */} +
+ + + + + Terug naar lijst + + {applicationIds.length > 0 && ( + + Applicatie {currentIndex + 1} van {applicationIds.length} + + )} +
+ + {/* Application Info (Read-only) */} +
+
+

+ Applicatie informatie +

+

Read-only velden

+
+
+
+
+ +
+ {jiraHost && application.key ? ( + + {application.name} + + + + + ) : ( +

{application.name}

+ )} + {application.technischeArchitectuur && application.technischeArchitectuur.trim() !== '' && ( + + + + + + )} +
+
+
+ +

{application.searchReference || '-'}

+
+
+ +

{application.hostingType?.name || '-'}

+
+
+ + +
+
+ +

+ {application.functionalApplicationManagement || '-'} +

+
+
+ +

+ {application.technicalApplicationManagement || '-'} +

+
+
+ +

{application.description || '-'}

+
+
+
+
+ + {/* Additional Info - Collapsible Block */} +
+
setAdditionalInfoExpanded(!additionalInfoExpanded)} + > +
+

Additional info

+

Read-only velden

+
+
+ + + +
+
+ + {additionalInfoExpanded && ( +
+
+
+ +

{application.supplierProduct || '-'}

+
+
+ +

{application.organisation || '-'}

+
+
+ + +
+
+ +

{application.businessImpactAnalyse?.name || '-'}

+
+
+ +

{application.businessOwner || '-'}

+
+
+ +

{application.systemOwner || '-'}

+
+
+ +

+ {(() => { + const primary = application.technicalApplicationManagementPrimary?.trim(); + const secondary = application.technicalApplicationManagementSecondary?.trim(); + const parts = []; + if (primary) parts.push(primary); + if (secondary) parts.push(secondary); + return parts.length > 0 ? parts.join(', ') : '-'; + })()} +

+
+
+ +

+ {application.medischeTechniek ? 'Ja' : 'Nee'} +

+
+
+
+ )} +
+ + {/* AI Classification - Central Block */} +
+
+
+
+

AI Classificatie

+

+ Vraag een AI-suggestie voor alle classificatievelden +

+
+
+ {/* AI Provider Selector */} + {aiStatus && aiStatus.providers.length > 1 && ( + + )} + {/* Show current provider badge if only one is available */} + {aiStatus && aiStatus.providers.length === 1 && ( + + {aiStatus.providers[0] === 'claude' ? 'Claude' : 'OpenAI'} + + )} + + +
+
+
+ + {/* AI Prompt Display */} + {showPrompt && aiPrompt && ( +
+
+ + +
+
+              {aiPrompt}
+            
+
+ )} + + {aiError && ( +
+ {aiError} +
+ )} + + {/* Confidence and Aandachtspunten - shown when AI suggestion is available */} + {aiSuggestion && ( +
+
+ Confidence: + + {aiSuggestion.confidence} + +
+ {aiSuggestion.notes && ( +
+ +

{aiSuggestion.notes}

+
+ )} +
+ )} + + {/* AI Chat Interface */} + {aiSuggestion && ( +
+ {!chatExpanded ? ( +
+ +
+ ) : ( +
+
+

+ + + + Chat met AI +

+
+ {chatMessages.length > 0 && ( + + )} + +
+
+ + {/* Chat Messages */} +
+ {chatMessages.length === 0 ? ( +
+

Stel een vraag aan de AI over de classificatie, of geef extra informatie om de suggesties te verbeteren.

+

Voorbeeld: "Waarom adviseer je deze BIA classificatie?" of "Dit systeem heeft 15 integraties met andere systemen."

+
+ ) : ( + chatMessages.filter(m => m.role !== 'system').map((message) => ( +
+
+ {message.role === 'assistant' && ( + 🤖 + )} + {message.role === 'user' && ( + 👤 + )} +
+

{message.content}

+ {message.suggestion && ( +
+ ✨ AI heeft een herziene classificatie gegenereerd +
+ )} +
+
+
+ {new Date(message.timestamp).toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' })} +
+
+ )) + )} + {chatLoading && ( +
+
+ 🤖 +
+ + + +
+
+
+ )} +
+ + {/* Chat Input */} +
+ setChatInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSendChat(); + } + }} + placeholder="Typ een vraag of geef extra informatie..." + className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + disabled={chatLoading} + /> + +
+
+ )} +
+ )} +
+ + {/* Application Functions - Collapsible Block */} +
+
+
+
setApplicationFunctionsExpanded(!applicationFunctionsExpanded)} + > +
+

Applicatiefuncties

+ {(() => { + const hasAISuggestion = aiSuggestion && (aiSuggestion.primaryFunction || (aiSuggestion.secondaryFunctions && aiSuggestion.secondaryFunctions.length > 0)); + if (hasAISuggestion) { + return ( + + ✨ AI Suggestie beschikbaar + + ); + } + return null; + })()} +
+ {!applicationFunctionsExpanded && ( +

+ {(() => { + const currentFunctions = selectedFunctions.length > 0 ? selectedFunctions : (application.applicationFunctions || []); + const hasAISuggestion = aiSuggestion && (aiSuggestion.primaryFunction || (aiSuggestion.secondaryFunctions && aiSuggestion.secondaryFunctions.length > 0)); + if (currentFunctions.length === 0 && !hasAISuggestion) { + return 'Geen functies geselecteerd'; + } + if (hasAISuggestion && currentFunctions.length === 0) { + return 'AI suggestie beschikbaar - klik om te bekijken'; + } + return `Geselecteerd (${currentFunctions.length}): ${currentFunctions.map(f => + `${f.name}${f.applicationFunctionCategory ? ` (${f.applicationFunctionCategory.name})` : ''}` + ).join(', ')}`; + })()} +

+ )} +
+
+ {(() => { + const hasAISuggestion = aiSuggestion && (aiSuggestion.primaryFunction || (aiSuggestion.secondaryFunctions && aiSuggestion.secondaryFunctions.length > 0)); + if (hasAISuggestion) { + return ( +
+
+ + +
+
+ ); + } + return null; + })()} +
{ + e.stopPropagation(); + setApplicationFunctionsExpanded(!applicationFunctionsExpanded); + }} + className="cursor-pointer pt-1" + > + + + +
+
+
+ {/* AI suggestion preview when collapsed - full width */} + {!applicationFunctionsExpanded && (() => { + const hasAISuggestion = aiSuggestion && (aiSuggestion.primaryFunction || (aiSuggestion.secondaryFunctions && aiSuggestion.secondaryFunctions.length > 0)); + if (hasAISuggestion) { + const suggestedFunctions: string[] = []; + if (aiSuggestion.primaryFunction) { + suggestedFunctions.push(aiSuggestion.primaryFunction.name); + } + if (aiSuggestion.secondaryFunctions && aiSuggestion.secondaryFunctions.length > 0) { + suggestedFunctions.push(...aiSuggestion.secondaryFunctions.map(f => f.name)); + } + return ( +
+

AI Suggestie:

+

+ {suggestedFunctions.join(', ')} +

+
+ ); + } + return null; + })()} +
+ + {applicationFunctionsExpanded && ( +
+ {/* Application Functions Selector */} +
+
+ + {(() => { + const currentFunctionIds = (application.applicationFunctions || []) + .map((f) => f.objectId) + .sort() + .join(','); + const selectedFunctionIds = selectedFunctions + .map((f) => f.objectId) + .sort() + .join(','); + const functionsChanged = currentFunctionIds !== selectedFunctionIds; + return functionsChanged && ( + + ); + })()} +
+
+ setFunctionFilter(e.target.value)} + className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + /> +
+ {filteredFunctions.map((func) => { + const isSelected = selectedFunctions.some( + (f) => f.objectId === func.objectId + ); + return ( + + ); + })} + {filteredFunctions.length === 0 && ( +

+ Geen functies gevonden +

+ )} +
+
+

+ Huidige waarden:{' '} + {application.applicationFunctions && application.applicationFunctions.length > 0 ? ( + application.applicationFunctions.map((f) => + `${f.name}${f.applicationFunctionCategory ? ` (${f.applicationFunctionCategory.name})` : ''}` + ).join(', ') + ) : ( + Leeg + )} +

+ {selectedFunctions.length > 0 && ( +
+

+ Geselecteerd ({selectedFunctions.length}): +

+
+ {selectedFunctions.map((func) => ( + + {func.name}{func.applicationFunctionCategory ? ` (${func.applicationFunctionCategory.name})` : ''} + + + ))} +
+
+ )} + + {/* AI Suggestion for Application Functions */} + {aiSuggestion && (aiSuggestion.primaryFunction || (aiSuggestion.secondaryFunctions && aiSuggestion.secondaryFunctions.length > 0)) && ( +
+ {/* Header with title and buttons on same line */} +
+

AI Suggestie:

+
+ + +
+
+ {/* Suggestions - full width */} +
+ {aiSuggestion.primaryFunction && ( +
+

+ Primaire: {aiSuggestion.primaryFunction.code} - {aiSuggestion.primaryFunction.name} +

+ {aiSuggestion.primaryFunction.reasoning && ( +

{aiSuggestion.primaryFunction.reasoning}

+ )} +
+ )} + {aiSuggestion.secondaryFunctions && aiSuggestion.secondaryFunctions.length > 0 && ( +
+

Secundaire functies:

+ {aiSuggestion.secondaryFunctions.map((func, i) => ( +
+

+ {func.code} - {func.name} +

+ {func.reasoning && ( +

{func.reasoning}

+ )} +
+ ))} +
+ )} +
+

+ Overschrijven vervangt bestaande functies, Samenvoegen voegt toe aan bestaande. +

+
+ )} +
+
+ )} +
+ + {/* Editable Fields */} +
+
+
+
+

Application Management

+

+ Pas de classificatie aan en sla op +

+
+ {aiSuggestion && ( + + )} +
+
+
+ {/* Section 01: Details */} +
+

Details

+
+ {/* 1. Application Type */} +
+
+ + {selectedType?.objectId !== application.applicationType?.objectId && ( + + )} +
+ { + const type = applicationTypes.find((t) => t.objectId === value); + setSelectedType(type || null); + }} + options={applicationTypes} + placeholder="Selecteer..." + showSummary={true} + /> + {/* AI Suggestion for Application Type */} + {aiSuggestion?.managementClassification?.applicationType && (() => { + const aiValue = aiSuggestion.managementClassification.applicationType.value; + const suggested = applicationTypes.find( + (t) => t.name === aiValue || t.name.toLowerCase() === aiValue.toLowerCase() + ); + const isAccepted = suggested && selectedType?.objectId === suggested.objectId; + return ( +
+
+
+

AI Suggestie:

+

{aiValue}

+ {aiSuggestion.managementClassification.applicationType.reasoning && ( +

{aiSuggestion.managementClassification.applicationType.reasoning}

+ )} +
+ {!isAccepted && suggested && ( + + )} + {isAccepted && ( + ✓ Geaccepteerd + )} + {!suggested && ( + ⚠ Niet gevonden + )} +
+
+ ); + })()} +
+ + {/* 2. Application Component Hosting Type */} +
+
+ + {selectedHostingType?.objectId !== application.hostingType?.objectId && ( + + )} +
+ { + const hostingType = hostingTypes.find((h) => h.objectId === value); + setSelectedHostingType(hostingType || null); + }} + options={hostingTypes} + placeholder="Selecteer..." + showSummary={true} + /> + {/* Note: Application Component Hosting Type is set by IT Architect, not AI generated */} +
+ + {/* 3. Application Management - Hosting */} +
+
+ + {selectedApplicationManagementHosting?.objectId !== application.applicationManagementHosting?.objectId && ( + + )} +
+ { + const hosting = applicationManagementHosting.find((h) => h.objectId === value); + setSelectedApplicationManagementHosting(hosting || null); + setHasChanges(true); + }} + options={applicationManagementHosting} + placeholder="Selecteer..." + showSummary={false} + /> + {/* AI Suggestion for Application Management - Hosting */} + {aiSuggestion?.managementClassification?.applicationManagementHosting && (() => { + const aiValue = aiSuggestion.managementClassification.applicationManagementHosting.value; + let suggested = applicationManagementHosting.find((h) => h.name === aiValue); + if (!suggested) { + suggested = applicationManagementHosting.find( + (h) => h.name.toLowerCase() === aiValue.toLowerCase() + ); + } + if (!suggested) { + suggested = applicationManagementHosting.find( + (h) => h.name.toLowerCase().includes(aiValue.toLowerCase()) || + aiValue.toLowerCase().includes(h.name.toLowerCase()) + ); + } + const isAccepted = suggested && selectedApplicationManagementHosting?.objectId === suggested.objectId; + return ( +
+
+
+

AI Suggestie:

+

{aiValue}

+ {aiSuggestion.managementClassification.applicationManagementHosting.reasoning && ( +

{aiSuggestion.managementClassification.applicationManagementHosting.reasoning}

+ )} +
+ {!isAccepted && suggested && ( + + )} + {isAccepted && ( + ✓ Geaccepteerd + )} + {!suggested && ( + ⚠ Niet gevonden + )} +
+
+ ); + })()} +
+ + {/* 4. Application Management - TAM */} +
+
+ + {selectedApplicationManagementTAM?.objectId !== application.applicationManagementTAM?.objectId && ( + + )} +
+ { + const tam = applicationManagementTAM.find((t) => t.objectId === value); + setSelectedApplicationManagementTAM(tam || null); + setHasChanges(true); + }} + options={applicationManagementTAM} + placeholder="Selecteer..." + showSummary={true} + /> + {/* AI Suggestion for Application Management - TAM */} + {aiSuggestion?.managementClassification?.applicationManagementTAM && (() => { + const aiValue = aiSuggestion.managementClassification.applicationManagementTAM.value; + let suggested = applicationManagementTAM.find((t) => t.name === aiValue); + if (!suggested) { + suggested = applicationManagementTAM.find( + (t) => t.name.toLowerCase() === aiValue.toLowerCase() + ); + } + if (!suggested) { + suggested = applicationManagementTAM.find( + (t) => t.name.toLowerCase().includes(aiValue.toLowerCase()) || + aiValue.toLowerCase().includes(t.name.toLowerCase()) + ); + } + const isAccepted = suggested && selectedApplicationManagementTAM?.objectId === suggested.objectId; + return ( +
+
+
+

AI Suggestie:

+

{aiValue}

+ {aiSuggestion.managementClassification.applicationManagementTAM.reasoning && ( +

{aiSuggestion.managementClassification.applicationManagementTAM.reasoning}

+ )} +
+ {!isAccepted && suggested && ( + + )} + {isAccepted && ( + ✓ Geaccepteerd + )} + {!suggested && ( + ⚠ Niet gevonden + )} +
+
+ ); + })()} +
+
+
+ + {/* Section 02: Classification */} +
+

Classification

+
+ {/* 1. Business Impact Analyse */} +
+
+ + {selectedBusinessImpactAnalyse?.objectId !== application.businessImpactAnalyse?.objectId && ( + + )} +
+ + {/* AI Suggestion for BIA Classification */} + {aiSuggestion?.managementClassification?.biaClassification && (() => { + const aiValue = aiSuggestion.managementClassification.biaClassification.value; + const suggested = businessImpactAnalyses.find( + (b) => b.name === aiValue || + b.name.includes(aiValue) || + aiValue.includes(b.name) + ); + const isAccepted = suggested && selectedBusinessImpactAnalyse?.objectId === suggested.objectId; + return ( +
+
+
+

AI Suggestie:

+

{aiValue}

+ {aiSuggestion.managementClassification.biaClassification.reasoning && ( +

{aiSuggestion.managementClassification.biaClassification.reasoning}

+ )} +
+ {!isAccepted && suggested && ( + + )} + {isAccepted && ( + ✓ Geaccepteerd + )} + {!suggested && ( + ⚠ Niet gevonden + )} +
+
+ ); + })()} +
+ + {/* 2. Aantal Gebruikers */} + {/* 4. Aantal Gebruikers */} +
+
+ + {selectedUsers?.objectId !== application.numberOfUsers?.objectId && ( + + )} +
+ +
+ + {/* 3. Dynamiek Factor */} +
+
+ + {selectedDynamics?.objectId !== application.dynamicsFactor?.objectId && ( + + )} +
+ { + const factor = dynamicsFactors.find((f) => f.objectId === value); + setSelectedDynamics(factor || null); + }} + options={dynamicsFactors} + placeholder="Selecteer..." + showSummary={true} + /> + {/* AI Suggestion for Dynamics Factor */} + {aiSuggestion?.managementClassification?.dynamicsFactor && (() => { + const aiValue = aiSuggestion.managementClassification.dynamicsFactor.value; + const suggested = dynamicsFactors.find( + (f) => f.name === aiValue || f.name.toLowerCase() === aiValue.toLowerCase() + ); + const isAccepted = suggested && selectedDynamics?.objectId === suggested.objectId; + return ( +
+
+
+

AI Suggestie:

+

{aiValue}

+ {aiSuggestion.managementClassification.dynamicsFactor.reasoning && ( +

{aiSuggestion.managementClassification.dynamicsFactor.reasoning}

+ )} +
+ {!isAccepted && suggested && ( + + )} + {isAccepted && ( + ✓ Geaccepteerd + )} + {!suggested && ( + ⚠ Niet gevonden + )} +
+
+ ); + })()} +
+ + {/* 4. Complexiteit Factor */} +
+
+ + {selectedComplexity?.objectId !== application.complexityFactor?.objectId && ( + + )} +
+ { + const factor = complexityFactors.find((f) => f.objectId === value); + setSelectedComplexity(factor || null); + }} + options={complexityFactors} + placeholder="Selecteer..." + showSummary={true} + /> + {/* AI Suggestion for Complexity Factor */} + {aiSuggestion?.managementClassification?.complexityFactor && (() => { + const aiValue = aiSuggestion.managementClassification.complexityFactor.value; + const suggested = complexityFactors.find( + (f) => f.name === aiValue || f.name.toLowerCase() === aiValue.toLowerCase() + ); + const isAccepted = suggested && selectedComplexity?.objectId === suggested.objectId; + return ( +
+
+
+

AI Suggestie:

+

{aiValue}

+ {aiSuggestion.managementClassification.complexityFactor.reasoning && ( +

{aiSuggestion.managementClassification.complexityFactor.reasoning}

+ )} +
+ {!isAccepted && suggested && ( + + )} + {isAccepted && ( + ✓ Geaccepteerd + )} + {!suggested && ( + ⚠ Niet gevonden + )} +
+
+ ); + })()} +
+
+
+ + {/* Section 03: Application Management */} +
+

Application Management

+
+ {/* 1. ICT Governance Model - Full width */} +
+
+ + {selectedGovernance?.objectId !== application.governanceModel?.objectId && ( + + )} +
+ { + const model = governanceModels.find( + (m) => m.objectId === objectId + ); + setSelectedGovernance(model || null); + }} + options={governanceModels} + placeholder="Selecteer regiemodel..." + showRemarks={true} + /> + {/* AI Suggestion for Governance Model */} + {aiSuggestion?.managementClassification?.governanceModel && (() => { + const aiValue = aiSuggestion.managementClassification.governanceModel.value; + const suggested = governanceModels.find( + (m) => m.name === aiValue || m.name.toLowerCase() === aiValue.toLowerCase() + ); + const isAccepted = suggested && selectedGovernance?.objectId === suggested.objectId; + return ( +
+
+
+

AI Suggestie:

+

{aiValue}

+ {aiSuggestion.managementClassification.governanceModel.reasoning && ( +

{aiSuggestion.managementClassification.governanceModel.reasoning}

+ )} +
+ {!isAccepted && suggested && ( + + )} + {isAccepted && ( + ✓ Geaccepteerd + )} + {!suggested && ( + ⚠ Niet gevonden + )} +
+
+ ); + })()} +
+ + {/* 2. Application Cluster - Full width */} +
+
+ + {selectedCluster?.objectId !== application.applicationCluster?.objectId && ( + + )} +
+ +
+
+
+ + {/* Required Effort Application Management - Full width */} +
+
+ +
+ + {overrideFTE !== null && overrideFTE !== application.overrideFTE && ( + + )} +
+
+
+ {(() => { + // Use calculated effort if available (real-time), otherwise use saved value + const calculatedEffortValue = calculatedEffort !== null ? calculatedEffort : application.requiredEffortApplicationManagement; + // Use override FTE if set, otherwise use calculated + const currentOverrideFTE = overrideFTE !== null ? overrideFTE : (application.overrideFTE ?? null); + const effort = currentOverrideFTE !== null ? currentOverrideFTE : calculatedEffortValue; + const breakdown = calculatedBreakdown || null; + + // Use breakdown values from v25 structure + const baseEffort = breakdown?.baseEffort ?? null; + const baseEffortMin = breakdown?.baseEffortMin ?? null; + const baseEffortMax = breakdown?.baseEffortMax ?? null; + + const numberOfUsersFactor = breakdown?.numberOfUsersFactor.value ?? (calculatedEffort !== null ? selectedUsers?.factor : application.numberOfUsers?.factor) ?? 1.0; + const dynamicsFactorValue = breakdown?.dynamicsFactor.value ?? (calculatedEffort !== null ? selectedDynamics?.factor : application.dynamicsFactor?.factor) ?? 1.0; + const complexityFactorValue = breakdown?.complexityFactor.value ?? (calculatedEffort !== null ? selectedComplexity?.factor : application.complexityFactor?.factor) ?? 1.0; + + const numberOfUsersName = breakdown?.numberOfUsersFactor.name ?? (calculatedEffort !== null ? selectedUsers?.name : application.numberOfUsers?.name) ?? null; + const dynamicsFactorName = breakdown?.dynamicsFactor.name ?? (calculatedEffort !== null ? selectedDynamics?.name : application.dynamicsFactor?.name) ?? null; + const complexityFactorName = breakdown?.complexityFactor.name ?? (calculatedEffort !== null ? selectedComplexity?.name : application.complexityFactor?.name) ?? null; + + const governanceModelName = breakdown?.governanceModelName ?? breakdown?.governanceModel ?? (calculatedEffort !== null ? selectedGovernance?.name : application.governanceModel?.name) ?? null; + const applicationTypeName = breakdown?.applicationType ?? (calculatedEffort !== null ? selectedType?.name : application.applicationType?.name) ?? null; + const businessImpactAnalyse = breakdown?.businessImpactAnalyse ?? null; + const applicationManagementHosting = breakdown?.applicationManagementHosting ?? (calculatedEffort !== null ? selectedApplicationManagementHosting?.name : application.applicationManagementHosting?.name) ?? null; + + // Warnings and errors from v25 breakdown + const warnings = breakdown?.warnings ?? []; + const errors = breakdown?.errors ?? []; + const usedDefaults = breakdown?.usedDefaults ?? []; + const requiresManualAssessment = breakdown?.requiresManualAssessment ?? false; + const isFixedFte = breakdown?.isFixedFte ?? false; + + // Hours from breakdown or calculate + const hoursPerYear = breakdown?.hoursPerYear ?? (effort !== null ? 36 * 46 * effort * 0.75 : 0); + const hoursPerMonth = breakdown?.hoursPerMonth ?? hoursPerYear / 12; + const hoursPerWeekCalculated = breakdown?.hoursPerWeek ?? hoursPerYear / 46; + const minutesPerWeek = hoursPerWeekCalculated * 60; + + // Calculation constants + const hoursPerWeek = 36; + const workWeeksPerYear = 46; + const declarablePercentage = 0.75; + + if (effort !== null && baseEffort !== null) { + // Net hours per year + const netHoursPerYear = hoursPerWeek * workWeeksPerYear * effort; + // Declarable/really usable hours per year + const declarableHoursPerYear = netHoursPerYear * declarablePercentage; + + return ( +
+ {/* Errors */} + {errors.length > 0 && ( +
+ {errors.map((error, i) => ( +
+ + {error} +
+ ))} +
+ )} + + {/* Warnings */} + {warnings.length > 0 && ( +
+ {warnings.map((warning, i) => ( +
+ {warning.startsWith('⚠️') || warning.startsWith('ℹ️') ? '' : 'ℹ️'} + {warning} +
+ ))} +
+ )} + +
+ {effort.toFixed(2)} FTE + {currentOverrideFTE !== null && ( + (Override) + )} + {calculatedEffort !== null && calculatedEffort !== application.requiredEffortApplicationManagement && currentOverrideFTE === null && ( + (voorvertoning) + )} + {isFixedFte && ( + (vast) + )} + {requiresManualAssessment && ( + (handmatige beoordeling) + )} +
+ {currentOverrideFTE !== null && calculatedEffortValue !== null && ( +
+ Berekende waarde: {calculatedEffortValue.toFixed(2)} FTE +
+ )} +
+
+ Basis FTE: {baseEffort.toFixed(2)} FTE + {baseEffortMin !== null && baseEffortMax !== null && baseEffortMin !== baseEffortMax && ( + + (range: {baseEffortMin.toFixed(2)} - {baseEffortMax.toFixed(2)}) + + )} +
+
+
+ ICT Governance Model: + {governanceModelName || 'Niet ingesteld'} + {usedDefaults.includes('regiemodel') && (default)} +
+
+ Application Management - Application Type: + {applicationTypeName || 'Niet ingesteld'} + {usedDefaults.includes('applicationType') && (default)} +
+
+ Business Impact Analyse: + {businessImpactAnalyse || 'Niet ingesteld'} + {usedDefaults.includes('businessImpact') && (default)} +
+
+ Application Management - Hosting: + {applicationManagementHosting || 'Niet ingesteld'} + {usedDefaults.includes('hosting') && (default)} +
+
+
Factoren:
+
+ Number of Users: × {numberOfUsersFactor.toFixed(2)} + {numberOfUsersName && ` (${numberOfUsersName})`} +
+
+ Dynamics Factor: × {dynamicsFactorValue.toFixed(2)} + {dynamicsFactorName && ` (${dynamicsFactorName})`} +
+
+ Complexity Factor: × {complexityFactorValue.toFixed(2)} + {complexityFactorName && ` (${complexityFactorName})`} +
+ + {/* Hours breakdown */} +
+ Uren per jaar (écht inzetbaar): +
+
+
+ {hoursPerYear.toFixed(1)} uur per jaar +
+
+ ≈ {hoursPerMonth.toFixed(1)} uur per maand +
+
+ ≈ {hoursPerWeekCalculated.toFixed(2)} uur per week +
+
+ ≈ {minutesPerWeek.toFixed(0)} minuten per week +
+
+
Berekening: {hoursPerWeek} uur/week × {workWeeksPerYear} weken × {effort.toFixed(2)} FTE × {declarablePercentage * 100}% = {declarableHoursPerYear.toFixed(1)} uur/jaar
+
(Netto: {netHoursPerYear.toFixed(1)} uur/jaar, waarvan {declarablePercentage * 100}% declarabel)
+
+
+
+
+ ); + } else if (errors.length > 0) { + return ( +
+
+ {errors.map((error, i) => ( +
+ + {error} +
+ ))} +
+ Niet berekend - configuratie onvolledig +
+ ); + } else { + return Niet berekend; + } + })()} +
+

+ Automatisch berekend op basis van Regiemodel, Application Type, Business Impact Analyse en Hosting (v25) +

+
+
+
+ + {/* Actions */} +
+
+ + +
+ +
+ + + + +
+
+
+ ); +} diff --git a/frontend/src/components/ApplicationList.tsx b/frontend/src/components/ApplicationList.tsx new file mode 100644 index 0000000..a496a4f --- /dev/null +++ b/frontend/src/components/ApplicationList.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [organisations, setOrganisations] = useState([]); + const [hostingTypes, setHostingTypes] = useState([]); + const [businessImportanceOptions, setBusinessImportanceOptions] = useState([]); + 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 ( +
+ {/* Page header */} +
+
+

Applicaties

+

Zoek en classificeer applicatiecomponenten

+
+ +
+ + {/* Search and filters */} +
+ {/* Search bar */} +
+
+ 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" + /> + + + +
+
+ + {/* Filters */} + {showFilters && ( +
+
+

Filters

+ +
+ +
+ {/* Status filter */} +
+ +
+ {ALL_STATUSES.map((status) => ( + + ))} +
+
+ + {/* Classification filters */} +
+
+ +
+ {(['all', 'filled', 'empty'] as const).map((value) => ( + + ))} +
+
+ +
+ +
+ {(['all', 'filled', 'empty'] as const).map((value) => ( + + ))} +
+
+ +
+ +
+ {(['all', 'filled', 'empty'] as const).map((value) => ( + + ))} +
+
+ +
+ +
+ {(['all', 'filled', 'empty'] as const).map((value) => ( + + ))} +
+
+
+ + {/* Dropdown filters */} +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ )} + + {/* Results count */} +
+ + {result ? ( + <> + Resultaten: {result.totalCount} applicaties + + ) : ( + 'Laden...' + )} + +
+ + {/* Results table */} + {loading ? ( +
+
+
+ ) : error ? ( +
{error}
+ ) : ( +
+ + + + + + + + + + + + + {result?.applications.map((app, index) => ( + + + + + + + + + ))} + +
+ # + + Naam + + Status + + AppFunctie + + Governance + + Benodigde inspanning +
+ handleRowClick(app, index, e)} + className="block px-4 py-3 text-sm text-gray-500" + > + {(currentPage - 1) * pageSize + index + 1} + + + handleRowClick(app, index, e)} + className="block px-4 py-3" + > +
+ {app.name} +
+
{app.key}
+ +
+ handleRowClick(app, index, e)} + className="block px-4 py-3" + > + + + + handleRowClick(app, index, e)} + className="block px-4 py-3" + > + {app.applicationFunctions && app.applicationFunctions.length > 0 ? ( +
+ {app.applicationFunctions.map((func) => ( + + {func.name}{func.applicationFunctionCategory ? ` (${func.applicationFunctionCategory.name})` : ''} + + ))} +
+ ) : ( + + Leeg + + )} + +
+ handleRowClick(app, index, e)} + className="block px-4 py-3" + > + {app.governanceModel ? ( + + {app.governanceModel.name} + + ) : ( + + Leeg + + )} + + + handleRowClick(app, index, e)} + className="block px-4 py-3 text-sm text-gray-900" + > + {app.requiredEffortApplicationManagement !== null ? ( + + {app.requiredEffortApplicationManagement.toFixed(2)} FTE + + ) : ( + - + )} + +
+
+ )} + + {/* Pagination */} + {result && result.totalPages > 1 && ( +
+ {currentPage > 1 ? ( + setCurrentPage(currentPage - 1)} + className="btn btn-secondary" + > + Vorige + + ) : ( + + )} + + Pagina {currentPage} van {result.totalPages} + + {currentPage < result.totalPages ? ( + setCurrentPage(currentPage + 1)} + className="btn btn-secondary" + > + Volgende + + ) : ( + + )} +
+ )} +
+
+ ); +} + +export function StatusBadge({ status }: { status: string | null }) { + const statusColors: Record = { + '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 -; + + return ( + + {status} + + ); +} + +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 -; + + const config = getImportanceConfig(importanceNumber); + + // Icon components + const WarningIcon = () => ( + + + + ); + + const ExclamationIcon = () => ( + + + + ); + + const CircleIcon = () => ( + + + + ); + + const QuestionIcon = () => ( + + + + ); + + const renderIcon = () => { + switch (config.icon) { + case 'warning': + return ; + case 'exclamation': + return ; + case 'circle': + return ; + case 'question': + return ; + default: + return null; + } + }; + + return ( + + {renderIcon()} + {config.label} + + ); +} diff --git a/frontend/src/components/Configuration.tsx b/frontend/src/components/Configuration.tsx new file mode 100644 index 0000000..1f4ea9c --- /dev/null +++ b/frontend/src/components/Configuration.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [applicationManagementHosting, setApplicationManagementHosting] = useState([]); + const [applicationTypes, setApplicationTypes] = useState([]); + + 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) => { + 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 ( +
+
Loading configuration...
+
+ ); + } + + if (!config) { + return ( +
Failed to load configuration
+ ); + } + + return ( +
+
+
+

Basis FTE Configuration

+

+ Configure the Required Effort Application Management calculation rules +

+
+
+ + +
+
+ + {error && ( +
+

{error}

+
+ )} + + {success && ( +
+

{success}

+
+ )} + + {/* Default Result */} +
+

Default Result

+
+ + 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" + /> +
+
+ + {/* Governance Model Rules */} +
+
+

Governance Model Rules

+ +
+ +
+ {[...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 ( + updateGovernanceModelRule(index, updates)} + onRemove={() => removeGovernanceModelRule(index)} + /> + ); + })} +
+
+
+ ); +} + +interface GovernanceModelRuleEditorProps { + rule: EffortCalculationConfig['governanceModelRules'][0]; + index: number; + applicationManagementHosting: ReferenceValue[]; + applicationTypes: ReferenceValue[]; + onUpdate: (updates: Partial) => 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 ( +
+
+
+ + 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" + /> +
+ +
+ + {expanded && ( +
+ {/* Default Result for this Governance Model */} +
+ + 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" + /> +
+ + {/* Application Type Rules */} +
+
+ + +
+
+ {Object.entries(rule.applicationTypeRules).map(([key, appTypeRule]) => ( + updateApplicationTypeRule(key, updates)} + onRemove={() => updateApplicationTypeRule(key, null)} + /> + ))} +
+
+
+ )} +
+ ); +} + +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 ( +
+
+
+ {ruleKey}: + 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" + /> + FTE +
+ +
+
+ ); + } + + // 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 ( +
+
+
+ +
+ at.name)} + selected={selectedApplicationTypeNames} + onChange={updateApplicationTypes} + placeholder="Select Application Types" + /> +
+
+ +
+ + {expanded && ( +
+ {/* Business Impact Rules */} +
+
+ + +
+
+ {Object.entries(rule.businessImpactRules).map(([key, impactRule]) => ( + updateBusinessImpactRule(key, updates)} + onRemove={() => updateBusinessImpactRule(key, null)} + /> + ))} +
+
+ + {/* Default Rule */} +
+
+ + {!rule.default && ( + + )} +
+ {rule.default ? ( + Array.isArray(rule.default) ? ( +
+ {rule.default.map((r, index) => ( + { + 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} + /> + ))} + +
+ ) : ( + + ) + ) : ( +

No default rule set

+ )} +
+
+ )} +
+ ); +} + +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 ( +
+
+
+ + {ruleKey}: + {!expanded && ( + <> + 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" + /> + FTE + + )} +
+ +
+ {expanded && ( +
+ + +
+ )} +
+ ); + } + + // Array of EffortRules + return ( +
+
+
+ + {ruleKey} ({rule.length} rules) +
+ +
+ + {expanded && ( +
+ {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 ( +
+ {isLastDefault && ( +
Default Rule (no Application Management - Hosting match)
+ )} + { + 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} + /> +
+ ); + })} + +
+ )} +
+ ); +} + +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 ( +
+
+
+
+ + 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" + /> +
+ {onRemove && ( + + )} +
+
+ + ht.name)} + selected={selectedHostingTypeNames} + onChange={updateHostingTypes} + placeholder="Select Application Management - Hosting" + /> +
+
+
+ ); +} + +// 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 ( +
+ + + {isOpen && ( +
+
+ 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()} + /> +
+
+ {filteredOptions.length === 0 ? ( +

No options found

+ ) : ( + filteredOptions.map((option) => { + const isSelected = selected.includes(option); + return ( + + ); + }) + )} +
+
+ )} + + + {isOpen && ( +
setIsOpen(false)} + /> + )} +
+ ); +} + diff --git a/frontend/src/components/ConfigurationV25.tsx b/frontend/src/components/ConfigurationV25.tsx new file mode 100644 index 0000000..6289cdf --- /dev/null +++ b/frontend/src/components/ConfigurationV25.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + // Reference data from Jira Assets + const [hostingOptions, setHostingOptions] = useState([]); + const [applicationTypeOptions, setApplicationTypeOptions] = useState([]); + const [biaOptions, setBiaOptions] = useState([]); + const [governanceOptions, setGovernanceOptions] = useState([]); + + 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) => { + if (!config) return; + setConfig({ + ...config, + regiemodellen: { + ...config.regiemodellen, + [code]: { + ...config.regiemodellen[code], + ...updates, + }, + }, + }); + }; + + const updateApplicationType = (regieModelCode: string, appType: string, updates: Partial) => { + 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 ( +
+
Loading configuration v25...
+
+ ); + } + + if (!config) { + return ( +
Failed to load configuration
+ ); + } + + return ( +
+
+
+

FTE Configuration v25

+

+ Configure the Required Effort Application Management calculation (Dienstencatalogus Applicatiebeheer v25) +

+
+
+ + +
+
+ + {error && ( +
+

{error}

+
+ )} + + {success && ( +
+

{success}

+
+ )} + + {/* Metadata */} +
+

Configuration Info

+
+
Version: {config.metadata.version}
+
Date: {config.metadata.date}
+
Formula: {config.metadata.formula}
+
+
+ + {/* Validation Rules Summary */} +
+

Validation Rules

+
+
+

BIA vs Regiemodel Constraints

+
+ {Object.entries(config.validationRules.biaRegieModelConstraints).map(([model, biaLevels]) => ( +
+ {model}: {biaLevels.join(', ')} +
+ ))} +
+
+
+

Platform Restrictions

+
+ {config.validationRules.platformRestrictions.map((r, i) => ( +
+ {r.regiemodel} + {r.applicationType}: {r.warning} +
+ ))} +
+
+
+
+ + {/* Regiemodellen */} +
+

Regiemodellen

+ {Object.entries(config.regiemodellen) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([code, model]) => ( + updateRegieModel(code, updates)} + onUpdateAppType={(appType, updates) => updateApplicationType(code, appType, updates)} + /> + ))} +
+
+ ); +} + +interface RegieModelEditorProps { + code: string; + model: GovernanceModelConfigV25; + hostingOptions: ReferenceValue[]; + applicationTypeOptions: ReferenceValue[]; + biaOptions: ReferenceValue[]; + onUpdate: (updates: Partial) => void; + onUpdateAppType: (appType: string, updates: Partial) => void; +} + +function RegieModelEditor({ + code, + model, + hostingOptions, + applicationTypeOptions, + biaOptions, + onUpdate, + onUpdateAppType +}: RegieModelEditorProps) { + const [expanded, setExpanded] = useState(false); + + return ( +
+
setExpanded(!expanded)} + > +
+ {expanded ? '▼' : '▶'} +
+

+ Regiemodel {code}: {model.name} +

+

{model.description}

+
+
+
+
Default FTE: {model.defaultFte.min} - {model.defaultFte.max}
+
Allowed BIA: {model.allowedBia.join(', ')}
+
+
+ + {expanded && ( +
+ {/* Default FTE Range */} +
+ +
+ onUpdate({ + defaultFte: { ...model.defaultFte, min: parseFloat(e.target.value) || 0 } + })} + className="w-20 px-2 py-1 text-sm border border-gray-300 rounded" + /> + - + onUpdate({ + defaultFte: { ...model.defaultFte, max: parseFloat(e.target.value) || 0 } + })} + className="w-20 px-2 py-1 text-sm border border-gray-300 rounded" + /> +
+
+ + {/* Application Types */} +
+

Application Types

+ {Object.entries(model.applicationTypes).map(([appType, appConfig]) => ( + onUpdateAppType(appType, updates)} + /> + ))} +
+
+ )} +
+ ); +} + +interface ApplicationTypeEditorProps { + appType: string; + config: ApplicationTypeConfigV25; + hostingOptions: ReferenceValue[]; + biaOptions: ReferenceValue[]; + onUpdate: (updates: Partial) => void; +} + +function ApplicationTypeEditor({ appType, config, hostingOptions, biaOptions, onUpdate }: ApplicationTypeEditorProps) { + const [expanded, setExpanded] = useState(false); + + return ( +
+
setExpanded(!expanded)} + > +
+ {expanded ? '▼' : '▶'} + {appType} + {config.fixedFte && Vast} + {config.requiresManualAssessment && Handmatig} + {config.notRecommended && Niet aanbevolen} +
+ {config.defaultFte && ( + + Default: {config.defaultFte.min} - {config.defaultFte.max} FTE + + )} +
+ + {expanded && ( +
+ {/* Flags */} +
+ + + +
+ + {/* Default FTE */} + {config.defaultFte && ( +
+ +
+ onUpdate({ + defaultFte: { ...config.defaultFte!, min: parseFloat(e.target.value) || 0 } + })} + className="w-16 px-2 py-1 text-sm border border-gray-300 rounded" + /> + - + onUpdate({ + defaultFte: { ...config.defaultFte!, max: parseFloat(e.target.value) || 0 } + })} + className="w-16 px-2 py-1 text-sm border border-gray-300 rounded" + /> +
+
+ )} + + {/* BIA Levels */} +
+
BIA Levels
+ {Object.entries(config.biaLevels).map(([biaLevel, biaConfig]) => ( + { + const newBiaLevels = { ...config.biaLevels }; + newBiaLevels[biaLevel] = { ...newBiaLevels[biaLevel], ...updates }; + onUpdate({ biaLevels: newBiaLevels }); + }} + /> + ))} +
+ + {config.note && ( +
+ Note: {config.note} +
+ )} +
+ )} +
+ ); +} + +interface BIALevelEditorProps { + biaLevel: string; + config: BIALevelConfig; + hostingOptions: ReferenceValue[]; + onUpdate: (updates: Partial) => void; +} + +function BIALevelEditor({ biaLevel, config, hostingOptions, onUpdate }: BIALevelEditorProps) { + const [expanded, setExpanded] = useState(false); + + return ( +
+
setExpanded(!expanded)} + > +
+ {expanded ? '▼' : '▶'} + + {biaLevel === '_all' ? 'All BIA Levels' : `BIA ${biaLevel}`} + + {config.description && ( + - {config.description} + )} +
+ {config.defaultFte && ( + + Default: {config.defaultFte.min} - {config.defaultFte.max} + + )} +
+ + {expanded && ( +
+ {/* Hosting Rules */} +
+
Hosting Rules
+ {Object.entries(config.hosting).map(([hostingKey, hostingRule]) => ( +
+ {hostingKey === '_all' ? 'All Hosting' : hostingKey}: + + [{hostingRule.hostingValues.join(', ')}] + + + FTE: {hostingRule.fte.min} - {hostingRule.fte.max} + + { + 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()} + /> + - + { + 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()} + /> +
+ ))} +
+
+ )} +
+ ); +} + + + + diff --git a/frontend/src/components/CustomSelect.tsx b/frontend/src/components/CustomSelect.tsx new file mode 100644 index 0000000..4375aa0 --- /dev/null +++ b/frontend/src/components/CustomSelect.tsx @@ -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(null); + const dropdownRef = useRef(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 ( +
+
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 ? ( +
+
{selectedOption.name}
+ {(() => { + const displayText = getDisplayText(selectedOption, showSummary, showRemarks); + return displayText ? ( +
+ {displayText} +
+ ) : null; + })()} +
+ ) : ( + {placeholder} + )} + + + +
+ + {isOpen && ( +
+ {options.length === 0 ? ( +
Geen opties beschikbaar
+ ) : ( + options.map((option, index) => { + const isSelected = option.objectId === value; + const isHighlighted = index === highlightedIndex; + + return ( +
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' + }`} + > +
{option.name}
+ {(() => { + const displayText = getDisplayText(option, showSummary, showRemarks); + return displayText ? ( +
+ {displayText} +
+ ) : null; + })()} +
+ ); + }) + )} +
+ )} +
+ ); +} + diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx new file mode 100644 index 0000000..c6e6d3c --- /dev/null +++ b/frontend/src/components/Dashboard.tsx @@ -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(null); + const [recentClassifications, setRecentClassifications] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(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 ( +
+
+
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + const progressPercentage = stats + ? Math.round((stats.classifiedCount / stats.totalApplications) * 100) + : 0; + + return ( +
+ {/* Page header */} +
+
+

Dashboard

+

+ Overzicht van de ZiRA classificatie voortgang + {stats?.stale && ( + + (gecachte data - API timeout) + + )} +

+
+
+ + + Start classificeren + +
+
+ + {/* Stats cards */} +
+
+
Totaal applicaties
+
+ {stats?.totalApplications || 0} +
+
+ +
+
Geclassificeerd
+
+ {stats?.classifiedCount || 0} +
+
+ +
+
Nog te classificeren
+
+ {Math.max(0, stats?.unclassifiedCount || 0)} +
+
+ +
+
Voortgang
+
+ {progressPercentage}% +
+
+
+ + {/* Progress bar */} +
+

+ Classificatie voortgang +

+
+
+ ApplicationFunction ingevuld + + {stats?.classifiedCount || 0} / {stats?.totalApplications || 0} + +
+
+
+
+
+
+ + {/* Two column layout */} +
+ {/* Status distribution */} +
+

+ Verdeling per status +

+
+ {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]) => ( +
+ {status} +
+
+
+
+ + {count} + +
+
+ ))} +
+
+ + {/* Governance model distribution */} +
+

+ Verdeling per regiemodel +

+
+ {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]) => ( +
+ {model} +
+
+
+
+ + {count} + +
+
+ ))} + {(!stats?.byGovernanceModel || + Object.keys(stats.byGovernanceModel).length === 0) && ( +

Geen data beschikbaar

+ )} +
+
+
+ + {/* Recent classifications */} +
+
+

+ Recente classificaties +

+
+
+ {recentClassifications.length === 0 ? ( +
+ Nog geen classificaties uitgevoerd +
+ ) : ( + recentClassifications.map((item, index) => ( +
+
+
+ {item.applicationName} +
+
+ {item.changes.applicationFunctions && item.changes.applicationFunctions.to.length > 0 && ( + + ApplicationFunctions: {item.changes.applicationFunctions.to.map((f) => f.name).join(', ')} + + )} +
+
+
+ + {item.source === 'AI_ACCEPTED' + ? 'AI Geaccepteerd' + : item.source === 'AI_MODIFIED' + ? 'AI Aangepast' + : 'Handmatig'} + + + {new Date(item.timestamp).toLocaleString('nl-NL')} + +
+
+ )) + )} +
+
+
+ ); +} diff --git a/frontend/src/components/TeamDashboard.tsx b/frontend/src/components/TeamDashboard.tsx new file mode 100644 index 0000000..c811830 --- /dev/null +++ b/frontend/src/components/TeamDashboard.tsx @@ -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 = { + '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(null); + const [initialLoading, setInitialLoading] = useState(true); // Only for first load + const [dataLoading, setDataLoading] = useState(false); // For filter changes + const [error, setError] = useState(null); + const [expandedClusters, setExpandedClusters] = useState>(new Set()); // Start with all clusters collapsed + const [expandedPlatforms, setExpandedPlatforms] = useState>(new Set()); // Track expanded platforms + // Status filter: excludedStatuses contains statuses that are NOT shown + const [excludedStatuses, setExcludedStatuses] = useState(['Closed', 'Deprecated']); // Default: exclude Closed and Deprecated + const [sortOption, setSortOption] = useState('fte-descending'); + const [statusDropdownOpen, setStatusDropdownOpen] = useState(false); + const [governanceModels, setGovernanceModels] = useState([]); + const [hoveredGovModel, setHoveredGovModel] = useState(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 ( +
+
+
+ ); + } + + 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 = {}; + 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 ( +
+ + + {isExpanded && ( +
+ {clusterData.applications.length === 0 && clusterData.platforms.length === 0 ? ( +

Geen applicaties in dit cluster

+ ) : ( +
+ {/* 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 ( +
+ {/* Governance Model indicator */} +
+ {platformGovStyle.letter} +
+ +
+ {/* Platform header */} +
+ {hasWorkloads && ( + + )} + +
+
+
+
{platformWithWorkloads.platform.name}
+ + Platform + + {platform.applicationManagementHosting?.name && ( + + {platform.applicationManagementHosting.name} + + )} +
+
{platformWithWorkloads.platform.key}
+
+
+ {(() => { + 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 ( + <> +
+ {platformWithWorkloads.totalEffort.toFixed(2)} FTE +
+
+ {totalMinFTE.toFixed(2)} - {totalMaxFTE.toFixed(2)} +
+ {hasAnyOverride && ( +
+ (berekend: {totalCalculated.toFixed(2)}) +
+ )} +
+ Platform: {platformWithWorkloads.platformEffort.toFixed(2)} FTE + {platformHasOverride && platformCalculated !== null && ( + (berekend: {platformCalculated.toFixed(2)}) + )} + {hasWorkloads && ( + <> + Workloads: {platformWithWorkloads.workloadsEffort.toFixed(2)} FTE + )} +
+ + ); + })()} +
+
+ +
+ + {/* Workloads list */} + {hasWorkloads && isPlatformExpanded && ( +
+
+
+ Workloads ({platformWithWorkloads.workloads.length}) +
+
+
+ {[...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 ( +
+ {/* Governance Model indicator for workload */} +
+ {workloadGovStyle.letter} +
+ +
+
+
+ {workload.name} + + {workloadType} + + {workloadHosting && ( + + {workloadHosting} + + )} +
+
{workload.key}
+
+
+ {workloadEffectiveFTE !== null && workloadEffectiveFTE !== undefined ? ( +
+
+ {workloadEffectiveFTE.toFixed(2)} FTE +
+
+ {workloadMinFTE.toFixed(2)} - {workloadMaxFTE.toFixed(2)} +
+
+ ) : ( +
Niet berekend
+ )} +
+
+ +
+ ); + })} +
+
+ )} +
+
+ ); + })} + + {/* 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 ( + + {/* Governance Model indicator */} +
+ {govStyle.letter} +
+ +
+
+
+ {app.name} + + {appType} + + {appHosting && ( + + {appHosting} + + )} +
+
{app.key}
+
+
+ {effectiveFTE !== null && effectiveFTE !== undefined ? ( +
+
+ {effectiveFTE.toFixed(2)} FTE +
+
+ {appMinFTE.toFixed(2)} - {appMaxFTE.toFixed(2)} +
+
+ ) : ( +
Niet berekend
+ )} +
+
+ + ); + })} +
+ )} +
+ )} +
+ ); + }; + + return ( +
+
+

Team-indeling

+

+ Overzicht van applicaties gegroepeerd per Application Cluster +

+
+ + {/* Compact Filter Bar */} +
+
+ {/* Sort Option */} +
+ + +
+ + {/* Status Filter Dropdown */} +
+ +
+ + + {statusDropdownOpen && ( + <> +
setStatusDropdownOpen(false)} + /> +
+
+ Selecteer statussen + +
+
+ {ALL_STATUSES.map((status) => { + const isExcluded = excludedStatuses.includes(status); + return ( + + ); + })} +
+
+

+ Uitgevinkte statussen worden verborgen +

+
+
+ + )} +
+
+
+
+ + {/* Error message */} + {error && ( +
+ {error} +
+ )} + + {/* Loading indicator for data updates */} + {dataLoading && ( +
+
+
+ Resultaten bijwerken... +
+
+ )} + + {/* Clusters */} + {!dataLoading && data && data.clusters.length > 0 && ( +
+ {data.clusters.map((clusterData) => ( + + ))} +
+ )} + + {/* Unassigned applications */} + {!dataLoading && data && (data.unassigned.applications.length > 0 || data.unassigned.platforms.length > 0) && ( +
+ +
+

+ Deze applicaties zijn nog niet toegekend aan een cluster. +

+
+
+ )} + + {!dataLoading && data && hasNoApplications && ( +
+

Geen applicaties gevonden

+
+ )} +
+ ); +} + diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..c4cf896 --- /dev/null +++ b/frontend/src/index.css @@ -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; + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..a814b52 --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + + + +); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..a8c1b35 --- /dev/null +++ b/frontend/src/services/api.ts @@ -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( + endpoint: string, + options: RequestInit = {} +): Promise { + 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 { + return fetchApi('/applications/search', { + method: 'POST', + body: JSON.stringify({ filters, page, pageSize }), + }); +} + +export async function getApplicationById(id: string): Promise { + return fetchApi(`/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 { + return fetchApi(`/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 +): 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 { + return fetchApi(`/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 { + const url = provider + ? `/classifications/suggest/${id}?provider=${provider}` + : `/classifications/suggest/${id}`; + return fetchApi(url, { + method: 'POST', + }); +} + +export async function getTaxonomy(): Promise { + return fetchApi('/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 { + return fetchApi(`/classifications/history?limit=${limit}`); +} + +export async function getAIStatus(): Promise { + 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 { + return fetchApi('/reference-data/application-functions'); +} + +export async function getDynamicsFactors(): Promise { + return fetchApi('/reference-data/dynamics-factors'); +} + +export async function getComplexityFactors(): Promise { + return fetchApi('/reference-data/complexity-factors'); +} + +export async function getNumberOfUsers(): Promise { + return fetchApi('/reference-data/number-of-users'); +} + +export async function getGovernanceModels(): Promise { + return fetchApi('/reference-data/governance-models'); +} + +export async function getOrganisations(): Promise { + return fetchApi('/reference-data/organisations'); +} + +export async function getHostingTypes(): Promise { + return fetchApi('/reference-data/hosting-types'); +} + +export async function getApplicationClusters(): Promise { + return fetchApi('/reference-data/application-clusters'); +} + +export async function getApplicationTypes(): Promise { + return fetchApi('/reference-data/application-types'); +} + +export async function getApplicationManagementHosting(): Promise { + return fetchApi('/reference-data/application-management-hosting'); +} + +export async function getBusinessImportance(): Promise { + return fetchApi('/reference-data/business-importance'); +} + +export async function getBusinessImpactAnalyses(): Promise { + return fetchApi('/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 { + const params = forceRefresh ? '?refresh=true' : ''; + return fetchApi(`/dashboard/stats${params}`); +} + +export async function getRecentClassifications(limit: number = 10): Promise { + return fetchApi(`/dashboard/recent?limit=${limit}`); +} + +// Team Dashboard +export async function getTeamDashboardData(excludedStatuses: ApplicationStatus[] = []): Promise { + 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(`/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 { + return fetchApi('/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 { + return fetchApi('/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 { + return fetchApi(`/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', + }); +} diff --git a/frontend/src/stores/navigationStore.ts b/frontend/src/stores/navigationStore.ts new file mode 100644 index 0000000..b4d459f --- /dev/null +++ b/frontend/src/stores/navigationStore.ts @@ -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()( + 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', + } + ) +); diff --git a/frontend/src/stores/searchStore.ts b/frontend/src/stores/searchStore.ts new file mode 100644 index 0000000..e552c57 --- /dev/null +++ b/frontend/src/stores/searchStore.ts @@ -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((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, + }), +})); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..a4ea1fd --- /dev/null +++ b/frontend/src/types/index.ts @@ -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; + byDomain: Record; + byGovernanceModel: Record; + 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; // 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; // 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 +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..6026e30 --- /dev/null +++ b/frontend/tailwind.config.js @@ -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: [], +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..c20738e --- /dev/null +++ b/frontend/tsconfig.json @@ -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" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..97ede7e --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..18eebca --- /dev/null +++ b/frontend/vite.config.ts @@ -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, + }, + }, + }, +}); diff --git a/management-parameters.json b/management-parameters.json new file mode 100644 index 0000000..10a6f3c --- /dev/null +++ b/management-parameters.json @@ -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" + ] + } + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d012c62 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,5251 @@ +{ + "name": "zira-classificatie-tool", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "zira-classificatie-tool", + "version": "1.0.0", + "workspaces": [ + "backend", + "frontend" + ], + "devDependencies": { + "concurrently": "^8.2.2" + } + }, + "backend": { + "name": "zira-backend", + "version": "1.0.0", + "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" + } + }, + "frontend": { + "name": "zira-frontend", + "version": "1.0.0", + "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" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.32.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.32.1.tgz", + "integrity": "sha512-U9JwTrDvdQ9iWuABVsMLj8nJVwAyQz6QXvgLsVhryhCEPkLsbcP/MXxm+jYcAwLoV8ESbaTTjnD4kuAFa+Hyjg==", + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", + "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", + "license": "MIT", + "dependencies": { + "@so-ric/colorspace": "^1.1.6", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.1", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.1.tgz", + "integrity": "sha512-vDbaOzF7yT2Qs4vO6XV1MHcJv+3dgR1sT+l3B8xxOVhUC336prMvqrvsLL/9Dnw2xr6Qhz4J0dmS0llNAbnUmQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.5.tgz", + "integrity": "sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.5.tgz", + "integrity": "sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.5.tgz", + "integrity": "sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.5.tgz", + "integrity": "sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.5.tgz", + "integrity": "sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.5.tgz", + "integrity": "sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.5.tgz", + "integrity": "sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.5.tgz", + "integrity": "sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.5.tgz", + "integrity": "sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.5.tgz", + "integrity": "sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.5.tgz", + "integrity": "sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.5.tgz", + "integrity": "sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.5.tgz", + "integrity": "sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.5.tgz", + "integrity": "sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.5.tgz", + "integrity": "sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.5.tgz", + "integrity": "sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.5.tgz", + "integrity": "sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.5.tgz", + "integrity": "sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.5.tgz", + "integrity": "sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.5.tgz", + "integrity": "sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.5.tgz", + "integrity": "sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.5.tgz", + "integrity": "sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@so-ric/colorspace": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", + "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", + "license": "MIT", + "dependencies": { + "color": "^5.0.2", + "text-hex": "1.0.x" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz", + "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", + "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.27", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", + "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", + "license": "MIT" + }, + "node_modules/@types/xlsx": { + "version": "0.0.35", + "resolved": "https://registry.npmjs.org/@types/xlsx/-/xlsx-0.0.35.tgz", + "integrity": "sha512-s0x3DYHZzOkxtjqOk/Nv1ezGzpbN7I8WX+lzlV/nFfTDOv7x4d8ZwGHcnaiB8UCx89omPsftQhS5II3jeWePxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.23", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", + "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001760", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.10", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.10.tgz", + "integrity": "sha512-2VIKvDx8Z1a9rTB2eCkdPE5nSe28XnA+qivGnWHoB40hMMt/h1hSz0960Zqsn6ZyxWXUie0EBdElKv8may20AA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001760", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz", + "integrity": "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", + "license": "MIT", + "dependencies": { + "color-convert": "^3.1.3", + "color-string": "^2.1.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-string": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-string/node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/color/node_modules/color-convert": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/color/node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concurrently": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz", + "integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "date-fns": "^2.30.0", + "lodash": "^4.17.21", + "rxjs": "^7.8.1", + "shell-quote": "^1.8.1", + "spawn-command": "0.0.2", + "supports-color": "^8.1.1", + "tree-kill": "^1.2.2", + "yargs": "^17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": "^14.13.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "license": "MIT" + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + "license": "MIT" + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "license": "MIT" + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "license": "MIT", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.85.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", + "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } + }, + "node_modules/openai": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.15.0.tgz", + "integrity": "sha512-F1Lvs5BoVvmZtzkUEVyh8mDQPPFolq4F+xdsx/DO8Hee8YF3IGAlZqUIsF+DVGhqf4aU0a3bTghsxB6OIsRy1g==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.2.tgz", + "integrity": "sha512-H2Bm38Zu1bm8KUE5NVWRMzuIyAV8p/JrOaBJAwVmp37AXG72+CZJlEBw6pdn9i5TBgLMhNDgijS4ZlblpHyWTA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.2.tgz", + "integrity": "sha512-l2OwHn3UUnEVUqc6/1VMmR1cvZryZ3j3NzapC2eUXO1dB0sYp5mvwdjiXhpUbRb21eFow3qSxpP8Yv6oAU824Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.1", + "react-router": "6.30.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.5.tgz", + "integrity": "sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.5", + "@rollup/rollup-android-arm64": "4.53.5", + "@rollup/rollup-darwin-arm64": "4.53.5", + "@rollup/rollup-darwin-x64": "4.53.5", + "@rollup/rollup-freebsd-arm64": "4.53.5", + "@rollup/rollup-freebsd-x64": "4.53.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.5", + "@rollup/rollup-linux-arm-musleabihf": "4.53.5", + "@rollup/rollup-linux-arm64-gnu": "4.53.5", + "@rollup/rollup-linux-arm64-musl": "4.53.5", + "@rollup/rollup-linux-loong64-gnu": "4.53.5", + "@rollup/rollup-linux-ppc64-gnu": "4.53.5", + "@rollup/rollup-linux-riscv64-gnu": "4.53.5", + "@rollup/rollup-linux-riscv64-musl": "4.53.5", + "@rollup/rollup-linux-s390x-gnu": "4.53.5", + "@rollup/rollup-linux-x64-gnu": "4.53.5", + "@rollup/rollup-linux-x64-musl": "4.53.5", + "@rollup/rollup-openharmony-arm64": "4.53.5", + "@rollup/rollup-win32-arm64-msvc": "4.53.5", + "@rollup/rollup-win32-ia32-msvc": "4.53.5", + "@rollup/rollup-win32-x64-gnu": "4.53.5", + "@rollup/rollup-win32-x64-msvc": "4.53.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/spawn-command": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz", + "integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==", + "dev": true + }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "license": "MIT" + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", + "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/winston": { + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", + "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", + "license": "MIT", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.8", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "license": "MIT", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/zira-backend": { + "resolved": "backend", + "link": true + }, + "node_modules/zira-frontend": { + "resolved": "frontend", + "link": true + }, + "node_modules/zustand": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz", + "integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e0b3753 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/zira-classificatie-tool-specificatie.md b/zira-classificatie-tool-specificatie.md new file mode 100644 index 0000000..7140b65 --- /dev/null +++ b/zira-classificatie-tool-specificatie.md @@ -0,0 +1,1036 @@ +# ZiRA Classificatie Tool - Technische Specificatie + +## Projectoverzicht + +### Doel +Ontwikkelen van een interactieve tool voor het classificeren van applicatiecomponenten naar ZiRA-applicatiefuncties, met directe integratie met Jira Assets CMDB. + +### Organisatie +- **Organisatie:** Zuyderland Medisch Centrum +- **Afdeling:** ICMT - Zorg en Ondersteunende Applicaties +- **Scope:** ~500 applicatiecomponenten + +### Functionaliteit +1. Ophalen van applicatiecomponenten uit Jira Assets zonder ApplicationFunction +2. AI-gestuurde classificatiesuggestie op basis van beschikbare metadata +3. Handmatige validatie/correctie door beheerder +4. Terugschrijven van classificatie naar Jira Assets +5. Rapportage en voortgangsmonitoring + +--- + +## Technische Architectuur + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ ZiRA Classificatie Tool │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ │ +│ │ React │ │ Express │ │ Jira Assets │ │ +│ │ Frontend │◄───►│ Backend │◄───►│ REST API │ │ +│ │ (Vite) │ │ (Node.js) │ │ (Data Center) │ │ +│ └──────────────┘ └──────┬───────┘ └─────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ Claude API │ │ +│ │ (Anthropic) │ │ +│ └─────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Tech Stack +- **Frontend:** React + TypeScript + Vite + TailwindCSS +- **Backend:** Node.js + Express + TypeScript +- **AI:** Anthropic Claude API (claude-sonnet-4-20250514) +- **Database:** SQLite (lokale cache voor sessie/voortgang) + +--- + +## Jira Assets API Specificaties + +### Authenticatie +Jira Data Center met Personal Access Token (PAT). + +```typescript +// Headers voor alle requests +const headers = { + 'Authorization': `Bearer ${process.env.JIRA_PAT}`, + 'Content-Type': 'application/json', + 'Accept': 'application/json' +}; +``` + +### Base URL +``` +https://{jira-host}/rest/assets/1.0 +``` + +### Schema Details +- **Schema naam:** ICMT - CMDB +- **ObjectType:** Application Component + +### Relevante Attributen Application Component +| Attribuut | Type | Beschrijving | +|-----------|------|--------------| +| Name | Text | Applicatienaam | +| SearchReference | Text | Zoeksleutel/alias | +| Description | Text | Functionele beschrijving | +| Organisation | Reference | Organisatieonderdeel | +| ApplicationFunction | Reference | **Te vullen: verwijzing naar ApplicationFunction object** | +| Status | Select | Status van applicatie | +| Business Importance | Select | Bedrijfsbelang | +| Business Impact Analyse | Reference | BIA-document | +| Application Component Hosting Type | Select | On-prem/SaaS/Cloud | +| Supplier Product | Reference | Leverancier/product | +| Business Owner | Reference | Eigenaar business | +| System Owner | Reference | Systeemeigenaar | +| Functional Application Management | Reference | FAB-team | +| Technical Application Management | Reference | TAB-team | +| Medische Techniek | Boolean | MT-gerelateerd | +| Application Management - Dynamics Factor | Reference | **Bewerkbaar: Dynamiekfactor** | +| Application Management - Complexity Factor | Reference | **Bewerkbaar: Complexiteitsfactor** | +| Application Management - Number of Users | Reference | **Bewerkbaar: Aantal gebruikers** | +| ICT Governance Model | Reference | **Bewerkbaar: Regiemodel** | + +### Reference Objects voor Filters + +#### Status (Filter - niet bewerkbaar) +| Key | Naam | Beschrijving | +|-----|------|--------------| +| status | Status | Algemene status | +| closed | Closed | Afgesloten | +| deprecated | Deprecated | Verouderd, wordt uitgefaseerd | +| eol | End of life | Einde levensduur | +| eos | End of support | Geen ondersteuning meer | +| impl | Implementation | In implementatie | +| prod | In Production | Productie | +| poc | Proof of Concept | Proefproject | +| shadow | Shadow IT | Niet-geautoriseerde IT | +| undefined | Undefined | Niet gedefinieerd | + +### Reference Objects voor Bewerkbare Velden + +#### Application Management - Dynamics Factor +| Key | Naam | Beschrijving | +|-----|------|--------------| +| 1 | Stabiel | Weinig wijzigingen, uitgekristalliseerd systeem, < 2 releases/jaar | +| 2 | Gemiddeld | Regelmatige wijzigingen, 2-4 releases/jaar, incidentele projecten | +| 3 | Hoog | Veel wijzigingen, > 4 releases/jaar, continue doorontwikkeling | +| 4 | Zeer hoog | Continu in beweging, grote transformatieprojecten, veel nieuwe functionaliteit | + +#### Application Management - Complexity Factor +| Key | Naam | Beschrijving | +|-----|------|--------------| +| 1 | Laag | Standalone applicatie, geen/weinig integraties, standaard configuratie | +| 2 | Gemiddeld | Enkele integraties, beperkt maatwerk, standaard governance | +| 3 | Hoog | Veel integraties, significant maatwerk, meerdere stakeholdergroepen | +| 4 | Zeer hoog | Platform met meerdere workloads, uitgebreide governance, veel maatwerk | + +#### Application Management - Number of Users +| Key | Naam | +|-----|------| +| 1 | < 100 | +| 2 | 100 - 500 | +| 3 | 500 - 2.000 | +| 4 | 2.000 - 5.000 | +| 5 | 5.000 - 10.000 | +| 6 | 10.000 - 15.000 | +| 7 | > 15.000 | + +#### ICT Governance Model (Regiemodel) +| Key | Naam | Beschrijving | Toelichting | +|-----|------|--------------|-------------| +| A | Centraal Beheer | ICMT voert volledig beheer uit | Volledige dienstverlening door ICMT. Standaardmodel voor kernapplicaties met BIA D/E/F. Voorbeelden: EPD (HiX), ERP, Microsoft 365 | +| B | Federatief Beheer | ICMT + business delen beheer | ICMT en business delen verantwoordelijkheid. Geschikt voor applicaties met sterke key user organisatie | +| C | Uitbesteed met ICMT-Regie | Leverancier beheert, ICMT regisseert | Leverancier voert beheer uit, ICMT houdt regie. Standaardmodel voor SaaS waar ICMT contractpartij is | +| D | Uitbesteed met Business-Regie | Leverancier beheert, business regisseert | Business onderhoudt leveranciersrelatie. ICMT heeft beperkte betrokkenheid. SaaS waar business contract beheert | +| E | Volledig Decentraal Beheer | Business voert volledig beheer uit | Business voert zelf beheer uit. ICMT minimaal betrokken. Shadow IT, niche tools, pilotapplicaties | + +### API Endpoints + +#### 1. Object Schema ophalen +```bash +GET /rest/assets/1.0/objectschema/list +``` + +#### 2. ObjectType ID vinden +```bash +GET /rest/assets/1.0/objectschema/{schemaId}/objecttypes +``` + +#### 3. Applicaties ophalen (met filters) +```bash +# Voorbeeld: Alle applicaties zonder ApplicationFunction, status In Production of Implementation +POST /rest/assets/1.0/aql/objects +Content-Type: application/json + +{ + "qlQuery": "objectType = \"Application Component\" AND ApplicationFunction IS EMPTY AND Status IN (\"In Production\", \"Implementation\")", + "page": 1, + "resultPerPage": 50, + "includeAttributes": true +} +``` + +```bash +# Voorbeeld: Vrije tekst zoeken +POST /rest/assets/1.0/aql/objects +Content-Type: application/json + +{ + "qlQuery": "objectType = \"Application Component\" AND (Name LIKE \"%epic%\" OR Description LIKE \"%epic%\")", + "page": 1, + "resultPerPage": 50, + "includeAttributes": true +} +``` + +```bash +# Voorbeeld: Gecombineerde filters +POST /rest/assets/1.0/aql/objects +Content-Type: application/json + +{ + "qlQuery": "objectType = \"Application Component\" AND Status = \"In Production\" AND \"ICT Governance Model\" IS EMPTY AND \"Application Management - Dynamics Factor\" IS NOT EMPTY", + "page": 1, + "resultPerPage": 50, + "includeAttributes": true +} +``` + +#### 4. ApplicationFunction objecten ophalen (voor dropdown) +```bash +POST /rest/assets/1.0/aql/objects +Content-Type: application/json + +{ + "qlQuery": "objectType = \"ApplicationFunction\"", + "resultPerPage": 200, + "includeAttributes": true +} +``` + +#### 5. Dynamics Factor objecten ophalen +```bash +POST /rest/assets/1.0/aql/objects +Content-Type: application/json + +{ + "qlQuery": "objectType = \"Application Management - Dynamics Factor\"", + "resultPerPage": 10, + "includeAttributes": true +} +``` + +#### 6. Complexity Factor objecten ophalen +```bash +POST /rest/assets/1.0/aql/objects +Content-Type: application/json + +{ + "qlQuery": "objectType = \"Application Management - Complexity Factor\"", + "resultPerPage": 10, + "includeAttributes": true +} +``` + +#### 7. Number of Users objecten ophalen +```bash +POST /rest/assets/1.0/aql/objects +Content-Type: application/json + +{ + "qlQuery": "objectType = \"Application Management - Number of Users\"", + "resultPerPage": 10, + "includeAttributes": true +} +``` + +#### 8. ICT Governance Model objecten ophalen +```bash +POST /rest/assets/1.0/aql/objects +Content-Type: application/json + +{ + "qlQuery": "objectType = \"ICT Governance Model\"", + "resultPerPage": 10, + "includeAttributes": true +} +``` + +#### 9. Applicatie updaten met meerdere velden +```bash +PUT /rest/assets/1.0/object/{objectId} +Content-Type: application/json + +{ + "attributes": [ + { + "objectTypeAttributeId": "{applicationFunctionAttributeId}", + "objectAttributeValues": [ + { + "referencedObjectBeanKey": "{applicationFunctionObjectKey}" + } + ] + }, + { + "objectTypeAttributeId": "{dynamicsFactorAttributeId}", + "objectAttributeValues": [ + { + "referencedObjectBeanKey": "{dynamicsFactorObjectKey}" + } + ] + }, + { + "objectTypeAttributeId": "{complexityFactorAttributeId}", + "objectAttributeValues": [ + { + "referencedObjectBeanKey": "{complexityFactorObjectKey}" + } + ] + }, + { + "objectTypeAttributeId": "{numberOfUsersAttributeId}", + "objectAttributeValues": [ + { + "referencedObjectBeanKey": "{numberOfUsersObjectKey}" + } + ] + }, + { + "objectTypeAttributeId": "{governanceModelAttributeId}", + "objectAttributeValues": [ + { + "referencedObjectBeanKey": "{governanceModelObjectKey}" + } + ] + } + ] +} +``` + +#### 10. Attribuut ID's ophalen +```bash +GET /rest/assets/1.0/objecttype/{objectTypeId}/attributes +``` + +--- + +## ZiRA Applicatiefuncties Taxonomie + +### Domein: STURING +| Code | Naam | Beschrijving | +|------|------|--------------| +| STU-001 | Beleid & Innovatie | Functionaliteit voor ondersteuning van het bepalen en beheren van beleid, ontwikkeling producten & diensten, planning & control cyclus en ondersteunende managementinformatie | +| STU-002 | Proces & Architectuur | Functionaliteit voor het ontwikkelen en beheren van de enterprise architectuur (organisatie, processen, informatie, applicatie, techniek) | +| STU-003 | Project & Portfoliomanagement | Functionaliteit voor het beheren van projecten en programma's | +| STU-004 | Kwaliteitsinformatiemanagement | Functionaliteit voor de ondersteuning van het maken, verwerken en beheren van kwaliteitsdocumenten (inclusief protocollen) | +| STU-005 | Performance & Verantwoording | Functionaliteit voor het beheren van productieafspraken, KPI's inclusief beheer van de verantwoording in het kader van wet & regelgeving alsmede prestaties en maatschappelijk verantwoordschap | +| STU-006 | Marketing & Contractmanagement | Functionaliteit voor ondersteuning van marktanalyses en contractmanagement | + +### Domein: ONDERZOEK +| Code | Naam | Beschrijving | +|------|------|--------------| +| ONZ-001 | Onderzoek ontwikkeling | 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 | +| ONZ-002 | Onderzoekvoorbereiding | Functionaliteit voor de administratieve voorbereiding van het onderzoek als aanvraag van vergunningen en financieringen | +| ONZ-003 | Onderzoeksmanagement | Functionaliteit voor de administratieve uitvoering van het onderzoek als aanvraag patientenselectie, verkrijgen consent | +| ONZ-004 | Researchdatamanagement | Functionaliteit voor het verzamelen, bewerken, analyseren en publiceren van onderzoeksdata | +| ONZ-005 | Onderzoekpublicatie | Functionaliteit voor de opslag van publicaties van onderzoeksresultaten | + +### Domein: ZORG - Samenwerking +| Code | Naam | Beschrijving | +|------|------|--------------| +| ZRG-SAM-001 | Dossier inzage | Functionaliteit die het mogelijk maakt voor patiënten om digitale inzage te krijgen in medische dossiers | +| ZRG-SAM-002 | Behandelondersteuning | Functionaliteit voor het voorlichten en coachen van en communiceren met de patiënt over zijn zorg (patientempowerment) | +| ZRG-SAM-003 | Interactie PGO | Functionaliteit voor ondersteuning en integraties met een persoonlijke gezondheidsomgeving | +| ZRG-SAM-004 | Patientenforum | Functionaliteit voor het aanbieden van een online omgeving voor patienten | +| ZRG-SAM-005 | Preventie | Functionaliteit ter bevordering van de gezondheid en ter voorkoming van klachten en problemen | +| ZRG-SAM-006 | Gezondheidsvragen | Functionaliteit voor het on-line invullen van vragenlijsten | +| ZRG-SAM-007 | Kwaliteit en tevredenheidsmeting | Functionaliteit om de effecten van behandelingen en de patiënttevredenheid te kunnen meten | +| ZRG-SAM-008 | Tele-consultatie | Functionaliteit om een zorgprofessional remote te raadplegen | +| ZRG-SAM-009 | Zelfmonitoring | Functionaliteit om de eigen gezondheidstoestand te bewaken | +| ZRG-SAM-010 | Tele-monitoring | Functionaliteit waarmee de patient op afstand gevolgd en begeleid wordt | +| ZRG-SAM-011 | On-line afspraken | Functionaliteit voor het on-line maken van afspraken | +| ZRG-SAM-012 | Dossieruitwisseling | Functionaliteit voor het versturen en ontvangen en verwerken van dossierinformatie | +| ZRG-SAM-013 | Interactie externe bronnen | Functionaliteit voor informatieuitwisseling met derden | +| ZRG-SAM-014 | Samenwerking betrokken zorgverleners | Functionaliteit voor het coördineren van zorg met andere zorgverleners | + +### Domein: ZORG - Consultatie & Behandeling +| Code | Naam | Beschrijving | +|------|------|--------------| +| ZRG-CON-001 | Dossierraadpleging | Functionaliteit voor het raadplegen van het dossier via verschillende views | +| ZRG-CON-002 | Dossiervoering | Functionaliteit voor het bijwerken van het dossier | +| ZRG-CON-003 | Medicatie | Functionaliteit van de ondersteuning van de medicamenteuze behandeling | +| ZRG-CON-004 | Operatie | Functionaliteit voor de ondersteuning van het operatieve proces | +| ZRG-CON-005 | Patientbewaking | Functionaliteit voor bewaking van de patienten (monitoring, alarming) | +| ZRG-CON-006 | Beslissingsondersteuning | Functionaliteit voor de ondersteuning van besluiten van de zorgverlener | +| ZRG-CON-007 | Verzorgingondersteuning | Functionaliteit voor de ondersteuning van het verzorgingsproces | +| ZRG-CON-008 | Ordermanagement | Functionaliteit voor de uitvoering van de closed order loop | +| ZRG-CON-009 | Resultaat afhandeling | Functionaliteit voor de analyse en rapportage van resultaten | +| ZRG-CON-010 | Kwaliteitsbewaking | Functionaliteit voor de bewaking en signalering van fouten | + +### Domein: ZORG - Aanvullend onderzoek +| Code | Naam | Beschrijving | +|------|------|--------------| +| ZRG-AOZ-001 | Laboratoriumonderzoek | Functionaliteit voor de ondersteuning van processen op laboratoria (kcl, microbiologie, pathologie, klinische genetica, apotheeklab) | +| ZRG-AOZ-002 | Beeldvormend onderzoek | Functionaliteit voor de ondersteuning van beeldvormend onderzoek (Radiologie, Nucleair, Cardiologie) inclusief beeldmanagement (VNA) | +| ZRG-AOZ-003 | Functieonderzoek | Functionaliteit voor de ondersteuning van functieonderzoek (ECG, Longfunctie, Audiologie) | + +### Domein: ZORG - Zorgondersteuning +| Code | Naam | Beschrijving | +|------|------|--------------| +| ZRG-ZON-001 | Zorgrelatiebeheer | Functionaliteit voor beheren van alle gegevens van zorgrelaties | +| ZRG-ZON-002 | Zorgplanning | Functionaliteit voor het maken en beheren van afspraken, opnames, overplaatsingen, ontslag en verwijzing | +| ZRG-ZON-003 | Resource planning | Functionaliteit voor het plannen van resources en middelen | +| ZRG-ZON-004 | Patiëntadministratie | Functionaliteit voor beheer van demografie, contactpersonen en niet-medische informatie | +| ZRG-ZON-005 | Patiëntenlogistiek | Functionaliteit voor de ondersteuning van het verplaatsen van mensen en middelen | +| ZRG-ZON-006 | Zorgfacturering | Functionaliteit voor de vastlegging van de verrichting en factureren van het zorgproduct | + +### Domein: ONDERWIJS +| Code | Naam | Beschrijving | +|------|------|--------------| +| OND-001 | Onderwijsportfolio | Functionaliteit voor creatie en beheer van het onderwijsportfolio | +| OND-002 | Learning Content Management | Functionaliteit creatie en beheer van onderwijscontent | +| OND-003 | Educatie | Functionaliteit voor het geven van educatie dmv digitale middelen | +| OND-004 | Toetsing | Functionaliteit voor het geven en beoordelen van toetsen | +| OND-005 | Student Informatie | Functionaliteit voor het beheren van alle informatie van en over de student | +| OND-006 | Onderwijs rooster & planning | Functionaliteit voor het roosteren en plannen van het onderwijsprogramma | + +### Domein: BEDRIJFSONDERSTEUNING +| Code | Naam | Beschrijving | +|------|------|--------------| +| BED-001 | Vastgoed | Functionaliteit die beheer, bouw en exploitatie van gebouwen ondersteunt | +| BED-002 | Inkoop | Functionaliteit die inkopen van producten en diensten ondersteunt | +| BED-003 | Voorraadbeheer | Beheren/beheersen van de in- en uitgaande goederenstroom | +| BED-004 | Kennismanagement | Functionaliteit die het creëeren en delen van gezamenlijke kennis ondersteunt | +| BED-005 | Datamanagement | Functionaliteit voor ondersteunen van datamanagement (reference, master, metadata, analytics) | +| BED-006 | Voorlichting | Functionaliteit die het geven van voorlichting via verschillende kanalen ondersteunt | +| BED-007 | Hotelservice | Functionaliteit die de hotelfunctie ondersteunt (parkeren, catering, kassa) | +| BED-008 | Klachtenafhandeling | Functionaliteit die de afhandeling van klachten ondersteunt | +| BED-009 | Personeelbeheer | Functionaliteit die het administreren en managen van medewerkers ondersteunt | +| BED-010 | Tijdsregistratie | Functionaliteit waarmee het registreren van de bestede tijd wordt ondersteund | +| BED-011 | Financieel beheer | Functionaliteit waarmee de financiële administratie wordt ondersteund | +| BED-012 | Salarisverwerking | Functionaliteit waarmee het uitbetalen van salarissen wordt ondersteund | +| BED-013 | Beheren medische technologie | Functionaliteit die beheer, onderhoud en gebruik van medische apparatuur ondersteunt | +| BED-014 | Beveiliging | Functionaliteit die ondersteunt bij veiligheid, kwaliteit en milieu taken | +| BED-015 | Relatiebeheer | Functionaliteit ter ondersteuning van relatiebeheer in brede zin | +| BED-016 | ICT-change en servicemanagement | Functies voor het faciliteren van hulpvragen en oplossingen | + +### Domein: GENERIEKE ICT FUNCTIES - Werkplek en samenwerken +| Code | Naam | Beschrijving | +|------|------|--------------| +| GEN-WRK-001 | Beheren werkplek | Functionaliteit voor beheren hardware en software op de werkplek | +| GEN-WRK-002 | Printing & scanning | Functionaliteit voor het afdrukken en scannen | +| GEN-WRK-003 | Kantoorautomatisering | Functionaliteit voor standaard kantoorondersteuning | +| GEN-WRK-004 | Unified communications | Functionaliteit voor geïntegreerde communicatie | +| GEN-WRK-005 | Document & Beeld beheer | Functionaliteit voor het beheren van documenten en beelden | +| GEN-WRK-006 | Content management | Functionaliteit voor het verzamelen, managen en publiceren van informatie | +| GEN-WRK-007 | Publieke ICT services | Functionaliteit voor het aanbieden van publieke diensten | + +### Domein: GENERIEKE ICT FUNCTIES - Identiteit, toegang en beveiliging +| Code | Naam | Beschrijving | +|------|------|--------------| +| GEN-IAM-001 | Identiteit & Authenticatie | Functionaliteit voor het identificeren en authenticeren van individuen | +| GEN-IAM-002 | Autorisatie management | Functionaliteit voor beheren van rechten en toegang | +| GEN-IAM-003 | Auditing & monitoring | Functionaliteit voor audits en monitoring | +| GEN-IAM-004 | Certificate service | Functionaliteit voor uitgifte en beheer van certificaten | +| GEN-IAM-005 | ICT Preventie en protectie | Functionaliteit voor beheersen van kwetsbaarheden en penetraties | + +### Domein: GENERIEKE ICT FUNCTIES - Datacenter +| Code | Naam | Beschrijving | +|------|------|--------------| +| GEN-DC-001 | Hosting servercapaciteit | Functionaliteit voor het leveren van serverinfrastructuur | +| GEN-DC-002 | Datacenter housing | Functionaliteit voor beheren van het datacenter | +| GEN-DC-003 | Hosting data storage | Functionaliteit voor data opslag | +| GEN-DC-004 | Data archiving | Functionaliteit voor het archiveren van gegevens | +| GEN-DC-005 | Backup & recovery | Functionaliteit voor back-up en herstel | +| GEN-DC-006 | Database management | Functionaliteit voor het beheren van databases | +| GEN-DC-007 | Provisioning & automation service | Functionaliteit voor het distribueren en automatiseren van diensten | +| GEN-DC-008 | Monitoring & alerting | Functionaliteit voor het monitoren en analyseren van het datacentrum | +| GEN-DC-009 | Servermanagement | Functionaliteit voor het beheren van servers | + +### Domein: GENERIEKE ICT FUNCTIES - Connectiviteit +| Code | Naam | Beschrijving | +|------|------|--------------| +| GEN-CON-001 | Netwerkmanagement | Functionaliteit voor het beheren van het netwerk | +| GEN-CON-002 | Locatiebepaling | Functies voor het traceren en volgen van items | +| GEN-CON-003 | DNS & IP Adress management | Functionaliteit voor het beheren van DNS en IP adressen | +| GEN-CON-004 | Remote Access | Functionaliteit voor toegang op afstand | +| GEN-CON-005 | Load Balancing | Functionaliteit voor beheren van server en netwerkbelasting | +| GEN-CON-006 | Gegevensuitwisseling | Functionaliteit voor de ondersteuning van gegevensuitwisseling (ESB, Message broker) | + +--- + +## AI Classificatie Prompt + +```typescript +const CLASSIFICATION_PROMPT = `Je bent een ervaren informatiemanager in de Nederlandse zorg met diepgaande expertise in de Ziekenhuis Referentie Architectuur (ZiRA). Je taak is om applicatiecomponenten te classificeren naar de juiste ZiRA-applicatiefunctie(s). + +## Context +Een applicatiefunctie is volgens de ZiRA: "met elkaar samenhangende functionaliteit (geautomatiseerd gedrag) die wordt geboden door een applicatie(component), die ondersteuning biedt aan één of meerdere bedrijfsactiviteiten." + +De classificatie is: +- Productonafhankelijk (focus op FUNCTIONALITEIT, niet op merknaam) +- Gebaseerd op de primaire functie van de applicatie +- Aangevuld met secundaire functies indien de applicatie meerdere domeinen bedient + +## ZiRA Applicatiefuncties +{ZIRA_TAXONOMY} + +## Te classificeren applicatie +Naam: {applicatie_naam} +Beschrijving: {beschrijving} +Leverancier/Product: {supplier_product} +Organisatie: {organisatie} +Hosting Type: {hosting_type} +Status: {status} +Business Importance: {business_importance} +Systeemeigenaar: {system_owner} +Business Owner: {business_owner} +Medische Techniek: {medische_techniek} + +## Classificatie-instructies + +1. **Analyseer** de beschikbare informatie: + - Wat doet de applicatie functioneel? + - Welke bedrijfsactiviteiten ondersteunt het? + - In welk zorgdomein wordt het primair gebruikt? + +2. **Match** met ZiRA-functies: + - Bepaal de PRIMAIRE functie (hoofddoel van de applicatie) + - Identificeer SECUNDAIRE functies (indien van toepassing) + - Let op: veel EPD-functies vallen onder Consultatie & Behandeling + - Generieke tooling valt vaak onder Bedrijfsondersteuning of Generieke ICT + +3. **Beoordeel** de betrouwbaarheid: + - HOOG: Duidelijke beschrijving, bekende applicatie, eenduidige functie + - MIDDEN: Beperkte beschrijving maar herkenbare functie + - LAAG: Onduidelijke beschrijving, generieke naam, meerdere mogelijke functies + +## Output (JSON) +{ + "primaire_functie": { + "code": "[ZiRA-code]", + "naam": "[ZiRA-naam]", + "onderbouwing": "[korte uitleg waarom deze classificatie past]" + }, + "secundaire_functies": [ + { + "code": "[ZiRA-code]", + "naam": "[ZiRA-naam]", + "onderbouwing": "[korte uitleg]" + } + ], + "confidence": "[HOOG|MIDDEN|LAAG]", + "aandachtspunten": "[eventuele onzekerheden of suggesties voor verificatie]" +}`; +``` + +--- + +## User Interface Specificaties + +### Gebruikersflow + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ GEBRUIKERSFLOW │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. ZOEKEN & FILTEREN │ +│ ↓ │ +│ 2. RESULTATENLIJST (klik op applicatie) │ +│ ↓ │ +│ 3. DETAIL/WIJZIGINGSSCHERM │ +│ • Bekijk alle velden (read-only) │ +│ • AI-classificatie aanvragen │ +│ • Bewerkbare velden aanpassen │ +│ ↓ │ +│ 4. OPSLAAN → Automatisch volgende applicatie uit zoekresultaten │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### Veldtypes + +| Type | Gedrag | Velden | +|------|--------|--------| +| **Read-only** | Alleen weergeven | Name, SearchReference, Description, Organisation, Status, Business Importance, Business Impact Analyse, Hosting Type, Supplier Product, Business Owner, System Owner, FAB, TAB, Medische Techniek | +| **Bewerkbaar** | Weergeven + wijzigen | ApplicationFunction, Dynamics Factor, Complexity Factor, Number of Users, ICT Governance Model | + +### Schermen + +#### 1. Dashboard +- Totaal aantal applicaties +- Aantal geclassificeerd / nog te classificeren (ApplicationFunction) +- Voortgangsbalk per veld (ApplicationFunction, Dynamics, Complexity, Users, Governance) +- Verdeling over ZiRA-domeinen (pie chart) +- Verdeling over Regiemodellen (bar chart) +- Capaciteitsmatrix (Dynamics x Complexity heatmap) +- Recent geclassificeerd (activity feed) + +#### 2. Zoeken & Filteren (Hoofdscherm) +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Application Components [Dashboard] │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌───────────────────────────────────────────────────────────────────────┐ │ +│ │ 🔍 Zoeken... │ │ +│ │ Zoek op naam, beschrijving, leverancier, systeemeigenaar... │ │ +│ └───────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────────┐│ +│ │ FILTERS [Wis alle] ││ +│ │ ││ +│ │ Status: ApplicationFunction: Governance Model: ││ +│ │ ☑ In Production ○ Alle ○ Alle ││ +│ │ ☑ Implementation ○ Ingevuld ○ Ingevuld ││ +│ │ ☐ Proof of Concept ● Leeg ○ Leeg ││ +│ │ ☐ End of support ││ +│ │ ☐ End of life Dynamics Factor: Complexity Factor: ││ +│ │ ☐ Deprecated ○ Alle ○ Alle ││ +│ │ ☐ Shadow IT ○ Ingevuld ○ Ingevuld ││ +│ │ ☐ Closed ○ Leeg ○ Leeg ││ +│ │ ☐ Undefined ││ +│ │ Organisation: Hosting Type: ││ +│ │ [Alle ▼] [Alle ▼] ││ +│ └─────────────────────────────────────────────────────────────────────────┘│ +│ │ +│ Resultaten: 127 van 500 applicaties [Sorteer: Naam ▼] │ +│ ┌─────────────────────────────────────────────────────────────────────────┐│ +│ │ # │ Naam │ Status │ AppFunctie │ Governance │ Dyn│Cmp││ +│ ├────┼───────────────────┼─────────────┼────────────┼────────────┼────┼───┤│ +│ │ 1 │ Epic Hyperspace │ Production │ ⚠️ Leeg │ Model A │ 3 │ 4 ││ +│ │ 2 │ SAP Finance │ Production │ ⚠️ Leeg │ ⚠️ Leeg │ 2 │ 3 ││ +│ │ 3 │ Philips PACS │ Production │ ⚠️ Leeg │ Model C │ - │ - ││ +│ │ 4 │ ChipSoft HiX │ Production │ ⚠️ Leeg │ Model A │ 4 │ 4 ││ +│ │ 5 │ TOPdesk │ Production │ ⚠️ Leeg │ ⚠️ Leeg │ 2 │ 2 ││ +│ │ ...│ ... │ ... │ ... │ ... │ ...│...││ +│ └─────────────────────────────────────────────────────────────────────────┘│ +│ │ +│ [◀ Vorige] Pagina 1 van 13 [Volgende ▶] [Bulk AI Classificatie] │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +#### 3. Detail/Wijzigingsscherm (na klik op applicatie) +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ ← Terug naar lijst Applicatie 3 van 127 │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────────┐│ +│ │ APPLICATIE INFORMATIE (read-only) ││ +│ │ ││ +│ │ Naam: Epic Hyperspace ││ +│ │ Search Reference: EPIC-HS ││ +│ │ Beschrijving: Elektronisch Patiëntendossier module voor ││ +│ │ klinische documentatie en workflow ││ +│ │ ────────────────────────────────────────────────────────────────────── ││ +│ │ Leverancier/Product: Epic Systems / Hyperspace ││ +│ │ Organisatie: Zorg ││ +│ │ Hosting Type: On-premises ││ +│ │ Status: 🟢 In Production ││ +│ │ ────────────────────────────────────────────────────────────────────── ││ +│ │ Business Importance: Kritiek ││ +│ │ Business Impact Analyse: BIA-2024-0042 (Klasse E) ││ +│ │ Medische Techniek: Nee ││ +│ │ ────────────────────────────────────────────────────────────────────── ││ +│ │ Business Owner: Dr. A. van der Berg ││ +│ │ System Owner: J. Janssen ││ +│ │ Functioneel Beheer: Team EPD ││ +│ │ Technisch Beheer: Team Zorgapplicaties ││ +│ └─────────────────────────────────────────────────────────────────────────┘│ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────────┐│ +│ │ AI CLASSIFICATIE [🤖 Analyseer] ││ +│ │ ││ +│ │ ┌─────────────────────────────────────────────────────────────────────┐ ││ +│ │ │ Status: ✅ Analyse voltooid Confidence: HOOG │ ││ +│ │ │ │ ││ +│ │ │ Primaire functie: ZRG-CON-002 - Dossiervoering │ ││ +│ │ │ "Epic Hyperspace is primair een EPD-module voor klinische │ ││ +│ │ │ documentatie, wat direct past bij de ZiRA-functie Dossiervoering" │ ││ +│ │ │ │ ││ +│ │ │ Secundaire functies: │ ││ +│ │ │ • ZRG-CON-008 - Ordermanagement │ ││ +│ │ │ • ZRG-CON-003 - Medicatie │ ││ +│ │ │ │ ││ +│ │ │ [✓ Accepteer primaire suggestie] │ ││ +│ │ └─────────────────────────────────────────────────────────────────────┘ ││ +│ └─────────────────────────────────────────────────────────────────────────┘│ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────────┐│ +│ │ BEWERKBARE VELDEN ││ +│ │ ││ +│ │ ┌─────────────────────────────────┐ ┌───────────────────────────────┐ ││ +│ │ │ ZiRA Applicatiefunctie │ │ ICT Governance Model │ ││ +│ │ │ │ │ │ ││ +│ │ │ Domein: │ │ [Regiemodel A ▼] │ ││ +│ │ │ [Zorg - Consultatie & Beh. ▼] │ │ │ ││ +│ │ │ │ │ Centraal Beheer │ ││ +│ │ │ Functie: │ │ ICMT voert volledig beheer │ ││ +│ │ │ [ZRG-CON-002 Dossiervoer. ▼] │ │ uit │ ││ +│ │ │ │ │ │ ││ +│ │ │ Huidige waarde: ⚠️ Leeg │ │ Huidige waarde: Model A │ ││ +│ │ └─────────────────────────────────┘ └───────────────────────────────┘ ││ +│ │ ││ +│ │ ┌─────────────────────────────────┐ ┌───────────────────────────────┐ ││ +│ │ │ Dynamiek Factor │ │ Complexiteit Factor │ ││ +│ │ │ │ │ │ ││ +│ │ │ [3 - Hoog ▼] │ │ [4 - Zeer hoog ▼] │ ││ +│ │ │ │ │ │ ││ +│ │ │ Veel wijzigingen, │ │ Platform met meerdere │ ││ +│ │ │ > 4 releases/jaar │ │ workloads, uitgebreide │ ││ +│ │ │ │ │ governance │ ││ +│ │ │ Huidige waarde: 3 - Hoog │ │ Huidige waarde: 4 - Zeer hoog │ ││ +│ │ └─────────────────────────────────┘ └───────────────────────────────┘ ││ +│ │ ││ +│ │ ┌─────────────────────────────────┐ ││ +│ │ │ Aantal Gebruikers │ ││ +│ │ │ │ ││ +│ │ │ [10.000 - 15.000 ▼] │ ││ +│ │ │ │ ││ +│ │ │ Huidige waarde: ⚠️ Leeg │ ││ +│ │ └─────────────────────────────────┘ ││ +│ └─────────────────────────────────────────────────────────────────────────┘│ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────────┐│ +│ │ Wijzigingen: • ApplicationFunction: Leeg → ZRG-CON-002 ││ +│ │ • Number of Users: Leeg → 10.000 - 15.000 ││ +│ └─────────────────────────────────────────────────────────────────────────┘│ +│ │ +│ [◀ Vorige] [Annuleren] [Opslaan & Volgende ▶] [Opslaan & Sluiten] │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +**Navigatie-gedrag:** +- **[Opslaan & Volgende]**: Slaat wijzigingen op en opent automatisch de volgende applicatie uit de gefilterde resultatenlijst +- **[Opslaan & Sluiten]**: Slaat wijzigingen op en keert terug naar de zoekresultaten +- **[◀ Vorige]**: Gaat naar vorige applicatie (zonder op te slaan, vraagt bevestiging bij wijzigingen) +- **[Annuleren]**: Keert terug naar zoekresultaten zonder op te slaan + +#### 4. Bulk Operaties +- Filter op confidence niveau (bijv. alleen HOOG auto-accepteren) +- Preview van bulk-wijzigingen +- Bevestigingsdialoog +- Rollback mogelijkheid + +#### 4. Rapportage +- Export naar Excel +- Classificatie-log met timestamps +- Overzicht per domein +- Niet-geclassificeerde applicaties met redenen + +### UI Componenten + +```typescript +// Status enum for filtering +type ApplicationStatus = + | 'Status' + | 'Closed' + | 'Deprecated' + | 'End of life' + | 'End of support' + | 'Implementation' + | 'In Production' + | 'Proof of Concept' + | 'Shadow IT' + | 'Undefined'; + +// Filter state for search screen +interface SearchFilters { + searchText: string; + statuses: ApplicationStatus[]; + applicationFunction: 'all' | 'filled' | 'empty'; + governanceModel: 'all' | 'filled' | 'empty'; + dynamicsFactor: 'all' | 'filled' | 'empty'; + complexityFactor: 'all' | 'filled' | 'empty'; + organisation: string | null; + hostingType: string | null; +} + +// Search results with pagination +interface SearchResult { + applications: ApplicationListItem[]; + totalCount: number; + currentPage: number; + pageSize: number; + totalPages: number; +} + +// List item (summary view) +interface ApplicationListItem { + id: string; + key: string; + name: string; + status: ApplicationStatus; + applicationFunction: ReferenceValue | null; + governanceModel: ReferenceValue | null; + dynamicsFactor: ReferenceValue | null; + complexityFactor: ReferenceValue | null; +} + +// Full application details (for detail screen) +interface ApplicationDetails { + // Identity + id: string; + key: string; + + // Read-only fields + name: string; + searchReference: string; + description: string; + supplierProduct: string; + organisation: string; + hostingType: string; + status: ApplicationStatus; + businessImportance: string; + businessImpactAnalyse: string; + systemOwner: string; + businessOwner: string; + functionalApplicationManagement: string; + technicalApplicationManagement: string; + medischeTechniek: boolean; + + // Editable reference fields + applicationFunction: ReferenceValue | null; + dynamicsFactor: ReferenceValue | null; + complexityFactor: ReferenceValue | null; + numberOfUsers: ReferenceValue | null; + governanceModel: ReferenceValue | null; +} + +// Navigation state for detail screen +interface NavigationState { + currentIndex: number; // Position in filtered results (0-based) + totalInResults: number; // Total applications in current filter + applicationIds: string[]; // Ordered list of IDs from search results + filters: SearchFilters; // Active filters (to restore on "back") +} + +interface ReferenceValue { + objectId: string; + key: string; + name: string; + description?: string; +} + +interface AISuggestion { + primaryFunction: { + code: string; + name: string; + reasoning: string; + }; + secondaryFunctions: Array<{ + code: string; + name: string; + reasoning: string; + }>; + confidence: 'HOOG' | 'MIDDEN' | 'LAAG'; + notes: string; +} + +// Pending changes (shown before save) +interface PendingChanges { + applicationFunction?: { from: ReferenceValue | null; 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 }; +} + +interface ClassificationResult { + applicationId: string; + changes: PendingChanges; + source: 'AI_ACCEPTED' | 'AI_MODIFIED' | 'MANUAL'; + timestamp: Date; + userId: string; +} + +// Reference option lists (loaded from Jira Assets) +interface ReferenceOptions { + dynamicsFactors: ReferenceValue[]; + complexityFactors: ReferenceValue[]; + numberOfUsers: ReferenceValue[]; + governanceModels: ReferenceValue[]; + applicationFunctions: ReferenceValue[]; + organisations: ReferenceValue[]; + hostingTypes: ReferenceValue[]; +} +``` + +--- + +## Project Structuur + +``` +zira-classificatie-tool/ +├── package.json +├── .env.example +├── README.md +├── docker-compose.yml # Voor lokale development +│ +├── backend/ +│ ├── package.json +│ ├── tsconfig.json +│ ├── src/ +│ │ ├── index.ts # Express server entry +│ │ ├── config/ +│ │ │ └── env.ts # Environment configuratie +│ │ ├── services/ +│ │ │ ├── jiraAssets.ts # Jira Assets API client +│ │ │ ├── claude.ts # Claude API client +│ │ │ └── classification.ts +│ │ ├── routes/ +│ │ │ ├── applications.ts +│ │ │ ├── classifications.ts +│ │ │ ├── applicationFunctions.ts +│ │ │ └── referenceData.ts # NEW: Dynamics, Complexity, Users, Governance +│ │ ├── data/ +│ │ │ ├── zira-taxonomy.json +│ │ │ └── management-parameters.json # NEW: Fallback reference data +│ │ └── types/ +│ │ └── index.ts +│ └── tests/ +│ +├── frontend/ +│ ├── package.json +│ ├── vite.config.ts +│ ├── tailwind.config.js +│ ├── src/ +│ │ ├── main.tsx +│ │ ├── App.tsx +│ │ ├── components/ +│ │ │ ├── Dashboard.tsx +│ │ │ ├── SearchFilterBar.tsx # NEW: Zoekbalk en filters +│ │ │ ├── ApplicationList.tsx # NEW: Resultatenlijst +│ │ │ ├── ApplicationListItem.tsx # NEW: Regel in resultatenlijst +│ │ │ ├── ApplicationDetail.tsx # Detail/wijzigingsscherm +│ │ │ ├── ReadOnlyFields.tsx # NEW: Read-only velden sectie +│ │ │ ├── AIClassification.tsx # AI suggestie sectie +│ │ │ ├── EditableFields.tsx # NEW: Bewerkbare velden sectie +│ │ │ ├── FunctionSelector.tsx # ZiRA functie dropdown +│ │ │ ├── ManagementParameters.tsx # Dynamics, Complexity, Users +│ │ │ ├── GovernanceSelector.tsx # Regiemodel selector +│ │ │ ├── PendingChanges.tsx # NEW: Wijzigingen preview +│ │ │ ├── NavigationBar.tsx # NEW: Vorige/Volgende navigatie +│ │ │ ├── CapacityMatrix.tsx # Visualisatie matrix +│ │ │ └── BulkOperations.tsx +│ │ ├── hooks/ +│ │ │ ├── useApplications.ts +│ │ │ ├── useClassification.ts +│ │ │ ├── useReferenceData.ts # Hook voor reference data +│ │ │ ├── useSearch.ts # NEW: Zoeken en filteren +│ │ │ └── useNavigation.ts # NEW: Detail navigatie state +│ │ ├── services/ +│ │ │ └── api.ts +│ │ ├── stores/ # NEW: State management +│ │ │ ├── searchStore.ts # Filter/zoek state +│ │ │ └── navigationStore.ts # Navigatie state +│ │ └── types/ +│ │ └── index.ts +│ └── public/ +│ +└── data/ + ├── zira-taxonomy.json # ZiRA functies als JSON + └── management-parameters.json # Beheerparameters als JSON +``` + +--- + +## Environment Variables + +```env +# .env.example + +# Jira Assets +JIRA_HOST=https://jira.zuyderland.nl +JIRA_PAT=your_personal_access_token_here +JIRA_SCHEMA_ID=your_schema_id + +# Object Type IDs (ophalen 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 + +# Attribute IDs (ophalen via API - nodig voor 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 + +# Claude API +ANTHROPIC_API_KEY=your_anthropic_api_key_here + +# Application +PORT=3001 +NODE_ENV=development +``` + +--- + +## Implementatiestappen + +### Fase 1: Setup (dag 1) +1. Project initialiseren met Vite + Express +2. TypeScript configuratie +3. Jira Assets API connectie testen +4. Environment setup + +### Fase 2: Backend (dag 2-3) +1. Jira Assets service implementeren +2. Claude API integratie +3. REST endpoints +4. Error handling & logging + +### Fase 3: Frontend (dag 4-5) +1. Dashboard component +2. Classificatie werkscherm +3. Function selector met search +4. State management + +### Fase 4: Integratie (dag 6) +1. End-to-end flow testen +2. Bulk operaties +3. Rapportage export +4. Performance optimalisatie + +### Fase 5: Testing & Deployment (dag 7) +1. Unit tests +2. Integration tests +3. Documentatie +4. Deployment instructies + +--- + +## Aandachtspunten voor Claude Code + +1. **Start met Jira Assets API testen** - Verifieer eerst dat de connectie werkt en je de juiste schema/objecttype ID's hebt + +2. **ApplicationFunction objecten ophalen** - Deze moeten al bestaan in Jira Assets. Check of ze overeenkomen met de ZiRA-taxonomie + +3. **Beveiliging** - PAT tokens nooit committen, altijd via .env + +4. **Rate limiting** - Jira Assets kan rate limits hebben, implementeer exponential backoff + +5. **Validatie** - Valideer dat de ApplicationFunction die geselecteerd wordt daadwerkelijk bestaat voordat je update + +6. **Logging** - Uitgebreide logging voor troubleshooting en audit trail + +--- + +## Contactgegevens + +- **Project eigenaar:** Bert Hausmans +- **Afdeling:** ICMT - Zorg en Ondersteunende Applicaties +- **Organisatie:** Zuyderland Medisch Centrum diff --git a/zira-taxonomy.json b/zira-taxonomy.json new file mode 100644 index 0000000..82da0ac --- /dev/null +++ b/zira-taxonomy.json @@ -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"] + } + ] + } + ] +}